diff --git a/CLAUDE.md b/CLAUDE.md index 300a352..166b058 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,7 +49,7 @@ frontend/src/ └── technical/ # MEMBERSHIP_SYSTEM.md ``` -## Aktuelle Version: v9c (komplett) +## Aktuelle Version: v9c (komplett) 🚀 Production seit 21.03.2026 ### Implementiert ✅ - Login (E-Mail + bcrypt), Auth-Middleware alle Endpoints, Rate Limiting @@ -81,15 +81,55 @@ frontend/src/ - ✅ **BUG-002:** Ernährungs-Daten Tab fehlte – importierte Einträge nicht sichtbar - ✅ **BUG-003:** Korrelations-Chart Extrapolation (gestrichelte Linien für fehlende Werte) - ✅ **BUG-004:** Import-Historie Refresh (Force remount via key prop) +- ✅ **BUG-005:** Login → leere Seite (window.location.href='/' nach login) +- ✅ **BUG-006:** Email-Verifizierung → leere Seite (window.location.href='/' statt navigate) +- ✅ **BUG-007:** Doppelklick Verifizierungslink → generischer JSON-Fehler (Error-Parsing + bessere Backend-Meldung) +- ✅ **BUG-008:** Dashboard infinite loading bei API-Fehlern (.catch() handler in load()) -### v9c Finalisierung ✅ +### v9c Finalisierung ✅ (Deployed to Production 21.03.2026) - ✅ **Selbst-Registrierung:** POST /api/auth/register, E-Mail-Verifizierung, Auto-Login - ✅ **Trial-System UI:** Countdown-Banner im Dashboard (3 Urgency-Level) - ✅ **Migrations-System:** Automatische Schema-Migrationen beim Start (db_init.py) +- ✅ **Navigation-Fixes:** Alle Login/Verify-Flows funktionieren korrekt +- ✅ **Error-Handling:** JSON-Fehler sauber formatiert, Dashboard robust bei API-Fehlern -### Offen v9d 🔲 -- Schlaf-Modul -- Trainingstypen + Herzfrequenz +### Auf develop (bereit für Prod) 🚀 +**v9d Phase 1b - Feature-komplett, ready for deployment** + +- ✅ **Trainingstypen-System (komplett):** + - 29 Trainingstypen (7 Kategorien) + - Admin-CRUD mit vollständiger UI + - Automatisches Apple Health Mapping (23 Workout-Typen) + - Bulk-Kategorisierung für bestehende Aktivitäten + - Farbige Typ-Badges in Aktivitätsliste + - TrainingTypeDistribution Chart in History-Seite + - TrainingTypeSelect in ActivityPage + +- ✅ **Weitere Verbesserungen:** + - TrialBanner mailto (Vorbereitung zentrales Abo-System) + - Admin-Formular UX-Optimierung (Full-width inputs, größere Textareas) + +- 📚 **Dokumentation:** + - `.claude/docs/technical/CENTRAL_SUBSCRIPTION_SYSTEM.md` + - `.claude/docs/functional/AI_PROMPTS.md` (erweitert um Fähigkeiten-Mapping) + +### v9d – Phase 1 ✅ (Deployed 21.03.2026) +- ✅ **Trainingstypen Basis:** DB-Schema, 23 Typen, API-Endpoints +- ✅ **Logout-Button:** Im Header neben Avatar, mit Bestätigung +- ✅ **Components:** TrainingTypeSelect, TrainingTypeDistribution + +### v9d – Phase 1b ✅ (Abgeschlossen, auf develop) +- ✅ ActivityPage: TrainingTypeSelect eingebunden +- ✅ History: TrainingTypeDistribution Chart + Typ-Badges bei Aktivitäten +- ✅ Apple Health Import: Automatisches Mapping (29 Typen) +- ✅ Bulk-Kategorisierung: UI + Endpoints +- ✅ Admin-CRUD: Vollständige Verwaltung inkl. UX-Optimierungen + +### v9d – Phase 2+ 🔲 (Später) +- 🔲 Ruhetage erfassen (rest_days Tabelle) +- 🔲 Ruhepuls erfassen (vitals_log Tabelle) +- 🔲 HF-Zonen + Erholungsstatus +- 🔲 Schlaf-Modul 📚 Details: `.claude/docs/technical/MEMBERSHIP_SYSTEM.md` · `.claude/docs/architecture/FEATURE_ENFORCEMENT.md` diff --git a/backend/main.py b/backend/main.py index f4bf4f8..e67e3b9 100644 --- a/backend/main.py +++ b/backend/main.py @@ -19,7 +19,8 @@ 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, admin_training_types +from routers import admin_activity_mappings # ── App Configuration ───────────────────────────────────────────────────────── DATA_DIR = Path(os.getenv("DATA_DIR", "./data")) @@ -85,6 +86,11 @@ 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/* +app.include_router(admin_training_types.router) # /api/admin/training-types/* +app.include_router(admin_activity_mappings.router) # /api/admin/activity-mappings/* + # ── 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/migrations/005_training_types_extended.sql b/backend/migrations/005_training_types_extended.sql new file mode 100644 index 0000000..35700e4 --- /dev/null +++ b/backend/migrations/005_training_types_extended.sql @@ -0,0 +1,24 @@ +-- Migration 005: Extended Training Types +-- Add: Cardio (Gehen, Tanzen), Mind & Meditation category +-- Created: 2026-03-21 + +-- ======================================== +-- Add new cardio subcategories +-- ======================================== +INSERT INTO training_types (category, subcategory, name_de, name_en, icon, sort_order) VALUES + ('cardio', 'walk', 'Gehen', 'Walking', '🚶', 105), + ('cardio', 'dance', 'Tanzen', 'Dance', '💃', 106); + +-- ======================================== +-- Add new category: Geist & Meditation +-- ======================================== +INSERT INTO training_types (category, subcategory, name_de, name_en, icon, sort_order) VALUES + ('mind', 'meditation', 'Meditation', 'Meditation', '🧘♂️', 700), + ('mind', 'breathwork', 'Atemarbeit', 'Breathwork', '🫁', 701), + ('mind', 'mindfulness', 'Achtsamkeit', 'Mindfulness', '☮️', 702), + ('mind', 'visualization', 'Visualisierung', 'Visualization', '🎨', 703); + +-- ======================================== +-- Add comment +-- ======================================== +COMMENT ON TABLE training_types IS 'v9d Phase 1b: Extended with cardio walk/dance and mind category'; diff --git a/backend/migrations/006_training_types_abilities.sql b/backend/migrations/006_training_types_abilities.sql new file mode 100644 index 0000000..4327e79 --- /dev/null +++ b/backend/migrations/006_training_types_abilities.sql @@ -0,0 +1,29 @@ +-- Migration 006: Training Types - Abilities Mapping +-- Add abilities JSONB column for future AI analysis +-- Maps to: koordinativ, konditionell, kognitiv, psychisch, taktisch +-- Created: 2026-03-21 + +-- ======================================== +-- Add abilities column +-- ======================================== +ALTER TABLE training_types + ADD COLUMN IF NOT EXISTS abilities JSONB DEFAULT '{}'; + +-- ======================================== +-- Add description columns for better documentation +-- ======================================== +ALTER TABLE training_types + ADD COLUMN IF NOT EXISTS description_de TEXT, + ADD COLUMN IF NOT EXISTS description_en TEXT; + +-- ======================================== +-- Add index for abilities queries +-- ======================================== +CREATE INDEX IF NOT EXISTS idx_training_types_abilities ON training_types USING GIN (abilities); + +-- ======================================== +-- Comment +-- ======================================== +COMMENT ON COLUMN training_types.abilities IS 'JSONB: Maps to athletic abilities for AI analysis (koordinativ, konditionell, kognitiv, psychisch, taktisch)'; +COMMENT ON COLUMN training_types.description_de IS 'German description for admin UI and AI context'; +COMMENT ON COLUMN training_types.description_en IS 'English description for admin UI and AI context'; diff --git a/backend/migrations/007_activity_type_mappings.sql b/backend/migrations/007_activity_type_mappings.sql new file mode 100644 index 0000000..78bfbbe --- /dev/null +++ b/backend/migrations/007_activity_type_mappings.sql @@ -0,0 +1,121 @@ +-- Migration 007: Activity Type Mappings (Learnable System) +-- Replaces hardcoded mappings with DB-based configurable system +-- Created: 2026-03-21 + +-- ======================================== +-- 1. Create activity_type_mappings table +-- ======================================== +CREATE TABLE IF NOT EXISTS activity_type_mappings ( + id SERIAL PRIMARY KEY, + activity_type VARCHAR(100) NOT NULL, + training_type_id INTEGER NOT NULL REFERENCES training_types(id) ON DELETE CASCADE, + profile_id VARCHAR(36), -- NULL = global mapping, otherwise user-specific + source VARCHAR(20) DEFAULT 'manual', -- 'manual', 'bulk', 'admin', 'default' + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT unique_activity_type_per_profile UNIQUE(activity_type, profile_id) +); + +-- ======================================== +-- 2. Create indexes +-- ======================================== +CREATE INDEX IF NOT EXISTS idx_activity_type_mappings_type ON activity_type_mappings(activity_type); +CREATE INDEX IF NOT EXISTS idx_activity_type_mappings_profile ON activity_type_mappings(profile_id); + +-- ======================================== +-- 3. Seed default mappings (global) +-- ======================================== +-- Note: These are the German Apple Health workout types +-- training_type_id references are based on existing training_types data + +-- Helper function to get training_type_id by subcategory +DO $$ +DECLARE + v_running_id INTEGER; + v_walk_id INTEGER; + v_cycling_id INTEGER; + v_swimming_id INTEGER; + v_hypertrophy_id INTEGER; + v_functional_id INTEGER; + v_hiit_id INTEGER; + v_yoga_id INTEGER; + v_technique_id INTEGER; + v_sparring_id INTEGER; + v_rowing_id INTEGER; + v_dance_id INTEGER; + v_static_id INTEGER; + v_regeneration_id INTEGER; + v_meditation_id INTEGER; + v_mindfulness_id INTEGER; +BEGIN + -- Get training_type IDs + SELECT id INTO v_running_id FROM training_types WHERE subcategory = 'running' LIMIT 1; + SELECT id INTO v_walk_id FROM training_types WHERE subcategory = 'walk' LIMIT 1; + SELECT id INTO v_cycling_id FROM training_types WHERE subcategory = 'cycling' LIMIT 1; + SELECT id INTO v_swimming_id FROM training_types WHERE subcategory = 'swimming' LIMIT 1; + SELECT id INTO v_hypertrophy_id FROM training_types WHERE subcategory = 'hypertrophy' LIMIT 1; + SELECT id INTO v_functional_id FROM training_types WHERE subcategory = 'functional' LIMIT 1; + SELECT id INTO v_hiit_id FROM training_types WHERE subcategory = 'hiit' LIMIT 1; + SELECT id INTO v_yoga_id FROM training_types WHERE subcategory = 'yoga' LIMIT 1; + SELECT id INTO v_technique_id FROM training_types WHERE subcategory = 'technique' LIMIT 1; + SELECT id INTO v_sparring_id FROM training_types WHERE subcategory = 'sparring' LIMIT 1; + SELECT id INTO v_rowing_id FROM training_types WHERE subcategory = 'rowing' LIMIT 1; + SELECT id INTO v_dance_id FROM training_types WHERE subcategory = 'dance' LIMIT 1; + SELECT id INTO v_static_id FROM training_types WHERE subcategory = 'static' LIMIT 1; + SELECT id INTO v_regeneration_id FROM training_types WHERE subcategory = 'regeneration' LIMIT 1; + SELECT id INTO v_meditation_id FROM training_types WHERE subcategory = 'meditation' LIMIT 1; + SELECT id INTO v_mindfulness_id FROM training_types WHERE subcategory = 'mindfulness' LIMIT 1; + + -- Insert default mappings (German Apple Health names) + INSERT INTO activity_type_mappings (activity_type, training_type_id, profile_id, source) VALUES + -- German workout types + ('Laufen', v_running_id, NULL, 'default'), + ('Gehen', v_walk_id, NULL, 'default'), + ('Wandern', v_walk_id, NULL, 'default'), + ('Outdoor Spaziergang', v_walk_id, NULL, 'default'), + ('Innenräume Spaziergang', v_walk_id, NULL, 'default'), + ('Spaziergang', v_walk_id, NULL, 'default'), + ('Radfahren', v_cycling_id, NULL, 'default'), + ('Schwimmen', v_swimming_id, NULL, 'default'), + ('Traditionelles Krafttraining', v_hypertrophy_id, NULL, 'default'), + ('Funktionelles Krafttraining', v_functional_id, NULL, 'default'), + ('Hochintensives Intervalltraining', v_hiit_id, NULL, 'default'), + ('Yoga', v_yoga_id, NULL, 'default'), + ('Kampfsport', v_technique_id, NULL, 'default'), + ('Matrial Arts', v_technique_id, NULL, 'default'), -- Common typo + ('Boxen', v_sparring_id, NULL, 'default'), + ('Rudern', v_rowing_id, NULL, 'default'), + ('Tanzen', v_dance_id, NULL, 'default'), + ('Cardio Dance', v_dance_id, NULL, 'default'), + ('Flexibilität', v_static_id, NULL, 'default'), + ('Abwärmen', v_regeneration_id, NULL, 'default'), + ('Cooldown', v_regeneration_id, NULL, 'default'), + ('Meditation', v_meditation_id, NULL, 'default'), + ('Achtsamkeit', v_mindfulness_id, NULL, 'default'), + ('Geist & Körper', v_yoga_id, NULL, 'default') + ON CONFLICT (activity_type, profile_id) DO NOTHING; + + -- English workout types (for compatibility) + INSERT INTO activity_type_mappings (activity_type, training_type_id, profile_id, source) VALUES + ('Running', v_running_id, NULL, 'default'), + ('Walking', v_walk_id, NULL, 'default'), + ('Hiking', v_walk_id, NULL, 'default'), + ('Cycling', v_cycling_id, NULL, 'default'), + ('Swimming', v_swimming_id, NULL, 'default'), + ('Traditional Strength Training', v_hypertrophy_id, NULL, 'default'), + ('Functional Strength Training', v_functional_id, NULL, 'default'), + ('High Intensity Interval Training', v_hiit_id, NULL, 'default'), + ('Martial Arts', v_technique_id, NULL, 'default'), + ('Boxing', v_sparring_id, NULL, 'default'), + ('Rowing', v_rowing_id, NULL, 'default'), + ('Dance', v_dance_id, NULL, 'default'), + ('Core Training', v_functional_id, NULL, 'default'), + ('Flexibility', v_static_id, NULL, 'default'), + ('Mindfulness', v_mindfulness_id, NULL, 'default') + ON CONFLICT (activity_type, profile_id) DO NOTHING; +END $$; + +-- ======================================== +-- 4. Add comment +-- ======================================== +COMMENT ON TABLE activity_type_mappings IS 'v9d Phase 1b: Learnable activity type to training type mappings. Replaces hardcoded mappings.'; 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/activity.py b/backend/routers/activity.py index 0d12000..a37b0bb 100644 --- a/backend/routers/activity.py +++ b/backend/routers/activity.py @@ -113,9 +113,126 @@ def activity_stats(x_profile_id: Optional[str]=Header(default=None), session: di return {"count":len(rows),"total_kcal":round(total_kcal),"total_min":round(total_min),"by_type":by_type} +def get_training_type_for_activity(activity_type: str, profile_id: str = None): + """ + Map activity_type to training_type_id using database mappings. + + Priority: + 1. User-specific mapping (profile_id) + 2. Global mapping (profile_id = NULL) + 3. No mapping found → returns (None, None, None) + + Returns: (training_type_id, category, subcategory) or (None, None, None) + """ + with get_db() as conn: + cur = get_cursor(conn) + + # Try user-specific mapping first + if profile_id: + cur.execute(""" + SELECT m.training_type_id, t.category, t.subcategory + FROM activity_type_mappings m + JOIN training_types t ON m.training_type_id = t.id + WHERE m.activity_type = %s AND m.profile_id = %s + LIMIT 1 + """, (activity_type, profile_id)) + row = cur.fetchone() + if row: + return (row['training_type_id'], row['category'], row['subcategory']) + + # Try global mapping + cur.execute(""" + SELECT m.training_type_id, t.category, t.subcategory + FROM activity_type_mappings m + JOIN training_types t ON m.training_type_id = t.id + WHERE m.activity_type = %s AND m.profile_id IS NULL + LIMIT 1 + """, (activity_type,)) + row = cur.fetchone() + if row: + return (row['training_type_id'], row['category'], row['subcategory']) + + return (None, None, None) + + +@router.get("/uncategorized") +def list_uncategorized_activities(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): + """Get activities without assigned training type, grouped by activity_type.""" + pid = get_pid(x_profile_id) + with get_db() as conn: + cur = get_cursor(conn) + cur.execute(""" + SELECT activity_type, COUNT(*) as count, + MIN(date) as first_date, MAX(date) as last_date + FROM activity_log + WHERE profile_id=%s AND training_type_id IS NULL + GROUP BY activity_type + ORDER BY count DESC + """, (pid,)) + return [r2d(r) for r in cur.fetchall()] + + +@router.post("/bulk-categorize") +def bulk_categorize_activities( + data: dict, + x_profile_id: Optional[str]=Header(default=None), + session: dict=Depends(require_auth) +): + """ + Bulk update training type for activities. + + Also saves the mapping to activity_type_mappings for future imports. + + Body: { + "activity_type": "Running", + "training_type_id": 1, + "training_category": "cardio", + "training_subcategory": "running" + } + """ + pid = get_pid(x_profile_id) + activity_type = data.get('activity_type') + training_type_id = data.get('training_type_id') + training_category = data.get('training_category') + training_subcategory = data.get('training_subcategory') + + if not activity_type or not training_type_id: + raise HTTPException(400, "activity_type and training_type_id required") + + with get_db() as conn: + cur = get_cursor(conn) + + # Update existing activities + cur.execute(""" + UPDATE activity_log + SET training_type_id = %s, + training_category = %s, + training_subcategory = %s + WHERE profile_id = %s + AND activity_type = %s + AND training_type_id IS NULL + """, (training_type_id, training_category, training_subcategory, pid, activity_type)) + updated_count = cur.rowcount + + # Save mapping for future imports (upsert) + cur.execute(""" + INSERT INTO activity_type_mappings (activity_type, training_type_id, profile_id, source, updated_at) + VALUES (%s, %s, %s, 'bulk', CURRENT_TIMESTAMP) + ON CONFLICT (activity_type, profile_id) + DO UPDATE SET + training_type_id = EXCLUDED.training_type_id, + source = 'bulk', + updated_at = CURRENT_TIMESTAMP + """, (activity_type, training_type_id, pid)) + + logger.info(f"[MAPPING] Saved bulk mapping: {activity_type} → training_type_id {training_type_id} (profile {pid})") + + return {"updated": updated_count, "activity_type": activity_type, "mapping_saved": True} + + @router.post("/import-csv") async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): - """Import Apple Health workout CSV.""" + """Import Apple Health workout CSV with automatic training type mapping.""" pid = get_pid(x_profile_id) raw = await file.read() try: text = raw.decode('utf-8') @@ -145,16 +262,58 @@ async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional def tf(v): try: return round(float(v),1) if v else None except: return None + # Map activity_type to training_type_id using database mappings + training_type_id, training_category, training_subcategory = get_training_type_for_activity(wtype, pid) + try: - cur.execute("""INSERT INTO activity_log - (id,profile_id,date,start_time,end_time,activity_type,duration_min,kcal_active,kcal_resting, - hr_avg,hr_max,distance_km,source,created) - VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'apple_health',CURRENT_TIMESTAMP)""", - (str(uuid.uuid4()),pid,date,start,row.get('End',''),wtype,duration_min, - kj(row.get('Aktive Energie (kJ)','')),kj(row.get('Ruheeinträge (kJ)','')), - tf(row.get('Durchschn. Herzfrequenz (count/min)','')), - tf(row.get('Max. Herzfrequenz (count/min)','')), - tf(row.get('Distanz (km)','')))) - inserted+=1 - except: skipped+=1 + # Check if entry already exists (duplicate detection by date + start_time) + cur.execute(""" + SELECT id FROM activity_log + WHERE profile_id = %s AND date = %s AND start_time = %s + """, (pid, date, start)) + existing = cur.fetchone() + + if existing: + # Update existing entry (e.g., to add training type mapping) + cur.execute(""" + UPDATE activity_log + SET end_time = %s, + activity_type = %s, + duration_min = %s, + kcal_active = %s, + kcal_resting = %s, + hr_avg = %s, + hr_max = %s, + distance_km = %s, + training_type_id = %s, + training_category = %s, + training_subcategory = %s + WHERE id = %s + """, ( + row.get('End',''), wtype, duration_min, + kj(row.get('Aktive Energie (kJ)','')), + kj(row.get('Ruheeinträge (kJ)','')), + tf(row.get('Durchschn. Herzfrequenz (count/min)','')), + tf(row.get('Max. Herzfrequenz (count/min)','')), + tf(row.get('Distanz (km)','')), + training_type_id, training_category, training_subcategory, + existing['id'] + )) + skipped += 1 # Count as skipped (not newly inserted) + else: + # Insert new entry + cur.execute("""INSERT INTO activity_log + (id,profile_id,date,start_time,end_time,activity_type,duration_min,kcal_active,kcal_resting, + hr_avg,hr_max,distance_km,source,training_type_id,training_category,training_subcategory,created) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'apple_health',%s,%s,%s,CURRENT_TIMESTAMP)""", + (str(uuid.uuid4()),pid,date,start,row.get('End',''),wtype,duration_min, + kj(row.get('Aktive Energie (kJ)','')),kj(row.get('Ruheeinträge (kJ)','')), + tf(row.get('Durchschn. Herzfrequenz (count/min)','')), + tf(row.get('Max. Herzfrequenz (count/min)','')), + tf(row.get('Distanz (km)','')), + training_type_id,training_category,training_subcategory)) + inserted+=1 + except Exception as e: + logger.warning(f"Import row failed: {e}") + skipped+=1 return {"inserted":inserted,"skipped":skipped,"message":f"{inserted} Trainings importiert"} diff --git a/backend/routers/admin_activity_mappings.py b/backend/routers/admin_activity_mappings.py new file mode 100644 index 0000000..6f04de6 --- /dev/null +++ b/backend/routers/admin_activity_mappings.py @@ -0,0 +1,219 @@ +""" +Admin Activity Type Mappings Management - v9d Phase 1b + +CRUD operations for activity_type_mappings (learnable system). +""" +import logging +from typing import Optional +from fastapi import APIRouter, HTTPException, Depends +from pydantic import BaseModel + +from db import get_db, get_cursor, r2d +from auth import require_admin + +router = APIRouter(prefix="/api/admin/activity-mappings", tags=["admin", "activity-mappings"]) +logger = logging.getLogger(__name__) + + +class ActivityMappingCreate(BaseModel): + activity_type: str + training_type_id: int + profile_id: Optional[str] = None + source: str = 'admin' + + +class ActivityMappingUpdate(BaseModel): + training_type_id: Optional[int] = None + profile_id: Optional[str] = None + source: Optional[str] = None + + +@router.get("") +def list_activity_mappings( + profile_id: Optional[str] = None, + global_only: bool = False, + session: dict = Depends(require_admin) +): + """ + Get all activity type mappings. + + Filters: + - profile_id: Show only mappings for specific profile + - global_only: Show only global mappings (profile_id IS NULL) + """ + with get_db() as conn: + cur = get_cursor(conn) + + query = """ + SELECT m.id, m.activity_type, m.training_type_id, m.profile_id, m.source, + m.created_at, m.updated_at, + t.name_de as training_type_name_de, + t.category, t.subcategory, t.icon + FROM activity_type_mappings m + JOIN training_types t ON m.training_type_id = t.id + """ + + conditions = [] + params = [] + + if global_only: + conditions.append("m.profile_id IS NULL") + elif profile_id: + conditions.append("m.profile_id = %s") + params.append(profile_id) + + if conditions: + query += " WHERE " + " AND ".join(conditions) + + query += " ORDER BY m.activity_type" + + cur.execute(query, params) + rows = cur.fetchall() + + return [r2d(r) for r in rows] + + +@router.get("/{mapping_id}") +def get_activity_mapping(mapping_id: int, session: dict = Depends(require_admin)): + """Get single activity mapping by ID.""" + with get_db() as conn: + cur = get_cursor(conn) + cur.execute(""" + SELECT m.id, m.activity_type, m.training_type_id, m.profile_id, m.source, + m.created_at, m.updated_at, + t.name_de as training_type_name_de, + t.category, t.subcategory + FROM activity_type_mappings m + JOIN training_types t ON m.training_type_id = t.id + WHERE m.id = %s + """, (mapping_id,)) + row = cur.fetchone() + + if not row: + raise HTTPException(404, "Mapping not found") + + return r2d(row) + + +@router.post("") +def create_activity_mapping(data: ActivityMappingCreate, session: dict = Depends(require_admin)): + """ + Create new activity type mapping. + + Note: Duplicate (activity_type, profile_id) will fail with 409 Conflict. + """ + with get_db() as conn: + cur = get_cursor(conn) + + try: + cur.execute(""" + INSERT INTO activity_type_mappings + (activity_type, training_type_id, profile_id, source) + VALUES (%s, %s, %s, %s) + RETURNING id + """, ( + data.activity_type, + data.training_type_id, + data.profile_id, + data.source + )) + + new_id = cur.fetchone()['id'] + + logger.info(f"[ADMIN] Mapping created: {data.activity_type} → training_type_id {data.training_type_id} (profile: {data.profile_id})") + + except Exception as e: + if 'unique_activity_type_per_profile' in str(e): + raise HTTPException(409, f"Mapping for '{data.activity_type}' already exists (profile: {data.profile_id})") + raise HTTPException(400, f"Failed to create mapping: {str(e)}") + + return {"id": new_id, "message": "Mapping created"} + + +@router.put("/{mapping_id}") +def update_activity_mapping( + mapping_id: int, + data: ActivityMappingUpdate, + session: dict = Depends(require_admin) +): + """Update existing activity type mapping.""" + with get_db() as conn: + cur = get_cursor(conn) + + # Build update query dynamically + updates = [] + values = [] + + if data.training_type_id is not None: + updates.append("training_type_id = %s") + values.append(data.training_type_id) + if data.profile_id is not None: + updates.append("profile_id = %s") + values.append(data.profile_id) + if data.source is not None: + updates.append("source = %s") + values.append(data.source) + + if not updates: + raise HTTPException(400, "No fields to update") + + updates.append("updated_at = CURRENT_TIMESTAMP") + values.append(mapping_id) + + cur.execute(f""" + UPDATE activity_type_mappings + SET {', '.join(updates)} + WHERE id = %s + """, values) + + if cur.rowcount == 0: + raise HTTPException(404, "Mapping not found") + + logger.info(f"[ADMIN] Mapping updated: {mapping_id}") + + return {"id": mapping_id, "message": "Mapping updated"} + + +@router.delete("/{mapping_id}") +def delete_activity_mapping(mapping_id: int, session: dict = Depends(require_admin)): + """ + Delete activity type mapping. + + This will cause future imports to NOT auto-assign training type for this activity_type. + Existing activities with this mapping remain unchanged. + """ + with get_db() as conn: + cur = get_cursor(conn) + + cur.execute("DELETE FROM activity_type_mappings WHERE id = %s", (mapping_id,)) + + if cur.rowcount == 0: + raise HTTPException(404, "Mapping not found") + + logger.info(f"[ADMIN] Mapping deleted: {mapping_id}") + + return {"message": "Mapping deleted"} + + +@router.get("/stats/coverage") +def get_mapping_coverage(session: dict = Depends(require_admin)): + """ + Get statistics about mapping coverage. + + Returns how many activities are mapped vs unmapped across all profiles. + """ + with get_db() as conn: + cur = get_cursor(conn) + + cur.execute(""" + SELECT + COUNT(*) as total_activities, + COUNT(training_type_id) as mapped_activities, + COUNT(*) - COUNT(training_type_id) as unmapped_activities, + COUNT(DISTINCT activity_type) as unique_activity_types, + COUNT(DISTINCT CASE WHEN training_type_id IS NULL THEN activity_type END) as unmapped_types + FROM activity_log + """) + stats = r2d(cur.fetchone()) + + return stats diff --git a/backend/routers/admin_training_types.py b/backend/routers/admin_training_types.py new file mode 100644 index 0000000..f26db55 --- /dev/null +++ b/backend/routers/admin_training_types.py @@ -0,0 +1,281 @@ +""" +Admin Training Types Management - v9d Phase 1b + +CRUD operations for training types with abilities mapping preparation. +""" +import logging +from typing import Optional +from fastapi import APIRouter, HTTPException, Depends +from pydantic import BaseModel + +from db import get_db, get_cursor, r2d +from auth import require_auth, require_admin + +router = APIRouter(prefix="/api/admin/training-types", tags=["admin", "training-types"]) +logger = logging.getLogger(__name__) + + +class TrainingTypeCreate(BaseModel): + category: str + subcategory: Optional[str] = None + name_de: str + name_en: str + icon: Optional[str] = None + description_de: Optional[str] = None + description_en: Optional[str] = None + sort_order: int = 0 + abilities: Optional[dict] = None + + +class TrainingTypeUpdate(BaseModel): + category: Optional[str] = None + subcategory: Optional[str] = None + name_de: Optional[str] = None + name_en: Optional[str] = None + icon: Optional[str] = None + description_de: Optional[str] = None + description_en: Optional[str] = None + sort_order: Optional[int] = None + abilities: Optional[dict] = None + + +@router.get("") +def list_training_types_admin(session: dict = Depends(require_admin)): + """ + Get all training types for admin management. + Returns full details including abilities. + """ + with get_db() as conn: + cur = get_cursor(conn) + cur.execute(""" + SELECT id, category, subcategory, name_de, name_en, icon, + description_de, description_en, sort_order, abilities, + created_at + FROM training_types + ORDER BY sort_order, category, subcategory + """) + rows = cur.fetchall() + + return [r2d(r) for r in rows] + + +@router.get("/{type_id}") +def get_training_type(type_id: int, session: dict = Depends(require_admin)): + """Get single training type by ID.""" + with get_db() as conn: + cur = get_cursor(conn) + cur.execute(""" + SELECT id, category, subcategory, name_de, name_en, icon, + description_de, description_en, sort_order, abilities, + created_at + FROM training_types + WHERE id = %s + """, (type_id,)) + row = cur.fetchone() + + if not row: + raise HTTPException(404, "Training type not found") + + return r2d(row) + + +@router.post("") +def create_training_type(data: TrainingTypeCreate, session: dict = Depends(require_admin)): + """Create new training type.""" + with get_db() as conn: + cur = get_cursor(conn) + + # Convert abilities dict to JSONB + abilities_json = data.abilities if data.abilities else {} + + cur.execute(""" + INSERT INTO training_types + (category, subcategory, name_de, name_en, icon, + description_de, description_en, sort_order, abilities) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) + RETURNING id + """, ( + data.category, + data.subcategory, + data.name_de, + data.name_en, + data.icon, + data.description_de, + data.description_en, + data.sort_order, + abilities_json + )) + + new_id = cur.fetchone()['id'] + + logger.info(f"[ADMIN] Training type created: {new_id} - {data.name_de} ({data.category}/{data.subcategory})") + + return {"id": new_id, "message": "Training type created"} + + +@router.put("/{type_id}") +def update_training_type( + type_id: int, + data: TrainingTypeUpdate, + session: dict = Depends(require_admin) +): + """Update existing training type.""" + with get_db() as conn: + cur = get_cursor(conn) + + # Build update query dynamically + updates = [] + values = [] + + if data.category is not None: + updates.append("category = %s") + values.append(data.category) + if data.subcategory is not None: + updates.append("subcategory = %s") + values.append(data.subcategory) + if data.name_de is not None: + updates.append("name_de = %s") + values.append(data.name_de) + if data.name_en is not None: + updates.append("name_en = %s") + values.append(data.name_en) + if data.icon is not None: + updates.append("icon = %s") + values.append(data.icon) + if data.description_de is not None: + updates.append("description_de = %s") + values.append(data.description_de) + if data.description_en is not None: + updates.append("description_en = %s") + values.append(data.description_en) + if data.sort_order is not None: + updates.append("sort_order = %s") + values.append(data.sort_order) + if data.abilities is not None: + updates.append("abilities = %s") + values.append(data.abilities) + + if not updates: + raise HTTPException(400, "No fields to update") + + values.append(type_id) + + cur.execute(f""" + UPDATE training_types + SET {', '.join(updates)} + WHERE id = %s + """, values) + + if cur.rowcount == 0: + raise HTTPException(404, "Training type not found") + + logger.info(f"[ADMIN] Training type updated: {type_id}") + + return {"id": type_id, "message": "Training type updated"} + + +@router.delete("/{type_id}") +def delete_training_type(type_id: int, session: dict = Depends(require_admin)): + """ + Delete training type. + + WARNING: This will fail if any activities reference this type. + Consider adding a soft-delete or archive mechanism if needed. + """ + with get_db() as conn: + cur = get_cursor(conn) + + # Check if any activities use this type + cur.execute(""" + SELECT COUNT(*) as count + FROM activity_log + WHERE training_type_id = %s + """, (type_id,)) + + count = cur.fetchone()['count'] + if count > 0: + raise HTTPException( + 400, + f"Cannot delete: {count} activities are using this training type. " + "Please reassign or delete those activities first." + ) + + cur.execute("DELETE FROM training_types WHERE id = %s", (type_id,)) + + if cur.rowcount == 0: + raise HTTPException(404, "Training type not found") + + logger.info(f"[ADMIN] Training type deleted: {type_id}") + + return {"message": "Training type deleted"} + + +@router.get("/taxonomy/abilities") +def get_abilities_taxonomy(session: dict = Depends(require_auth)): + """ + Get abilities taxonomy for UI and AI analysis. + + This defines the 5 dimensions of athletic development. + """ + taxonomy = { + "koordinativ": { + "name_de": "Koordinative Fähigkeiten", + "name_en": "Coordination Abilities", + "icon": "🎯", + "abilities": [ + {"key": "orientierung", "name_de": "Orientierung", "name_en": "Orientation"}, + {"key": "differenzierung", "name_de": "Differenzierung", "name_en": "Differentiation"}, + {"key": "kopplung", "name_de": "Kopplung", "name_en": "Coupling"}, + {"key": "gleichgewicht", "name_de": "Gleichgewicht", "name_en": "Balance"}, + {"key": "rhythmus", "name_de": "Rhythmisierung", "name_en": "Rhythm"}, + {"key": "reaktion", "name_de": "Reaktion", "name_en": "Reaction"}, + {"key": "umstellung", "name_de": "Umstellung", "name_en": "Adaptation"} + ] + }, + "konditionell": { + "name_de": "Konditionelle Fähigkeiten", + "name_en": "Conditional Abilities", + "icon": "💪", + "abilities": [ + {"key": "kraft", "name_de": "Kraft", "name_en": "Strength"}, + {"key": "ausdauer", "name_de": "Ausdauer", "name_en": "Endurance"}, + {"key": "schnelligkeit", "name_de": "Schnelligkeit", "name_en": "Speed"}, + {"key": "flexibilitaet", "name_de": "Flexibilität", "name_en": "Flexibility"} + ] + }, + "kognitiv": { + "name_de": "Kognitive Fähigkeiten", + "name_en": "Cognitive Abilities", + "icon": "🧠", + "abilities": [ + {"key": "konzentration", "name_de": "Konzentration", "name_en": "Concentration"}, + {"key": "aufmerksamkeit", "name_de": "Aufmerksamkeit", "name_en": "Attention"}, + {"key": "wahrnehmung", "name_de": "Wahrnehmung", "name_en": "Perception"}, + {"key": "entscheidung", "name_de": "Entscheidungsfindung", "name_en": "Decision Making"} + ] + }, + "psychisch": { + "name_de": "Psychische Fähigkeiten", + "name_en": "Psychological Abilities", + "icon": "🎭", + "abilities": [ + {"key": "motivation", "name_de": "Motivation", "name_en": "Motivation"}, + {"key": "willenskraft", "name_de": "Willenskraft", "name_en": "Willpower"}, + {"key": "stressresistenz", "name_de": "Stressresistenz", "name_en": "Stress Resistance"}, + {"key": "selbstvertrauen", "name_de": "Selbstvertrauen", "name_en": "Self-Confidence"} + ] + }, + "taktisch": { + "name_de": "Taktische Fähigkeiten", + "name_en": "Tactical Abilities", + "icon": "♟️", + "abilities": [ + {"key": "timing", "name_de": "Timing", "name_en": "Timing"}, + {"key": "strategie", "name_de": "Strategie", "name_en": "Strategy"}, + {"key": "antizipation", "name_de": "Antizipation", "name_en": "Anticipation"}, + {"key": "situationsanalyse", "name_de": "Situationsanalyse", "name_en": "Situation Analysis"} + ] + } + } + + return taxonomy diff --git a/backend/routers/training_types.py b/backend/routers/training_types.py new file mode 100644 index 0000000..e8c6fe7 --- /dev/null +++ b/backend/routers/training_types.py @@ -0,0 +1,129 @@ +""" +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' + }, + 'mind': { + 'name_de': 'Geist & Meditation', + 'name_en': 'Mind & Meditation', + 'icon': '🧘♂️', + 'color': '#A78BFA' + }, + '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..d65ebac 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' @@ -27,6 +27,8 @@ import AdminFeaturesPage from './pages/AdminFeaturesPage' import AdminTiersPage from './pages/AdminTiersPage' import AdminCouponsPage from './pages/AdminCouponsPage' import AdminUserRestrictionsPage from './pages/AdminUserRestrictionsPage' +import AdminTrainingTypesPage from './pages/AdminTrainingTypesPage' +import AdminActivityMappingsPage from './pages/AdminActivityMappingsPage' import SubscriptionPage from './pages/SubscriptionPage' import './app.css' @@ -50,10 +52,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 +128,32 @@ function AppShell() {