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/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.'; diff --git a/backend/migrations/032_user_focus_area_weights.sql b/backend/migrations/032_user_focus_area_weights.sql new file mode 100644 index 0000000..b48087f --- /dev/null +++ b/backend/migrations/032_user_focus_area_weights.sql @@ -0,0 +1,53 @@ +-- Migration 032: User Focus Area Weights +-- Date: 2026-03-27 +-- Purpose: Allow users to set custom weights for focus areas (dynamic preferences) + +-- ============================================================================ +-- User Focus Area Weights (many-to-many with weights) +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS user_focus_area_weights ( + profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + focus_area_id UUID NOT NULL REFERENCES focus_area_definitions(id) ON DELETE CASCADE, + weight INTEGER NOT NULL DEFAULT 0 CHECK (weight >= 0 AND weight <= 100), + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + PRIMARY KEY (profile_id, focus_area_id) +); + +CREATE INDEX idx_user_focus_weights_profile ON user_focus_area_weights(profile_id); +CREATE INDEX idx_user_focus_weights_area ON user_focus_area_weights(focus_area_id); + +COMMENT ON TABLE user_focus_area_weights IS 'User-specific weights for focus areas (dynamic system)'; +COMMENT ON COLUMN user_focus_area_weights.weight IS 'Relative weight (0-100) - will be normalized to percentages in UI'; + +-- ============================================================================ +-- Migrate legacy preferences to dynamic weights +-- ============================================================================ + +-- For each user with legacy preferences, create weights for the 6 base areas +INSERT INTO user_focus_area_weights (profile_id, focus_area_id, weight) +SELECT + ufp.profile_id, + fad.id as focus_area_id, + CASE fad.key + WHEN 'weight_loss' THEN ufp.weight_loss_pct + WHEN 'muscle_gain' THEN ufp.muscle_gain_pct + WHEN 'strength' THEN ufp.strength_pct + WHEN 'aerobic_endurance' THEN ufp.endurance_pct + WHEN 'flexibility' THEN ufp.flexibility_pct + WHEN 'general_health' THEN ufp.health_pct + ELSE 0 + END as weight +FROM user_focus_preferences ufp +CROSS JOIN focus_area_definitions fad +WHERE fad.key IN ('weight_loss', 'muscle_gain', 'strength', 'aerobic_endurance', 'flexibility', 'general_health') + AND ( + (fad.key = 'weight_loss' AND ufp.weight_loss_pct > 0) OR + (fad.key = 'muscle_gain' AND ufp.muscle_gain_pct > 0) OR + (fad.key = 'strength' AND ufp.strength_pct > 0) OR + (fad.key = 'aerobic_endurance' AND ufp.endurance_pct > 0) OR + (fad.key = 'flexibility' AND ufp.flexibility_pct > 0) OR + (fad.key = 'general_health' AND ufp.health_pct > 0) + ) +ON CONFLICT (profile_id, focus_area_id) DO NOTHING; diff --git a/backend/routers/focus_areas.py b/backend/routers/focus_areas.py new file mode 100644 index 0000000..d41d6b0 --- /dev/null +++ b/backend/routers/focus_areas.py @@ -0,0 +1,378 @@ +""" +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="/api/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 (dynamic system). + + Returns focus areas with user-set weights, grouped by category. + """ + pid = session['profile_id'] + + with get_db() as conn: + cur = get_cursor(conn) + + # Get dynamic preferences (Migration 032) + try: + cur.execute(""" + SELECT + fa.id, fa.key, fa.name_de, fa.name_en, fa.icon, + fa.category, fa.description, + ufw.weight + FROM user_focus_area_weights ufw + JOIN focus_area_definitions fa ON ufw.focus_area_id = fa.id + WHERE ufw.profile_id = %s AND ufw.weight > 0 + ORDER BY fa.category, fa.name_de + """, (pid,)) + + weights = [r2d(row) for row in cur.fetchall()] + + # Calculate percentages from weights + total_weight = sum(w['weight'] for w in weights) + if total_weight > 0: + for w in weights: + w['percentage'] = round((w['weight'] / total_weight) * 100) + else: + for w in weights: + w['percentage'] = 0 + + # Group by category + grouped = {} + for w in weights: + cat = w['category'] or 'other' + if cat not in grouped: + grouped[cat] = [] + grouped[cat].append(w) + + return { + "weights": weights, + "grouped": grouped, + "total_weight": total_weight + } + + except Exception as e: + # Migration 032 not applied yet - return empty + print(f"[WARNING] user_focus_area_weights not found: {e}") + return { + "weights": [], + "grouped": {}, + "total_weight": 0 + } + +@router.put("/user-preferences") +def update_user_focus_preferences( + data: dict, + session: dict = Depends(require_auth) +): + """ + Update user's focus area weightings (dynamic system). + + Expects: { "weights": { "focus_area_id": weight, ... } } + Weights are relative (0-100), normalized in display only. + """ + pid = session['profile_id'] + + if 'weights' not in data: + raise HTTPException(status_code=400, detail="'weights' field required") + + weights = data['weights'] # Dict: focus_area_id → weight + + with get_db() as conn: + cur = get_cursor(conn) + + # Delete existing weights + cur.execute( + "DELETE FROM user_focus_area_weights WHERE profile_id = %s", + (pid,) + ) + + # Insert new weights (only non-zero) + for focus_area_id, weight in weights.items(): + weight_int = int(weight) + if weight_int > 0: + cur.execute(""" + INSERT INTO user_focus_area_weights + (profile_id, focus_area_id, weight) + VALUES (%s, %s, %s) + ON CONFLICT (profile_id, focus_area_id) + DO UPDATE SET + weight = EXCLUDED.weight, + updated_at = NOW() + """, (pid, focus_area_id, weight_int)) + + return { + "message": "Focus Area Gewichtungen aktualisiert", + "count": len([w for w in weights.values() if int(w) > 0]) + } + +# ============================================================================ +# 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..3b0a529 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)""" @@ -194,17 +201,32 @@ def get_focus_areas(session: dict = Depends(require_auth)): with get_db() as conn: cur = get_cursor(conn) - # Try to get custom focus areas - cur.execute(""" - SELECT weight_loss_pct, muscle_gain_pct, strength_pct, - endurance_pct, flexibility_pct, health_pct, - created_at, updated_at - FROM focus_areas - WHERE profile_id = %s AND active = true - LIMIT 1 - """, (pid,)) - - row = cur.fetchone() + # Try to get custom focus areas (user_focus_preferences after Migration 031) + try: + cur.execute(""" + SELECT weight_loss_pct, muscle_gain_pct, strength_pct, + endurance_pct, flexibility_pct, health_pct, + created_at, updated_at + FROM user_focus_preferences + WHERE profile_id = %s + LIMIT 1 + """, (pid,)) + row = cur.fetchone() + except Exception as e: + # Migration 031 not applied yet, try old table name + print(f"[WARNING] user_focus_preferences not found, trying old focus_areas: {e}") + try: + cur.execute(""" + SELECT weight_loss_pct, muscle_gain_pct, strength_pct, + endurance_pct, flexibility_pct, health_pct, + created_at, updated_at + FROM focus_areas + WHERE profile_id = %s AND active = true + LIMIT 1 + """, (pid,)) + row = cur.fetchone() + except: + row = None if row: return { @@ -429,6 +451,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 +525,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 +730,50 @@ 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: + try: + 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']) + }) + except Exception as e: + # Migration 031 not yet applied - focus_contributions tables don't exist + print(f"[WARNING] Could not load focus_contributions: {e}") + # Continue without focus_contributions (backward compatible) + + # 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 diff --git a/backend/routers/vitals.py b/backend/routers/vitals.py index 9180c5f..36ecb09 100644 --- a/backend/routers/vitals.py +++ b/backend/routers/vitals.py @@ -140,63 +140,66 @@ def create_vitals( x_profile_id: Optional[str] = Header(default=None), session: dict = Depends(require_auth) ): - """Create or update vitals entry (upsert).""" + """ + Create or update vitals entry (upsert). + + Post-Migration-015: Routes to vitals_baseline (for RHR, HRV, etc.) + Note: BP measurements should use /api/blood-pressure endpoint instead. + """ pid = get_pid(x_profile_id, session) - # Validation: at least one vital must be provided - has_data = any([ - entry.resting_hr, entry.hrv, entry.blood_pressure_systolic, - entry.blood_pressure_diastolic, entry.vo2_max, entry.spo2, - entry.respiratory_rate + # Validation: at least one baseline vital must be provided + has_baseline = any([ + entry.resting_hr, entry.hrv, entry.vo2_max, + entry.spo2, entry.respiratory_rate ]) - if not has_data: - raise HTTPException(400, "Mindestens ein Vitalwert muss angegeben werden") + + if not has_baseline: + raise HTTPException(400, "Mindestens ein Vitalwert muss angegeben werden (RHR, HRV, VO2Max, SpO2, oder Atemfrequenz)") with get_db() as conn: cur = get_cursor(conn) - # Upsert: insert or update if date already exists + # Upsert into vitals_baseline (Migration 015) cur.execute( """ - INSERT INTO vitals_log ( + INSERT INTO vitals_baseline ( profile_id, date, resting_hr, hrv, - blood_pressure_systolic, blood_pressure_diastolic, pulse, vo2_max, spo2, respiratory_rate, - irregular_heartbeat, possible_afib, note, source ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'manual') + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, 'manual') ON CONFLICT (profile_id, date) DO UPDATE SET - resting_hr = COALESCE(EXCLUDED.resting_hr, vitals_log.resting_hr), - hrv = COALESCE(EXCLUDED.hrv, vitals_log.hrv), - blood_pressure_systolic = COALESCE(EXCLUDED.blood_pressure_systolic, vitals_log.blood_pressure_systolic), - blood_pressure_diastolic = COALESCE(EXCLUDED.blood_pressure_diastolic, vitals_log.blood_pressure_diastolic), - pulse = COALESCE(EXCLUDED.pulse, vitals_log.pulse), - vo2_max = COALESCE(EXCLUDED.vo2_max, vitals_log.vo2_max), - spo2 = COALESCE(EXCLUDED.spo2, vitals_log.spo2), - respiratory_rate = COALESCE(EXCLUDED.respiratory_rate, vitals_log.respiratory_rate), - irregular_heartbeat = COALESCE(EXCLUDED.irregular_heartbeat, vitals_log.irregular_heartbeat), - possible_afib = COALESCE(EXCLUDED.possible_afib, vitals_log.possible_afib), - note = COALESCE(EXCLUDED.note, vitals_log.note), + resting_hr = COALESCE(EXCLUDED.resting_hr, vitals_baseline.resting_hr), + hrv = COALESCE(EXCLUDED.hrv, vitals_baseline.hrv), + vo2_max = COALESCE(EXCLUDED.vo2_max, vitals_baseline.vo2_max), + spo2 = COALESCE(EXCLUDED.spo2, vitals_baseline.spo2), + respiratory_rate = COALESCE(EXCLUDED.respiratory_rate, vitals_baseline.respiratory_rate), + note = COALESCE(EXCLUDED.note, vitals_baseline.note), updated_at = CURRENT_TIMESTAMP RETURNING id, profile_id, date, resting_hr, hrv, - blood_pressure_systolic, blood_pressure_diastolic, pulse, vo2_max, spo2, respiratory_rate, - irregular_heartbeat, possible_afib, note, source, created_at, updated_at """, (pid, entry.date, entry.resting_hr, entry.hrv, - entry.blood_pressure_systolic, entry.blood_pressure_diastolic, entry.pulse, entry.vo2_max, entry.spo2, entry.respiratory_rate, - entry.irregular_heartbeat, entry.possible_afib, entry.note) ) row = cur.fetchone() conn.commit() - logger.info(f"[VITALS] Upserted vitals for {pid} on {entry.date}") - return r2d(row) + logger.info(f"[VITALS] Upserted baseline vitals for {pid} on {entry.date}") + + # Return in legacy format for backward compatibility + result = r2d(row) + result['blood_pressure_systolic'] = None + result['blood_pressure_diastolic'] = None + result['pulse'] = None + result['irregular_heartbeat'] = None + result['possible_afib'] = None + + return result @router.put("/{vitals_id}") 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%' }} + /> +
+ +
+ + +
+ +
+ +