From f312dd0dbb7de6b8c015e0505d3694492144795e Mon Sep 17 00:00:00 2001 From: Lars Date: Fri, 27 Mar 2026 19:48:05 +0100 Subject: [PATCH] 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