From 4a11d20c4d10fae512787046a2358bd21575eae5 Mon Sep 17 00:00:00 2001 From: Lars Date: Fri, 27 Mar 2026 08:38:03 +0100 Subject: [PATCH] feat: Goal System v2.0 - Focus Areas with weighted priorities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING: Replaces single 'primary goal' with weighted multi-goal system Migration 027: - New table: focus_areas (6 dimensions with percentages) - Constraint: Sum must equal 100% - Auto-migration: goal_mode → focus_areas for existing users - Unique constraint: One active focus_areas per profile Backend: - get_focus_weights() V2: Reads from focus_areas table - Fallback: Uses goal_mode if focus_areas not set - New endpoints: GET/PUT /api/goals/focus-areas - Validation: Sum=100, range 0-100 API: - getFocusAreas() - Get current weights - updateFocusAreas(data) - Update weights (upsert) Focus dimensions: 1. weight_loss_pct (Fettabbau) 2. muscle_gain_pct (Muskelaufbau) 3. strength_pct (Kraftsteigerung) 4. endurance_pct (Ausdauer) 5. flexibility_pct (Beweglichkeit) 6. health_pct (Allgemeine Gesundheit) Benefits: - Multiple goals with custom priorities - More flexible than single primary goal - KI can use weighted scores - Ready for Phase 0b placeholder integration UI: Coming in next commit (slider interface) --- backend/goal_utils.py | 100 ++++++---- backend/migrations/027_focus_areas_system.sql | 134 +++++++++++++ backend/routers/goals.py | 182 +++++++++++++++++- frontend/src/utils/api.js | 5 + 4 files changed, 385 insertions(+), 36 deletions(-) create mode 100644 backend/migrations/027_focus_areas_system.sql diff --git a/backend/goal_utils.py b/backend/goal_utils.py index 34fd725..7378792 100644 --- a/backend/goal_utils.py +++ b/backend/goal_utils.py @@ -24,9 +24,8 @@ def get_focus_weights(conn, profile_id: str) -> Dict[str, float]: """ Get focus area weights for a profile. - This is an abstraction layer that will evolve: - - V1 (now): Maps goal_mode → predefined weights - - V2 (later): Reads from focus_areas table → custom user weights + V2 (Goal System v2.0): Reads from focus_areas table with custom user weights. + Falls back to goal_mode mapping if focus_areas not set. Args: conn: Database connection @@ -55,7 +54,29 @@ def get_focus_weights(conn, profile_id: str) -> Dict[str, float]: """ cur = get_cursor(conn) - # Fetch current goal_mode + # V2: Try to fetch from focus_areas table + cur.execute(""" + SELECT weight_loss_pct, muscle_gain_pct, strength_pct, + endurance_pct, flexibility_pct, health_pct + FROM focus_areas + WHERE profile_id = %s AND active = true + LIMIT 1 + """, (profile_id,)) + + row = cur.fetchone() + + if row: + # Convert percentages to weights (0-1 range) + return { + 'weight_loss': row['weight_loss_pct'] / 100.0, + 'muscle_gain': row['muscle_gain_pct'] / 100.0, + 'strength': row['strength_pct'] / 100.0, + 'endurance': row['endurance_pct'] / 100.0, + 'flexibility': row['flexibility_pct'] / 100.0, + 'health': row['health_pct'] / 100.0 + } + + # V1 Fallback: Use goal_mode if focus_areas not set cur.execute( "SELECT goal_mode FROM profiles WHERE id = %s", (profile_id,) @@ -63,59 +84,68 @@ def get_focus_weights(conn, profile_id: str) -> Dict[str, float]: row = cur.fetchone() if not row: - # Fallback: balanced health focus + # Ultimate fallback: balanced health focus return { 'weight_loss': 0.0, 'muscle_gain': 0.0, - 'strength': 0.0, - 'endurance': 0.0, - 'flexibility': 0.0, - 'health': 1.0 + 'strength': 0.10, + 'endurance': 0.20, + 'flexibility': 0.15, + 'health': 0.55 } - goal_mode = row['goal_mode'] or 'health' + goal_mode = row['goal_mode'] - # V1: Predefined weight mappings per goal_mode - # These represent "typical" focus distributions for each mode + if not goal_mode: + return { + 'weight_loss': 0.0, + 'muscle_gain': 0.0, + 'strength': 0.10, + 'endurance': 0.20, + 'flexibility': 0.15, + 'health': 0.55 + } + + # V1: Predefined weight mappings per goal_mode (fallback) WEIGHT_MAPPINGS = { 'weight_loss': { - 'weight_loss': 0.60, # Primary: fat loss - 'endurance': 0.20, # Support: cardio for calorie burn - 'muscle_gain': 0.0, # Not compatible - 'strength': 0.10, # Maintain muscle during deficit - 'flexibility': 0.05, # Minor: mobility work - 'health': 0.05 # Minor: general wellness + 'weight_loss': 0.60, + 'endurance': 0.20, + 'muscle_gain': 0.0, + 'strength': 0.10, + 'flexibility': 0.05, + 'health': 0.05 }, 'strength': { - 'strength': 0.50, # Primary: strength gains - 'muscle_gain': 0.40, # Support: hypertrophy - 'endurance': 0.10, # Minor: work capacity - 'weight_loss': 0.0, # Not compatible with strength focus + 'strength': 0.50, + 'muscle_gain': 0.40, + 'endurance': 0.10, + 'weight_loss': 0.0, 'flexibility': 0.0, 'health': 0.0 }, 'endurance': { - 'endurance': 0.70, # Primary: aerobic capacity - 'health': 0.20, # Support: cardiovascular health - 'flexibility': 0.10, # Support: mobility for running + 'endurance': 0.70, + 'health': 0.20, + 'flexibility': 0.10, 'weight_loss': 0.0, 'muscle_gain': 0.0, 'strength': 0.0 }, 'recomposition': { - 'weight_loss': 0.30, # Equal: lose fat - 'muscle_gain': 0.30, # Equal: gain muscle - 'strength': 0.25, # Support: progressive overload - 'endurance': 0.10, # Minor: conditioning - 'flexibility': 0.05, # Minor: mobility + 'weight_loss': 0.30, + 'muscle_gain': 0.30, + 'strength': 0.25, + 'endurance': 0.10, + 'flexibility': 0.05, 'health': 0.0 }, 'health': { - 'health': 0.50, # Primary: general wellness - 'endurance': 0.20, # Support: cardio health - 'flexibility': 0.15, # Support: mobility - 'strength': 0.10, # Support: functional strength - 'weight_loss': 0.05, # Minor: maintain healthy weight + 'health': 0.50, + 'endurance': 0.20, + 'flexibility': 0.15, + 'strength': 0.10, + 'weight_loss': 0.05, 'muscle_gain': 0.0 } } diff --git a/backend/migrations/027_focus_areas_system.sql b/backend/migrations/027_focus_areas_system.sql new file mode 100644 index 0000000..c7a0eaa --- /dev/null +++ b/backend/migrations/027_focus_areas_system.sql @@ -0,0 +1,134 @@ +-- Migration 027: Focus Areas System (Goal System v2.0) +-- Date: 2026-03-27 +-- Purpose: Replace single primary goal with weighted multi-goal system + +-- ============================================================================ +-- Focus Areas Table +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS focus_areas ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + + -- Six focus dimensions (percentages, sum = 100) + weight_loss_pct INTEGER DEFAULT 0 CHECK (weight_loss_pct >= 0 AND weight_loss_pct <= 100), + muscle_gain_pct INTEGER DEFAULT 0 CHECK (muscle_gain_pct >= 0 AND muscle_gain_pct <= 100), + strength_pct INTEGER DEFAULT 0 CHECK (strength_pct >= 0 AND strength_pct <= 100), + endurance_pct INTEGER DEFAULT 0 CHECK (endurance_pct >= 0 AND endurance_pct <= 100), + flexibility_pct INTEGER DEFAULT 0 CHECK (flexibility_pct >= 0 AND flexibility_pct <= 100), + health_pct INTEGER DEFAULT 0 CHECK (health_pct >= 0 AND health_pct <= 100), + + -- Status + active BOOLEAN DEFAULT true, + + -- Audit + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + + -- Constraints + CONSTRAINT sum_equals_100 CHECK ( + weight_loss_pct + muscle_gain_pct + strength_pct + + endurance_pct + flexibility_pct + health_pct = 100 + ) +); + +-- Only one active focus_areas per profile +CREATE UNIQUE INDEX IF NOT EXISTS idx_focus_areas_profile_active +ON focus_areas(profile_id) WHERE active = true; + +COMMENT ON TABLE focus_areas IS 'User-defined focus area weights (replaces simple goal_mode). Enables multi-goal prioritization with custom percentages.'; +COMMENT ON COLUMN focus_areas.weight_loss_pct IS 'Focus on fat loss (0-100%)'; +COMMENT ON COLUMN focus_areas.muscle_gain_pct IS 'Focus on muscle growth (0-100%)'; +COMMENT ON COLUMN focus_areas.strength_pct IS 'Focus on strength gains (0-100%)'; +COMMENT ON COLUMN focus_areas.endurance_pct IS 'Focus on aerobic capacity (0-100%)'; +COMMENT ON COLUMN focus_areas.flexibility_pct IS 'Focus on mobility/flexibility (0-100%)'; +COMMENT ON COLUMN focus_areas.health_pct IS 'Focus on general health (0-100%)'; + +-- ============================================================================ +-- Migrate existing goal_mode to focus_areas +-- ============================================================================ + +-- For each profile with a goal_mode, create initial focus_areas +INSERT INTO focus_areas ( + profile_id, + weight_loss_pct, muscle_gain_pct, strength_pct, + endurance_pct, flexibility_pct, health_pct +) +SELECT + id AS profile_id, + CASE goal_mode + WHEN 'weight_loss' THEN 60 ELSE 0 + END + + CASE goal_mode + WHEN 'recomposition' THEN 30 ELSE 0 + END AS weight_loss_pct, + + CASE goal_mode + WHEN 'strength' THEN 40 ELSE 0 + END + + CASE goal_mode + WHEN 'recomposition' THEN 30 ELSE 0 + END AS muscle_gain_pct, + + CASE goal_mode + WHEN 'strength' THEN 50 ELSE 0 + END + + CASE goal_mode + WHEN 'recomposition' THEN 25 ELSE 0 + END + + CASE goal_mode + WHEN 'weight_loss' THEN 10 ELSE 0 + END AS strength_pct, + + CASE goal_mode + WHEN 'endurance' THEN 70 ELSE 0 + END + + CASE goal_mode + WHEN 'recomposition' THEN 10 ELSE 0 + END + + CASE goal_mode + WHEN 'weight_loss' THEN 20 ELSE 0 + END AS endurance_pct, + + CASE goal_mode + WHEN 'endurance' THEN 10 ELSE 0 + END + + CASE goal_mode + WHEN 'health' THEN 15 ELSE 0 + END + + CASE goal_mode + WHEN 'recomposition' THEN 5 ELSE 0 + END + + CASE goal_mode + WHEN 'weight_loss' THEN 5 ELSE 0 + END AS flexibility_pct, + + CASE goal_mode + WHEN 'health' THEN 50 ELSE 0 + END + + CASE goal_mode + WHEN 'endurance' THEN 20 ELSE 0 + END + + CASE goal_mode + WHEN 'strength' THEN 10 ELSE 0 + END + + CASE goal_mode + WHEN 'weight_loss' THEN 5 ELSE 0 + END AS health_pct +FROM profiles +WHERE goal_mode IS NOT NULL +ON CONFLICT DO NOTHING; + +-- For profiles without goal_mode, use balanced health focus +INSERT INTO focus_areas ( + profile_id, + weight_loss_pct, muscle_gain_pct, strength_pct, + endurance_pct, flexibility_pct, health_pct +) +SELECT + id AS profile_id, + 0, 0, 10, 20, 15, 55 +FROM profiles +WHERE goal_mode IS NULL + AND id NOT IN (SELECT profile_id FROM focus_areas WHERE active = true) +ON CONFLICT DO NOTHING; diff --git a/backend/routers/goals.py b/backend/routers/goals.py index 1f2f4a8..8856aa7 100644 --- a/backend/routers/goals.py +++ b/backend/routers/goals.py @@ -27,9 +27,18 @@ router = APIRouter(prefix="/api/goals", tags=["goals"]) # ============================================================================ class GoalModeUpdate(BaseModel): - """Update strategic goal mode""" + """Update strategic goal mode (deprecated - use FocusAreasUpdate)""" goal_mode: str # weight_loss, strength, endurance, recomposition, health +class FocusAreasUpdate(BaseModel): + """Update focus area weights (v2.0)""" + weight_loss_pct: int + muscle_gain_pct: int + strength_pct: int + endurance_pct: int + flexibility_pct: int + health_pct: int + class GoalCreate(BaseModel): """Create or update a concrete goal""" goal_type: str # weight, body_fat, lean_mass, vo2max, strength, flexibility, bp, rhr @@ -154,6 +163,177 @@ def _get_goal_mode_description(mode: str) -> str: } return descriptions.get(mode, 'Unbekannt') +# ============================================================================ +# Focus Areas (v2.0): Weighted Multi-Goal System +# ============================================================================ + +@router.get("/focus-areas") +def get_focus_areas(session: dict = Depends(require_auth)): + """ + Get current focus area weights. + + Returns custom weights if set, otherwise derives from goal_mode. + """ + pid = session['profile_id'] + + 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() + + if row: + return { + "custom": True, + "weight_loss_pct": row['weight_loss_pct'], + "muscle_gain_pct": row['muscle_gain_pct'], + "strength_pct": row['strength_pct'], + "endurance_pct": row['endurance_pct'], + "flexibility_pct": row['flexibility_pct'], + "health_pct": row['health_pct'], + "updated_at": row['updated_at'] + } + + # Fallback: Derive from goal_mode + cur.execute("SELECT goal_mode FROM profiles WHERE id = %s", (pid,)) + profile = cur.fetchone() + + if not profile or not profile['goal_mode']: + # Default balanced health + return { + "custom": False, + "weight_loss_pct": 0, + "muscle_gain_pct": 0, + "strength_pct": 10, + "endurance_pct": 20, + "flexibility_pct": 15, + "health_pct": 55, + "source": "default" + } + + # Derive from goal_mode (using same logic as migration) + mode = profile['goal_mode'] + mode_mappings = { + 'weight_loss': { + 'weight_loss_pct': 60, + 'muscle_gain_pct': 0, + 'strength_pct': 10, + 'endurance_pct': 20, + 'flexibility_pct': 5, + 'health_pct': 5 + }, + 'strength': { + 'weight_loss_pct': 0, + 'muscle_gain_pct': 40, + 'strength_pct': 50, + 'endurance_pct': 10, + 'flexibility_pct': 0, + 'health_pct': 0 + }, + 'endurance': { + 'weight_loss_pct': 0, + 'muscle_gain_pct': 0, + 'strength_pct': 0, + 'endurance_pct': 70, + 'flexibility_pct': 10, + 'health_pct': 20 + }, + 'recomposition': { + 'weight_loss_pct': 30, + 'muscle_gain_pct': 30, + 'strength_pct': 25, + 'endurance_pct': 10, + 'flexibility_pct': 5, + 'health_pct': 0 + }, + 'health': { + 'weight_loss_pct': 0, + 'muscle_gain_pct': 0, + 'strength_pct': 10, + 'endurance_pct': 20, + 'flexibility_pct': 15, + 'health_pct': 55 + } + } + + mapping = mode_mappings.get(mode, mode_mappings['health']) + mapping['custom'] = False + mapping['source'] = f"goal_mode:{mode}" + return mapping + +@router.put("/focus-areas") +def update_focus_areas(data: FocusAreasUpdate, session: dict = Depends(require_auth)): + """ + Update focus area weights (upsert). + + Validates that sum = 100 and all values are 0-100. + """ + pid = session['profile_id'] + + # Validate sum = 100 + total = ( + data.weight_loss_pct + data.muscle_gain_pct + data.strength_pct + + data.endurance_pct + data.flexibility_pct + data.health_pct + ) + + if total != 100: + raise HTTPException( + status_code=400, + detail=f"Summe muss 100% sein (aktuell: {total}%)" + ) + + # Validate range 0-100 + values = [ + data.weight_loss_pct, data.muscle_gain_pct, data.strength_pct, + data.endurance_pct, data.flexibility_pct, data.health_pct + ] + + if any(v < 0 or v > 100 for v in values): + raise HTTPException( + status_code=400, + detail="Alle Werte müssen zwischen 0 und 100 liegen" + ) + + with get_db() as conn: + cur = get_cursor(conn) + + # Deactivate old focus_areas + cur.execute( + "UPDATE focus_areas SET active = false WHERE profile_id = %s", + (pid,) + ) + + # Insert new focus_areas + cur.execute(""" + INSERT INTO focus_areas ( + profile_id, weight_loss_pct, muscle_gain_pct, strength_pct, + endurance_pct, flexibility_pct, health_pct + ) VALUES (%s, %s, %s, %s, %s, %s, %s) + RETURNING id + """, ( + pid, data.weight_loss_pct, data.muscle_gain_pct, data.strength_pct, + data.endurance_pct, data.flexibility_pct, data.health_pct + )) + + return { + "message": "Fokus-Bereiche aktualisiert", + "weight_loss_pct": data.weight_loss_pct, + "muscle_gain_pct": data.muscle_gain_pct, + "strength_pct": data.strength_pct, + "endurance_pct": data.endurance_pct, + "flexibility_pct": data.flexibility_pct, + "health_pct": data.health_pct + } + # ============================================================================ # Tactical Layer: Concrete Goals # ============================================================================ diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 335559f..142830b 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -334,6 +334,11 @@ export const api = { // v9e: Goals System (Strategic + Tactical) getGoalMode: () => req('/goals/mode'), updateGoalMode: (mode) => req('/goals/mode', jput({goal_mode: mode})), + + // Focus Areas (v2.0) + getFocusAreas: () => req('/goals/focus-areas'), + updateFocusAreas: (d) => req('/goals/focus-areas', jput(d)), + listGoals: () => req('/goals/list'), createGoal: (d) => req('/goals/create', json(d)), updateGoal: (id,d) => req(`/goals/${id}`, jput(d)),