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 activity, nutrition, photos, insights, prompts
from routers import admin, stats, exportdata, importdata from routers import admin, stats, exportdata, importdata
from routers import subscription, coupons, features, tiers_mgmt, tier_limits 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 ───────────────────────────────────────────────────────── # ── App Configuration ─────────────────────────────────────────────────────────
DATA_DIR = Path(os.getenv("DATA_DIR", "./data")) 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(user_restrictions.router) # /api/user-restrictions (admin)
app.include_router(access_grants.router) # /api/access-grants (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 ────────────────────────────────────────────────────────────── # ── Health Check ──────────────────────────────────────────────────────────────
@app.get("/") @app.get("/")
def root(): 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 rpe: Optional[int] = None
source: Optional[str] = 'manual' source: Optional[str] = 'manual'
notes: Optional[str] = None 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): 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 { useEffect } from 'react'
import { BrowserRouter, Routes, Route, NavLink, useNavigate } from 'react-router-dom' 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 { ProfileProvider, useProfile } from './context/ProfileContext'
import { AuthProvider, useAuth } from './context/AuthContext' import { AuthProvider, useAuth } from './context/AuthContext'
import { setProfileId } from './utils/api' import { setProfileId } from './utils/api'
@ -50,10 +50,17 @@ function Nav() {
} }
function AppShell() { function AppShell() {
const { session, loading: authLoading, needsSetup } = useAuth() const { session, loading: authLoading, needsSetup, logout } = useAuth()
const { activeProfile, loading: profileLoading } = useProfile() const { activeProfile, loading: profileLoading } = useProfile()
const nav = useNavigate() const nav = useNavigate()
const handleLogout = () => {
if (confirm('Wirklich abmelden?')) {
logout()
window.location.href = '/'
}
}
useEffect(()=>{ useEffect(()=>{
if (session?.profile_id) { if (session?.profile_id) {
setProfileId(session.profile_id) setProfileId(session.profile_id)
@ -119,12 +126,32 @@ function AppShell() {
<div className="app-shell"> <div className="app-shell">
<header className="app-header"> <header className="app-header">
<span className="app-logo">Mitai Jinkendo</span> <span className="app-logo">Mitai Jinkendo</span>
<NavLink to="/settings" style={{textDecoration:'none'}}> <div style={{display:'flex', gap:12, alignItems:'center'}}>
{activeProfile <button
? <Avatar profile={activeProfile} size={30}/> onClick={handleLogout}
: <div style={{width:30,height:30,borderRadius:'50%',background:'var(--accent)'}}/> title="Abmelden"
} style={{
</NavLink> 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> </header>
<main className="app-main"> <main className="app-main">
<Routes> <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)), createAccessGrant: (d) => req('/access-grants',json(d)),
updateAccessGrant: (id,d) => req(`/access-grants/${id}`,jput(d)), updateAccessGrant: (id,d) => req(`/access-grants/${id}`,jput(d)),
revokeAccessGrant: (id) => req(`/access-grants/${id}`,{method:'DELETE'}), 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
} }