diff --git a/CLAUDE.md b/CLAUDE.md index d7a7f52..30c82d5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,9 +76,16 @@ frontend/src/ └── technical/ # MEMBERSHIP_SYSTEM.md ``` -## Aktuelle Version: v9e+ (Phase 0a Goal System Complete) 🎯 Ready for Phase 0b - 26.03.2026 +## Aktuelle Version: v9e+ (Phase 1 Goal System Fixes) 🎯 Ready for Phase 0b - 27.03.2026 -### Letzte Updates (26.03.2026 - Phase 0a) 🆕 +### Letzte Updates (27.03.2026 - Phase 1 Fixes) 🆕 +- ✅ **Abstraction Layer:** goal_utils.py für zukunftssichere Phase 0b Platzhalter +- ✅ **Primary Goal Toggle Fix:** is_primary Update funktioniert korrekt +- ✅ **Lean Mass Berechnung:** Magermasse current_value wird berechnet +- ✅ **VO2Max Fix:** Spaltenname vo2_max (statt vo2max) korrigiert +- ✅ **Keine Doppelarbeit:** Phase 0b Platzhalter (120+) müssen bei v2.0 nicht umgeschrieben werden + +### Phase 0a Completion (26.03.2026) 🎯 - ✅ **Phase 0a: Minimal Goal System:** Strategic + Tactical Layers implementiert - ✅ **Migration 022:** goal_mode, goals, training_phases, fitness_tests tables - ✅ **Backend Router:** goals.py mit vollständigem CRUD (490 Zeilen) diff --git a/backend/goal_utils.py b/backend/goal_utils.py new file mode 100644 index 0000000..ef99871 --- /dev/null +++ b/backend/goal_utils.py @@ -0,0 +1,190 @@ +""" +Goal Utilities - Abstraction Layer for Focus Weights + +This module provides an abstraction layer between goal modes and focus weights. +This allows Phase 0b placeholders to work with the current simple goal_mode system, +while enabling future v2.0 redesign (focus_areas table) without rewriting 120+ placeholders. + +Version History: +- V1 (current): Maps goal_mode to predefined weights +- V2 (future): Reads from focus_areas table with custom user weights + +Part of Phase 1: Quick Fixes + Abstraction Layer +""" + +from typing import Dict +from db import get_cursor + + +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 + + Args: + conn: Database connection + profile_id: User's profile ID + + Returns: + Dict with focus weights (sum = 1.0): + { + 'weight_loss': 0.3, # Fat loss priority + 'muscle_gain': 0.2, # Muscle gain priority + 'strength': 0.25, # Strength training priority + 'endurance': 0.25, # Cardio/endurance priority + 'flexibility': 0.0, # Mobility priority + 'health': 0.0 # General health maintenance + } + + Example Usage in Phase 0b: + weights = get_focus_weights(conn, profile_id) + + # Score calculation considers user's focus + overall_score = ( + body_score * weights['weight_loss'] + + strength_score * weights['strength'] + + cardio_score * weights['endurance'] + ) + """ + cur = get_cursor(conn) + + # Fetch current goal_mode + cur.execute( + "SELECT goal_mode FROM profiles WHERE id = %s", + (profile_id,) + ) + row = cur.fetchone() + + if not row: + # 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 + } + + goal_mode = row['goal_mode'] or 'health' + + # V1: Predefined weight mappings per goal_mode + # These represent "typical" focus distributions for each mode + 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 + }, + '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 + '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 + '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 + '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 + 'muscle_gain': 0.0 + } + } + + return WEIGHT_MAPPINGS.get(goal_mode, WEIGHT_MAPPINGS['health']) + + +def get_primary_focus(conn, profile_id: str) -> str: + """ + Get the primary focus area for a profile. + + Returns the focus area with the highest weight. + Useful for UI labels and simple decision logic. + + Args: + conn: Database connection + profile_id: User's profile ID + + Returns: + Primary focus area name (e.g., 'weight_loss', 'strength') + """ + weights = get_focus_weights(conn, profile_id) + return max(weights.items(), key=lambda x: x[1])[0] + + +def get_focus_description(focus_area: str) -> str: + """ + Get human-readable description for a focus area. + + Args: + focus_area: Focus area key (e.g., 'weight_loss') + + Returns: + German description for UI display + """ + descriptions = { + 'weight_loss': 'Gewichtsreduktion & Fettabbau', + 'muscle_gain': 'Muskelaufbau & Hypertrophie', + 'strength': 'Kraftsteigerung & Performance', + 'endurance': 'Ausdauer & aerobe Kapazität', + 'flexibility': 'Beweglichkeit & Mobilität', + 'health': 'Allgemeine Gesundheit & Erhaltung' + } + return descriptions.get(focus_area, focus_area) + + +# Future V2 Implementation (commented out for reference): +""" +def get_focus_weights_v2(conn, profile_id: str) -> Dict[str, float]: + '''V2: Read from focus_areas table with custom user weights''' + cur = get_cursor(conn) + + cur.execute(''' + SELECT weight_loss_pct, muscle_gain_pct, endurance_pct, + strength_pct, flexibility_pct, health_pct + FROM focus_areas + WHERE profile_id = %s AND active = true + LIMIT 1 + ''', (profile_id,)) + + row = cur.fetchone() + + if not row: + # Fallback to V1 behavior + return get_focus_weights(conn, profile_id) + + # Convert percentages to weights (0-1 range) + return { + 'weight_loss': row['weight_loss_pct'] / 100.0, + 'muscle_gain': row['muscle_gain_pct'] / 100.0, + 'endurance': row['endurance_pct'] / 100.0, + 'strength': row['strength_pct'] / 100.0, + 'flexibility': row['flexibility_pct'] / 100.0, + 'health': row['health_pct'] / 100.0 + } +""" diff --git a/backend/routers/goals.py b/backend/routers/goals.py index 4cd7d49..9f172b9 100644 --- a/backend/routers/goals.py +++ b/backend/routers/goals.py @@ -43,6 +43,7 @@ class GoalUpdate(BaseModel): target_value: Optional[float] = None target_date: Optional[date] = None status: Optional[str] = None # active, reached, abandoned, expired + is_primary: Optional[bool] = None name: Optional[str] = None description: Optional[str] = None @@ -204,6 +205,13 @@ def update_goal(goal_id: str, data: GoalUpdate, session: dict = Depends(require_ if not cur.fetchone(): raise HTTPException(status_code=404, detail="Ziel nicht gefunden") + # If setting this goal as primary, unset all other primary goals + if data.is_primary is True: + cur.execute( + "UPDATE goals SET is_primary = false WHERE profile_id = %s AND id != %s", + (pid, goal_id) + ) + # Build update query dynamically updates = [] params = [] @@ -222,6 +230,10 @@ def update_goal(goal_id: str, data: GoalUpdate, session: dict = Depends(require_ if data.status == 'reached': updates.append("reached_date = CURRENT_DATE") + if data.is_primary is not None: + updates.append("is_primary = %s") + params.append(data.is_primary) + if data.name is not None: updates.append("name = %s") params.append(data.name) @@ -414,14 +426,38 @@ def _get_current_value_for_goal_type(conn, profile_id: str, goal_type: str) -> O row = cur.fetchone() return float(row['body_fat_pct']) if row else None + elif goal_type == 'lean_mass': + # Calculate lean mass: weight - (weight * body_fat_pct / 100) + # Need both latest weight and latest body fat percentage + cur.execute(""" + SELECT weight FROM weight_log + WHERE profile_id = %s + ORDER BY date DESC LIMIT 1 + """, (profile_id,)) + weight_row = cur.fetchone() + + cur.execute(""" + SELECT body_fat_pct FROM caliper_log + WHERE profile_id = %s + ORDER BY date DESC LIMIT 1 + """, (profile_id,)) + bf_row = cur.fetchone() + + if weight_row and bf_row: + weight = float(weight_row['weight']) + bf_pct = float(bf_row['body_fat_pct']) + lean_mass = weight - (weight * bf_pct / 100.0) + return round(lean_mass, 2) + return None + elif goal_type == 'vo2max': cur.execute(""" - SELECT vo2max FROM vitals_baseline - WHERE profile_id = %s AND vo2max IS NOT NULL + SELECT vo2_max FROM vitals_baseline + WHERE profile_id = %s AND vo2_max IS NOT NULL ORDER BY date DESC LIMIT 1 """, (profile_id,)) row = cur.fetchone() - return float(row['vo2max']) if row else None + return float(row['vo2_max']) if row else None elif goal_type == 'rhr': cur.execute("""