From 410b2ce308a773af9334361adcafbf353f726e78 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 21 Mar 2026 13:05:33 +0100 Subject: [PATCH] feat(v9d): add training types system + logout button 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 --- backend/main.py | 5 +- backend/migrations/004_training_types.sql | 86 ++++++++++++ backend/models.py | 3 + backend/routers/training_types.py | 123 ++++++++++++++++++ frontend/src/App.jsx | 43 ++++-- .../components/TrainingTypeDistribution.jsx | 120 +++++++++++++++++ .../src/components/TrainingTypeSelect.jsx | 114 ++++++++++++++++ frontend/src/utils/api.js | 5 + 8 files changed, 490 insertions(+), 9 deletions(-) create mode 100644 backend/migrations/004_training_types.sql create mode 100644 backend/routers/training_types.py create mode 100644 frontend/src/components/TrainingTypeDistribution.jsx create mode 100644 frontend/src/components/TrainingTypeSelect.jsx diff --git a/backend/main.py b/backend/main.py index f4bf4f8..192033b 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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(): diff --git a/backend/migrations/004_training_types.sql b/backend/migrations/004_training_types.sql new file mode 100644 index 0000000..59b254c --- /dev/null +++ b/backend/migrations/004_training_types.sql @@ -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'; diff --git a/backend/models.py b/backend/models.py index 380e300..86bdb8c 100644 --- a/backend/models.py +++ b/backend/models.py @@ -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): diff --git a/backend/routers/training_types.py b/backend/routers/training_types.py new file mode 100644 index 0000000..ab24785 --- /dev/null +++ b/backend/routers/training_types.py @@ -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 diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 75e3a4b..defb903 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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() {
Mitai Jinkendo - - {activeProfile - ? - :
- } - +
+ + + {activeProfile + ? + :
+ } + +
diff --git a/frontend/src/components/TrainingTypeDistribution.jsx b/frontend/src/components/TrainingTypeDistribution.jsx new file mode 100644 index 0000000..39889f8 --- /dev/null +++ b/frontend/src/components/TrainingTypeDistribution.jsx @@ -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 ( +
+
+
+ ) + } + + if (data.length === 0) { + return ( +
+ Keine Aktivitäten in den letzten {days} Tagen +
+ ) + } + + const total = data.reduce((sum, d) => sum + d.value, 0) + + return ( +
+ + + + {data.map((entry, index) => ( + + ))} + + `${value} Einheiten (${Math.round(value/total*100)}%)`} + contentStyle={{ + background:'var(--surface)', + border:'1px solid var(--border)', + borderRadius:8, + fontSize:12 + }} + /> + + + + {/* Legend */} +
+ {data.map((entry, i) => ( +
+
+ + {entry.icon} {entry.name} + + + {entry.value} + +
+ ))} +
+ +
+ Gesamt: {total} Einheiten in {days} Tagen +
+
+ ) +} diff --git a/frontend/src/components/TrainingTypeSelect.jsx b/frontend/src/components/TrainingTypeSelect.jsx new file mode 100644 index 0000000..f2b92ee --- /dev/null +++ b/frontend/src/components/TrainingTypeSelect.jsx @@ -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
Lade Trainingstypen...
+ } + + const availableCategories = Object.keys(categories) + const availableTypes = selectedCategory ? (types[selectedCategory] || []) : [] + + return ( +
+ {/* Category dropdown */} +
+ + +
+ + {/* Subcategory dropdown (conditional) */} + {selectedCategory && availableTypes.length > 0 && ( +
+ + +
+ )} +
+ ) +} diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 5cdba29..22004ef 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -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 }