From 2f64656d4da193d68e51b5283b0a35caa18ddf68 Mon Sep 17 00:00:00 2001 From: Lars Date: Fri, 27 Mar 2026 19:44:18 +0100 Subject: [PATCH 01/11] feat: Migration 031 - Focus Area System v2.0 (dynamic, extensible) --- .../migrations/031_focus_area_system_v2.sql | 254 ++++++++++++++++++ 1 file changed, 254 insertions(+) create mode 100644 backend/migrations/031_focus_area_system_v2.sql diff --git a/backend/migrations/031_focus_area_system_v2.sql b/backend/migrations/031_focus_area_system_v2.sql new file mode 100644 index 0000000..d897459 --- /dev/null +++ b/backend/migrations/031_focus_area_system_v2.sql @@ -0,0 +1,254 @@ +-- Migration 031: Focus Area System v2.0 +-- Date: 2026-03-27 +-- Purpose: Dynamic, extensible focus areas with Many-to-Many goal contributions + +-- ============================================================================ +-- Part 1: New Tables +-- ============================================================================ + +-- Focus Area Definitions (dynamic, user-extensible) +CREATE TABLE IF NOT EXISTS focus_area_definitions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + key VARCHAR(50) UNIQUE NOT NULL, -- e.g. 'strength', 'aerobic_endurance' + name_de VARCHAR(100) NOT NULL, + name_en VARCHAR(100), + icon VARCHAR(10), + description TEXT, + category VARCHAR(50), -- 'body_composition', 'training', 'endurance', 'coordination', 'mental', 'recovery', 'health' + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX idx_focus_area_key ON focus_area_definitions(key); +CREATE INDEX idx_focus_area_category ON focus_area_definitions(category); + +COMMENT ON TABLE focus_area_definitions IS 'Dynamic focus area registry - defines all available focus dimensions'; +COMMENT ON COLUMN focus_area_definitions.key IS 'Unique identifier for programmatic access'; +COMMENT ON COLUMN focus_area_definitions.category IS 'Grouping for UI display'; + +-- Many-to-Many: Goals contribute to Focus Areas +CREATE TABLE IF NOT EXISTS goal_focus_contributions ( + goal_id UUID NOT NULL REFERENCES goals(id) ON DELETE CASCADE, + focus_area_id UUID NOT NULL REFERENCES focus_area_definitions(id) ON DELETE CASCADE, + contribution_weight DECIMAL(5,2) DEFAULT 100.00 CHECK (contribution_weight >= 0 AND contribution_weight <= 100), + created_at TIMESTAMP DEFAULT NOW(), + PRIMARY KEY (goal_id, focus_area_id) +); + +CREATE INDEX idx_gfc_goal ON goal_focus_contributions(goal_id); +CREATE INDEX idx_gfc_focus_area ON goal_focus_contributions(focus_area_id); + +COMMENT ON TABLE goal_focus_contributions IS 'Maps goals to focus areas with contribution weights (0-100%)'; +COMMENT ON COLUMN goal_focus_contributions.contribution_weight IS 'How much this goal contributes to the focus area (0-100%)'; + +-- ============================================================================ +-- Part 2: Rename existing focus_areas table +-- ============================================================================ + +-- Old focus_areas table becomes user_focus_preferences +ALTER TABLE focus_areas RENAME TO user_focus_preferences; + +-- Add reference to new focus_area_definitions (for future use) +ALTER TABLE user_focus_preferences ADD COLUMN IF NOT EXISTS notes TEXT; + +COMMENT ON TABLE user_focus_preferences IS 'User-specific focus area weightings (legacy flat structure + new references)'; + +-- ============================================================================ +-- Part 3: Seed Data - Basis Focus Areas +-- ============================================================================ + +INSERT INTO focus_area_definitions (key, name_de, name_en, icon, category, description) VALUES +-- Body Composition +('weight_loss', 'Gewichtsverlust', 'Weight Loss', '📉', 'body_composition', 'Körpergewicht reduzieren'), +('muscle_gain', 'Muskelaufbau', 'Muscle Gain', '💪', 'body_composition', 'Muskelmasse aufbauen'), +('body_recomposition', 'Body Recomposition', 'Body Recomposition', '⚖️', 'body_composition', 'Gleichzeitig Fett abbauen und Muskeln aufbauen'), + +-- Training - Kraft +('strength', 'Maximalkraft', 'Strength', '🏋️', 'training', 'Maximale Kraftfähigkeit'), +('strength_endurance', 'Kraftausdauer', 'Strength Endurance', '💪🏃', 'training', 'Kraft über längere Zeit aufrechterhalten'), +('power', 'Schnellkraft', 'Power', '⚡', 'training', 'Kraft in kurzer Zeit entfalten'), + +-- Training - Beweglichkeit +('flexibility', 'Beweglichkeit', 'Flexibility', '🤸', 'training', 'Gelenkigkeit und Bewegungsumfang'), +('mobility', 'Mobilität', 'Mobility', '🦴', 'training', 'Aktive Beweglichkeit und Kontrolle'), + +-- Ausdauer +('aerobic_endurance', 'Aerobe Ausdauer', 'Aerobic Endurance', '🫁', 'endurance', 'VO2Max, lange moderate Belastung'), +('anaerobic_endurance', 'Anaerobe Ausdauer', 'Anaerobic Endurance', '⚡', 'endurance', 'Laktattoleranz, kurze intensive Belastung'), +('cardiovascular_health', 'Herz-Kreislauf', 'Cardiovascular Health', '❤️', 'endurance', 'Herzgesundheit und Ausdauer'), + +-- Koordination +('balance', 'Gleichgewicht', 'Balance', '⚖️', 'coordination', 'Statisches und dynamisches Gleichgewicht'), +('reaction', 'Reaktionsfähigkeit', 'Reaction', '⚡', 'coordination', 'Schnelligkeit der Reaktion auf Reize'), +('rhythm', 'Rhythmusgefühl', 'Rhythm', '🎵', 'coordination', 'Zeitliche Abstimmung von Bewegungen'), +('coordination', 'Koordination', 'Coordination', '🎯', 'coordination', 'Zusammenspiel verschiedener Bewegungen'), + +-- Mental +('stress_resistance', 'Stressresistenz', 'Stress Resistance', '🧘', 'mental', 'Umgang mit mentalem und physischem Stress'), +('concentration', 'Konzentration', 'Concentration', '🎯', 'mental', 'Fokussierung und Aufmerksamkeit'), +('willpower', 'Willenskraft', 'Willpower', '💎', 'mental', 'Durchhaltevermögen und Selbstdisziplin'), +('mental_health', 'Mentale Gesundheit', 'Mental Health', '🧠', 'mental', 'Psychisches Wohlbefinden'), + +-- Recovery +('sleep_quality', 'Schlafqualität', 'Sleep Quality', '😴', 'recovery', 'Erholsamer Schlaf'), +('regeneration', 'Regeneration', 'Regeneration', '♻️', 'recovery', 'Körperliche Erholung'), +('rest', 'Ruhe', 'Rest', '🛌', 'recovery', 'Aktive und passive Erholung'), + +-- Health +('metabolic_health', 'Stoffwechselgesundheit', 'Metabolic Health', '🔥', 'health', 'Blutzucker, Insulin, Stoffwechsel'), +('blood_pressure', 'Blutdruck', 'Blood Pressure', '❤️‍🩹', 'health', 'Gesunder Blutdruck'), +('hrv', 'Herzratenvariabilität', 'HRV', '💓', 'health', 'Autonomes Nervensystem'), +('general_health', 'Allgemeine Gesundheit', 'General Health', '🏥', 'health', 'Vitale Gesundheit und Wohlbefinden') +ON CONFLICT (key) DO NOTHING; + +-- ============================================================================ +-- Part 4: Auto-Mapping - Bestehende Goals zu Focus Areas +-- ============================================================================ + +-- Helper function to get focus_area_id by key +CREATE OR REPLACE FUNCTION get_focus_area_id(area_key VARCHAR) +RETURNS UUID AS $$ +BEGIN + RETURN (SELECT id FROM focus_area_definitions WHERE key = area_key LIMIT 1); +END; +$$ LANGUAGE plpgsql; + +-- Weight goals → weight_loss (100%) +INSERT INTO goal_focus_contributions (goal_id, focus_area_id, contribution_weight) +SELECT g.id, get_focus_area_id('weight_loss'), 100.00 +FROM goals g +WHERE g.goal_type = 'weight' +ON CONFLICT (goal_id, focus_area_id) DO NOTHING; + +-- Body Fat goals → weight_loss (60%) + body_recomposition (40%) +INSERT INTO goal_focus_contributions (goal_id, focus_area_id, contribution_weight) +SELECT g.id, fa.id, + CASE fa.key + WHEN 'weight_loss' THEN 60.00 + WHEN 'body_recomposition' THEN 40.00 + END +FROM goals g +CROSS JOIN focus_area_definitions fa +WHERE g.goal_type = 'body_fat' + AND fa.key IN ('weight_loss', 'body_recomposition') +ON CONFLICT (goal_id, focus_area_id) DO NOTHING; + +-- Lean Mass goals → muscle_gain (70%) + body_recomposition (30%) +INSERT INTO goal_focus_contributions (goal_id, focus_area_id, contribution_weight) +SELECT g.id, fa.id, + CASE fa.key + WHEN 'muscle_gain' THEN 70.00 + WHEN 'body_recomposition' THEN 30.00 + END +FROM goals g +CROSS JOIN focus_area_definitions fa +WHERE g.goal_type = 'lean_mass' + AND fa.key IN ('muscle_gain', 'body_recomposition') +ON CONFLICT (goal_id, focus_area_id) DO NOTHING; + +-- Strength goals → strength (70%) + muscle_gain (30%) +INSERT INTO goal_focus_contributions (goal_id, focus_area_id, contribution_weight) +SELECT g.id, fa.id, + CASE fa.key + WHEN 'strength' THEN 70.00 + WHEN 'muscle_gain' THEN 30.00 + END +FROM goals g +CROSS JOIN focus_area_definitions fa +WHERE g.goal_type = 'strength' + AND fa.key IN ('strength', 'muscle_gain') +ON CONFLICT (goal_id, focus_area_id) DO NOTHING; + +-- Flexibility goals → flexibility (100%) +INSERT INTO goal_focus_contributions (goal_id, focus_area_id, contribution_weight) +SELECT g.id, get_focus_area_id('flexibility'), 100.00 +FROM goals g +WHERE g.goal_type = 'flexibility' +ON CONFLICT (goal_id, focus_area_id) DO NOTHING; + +-- VO2Max goals → aerobic_endurance (80%) + cardiovascular_health (20%) +INSERT INTO goal_focus_contributions (goal_id, focus_area_id, contribution_weight) +SELECT g.id, fa.id, + CASE fa.key + WHEN 'aerobic_endurance' THEN 80.00 + WHEN 'cardiovascular_health' THEN 20.00 + END +FROM goals g +CROSS JOIN focus_area_definitions fa +WHERE g.goal_type = 'vo2max' + AND fa.key IN ('aerobic_endurance', 'cardiovascular_health') +ON CONFLICT (goal_id, focus_area_id) DO NOTHING; + +-- Resting Heart Rate goals → cardiovascular_health (100%) +INSERT INTO goal_focus_contributions (goal_id, focus_area_id, contribution_weight) +SELECT g.id, get_focus_area_id('cardiovascular_health'), 100.00 +FROM goals g +WHERE g.goal_type = 'rhr' +ON CONFLICT (goal_id, focus_area_id) DO NOTHING; + +-- Blood Pressure goals → blood_pressure (80%) + cardiovascular_health (20%) +INSERT INTO goal_focus_contributions (goal_id, focus_area_id, contribution_weight) +SELECT g.id, fa.id, + CASE fa.key + WHEN 'blood_pressure' THEN 80.00 + WHEN 'cardiovascular_health' THEN 20.00 + END +FROM goals g +CROSS JOIN focus_area_definitions fa +WHERE g.goal_type = 'bp' + AND fa.key IN ('blood_pressure', 'cardiovascular_health') +ON CONFLICT (goal_id, focus_area_id) DO NOTHING; + +-- HRV goals → hrv (70%) + stress_resistance (30%) +INSERT INTO goal_focus_contributions (goal_id, focus_area_id, contribution_weight) +SELECT g.id, fa.id, + CASE fa.key + WHEN 'hrv' THEN 70.00 + WHEN 'stress_resistance' THEN 30.00 + END +FROM goals g +CROSS JOIN focus_area_definitions fa +WHERE g.goal_type = 'hrv' + AND fa.key IN ('hrv', 'stress_resistance') +ON CONFLICT (goal_id, focus_area_id) DO NOTHING; + +-- Sleep Quality goals → sleep_quality (100%) +INSERT INTO goal_focus_contributions (goal_id, focus_area_id, contribution_weight) +SELECT g.id, get_focus_area_id('sleep_quality'), 100.00 +FROM goals g +WHERE g.goal_type = 'sleep_quality' +ON CONFLICT (goal_id, focus_area_id) DO NOTHING; + +-- Training Frequency goals → general catch-all (strength + endurance) +INSERT INTO goal_focus_contributions (goal_id, focus_area_id, contribution_weight) +SELECT g.id, fa.id, + CASE fa.key + WHEN 'strength' THEN 40.00 + WHEN 'aerobic_endurance' THEN 40.00 + WHEN 'general_health' THEN 20.00 + END +FROM goals g +CROSS JOIN focus_area_definitions fa +WHERE g.goal_type = 'training_frequency' + AND fa.key IN ('strength', 'aerobic_endurance', 'general_health') +ON CONFLICT (goal_id, focus_area_id) DO NOTHING; + +-- Cleanup helper function +DROP FUNCTION IF EXISTS get_focus_area_id(VARCHAR); + +-- ============================================================================ +-- Summary +-- ============================================================================ + +COMMENT ON TABLE focus_area_definitions IS +'v2.0: Dynamic focus areas - replaces hardcoded 6-dimension system. +26 base areas across 7 categories. User-extensible via admin UI.'; + +COMMENT ON TABLE goal_focus_contributions IS +'Many-to-Many mapping: Goals contribute to multiple focus areas with weights. +Auto-mapped from goal_type, editable by user.'; + +COMMENT ON TABLE user_focus_preferences IS +'Legacy flat structure (weight_loss_pct, muscle_gain_pct, etc.) remains for backward compatibility. +Future: Use focus_area_definitions + dynamic preferences.'; From f312dd0dbb7de6b8c015e0505d3694492144795e Mon Sep 17 00:00:00 2001 From: Lars Date: Fri, 27 Mar 2026 19:48:05 +0100 Subject: [PATCH 02/11] feat: Backend Phase 2 - Focus Areas API + Goals integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **New Router: focus_areas.py** - GET /focus-areas/definitions (list all, grouped by category) - POST/PUT/DELETE /focus-areas/definitions (Admin CRUD) - GET /focus-areas/user-preferences (legacy + future dynamic) - PUT /focus-areas/user-preferences (auto-normalize to 100%) - GET /focus-areas/stats (progress per focus area) **Goals Router Extended:** - FocusContribution model (focus_area_id + contribution_weight) - GoalCreate/Update: focus_contributions field - create_goal: Insert contributions after goal creation - update_goal: Delete old + insert new contributions - get_goals_grouped: Load focus_contributions per goal **Main.py:** - Registered focus_areas router **Features:** - Many-to-Many mapping (goals ↔ focus areas) - Contribution weights (0-100%) - Auto-mapped by Migration 031 - User can edit via UI (next: frontend) Co-Authored-By: Claude Opus 4.6 --- backend/main.py | 3 +- backend/routers/focus_areas.py | 384 +++++++++++++++++++++++++++++++++ backend/routers/goals.py | 85 +++++++- 3 files changed, 462 insertions(+), 10 deletions(-) create mode 100644 backend/routers/focus_areas.py diff --git a/backend/main.py b/backend/main.py index 738f07e..3999f85 100644 --- a/backend/main.py +++ b/backend/main.py @@ -23,7 +23,7 @@ from routers import user_restrictions, access_grants, training_types, admin_trai from routers import admin_activity_mappings, sleep, rest_days from routers import vitals_baseline, blood_pressure # v9d Phase 2d Refactored from routers import evaluation # v9d/v9e Training Type Profiles (#15) -from routers import goals # v9e Goal System (Strategic + Tactical) +from routers import goals, focus_areas # v9e/v9g Goal System v2.0 (Dynamic Focus Areas) # ── App Configuration ───────────────────────────────────────────────────────── DATA_DIR = Path(os.getenv("DATA_DIR", "./data")) @@ -99,6 +99,7 @@ app.include_router(vitals_baseline.router) # /api/vitals/baseline/* (v9d Ph app.include_router(blood_pressure.router) # /api/blood-pressure/* (v9d Phase 2d Refactored) app.include_router(evaluation.router) # /api/evaluation/* (v9d/v9e Training Profiles #15) app.include_router(goals.router) # /api/goals/* (v9e Goal System Strategic + Tactical) +app.include_router(focus_areas.router) # /api/focus-areas/* (v9g Focus Area System v2.0 - Dynamic) # ── Health Check ────────────────────────────────────────────────────────────── @app.get("/") diff --git a/backend/routers/focus_areas.py b/backend/routers/focus_areas.py new file mode 100644 index 0000000..13fba8a --- /dev/null +++ b/backend/routers/focus_areas.py @@ -0,0 +1,384 @@ +""" +Focus Areas Router +Manages dynamic focus area definitions and user preferences +""" +from fastapi import APIRouter, HTTPException, Depends +from pydantic import BaseModel +from typing import Optional, List +from db import get_db, get_cursor, r2d +from auth import require_auth + +router = APIRouter(prefix="/focus-areas", tags=["focus-areas"]) + +# ============================================================================ +# Models +# ============================================================================ + +class FocusAreaCreate(BaseModel): + """Create new focus area definition""" + key: str + name_de: str + name_en: Optional[str] = None + icon: Optional[str] = None + description: Optional[str] = None + category: str = 'custom' + +class FocusAreaUpdate(BaseModel): + """Update focus area definition""" + name_de: Optional[str] = None + name_en: Optional[str] = None + icon: Optional[str] = None + description: Optional[str] = None + category: Optional[str] = None + is_active: Optional[bool] = None + +class UserFocusPreferences(BaseModel): + """User's focus area weightings (dynamic)""" + preferences: dict # {focus_area_id: weight_pct} + +# ============================================================================ +# Focus Area Definitions (Admin) +# ============================================================================ + +@router.get("/definitions") +def list_focus_area_definitions( + session: dict = Depends(require_auth), + include_inactive: bool = False +): + """ + List all available focus area definitions. + + Query params: + - include_inactive: Include inactive focus areas (default: false) + + Returns focus areas grouped by category. + """ + with get_db() as conn: + cur = get_cursor(conn) + + query = """ + SELECT id, key, name_de, name_en, icon, description, category, is_active, + created_at, updated_at + FROM focus_area_definitions + WHERE is_active = true OR %s + ORDER BY category, name_de + """ + + cur.execute(query, (include_inactive,)) + areas = [r2d(row) for row in cur.fetchall()] + + # Group by category + grouped = {} + for area in areas: + cat = area['category'] or 'other' + if cat not in grouped: + grouped[cat] = [] + grouped[cat].append(area) + + return { + "areas": areas, + "grouped": grouped, + "total": len(areas) + } + +@router.post("/definitions") +def create_focus_area_definition( + data: FocusAreaCreate, + session: dict = Depends(require_auth) +): + """ + Create new focus area definition (Admin only). + + Note: Requires admin role. + """ + # Admin check + if session.get('role') != 'admin': + raise HTTPException(status_code=403, detail="Admin-Rechte erforderlich") + + with get_db() as conn: + cur = get_cursor(conn) + + # Check if key already exists + cur.execute( + "SELECT id FROM focus_area_definitions WHERE key = %s", + (data.key,) + ) + if cur.fetchone(): + raise HTTPException( + status_code=400, + detail=f"Focus Area mit Key '{data.key}' existiert bereits" + ) + + # Insert + cur.execute(""" + INSERT INTO focus_area_definitions + (key, name_de, name_en, icon, description, category) + VALUES (%s, %s, %s, %s, %s, %s) + RETURNING id + """, ( + data.key, data.name_de, data.name_en, + data.icon, data.description, data.category + )) + + area_id = cur.fetchone()['id'] + + return { + "id": area_id, + "message": f"Focus Area '{data.name_de}' erstellt" + } + +@router.put("/definitions/{area_id}") +def update_focus_area_definition( + area_id: str, + data: FocusAreaUpdate, + session: dict = Depends(require_auth) +): + """Update focus area definition (Admin only)""" + # Admin check + if session.get('role') != 'admin': + raise HTTPException(status_code=403, detail="Admin-Rechte erforderlich") + + with get_db() as conn: + cur = get_cursor(conn) + + # Build dynamic UPDATE + updates = [] + values = [] + + 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 is not None: + updates.append("description = %s") + values.append(data.description) + if data.category is not None: + updates.append("category = %s") + values.append(data.category) + if data.is_active is not None: + updates.append("is_active = %s") + values.append(data.is_active) + + if not updates: + raise HTTPException(status_code=400, detail="Keine Änderungen angegeben") + + updates.append("updated_at = NOW()") + values.append(area_id) + + query = f""" + UPDATE focus_area_definitions + SET {', '.join(updates)} + WHERE id = %s + RETURNING id + """ + + cur.execute(query, values) + + if not cur.fetchone(): + raise HTTPException(status_code=404, detail="Focus Area nicht gefunden") + + return {"message": "Focus Area aktualisiert"} + +@router.delete("/definitions/{area_id}") +def delete_focus_area_definition( + area_id: str, + session: dict = Depends(require_auth) +): + """ + Delete focus area definition (Admin only). + + Cascades: Deletes all goal_focus_contributions referencing this area. + """ + # Admin check + if session.get('role') != 'admin': + raise HTTPException(status_code=403, detail="Admin-Rechte erforderlich") + + with get_db() as conn: + cur = get_cursor(conn) + + # Check if area is used + cur.execute( + "SELECT COUNT(*) as count FROM goal_focus_contributions WHERE focus_area_id = %s", + (area_id,) + ) + count = cur.fetchone()['count'] + + if count > 0: + raise HTTPException( + status_code=400, + detail=f"Focus Area wird von {count} Ziel(en) verwendet. " + "Bitte erst Zuordnungen entfernen oder auf 'inaktiv' setzen." + ) + + # Delete + cur.execute( + "DELETE FROM focus_area_definitions WHERE id = %s RETURNING id", + (area_id,) + ) + + if not cur.fetchone(): + raise HTTPException(status_code=404, detail="Focus Area nicht gefunden") + + return {"message": "Focus Area gelöscht"} + +# ============================================================================ +# User Focus Preferences +# ============================================================================ + +@router.get("/user-preferences") +def get_user_focus_preferences(session: dict = Depends(require_auth)): + """ + Get user's focus area weightings. + + Returns: + - legacy: Old flat structure (weight_loss_pct, muscle_gain_pct, etc.) + - dynamic: New dynamic preferences (focus_area_id → weight_pct) + """ + pid = session['profile_id'] + + with get_db() as conn: + cur = get_cursor(conn) + + # Get legacy preferences + cur.execute(""" + SELECT weight_loss_pct, muscle_gain_pct, strength_pct, + endurance_pct, flexibility_pct, health_pct + FROM user_focus_preferences + WHERE profile_id = %s + """, (pid,)) + + legacy = cur.fetchone() + if legacy: + legacy = r2d(legacy) + else: + # Create default if not exists + cur.execute(""" + INSERT INTO user_focus_preferences (profile_id) + VALUES (%s) + RETURNING weight_loss_pct, muscle_gain_pct, strength_pct, + endurance_pct, flexibility_pct, health_pct + """, (pid,)) + legacy = r2d(cur.fetchone()) + + # TODO: Future - dynamic preferences from new table + # For now, return legacy structure + + return { + "legacy": legacy, + "dynamic": {} # Placeholder for future + } + +@router.put("/user-preferences") +def update_user_focus_preferences( + data: dict, + session: dict = Depends(require_auth) +): + """ + Update user's focus area weightings. + + Accepts flat structure (legacy) for now. + Auto-normalizes to sum=100%. + """ + pid = session['profile_id'] + + # Extract percentages + percentages = { + 'weight_loss_pct': data.get('weight_loss_pct', 0), + 'muscle_gain_pct': data.get('muscle_gain_pct', 0), + 'strength_pct': data.get('strength_pct', 0), + 'endurance_pct': data.get('endurance_pct', 0), + 'flexibility_pct': data.get('flexibility_pct', 0), + 'health_pct': data.get('health_pct', 0) + } + + # Normalize to 100% + total = sum(percentages.values()) + if total > 0: + for key in percentages: + percentages[key] = round((percentages[key] / total) * 100) + + # Adjust largest if sum != 100 due to rounding + current_sum = sum(percentages.values()) + if current_sum != 100 and total > 0: + largest_key = max(percentages, key=percentages.get) + percentages[largest_key] += (100 - current_sum) + + with get_db() as conn: + cur = get_cursor(conn) + + # Upsert + cur.execute(""" + INSERT INTO user_focus_preferences + (profile_id, weight_loss_pct, muscle_gain_pct, strength_pct, + endurance_pct, flexibility_pct, health_pct) + VALUES (%s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (profile_id) + DO UPDATE SET + weight_loss_pct = EXCLUDED.weight_loss_pct, + muscle_gain_pct = EXCLUDED.muscle_gain_pct, + strength_pct = EXCLUDED.strength_pct, + endurance_pct = EXCLUDED.endurance_pct, + flexibility_pct = EXCLUDED.flexibility_pct, + health_pct = EXCLUDED.health_pct, + updated_at = NOW() + """, ( + pid, + percentages['weight_loss_pct'], + percentages['muscle_gain_pct'], + percentages['strength_pct'], + percentages['endurance_pct'], + percentages['flexibility_pct'], + percentages['health_pct'] + )) + + return { + "message": "Focus Areas aktualisiert", + "normalized": percentages + } + +# ============================================================================ +# Stats & Analytics +# ============================================================================ + +@router.get("/stats") +def get_focus_area_stats(session: dict = Depends(require_auth)): + """ + Get focus area statistics for current user. + + Returns: + - Progress per focus area (avg of all contributing goals) + - Goal count per focus area + - Top/bottom performing areas + """ + pid = session['profile_id'] + + with get_db() as conn: + cur = get_cursor(conn) + + cur.execute(""" + SELECT + fa.id, fa.key, fa.name_de, fa.icon, fa.category, + COUNT(DISTINCT gfc.goal_id) as goal_count, + AVG(g.progress_pct) as avg_progress, + SUM(gfc.contribution_weight) as total_contribution + FROM focus_area_definitions fa + LEFT JOIN goal_focus_contributions gfc ON fa.id = gfc.focus_area_id + LEFT JOIN goals g ON gfc.goal_id = g.id AND g.profile_id = %s + WHERE fa.is_active = true + GROUP BY fa.id + HAVING COUNT(DISTINCT gfc.goal_id) > 0 -- Only areas with goals + ORDER BY avg_progress DESC NULLS LAST + """, (pid,)) + + stats = [r2d(row) for row in cur.fetchall()] + + return { + "stats": stats, + "top_area": stats[0] if stats else None, + "bottom_area": stats[-1] if len(stats) > 1 else None + } diff --git a/backend/routers/goals.py b/backend/routers/goals.py index be2af28..32ef1a1 100644 --- a/backend/routers/goals.py +++ b/backend/routers/goals.py @@ -39,6 +39,11 @@ class FocusAreasUpdate(BaseModel): flexibility_pct: int health_pct: int +class FocusContribution(BaseModel): + """Focus area contribution (v2.0)""" + focus_area_id: str + contribution_weight: float = 100.0 # 0-100% + class GoalCreate(BaseModel): """Create or update a concrete goal""" goal_type: str # weight, body_fat, lean_mass, vo2max, strength, flexibility, bp, rhr @@ -50,6 +55,7 @@ class GoalCreate(BaseModel): priority: Optional[int] = 2 # 1=high, 2=medium, 3=low name: Optional[str] = None description: Optional[str] = None + focus_contributions: Optional[List[FocusContribution]] = [] # v2.0: Many-to-Many class GoalUpdate(BaseModel): """Update existing goal""" @@ -61,6 +67,7 @@ class GoalUpdate(BaseModel): priority: Optional[int] = None # 1=high, 2=medium, 3=low name: Optional[str] = None description: Optional[str] = None + focus_contributions: Optional[List[FocusContribution]] = None # v2.0: Many-to-Many class TrainingPhaseCreate(BaseModel): """Create training phase (manual or auto-detected)""" @@ -429,6 +436,17 @@ def create_goal(data: GoalCreate, session: dict = Depends(require_auth)): goal_id = cur.fetchone()['id'] + # v2.0: Insert focus area contributions + if data.focus_contributions: + for contrib in data.focus_contributions: + cur.execute(""" + INSERT INTO goal_focus_contributions + (goal_id, focus_area_id, contribution_weight) + VALUES (%s, %s, %s) + ON CONFLICT (goal_id, focus_area_id) DO UPDATE + SET contribution_weight = EXCLUDED.contribution_weight + """, (goal_id, contrib.focus_area_id, contrib.contribution_weight)) + return {"id": goal_id, "message": "Ziel erstellt"} @router.put("/{goal_id}") @@ -492,16 +510,33 @@ def update_goal(goal_id: str, data: GoalUpdate, session: dict = Depends(require_ updates.append("description = %s") params.append(data.description) - if not updates: + # Handle focus_contributions separately (can be updated even if no other changes) + if data.focus_contributions is not None: + # Delete existing contributions + cur.execute( + "DELETE FROM goal_focus_contributions WHERE goal_id = %s", + (goal_id,) + ) + + # Insert new contributions + for contrib in data.focus_contributions: + cur.execute(""" + INSERT INTO goal_focus_contributions + (goal_id, focus_area_id, contribution_weight) + VALUES (%s, %s, %s) + """, (goal_id, contrib.focus_area_id, contrib.contribution_weight)) + + if not updates and data.focus_contributions is None: raise HTTPException(status_code=400, detail="Keine Änderungen angegeben") - updates.append("updated_at = NOW()") - params.extend([goal_id, pid]) + if updates: + updates.append("updated_at = NOW()") + params.extend([goal_id, pid]) - cur.execute( - f"UPDATE goals SET {', '.join(updates)} WHERE id = %s AND profile_id = %s", - tuple(params) - ) + cur.execute( + f"UPDATE goals SET {', '.join(updates)} WHERE id = %s AND profile_id = %s", + tuple(params) + ) return {"message": "Ziel aktualisiert"} @@ -680,13 +715,45 @@ def get_goals_grouped(session: dict = Depends(require_auth)): goals = cur.fetchall() - # Group by category + # v2.0: Load focus_contributions for each goal + goal_ids = [g['id'] for g in goals] + focus_map = {} # goal_id → [contributions] + + if goal_ids: + placeholders = ','.join(['%s'] * len(goal_ids)) + cur.execute(f""" + SELECT + gfc.goal_id, gfc.contribution_weight, + fa.id as focus_area_id, fa.key, fa.name_de, fa.icon, fa.category + FROM goal_focus_contributions gfc + JOIN focus_area_definitions fa ON gfc.focus_area_id = fa.id + WHERE gfc.goal_id IN ({placeholders}) + ORDER BY gfc.contribution_weight DESC + """, tuple(goal_ids)) + + for row in cur.fetchall(): + gid = row['goal_id'] + if gid not in focus_map: + focus_map[gid] = [] + focus_map[gid].append({ + 'focus_area_id': row['focus_area_id'], + 'key': row['key'], + 'name_de': row['name_de'], + 'icon': row['icon'], + 'category': row['category'], + 'contribution_weight': float(row['contribution_weight']) + }) + + # Group by category and attach focus_contributions grouped = {} for goal in goals: cat = goal['category'] or 'other' if cat not in grouped: grouped[cat] = [] - grouped[cat].append(r2d(goal)) + + goal_dict = r2d(goal) + goal_dict['focus_contributions'] = focus_map.get(goal['id'], []) + grouped[cat].append(goal_dict) return grouped From d14157f7ad5af2f2cc59637d026fff21e7f2b4d0 Mon Sep 17 00:00:00 2001 From: Lars Date: Fri, 27 Mar 2026 19:51:18 +0100 Subject: [PATCH 03/11] feat: Frontend Phase 3.1 - Focus Areas Admin UI - AdminFocusAreasPage: Full CRUD for focus area definitions - Route: /admin/focus-areas - AdminPanel: Link zu Focus Areas (neben Goal Types) - api.js: 7 neue Focus Area Endpoints Features: - Category-grouped display (7 categories) - Inline editing - Active/Inactive toggle - Create form with validation - Show/Hide inactive areas Next: Goal Form Multi-Select --- frontend/src/App.jsx | 2 + frontend/src/pages/AdminFocusAreasPage.jsx | 475 +++++++++++++++++++++ frontend/src/pages/AdminPanel.jsx | 17 + frontend/src/utils/api.js | 9 + 4 files changed, 503 insertions(+) create mode 100644 frontend/src/pages/AdminFocusAreasPage.jsx diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index bab73a1..c75f6cf 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -32,6 +32,7 @@ import AdminActivityMappingsPage from './pages/AdminActivityMappingsPage' import AdminTrainingProfiles from './pages/AdminTrainingProfiles' import AdminPromptsPage from './pages/AdminPromptsPage' import AdminGoalTypesPage from './pages/AdminGoalTypesPage' +import AdminFocusAreasPage from './pages/AdminFocusAreasPage' import SubscriptionPage from './pages/SubscriptionPage' import SleepPage from './pages/SleepPage' import RestDaysPage from './pages/RestDaysPage' @@ -192,6 +193,7 @@ function AppShell() { }/> }/> }/> + }/> }/> diff --git a/frontend/src/pages/AdminFocusAreasPage.jsx b/frontend/src/pages/AdminFocusAreasPage.jsx new file mode 100644 index 0000000..0369314 --- /dev/null +++ b/frontend/src/pages/AdminFocusAreasPage.jsx @@ -0,0 +1,475 @@ +import { useState, useEffect } from 'react' +import { Plus, Pencil, Trash2, Save, X, Eye, EyeOff } from 'lucide-react' +import { api } from '../utils/api' + +const CATEGORIES = [ + { value: 'body_composition', label: 'Körperzusammensetzung' }, + { value: 'training', label: 'Training' }, + { value: 'endurance', label: 'Ausdauer' }, + { value: 'coordination', label: 'Koordination' }, + { value: 'mental', label: 'Mental' }, + { value: 'recovery', label: 'Erholung' }, + { value: 'health', label: 'Gesundheit' }, + { value: 'custom', label: 'Eigene' } +] + +export default function AdminFocusAreasPage() { + const [data, setData] = useState({ areas: [], grouped: {}, total: 0 }) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [showInactive, setShowInactive] = useState(false) + const [editingId, setEditingId] = useState(null) + const [creating, setCreating] = useState(false) + const [formData, setFormData] = useState({ + key: '', + name_de: '', + name_en: '', + icon: '', + description: '', + category: 'custom' + }) + + useEffect(() => { + loadData() + }, [showInactive]) + + const loadData = async () => { + try { + setLoading(true) + const result = await api.listFocusAreaDefinitions(showInactive) + setData(result) + setError(null) + } catch (err) { + console.error('Failed to load focus areas:', err) + setError(err.message) + } finally { + setLoading(false) + } + } + + const handleCreate = async () => { + if (!formData.key || !formData.name_de) { + setError('Key und Name (DE) sind erforderlich') + return + } + + try { + await api.createFocusAreaDefinition(formData) + setCreating(false) + setFormData({ + key: '', + name_de: '', + name_en: '', + icon: '', + description: '', + category: 'custom' + }) + await loadData() + } catch (err) { + setError(err.message) + } + } + + const handleUpdate = async (id) => { + try { + const area = data.areas.find(a => a.id === id) + await api.updateFocusAreaDefinition(id, { + name_de: area.name_de, + name_en: area.name_en, + icon: area.icon, + description: area.description, + category: area.category, + is_active: area.is_active + }) + setEditingId(null) + await loadData() + } catch (err) { + setError(err.message) + } + } + + const handleDelete = async (id) => { + if (!confirm('Focus Area wirklich löschen?')) return + + try { + await api.deleteFocusAreaDefinition(id) + await loadData() + } catch (err) { + setError(err.message) + } + } + + const handleToggleActive = async (id) => { + const area = data.areas.find(a => a.id === id) + try { + await api.updateFocusAreaDefinition(id, { + is_active: !area.is_active + }) + await loadData() + } catch (err) { + setError(err.message) + } + } + + const updateField = (id, field, value) => { + setData(prev => ({ + ...prev, + areas: prev.areas.map(a => + a.id === id ? { ...a, [field]: value } : a + ) + })) + } + + if (loading) { + return ( +
+
+
+ ) + } + + return ( +
+ {/* Header */} +
+

+ 🎯 Focus Areas ({data.total}) +

+
+ + +
+
+ + {error && ( +
+ {error} +
+ )} + + {/* Create Form */} + {creating && ( +
+

+ Neue Focus Area +

+ +
+
+ + setFormData({ ...formData, key: e.target.value })} + placeholder="explosive_power" + style={{ width: '100%' }} + /> +
+ +
+ + setFormData({ ...formData, name_de: e.target.value })} + placeholder="Explosivkraft" + style={{ width: '100%' }} + /> +
+ +
+ + setFormData({ ...formData, name_en: e.target.value })} + placeholder="Explosive Power" + style={{ width: '100%' }} + /> +
+ +
+ + setFormData({ ...formData, icon: e.target.value })} + placeholder="💥" + style={{ width: '100%' }} + /> +
+ +
+ + +
+ +
+ +