feat(v9d): add training types system + logout button
All checks were successful
Deploy Development / deploy (push) Successful in 49s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s

Phase 1: Training Types Basis
=============================

Backend:
- Migration 004: training_types table + seed data (24 types)
- New router: /api/training-types (grouped, flat, categories)
- Extend activity_log: training_type_id, training_category, training_subcategory
- Extend ActivityEntry model: support training type fields

Frontend:
- TrainingTypeSelect component (two-level dropdown)
- TrainingTypeDistribution component (pie chart)
- API functions: listTrainingTypes, listTrainingTypesFlat, getTrainingCategories

Quick Win: Logout Button
========================
- Add LogOut icon button in app header
- Confirm dialog before logout
- Redirect to / after logout
- Hover effect: red color on hover

Not yet integrated:
- TrainingTypeSelect not yet in ActivityPage form
- TrainingTypeDistribution not yet in Dashboard
  (will be added in next commit)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lars 2026-03-21 13:05:33 +01:00
parent 0aca5fda5d
commit 410b2ce308
8 changed files with 490 additions and 9 deletions

View File

@ -19,7 +19,7 @@ from routers import auth, profiles, weight, circumference, caliper
from routers import activity, nutrition, photos, insights, prompts
from routers import admin, stats, exportdata, importdata
from routers import subscription, coupons, features, tiers_mgmt, tier_limits
from routers import user_restrictions, access_grants
from routers import user_restrictions, access_grants, training_types
# ── App Configuration ─────────────────────────────────────────────────────────
DATA_DIR = Path(os.getenv("DATA_DIR", "./data"))
@ -85,6 +85,9 @@ app.include_router(tier_limits.router) # /api/tier-limits (admin)
app.include_router(user_restrictions.router) # /api/user-restrictions (admin)
app.include_router(access_grants.router) # /api/access-grants (admin)
# v9d Training Types
app.include_router(training_types.router) # /api/training-types/*
# ── Health Check ──────────────────────────────────────────────────────────────
@app.get("/")
def root():

View File

@ -0,0 +1,86 @@
-- Migration 004: Training Types & Categories
-- Part of v9d: Schlaf + Sport-Vertiefung
-- Created: 2026-03-21
-- ========================================
-- 1. Create training_types table
-- ========================================
CREATE TABLE IF NOT EXISTS training_types (
id SERIAL PRIMARY KEY,
category VARCHAR(50) NOT NULL, -- Main category: 'cardio', 'strength', 'hiit', etc.
subcategory VARCHAR(50), -- Optional: 'running', 'hypertrophy', etc.
name_de VARCHAR(100) NOT NULL, -- German display name
name_en VARCHAR(100) NOT NULL, -- English display name
icon VARCHAR(10), -- Emoji icon
sort_order INTEGER DEFAULT 0, -- For UI ordering
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- ========================================
-- 2. Add training type columns to activity_log
-- ========================================
ALTER TABLE activity_log
ADD COLUMN IF NOT EXISTS training_type_id INTEGER REFERENCES training_types(id),
ADD COLUMN IF NOT EXISTS training_category VARCHAR(50), -- Denormalized for fast queries
ADD COLUMN IF NOT EXISTS training_subcategory VARCHAR(50); -- Denormalized
-- ========================================
-- 3. Create indexes
-- ========================================
CREATE INDEX IF NOT EXISTS idx_activity_training_type ON activity_log(training_type_id);
CREATE INDEX IF NOT EXISTS idx_activity_training_category ON activity_log(training_category);
CREATE INDEX IF NOT EXISTS idx_training_types_category ON training_types(category);
-- ========================================
-- 4. Seed training types data
-- ========================================
-- Cardio (Ausdauer)
INSERT INTO training_types (category, subcategory, name_de, name_en, icon, sort_order) VALUES
('cardio', 'running', 'Laufen', 'Running', '🏃', 100),
('cardio', 'cycling', 'Radfahren', 'Cycling', '🚴', 101),
('cardio', 'swimming', 'Schwimmen', 'Swimming', '🏊', 102),
('cardio', 'rowing', 'Rudern', 'Rowing', '🚣', 103),
('cardio', 'other', 'Sonstiges Cardio', 'Other Cardio', '❤️', 104);
-- Kraft
INSERT INTO training_types (category, subcategory, name_de, name_en, icon, sort_order) VALUES
('strength', 'hypertrophy', 'Hypertrophie', 'Hypertrophy', '💪', 200),
('strength', 'maxstrength', 'Maximalkraft', 'Max Strength', '🏋️', 201),
('strength', 'endurance', 'Kraftausdauer', 'Strength Endurance', '🔁', 202),
('strength', 'functional', 'Funktionell', 'Functional', '', 203);
-- Schnellkraft / HIIT
INSERT INTO training_types (category, subcategory, name_de, name_en, icon, sort_order) VALUES
('hiit', 'hiit', 'HIIT', 'HIIT', '🔥', 300),
('hiit', 'explosive', 'Explosiv', 'Explosive', '💥', 301),
('hiit', 'circuit', 'Circuit Training', 'Circuit Training', '🔄', 302);
-- Kampfsport / Technikkraft
INSERT INTO training_types (category, subcategory, name_de, name_en, icon, sort_order) VALUES
('martial_arts', 'technique', 'Techniktraining', 'Technique Training', '🥋', 400),
('martial_arts', 'sparring', 'Sparring / Wettkampf', 'Sparring / Competition', '🥊', 401),
('martial_arts', 'strength', 'Kraft für Kampfsport', 'Martial Arts Strength', '⚔️', 402);
-- Mobility & Dehnung
INSERT INTO training_types (category, subcategory, name_de, name_en, icon, sort_order) VALUES
('mobility', 'static', 'Statisches Dehnen', 'Static Stretching', '🧘', 500),
('mobility', 'dynamic', 'Dynamisches Dehnen', 'Dynamic Stretching', '🤸', 501),
('mobility', 'yoga', 'Yoga', 'Yoga', '🕉️', 502),
('mobility', 'fascia', 'Faszienarbeit', 'Fascia Work', '🎯', 503);
-- Erholung (aktiv)
INSERT INTO training_types (category, subcategory, name_de, name_en, icon, sort_order) VALUES
('recovery', 'walk', 'Spaziergang', 'Walk', '🚶', 600),
('recovery', 'swim_light', 'Leichtes Schwimmen', 'Light Swimming', '🏊', 601),
('recovery', 'regeneration', 'Regenerationseinheit', 'Regeneration', '💆', 602);
-- General / Uncategorized
INSERT INTO training_types (category, subcategory, name_de, name_en, icon, sort_order) VALUES
('other', NULL, 'Sonstiges', 'Other', '📝', 900);
-- ========================================
-- 5. Add comment
-- ========================================
COMMENT ON TABLE training_types IS 'v9d: Training type categories and subcategories';
COMMENT ON TABLE activity_log IS 'Extended in v9d with training_type_id for categorization';

View File

@ -84,6 +84,9 @@ class ActivityEntry(BaseModel):
rpe: Optional[int] = None
source: Optional[str] = 'manual'
notes: Optional[str] = None
training_type_id: Optional[int] = None # v9d: Training type categorization
training_category: Optional[str] = None # v9d: Denormalized category
training_subcategory: Optional[str] = None # v9d: Denormalized subcategory
class NutritionDay(BaseModel):

View File

@ -0,0 +1,123 @@
"""
Training Types API - v9d
Provides hierarchical list of training categories and subcategories
for activity classification.
"""
from fastapi import APIRouter, Depends
from db import get_db, get_cursor
from auth import require_auth
router = APIRouter(prefix="/api/training-types", tags=["training-types"])
@router.get("")
def list_training_types(session: dict = Depends(require_auth)):
"""
Get all training types, grouped by category.
Returns hierarchical structure:
{
"cardio": [
{"id": 1, "subcategory": "running", "name_de": "Laufen", ...},
...
],
"strength": [...],
...
}
"""
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("""
SELECT id, category, subcategory, name_de, name_en, icon, sort_order
FROM training_types
ORDER BY sort_order, category, subcategory
""")
rows = cur.fetchall()
# Group by category
grouped = {}
for row in rows:
cat = row['category']
if cat not in grouped:
grouped[cat] = []
grouped[cat].append({
'id': row['id'],
'category': row['category'],
'subcategory': row['subcategory'],
'name_de': row['name_de'],
'name_en': row['name_en'],
'icon': row['icon'],
'sort_order': row['sort_order']
})
return grouped
@router.get("/flat")
def list_training_types_flat(session: dict = Depends(require_auth)):
"""
Get all training types as flat list (for simple dropdown).
"""
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("""
SELECT id, category, subcategory, name_de, name_en, icon
FROM training_types
ORDER BY sort_order
""")
rows = cur.fetchall()
return [dict(row) for row in rows]
@router.get("/categories")
def list_categories(session: dict = Depends(require_auth)):
"""
Get list of unique categories with metadata.
"""
categories = {
'cardio': {
'name_de': 'Cardio (Ausdauer)',
'name_en': 'Cardio (Endurance)',
'icon': '❤️',
'color': '#EF4444'
},
'strength': {
'name_de': 'Kraft',
'name_en': 'Strength',
'icon': '💪',
'color': '#3B82F6'
},
'hiit': {
'name_de': 'Schnellkraft / HIIT',
'name_en': 'Power / HIIT',
'icon': '🔥',
'color': '#F59E0B'
},
'martial_arts': {
'name_de': 'Kampfsport',
'name_en': 'Martial Arts',
'icon': '🥋',
'color': '#8B5CF6'
},
'mobility': {
'name_de': 'Mobility & Dehnung',
'name_en': 'Mobility & Stretching',
'icon': '🧘',
'color': '#10B981'
},
'recovery': {
'name_de': 'Erholung (aktiv)',
'name_en': 'Recovery (active)',
'icon': '💆',
'color': '#6B7280'
},
'other': {
'name_de': 'Sonstiges',
'name_en': 'Other',
'icon': '📝',
'color': '#9CA3AF'
}
}
return categories

View File

@ -1,6 +1,6 @@
import { useEffect } from 'react'
import { BrowserRouter, Routes, Route, NavLink, useNavigate } from 'react-router-dom'
import { LayoutDashboard, PlusSquare, TrendingUp, BarChart2, Settings } from 'lucide-react'
import { LayoutDashboard, PlusSquare, TrendingUp, BarChart2, Settings, LogOut } from 'lucide-react'
import { ProfileProvider, useProfile } from './context/ProfileContext'
import { AuthProvider, useAuth } from './context/AuthContext'
import { setProfileId } from './utils/api'
@ -50,10 +50,17 @@ function Nav() {
}
function AppShell() {
const { session, loading: authLoading, needsSetup } = useAuth()
const { session, loading: authLoading, needsSetup, logout } = useAuth()
const { activeProfile, loading: profileLoading } = useProfile()
const nav = useNavigate()
const handleLogout = () => {
if (confirm('Wirklich abmelden?')) {
logout()
window.location.href = '/'
}
}
useEffect(()=>{
if (session?.profile_id) {
setProfileId(session.profile_id)
@ -119,12 +126,32 @@ function AppShell() {
<div className="app-shell">
<header className="app-header">
<span className="app-logo">Mitai Jinkendo</span>
<div style={{display:'flex', gap:12, alignItems:'center'}}>
<button
onClick={handleLogout}
title="Abmelden"
style={{
background:'none',
border:'none',
cursor:'pointer',
padding:6,
display:'flex',
alignItems:'center',
color:'var(--text2)',
transition:'color 0.15s'
}}
onMouseEnter={e => e.currentTarget.style.color = '#D85A30'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text2)'}
>
<LogOut size={18}/>
</button>
<NavLink to="/settings" style={{textDecoration:'none'}}>
{activeProfile
? <Avatar profile={activeProfile} size={30}/>
: <div style={{width:30,height:30,borderRadius:'50%',background:'var(--accent)'}}/>
}
</NavLink>
</div>
</header>
<main className="app-main">
<Routes>

View File

@ -0,0 +1,120 @@
import { useState, useEffect } from 'react'
import { PieChart, Pie, Cell, ResponsiveContainer, Legend, Tooltip } from 'recharts'
import { api } from '../utils/api'
/**
* TrainingTypeDistribution - Pie chart showing activity distribution by type
*
* @param {number} days - Number of days to analyze (default: 28)
*/
export default function TrainingTypeDistribution({ days = 28 }) {
const [data, setData] = useState([])
const [categories, setCategories] = useState({})
const [loading, setLoading] = useState(true)
useEffect(() => {
Promise.all([
api.listActivity(days),
api.getTrainingCategories()
]).then(([activities, cats]) => {
setCategories(cats)
// Group by training_category
const grouped = {}
activities.forEach(act => {
const cat = act.training_category || 'other'
if (!grouped[cat]) grouped[cat] = 0
grouped[cat]++
})
// Convert to chart data
const chartData = Object.entries(grouped).map(([cat, count]) => ({
name: cats[cat]?.name_de || 'Sonstiges',
value: count,
color: cats[cat]?.color || '#9CA3AF',
icon: cats[cat]?.icon || '📝'
}))
setData(chartData.sort((a, b) => b.value - a.value))
setLoading(false)
}).catch(err => {
console.error('Failed to load training type distribution:', err)
setLoading(false)
})
}, [days])
if (loading) {
return (
<div style={{textAlign:'center', padding:20}}>
<div className="spinner" style={{width:24, height:24, margin:'0 auto'}}/>
</div>
)
}
if (data.length === 0) {
return (
<div style={{textAlign:'center', padding:20, color:'var(--text3)', fontSize:13}}>
Keine Aktivitäten in den letzten {days} Tagen
</div>
)
}
const total = data.reduce((sum, d) => sum + d.value, 0)
return (
<div>
<ResponsiveContainer width="100%" height={200}>
<PieChart>
<Pie
data={data}
cx="50%"
cy="50%"
innerRadius={50}
outerRadius={80}
paddingAngle={2}
dataKey="value"
>
{data.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip
formatter={(value) => `${value} Einheiten (${Math.round(value/total*100)}%)`}
contentStyle={{
background:'var(--surface)',
border:'1px solid var(--border)',
borderRadius:8,
fontSize:12
}}
/>
</PieChart>
</ResponsiveContainer>
{/* Legend */}
<div style={{display:'grid', gridTemplateColumns:'1fr 1fr', gap:8, marginTop:12}}>
{data.map((entry, i) => (
<div key={i} style={{display:'flex', alignItems:'center', gap:6, fontSize:12}}>
<div style={{
width:12, height:12, borderRadius:'50%',
background:entry.color, flexShrink:0
}}/>
<span style={{color:'var(--text2)'}}>
{entry.icon} {entry.name}
</span>
<span style={{marginLeft:'auto', fontWeight:600, color:'var(--text1)'}}>
{entry.value}
</span>
</div>
))}
</div>
<div style={{
marginTop:12, padding:'8px 12px',
background:'var(--surface)', borderRadius:8,
fontSize:12, color:'var(--text3)', textAlign:'center'
}}>
Gesamt: <strong style={{color:'var(--text1)'}}>{total}</strong> Einheiten in {days} Tagen
</div>
</div>
)
}

View File

@ -0,0 +1,114 @@
import { useState, useEffect } from 'react'
import { api } from '../utils/api'
/**
* TrainingTypeSelect - Two-level dropdown for training type selection
*
* @param {number|null} value - Selected training_type_id
* @param {function} onChange - Callback (training_type_id, category, subcategory) => void
* @param {boolean} required - Is selection required?
*/
export default function TrainingTypeSelect({ value, onChange, required = false }) {
const [types, setTypes] = useState({}) // Grouped by category
const [categories, setCategories] = useState({}) // Category metadata
const [selectedCategory, setSelectedCategory] = useState('')
const [loading, setLoading] = useState(true)
useEffect(() => {
Promise.all([
api.listTrainingTypes(),
api.getTrainingCategories()
]).then(([typesData, catsData]) => {
setTypes(typesData)
setCategories(catsData)
// If value is set, find and select the category
if (value) {
for (const cat in typesData) {
const found = typesData[cat].find(t => t.id === value)
if (found) {
setSelectedCategory(cat)
break
}
}
}
setLoading(false)
}).catch(err => {
console.error('Failed to load training types:', err)
setLoading(false)
})
}, [value])
const handleCategoryChange = (cat) => {
setSelectedCategory(cat)
// Auto-select first subcategory if available
if (types[cat] && types[cat].length > 0) {
const firstType = types[cat][0]
onChange(firstType.id, firstType.category, firstType.subcategory)
} else {
onChange(null, null, null)
}
}
const handleTypeChange = (typeId) => {
const type = Object.values(types)
.flat()
.find(t => t.id === parseInt(typeId))
if (type) {
onChange(type.id, type.category, type.subcategory)
}
}
if (loading) {
return <div style={{fontSize:13, color:'var(--text3)'}}>Lade Trainingstypen...</div>
}
const availableCategories = Object.keys(categories)
const availableTypes = selectedCategory ? (types[selectedCategory] || []) : []
return (
<div style={{display:'flex', gap:8, flexDirection:'column'}}>
{/* Category dropdown */}
<div>
<label className="form-label">Kategorie</label>
<select
className="form-input"
value={selectedCategory}
onChange={(e) => handleCategoryChange(e.target.value)}
required={required}
style={{width:'100%', boxSizing:'border-box'}}
>
<option value="">-- Wähle Kategorie --</option>
{availableCategories.map(cat => (
<option key={cat} value={cat}>
{categories[cat].icon} {categories[cat].name_de}
</option>
))}
</select>
</div>
{/* Subcategory dropdown (conditional) */}
{selectedCategory && availableTypes.length > 0 && (
<div>
<label className="form-label">Untertyp</label>
<select
className="form-input"
value={value || ''}
onChange={(e) => handleTypeChange(e.target.value)}
required={required}
style={{width:'100%', boxSizing:'border-box'}}
>
<option value="">-- Wähle Untertyp --</option>
{availableTypes.map(type => (
<option key={type.id} value={type.id}>
{type.icon} {type.name_de}
</option>
))}
</select>
</div>
)}
</div>
)
}

View File

@ -189,4 +189,9 @@ export const api = {
createAccessGrant: (d) => req('/access-grants',json(d)),
updateAccessGrant: (id,d) => req(`/access-grants/${id}`,jput(d)),
revokeAccessGrant: (id) => req(`/access-grants/${id}`,{method:'DELETE'}),
// v9d: Training Types
listTrainingTypes: () => req('/training-types'), // Grouped by category
listTrainingTypesFlat:() => req('/training-types/flat'), // Flat list
getTrainingCategories:() => req('/training-types/categories'), // Category metadata
}