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() {
Mitai Jinkendo - - {activeProfile - ? - :
- } - +
+ + + {activeProfile + ? + :
+ } + +
@@ -145,6 +174,8 @@ function AppShell() { }/> }/> }/> + }/> + }/> }/>
diff --git a/frontend/src/components/BulkCategorize.jsx b/frontend/src/components/BulkCategorize.jsx new file mode 100644 index 0000000..f4fa36d --- /dev/null +++ b/frontend/src/components/BulkCategorize.jsx @@ -0,0 +1,191 @@ +import { useState, useEffect } from 'react' +import { api } from '../utils/api' +import TrainingTypeSelect from './TrainingTypeSelect' + +/** + * BulkCategorize - UI for categorizing existing activities without training type + * + * Shows uncategorized activities grouped by activity_type, + * allows bulk assignment of training type to all activities of same type. + */ +export default function BulkCategorize({ onComplete }) { + const [uncategorized, setUncategorized] = useState([]) + const [loading, setLoading] = useState(true) + const [assignments, setAssignments] = useState({}) + const [saving, setSaving] = useState(null) + + useEffect(() => { + loadUncategorized() + }, []) + + const loadUncategorized = () => { + setLoading(true) + api.listUncategorizedActivities() + .then(data => { + setUncategorized(data) + setLoading(false) + }) + .catch(err => { + console.error('Failed to load uncategorized activities:', err) + setLoading(false) + }) + } + + const handleAssignment = (activityType, typeId, category, subcategory) => { + setAssignments(prev => ({ + ...prev, + [activityType]: { + training_type_id: typeId, + training_category: category, + training_subcategory: subcategory + } + })) + } + + const handleSave = async (activityType) => { + const assignment = assignments[activityType] + if (!assignment || !assignment.training_type_id) { + alert('Bitte wähle einen Trainingstyp aus') + return + } + + setSaving(activityType) + try { + const result = await api.bulkCategorizeActivities({ + activity_type: activityType, + ...assignment + }) + + // Remove from list + setUncategorized(prev => prev.filter(u => u.activity_type !== activityType)) + setAssignments(prev => { + const newAssignments = { ...prev } + delete newAssignments[activityType] + return newAssignments + }) + + // Show success message + console.log(`✓ ${result.updated} activities categorized`) + + } catch (err) { + console.error('Failed to categorize:', err) + alert('Kategorisierung fehlgeschlagen: ' + err.message) + } finally { + setSaving(null) + } + } + + if (loading) { + return ( +
+
+
+ ) + } + + if (uncategorized.length === 0) { + return ( +
+
+
Alle Aktivitäten sind kategorisiert
+ {onComplete && ( + + )} +
+ ) + } + + return ( +
+
+ + {uncategorized.reduce((sum, u) => sum + u.count, 0)} Aktivitäten + ohne Trainingstyp gefunden. Weise jedem Aktivitätstyp einen Trainingstyp zu. +
+ +
+ {uncategorized.map(item => ( +
+
+
+
+ {item.activity_type} +
+
+ {item.count} Einheiten + {item.first_date && item.last_date && ( + <> · {item.first_date} bis {item.last_date} + )} +
+
+
+ + + handleAssignment(item.activity_type, typeId, category, subcategory) + } + required={false} + /> + + +
+ ))} +
+ + {onComplete && ( + + )} +
+ ) +} 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/components/TrialBanner.jsx b/frontend/src/components/TrialBanner.jsx index 6208700..e2c787b 100644 --- a/frontend/src/components/TrialBanner.jsx +++ b/frontend/src/components/TrialBanner.jsx @@ -64,8 +64,8 @@ export default function TrialBanner({ profile }) {
- - {isUrgent ? 'Jetzt upgraden' : 'Abo wählen'} - + {isUrgent ? 'Kontakt aufnehmen' : 'Abo anfragen'} +
) } diff --git a/frontend/src/pages/ActivityPage.jsx b/frontend/src/pages/ActivityPage.jsx index 3257180..dad77b4 100644 --- a/frontend/src/pages/ActivityPage.jsx +++ b/frontend/src/pages/ActivityPage.jsx @@ -3,6 +3,8 @@ import { Upload, Pencil, Trash2, Check, X, CheckCircle } from 'lucide-react' import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } from 'recharts' import { api } from '../utils/api' import UsageBadge from '../components/UsageBadge' +import TrainingTypeSelect from '../components/TrainingTypeSelect' +import BulkCategorize from '../components/BulkCategorize' import dayjs from 'dayjs' import 'dayjs/locale/de' dayjs.locale('de') @@ -18,7 +20,10 @@ function empty() { date: dayjs().format('YYYY-MM-DD'), activity_type: 'Traditionelles Krafttraining', duration_min: '', kcal_active: '', - hr_avg: '', hr_max: '', rpe: '', notes: '' + hr_avg: '', hr_max: '', rpe: '', notes: '', + training_type_id: null, + training_category: null, + training_subcategory: null } } @@ -89,11 +94,19 @@ function EntryForm({ form, setForm, onSave, onCancel, saveLabel='Speichern', sav set('date',e.target.value)}/>
-
- - +
+ { + setForm(f => ({ + ...f, + training_type_id: typeId, + training_category: category, + training_subcategory: subcategory + })) + }} + required={false} + />
@@ -167,6 +180,7 @@ export default function ActivityPage() { const [saved, setSaved] = useState(false) const [error, setError] = useState(null) const [activityUsage, setActivityUsage] = useState(null) // Phase 4: Usage badge + const [categories, setCategories] = useState({}) // v9d: Training categories const load = async () => { const [e, s] = await Promise.all([api.listActivity(), api.activityStats()]) @@ -183,6 +197,7 @@ export default function ActivityPage() { useEffect(()=>{ load() loadUsage() + api.getTrainingCategories().then(setCategories).catch(err => console.error('Failed to load categories:', err)) },[]) const handleSave = async () => { @@ -246,6 +261,7 @@ export default function ActivityPage() { +
@@ -267,6 +283,13 @@ export default function ActivityPage() { {tab==='import' && } + {tab==='categorize' && ( +
+
🏷️ Aktivitäten kategorisieren
+ { load(); setTab('list'); }} /> +
+ )} + {tab==='add' && (
@@ -330,7 +353,26 @@ export default function ActivityPage() {
-
{e.activity_type}
+
+
{e.activity_type}
+ {e.training_category && categories[e.training_category] && ( +
+ {categories[e.training_category].icon} + {categories[e.training_category].name_de} +
+ )} +
{dayjs(e.date).format('dd, DD. MMMM YYYY')} {e.start_time && e.start_time.length>10 && ` · ${e.start_time.slice(11,16)}`} diff --git a/frontend/src/pages/AdminActivityMappingsPage.jsx b/frontend/src/pages/AdminActivityMappingsPage.jsx new file mode 100644 index 0000000..ebf8414 --- /dev/null +++ b/frontend/src/pages/AdminActivityMappingsPage.jsx @@ -0,0 +1,446 @@ +import { useState, useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import { Pencil, Trash2, Plus, Save, X, ArrowLeft, TrendingUp } from 'lucide-react' +import { api } from '../utils/api' + +/** + * AdminActivityMappingsPage - Manage activity_type → training_type mappings + * v9d Phase 1b - Learnable system (replaces hardcoded mappings) + */ +export default function AdminActivityMappingsPage() { + const nav = useNavigate() + const [mappings, setMappings] = useState([]) + const [trainingTypes, setTrainingTypes] = useState([]) + const [coverage, setCoverage] = useState(null) + const [loading, setLoading] = useState(true) + const [editingId, setEditingId] = useState(null) + const [formData, setFormData] = useState(null) + const [error, setError] = useState(null) + const [saving, setSaving] = useState(false) + const [filter, setFilter] = useState('all') // 'all', 'global', 'user' + + useEffect(() => { + load() + }, [filter]) + + const load = () => { + setLoading(true) + Promise.all([ + api.adminListActivityMappings(null, filter === 'global'), + api.listTrainingTypesFlat(), + api.adminGetMappingCoverage() + ]).then(([mappingsData, typesData, coverageData]) => { + setMappings(mappingsData) + setTrainingTypes(typesData) + setCoverage(coverageData) + setLoading(false) + }).catch(err => { + console.error('Failed to load mappings:', err) + setError(err.message) + setLoading(false) + }) + } + + const startCreate = () => { + setFormData({ + activity_type: '', + training_type_id: trainingTypes[0]?.id || null, + profile_id: '', + source: 'admin' + }) + setEditingId('new') + } + + const startEdit = (mapping) => { + setFormData({ + activity_type: mapping.activity_type, + training_type_id: mapping.training_type_id, + profile_id: mapping.profile_id || '', + source: mapping.source + }) + setEditingId(mapping.id) + } + + const cancelEdit = () => { + setEditingId(null) + setFormData(null) + setError(null) + } + + const handleSave = async () => { + if (!formData.activity_type || !formData.training_type_id) { + setError('Activity Type und Training Type sind Pflichtfelder') + return + } + + setSaving(true) + setError(null) + + try { + const payload = { + ...formData, + profile_id: formData.profile_id || null + } + + if (editingId === 'new') { + await api.adminCreateActivityMapping(payload) + } else { + await api.adminUpdateActivityMapping(editingId, { + training_type_id: payload.training_type_id, + profile_id: payload.profile_id, + source: payload.source + }) + } + await load() + cancelEdit() + } catch (err) { + console.error('Save failed:', err) + setError(err.message) + } finally { + setSaving(false) + } + } + + const handleDelete = async (id, activityType) => { + if (!confirm(`Mapping für "${activityType}" wirklich löschen?\n\nZukünftige Imports werden diesen Typ nicht mehr automatisch zuordnen.`)) { + return + } + + try { + await api.adminDeleteActivityMapping(id) + await load() + } catch (err) { + alert('Löschen fehlgeschlagen: ' + err.message) + } + } + + if (loading) { + return ( +
+
+
+ ) + } + + const coveragePercent = coverage ? Math.round((coverage.mapped_activities / coverage.total_activities) * 100) : 0 + + return ( +
+
+ +

Activity-Mappings

+
+ + {error && ( +
+ {error} +
+ )} + + {/* Coverage Stats */} + {coverage && ( +
+
+ +
Mapping-Abdeckung
+
+
+
+
{coveragePercent}%
+
Zugeordnet
+
+
+
{coverage.mapped_activities}
+
Mit Typ
+
+
+
{coverage.unmapped_activities}
+
Ohne Typ
+
+
+
+ {coverage.unmapped_types} verschiedene Activity-Types noch nicht gemappt +
+
+ )} + + {/* Filter */} +
+ + +
+ + {/* Create new button */} + + + {/* New mapping form (only shown when creating) */} + {editingId === 'new' && formData && ( +
+
➕ Neues Mapping
+ +
+
+
Activity Type * (exakt wie in CSV)
+ setFormData({ ...formData, activity_type: e.target.value })} + placeholder="z.B. Traditionelles Krafttraining" + style={{ width: '100%' }} + autoFocus + /> +
+ Groß-/Kleinschreibung beachten! Muss exakt mit CSV übereinstimmen. +
+
+ +
+
Training Type *
+ +
+ +
+
Profil-ID (leer = global)
+ setFormData({ ...formData, profile_id: e.target.value })} + placeholder="Leer lassen für globales Mapping" + style={{ width: '100%' }} + /> +
+ Global = für alle User, sonst user-spezifisch +
+
+ +
+ + +
+
+
+ )} + + {/* List with inline editing */} + {mappings.length === 0 ? ( +
+ Keine Mappings gefunden +
+ ) : ( +
+ {mappings.map(mapping => { + const isEditing = editingId === mapping.id + + return ( +
+ {isEditing && formData ? ( + /* Inline edit form */ +
+
+ ✏️ Mapping bearbeiten +
+ +
+
+
Activity Type (nicht änderbar)
+ +
+ +
+
Training Type *
+ +
+ +
+
Profil-ID (leer = global)
+ setFormData({ ...formData, profile_id: e.target.value })} + placeholder="Leer lassen für globales Mapping" + style={{ width: '100%' }} + /> +
+ +
+ + +
+
+
+ ) : ( + /* Normal view */ +
+
{mapping.icon}
+
+
+ {mapping.activity_type} +
+
+ → {mapping.training_type_name_de} + {mapping.profile_id && <> · User-spezifisch} + {!mapping.profile_id && <> · Global} + {mapping.source && <> · {mapping.source}} +
+
+ + +
+ )} +
+ ) + })} +
+ )} + +
+ 💡 Tipp: Das System lernt automatisch! Wenn du im Tab "Kategorisieren" Aktivitäten zuordnest, wird das Mapping gespeichert und beim nächsten Import automatisch angewendet. +
+
+ ) +} diff --git a/frontend/src/pages/AdminPanel.jsx b/frontend/src/pages/AdminPanel.jsx index 8abf7ca..94b9822 100644 --- a/frontend/src/pages/AdminPanel.jsx +++ b/frontend/src/pages/AdminPanel.jsx @@ -424,6 +424,28 @@ export default function AdminPanel() {
+ + {/* v9d Training Types Management */} +
+
+ Trainingstypen (v9d) +
+
+ Verwalte Trainingstypen, Kategorien und Activity-Mappings (lernendes System). +
+
+ + + + + + +
+
) } diff --git a/frontend/src/pages/AdminTrainingTypesPage.jsx b/frontend/src/pages/AdminTrainingTypesPage.jsx new file mode 100644 index 0000000..f693b37 --- /dev/null +++ b/frontend/src/pages/AdminTrainingTypesPage.jsx @@ -0,0 +1,390 @@ +import { useState, useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import { Pencil, Trash2, Plus, Save, X, ArrowLeft } from 'lucide-react' +import { api } from '../utils/api' + +/** + * AdminTrainingTypesPage - CRUD for training types + * v9d Phase 1b - Basic CRUD without abilities mapping + */ +export default function AdminTrainingTypesPage() { + const nav = useNavigate() + const [types, setTypes] = useState([]) + const [categories, setCategories] = useState({}) + const [loading, setLoading] = useState(true) + const [editingId, setEditingId] = useState(null) + const [formData, setFormData] = useState(null) + const [error, setError] = useState(null) + const [saving, setSaving] = useState(false) + + useEffect(() => { + load() + }, []) + + const load = () => { + setLoading(true) + Promise.all([ + api.adminListTrainingTypes(), + api.getTrainingCategories() + ]).then(([typesData, catsData]) => { + setTypes(typesData) + setCategories(catsData) + setLoading(false) + }).catch(err => { + console.error('Failed to load training types:', err) + setError(err.message) + setLoading(false) + }) + } + + const startCreate = () => { + setFormData({ + category: 'cardio', + subcategory: '', + name_de: '', + name_en: '', + icon: '', + description_de: '', + description_en: '', + sort_order: 0 + }) + setEditingId('new') + } + + const startEdit = (type) => { + setFormData({ + category: type.category, + subcategory: type.subcategory || '', + name_de: type.name_de, + name_en: type.name_en, + icon: type.icon || '', + description_de: type.description_de || '', + description_en: type.description_en || '', + sort_order: type.sort_order + }) + setEditingId(type.id) + } + + const cancelEdit = () => { + setEditingId(null) + setFormData(null) + setError(null) + } + + const handleSave = async () => { + if (!formData.name_de || !formData.name_en) { + setError('Name (DE) und Name (EN) sind Pflichtfelder') + return + } + + setSaving(true) + setError(null) + + try { + if (editingId === 'new') { + await api.adminCreateTrainingType(formData) + } else { + await api.adminUpdateTrainingType(editingId, formData) + } + await load() + cancelEdit() + } catch (err) { + console.error('Save failed:', err) + setError(err.message) + } finally { + setSaving(false) + } + } + + const handleDelete = async (id, name) => { + if (!confirm(`Trainingstyp "${name}" wirklich löschen?\n\nHinweis: Löschen ist nur möglich wenn keine Aktivitäten diesen Typ verwenden.`)) { + return + } + + try { + await api.adminDeleteTrainingType(id) + await load() + } catch (err) { + alert('Löschen fehlgeschlagen: ' + err.message) + } + } + + // Group by category + const grouped = {} + types.forEach(type => { + if (!grouped[type.category]) { + grouped[type.category] = [] + } + grouped[type.category].push(type) + }) + + if (loading) { + return ( +
+
+
+ ) + } + + return ( +
+
+ +

Trainingstypen verwalten

+
+ + {error && ( +
+ {error} +
+ )} + + {/* Create new button */} + {!editingId && ( + + )} + + {/* Edit form */} + {editingId && formData && ( +
+
+ {editingId === 'new' ? '➕ Neuer Trainingstyp' : '✏️ Trainingstyp bearbeiten'} +
+ +
+
+
Kategorie *
+ +
+ +
+
Subkategorie
+ setFormData({ ...formData, subcategory: e.target.value })} + placeholder="z.B. running, hypertrophy, meditation" + style={{ width: '100%' }} + /> +
+ Kleingeschrieben, ohne Leerzeichen, eindeutig +
+
+ +
+
Name (Deutsch) *
+ setFormData({ ...formData, name_de: e.target.value })} + placeholder="z.B. Laufen" + style={{ width: '100%' }} + /> +
+ +
+
Name (English) *
+ setFormData({ ...formData, name_en: e.target.value })} + placeholder="e.g. Running" + style={{ width: '100%' }} + /> +
+ +
+
Icon (Emoji)
+ setFormData({ ...formData, icon: e.target.value })} + placeholder="🏃" + maxLength={10} + style={{ width: '100%' }} + /> +
+ +
+
Sortierung
+ setFormData({ ...formData, sort_order: parseInt(e.target.value) })} + style={{ width: '100%' }} + /> +
+ Niedrigere Zahlen werden zuerst angezeigt +
+
+ +
+
Beschreibung (Deutsch)
+