fix: Phase 1 - Goal System Quick Fixes + Abstraction Layer
Behebt 4 kritische Bugs in Phase 0a und schafft Basis für Phase 0b ohne spätere Doppelarbeit. Backend: - NEW: goal_utils.py mit get_focus_weights() Abstraction Layer → V1: Mappt goal_mode zu Gewichten → V2 (später): Liest aus focus_areas Tabelle → Phase 0b Platzhalter (120+) müssen NICHT umgeschrieben werden - FIX: Primary goal toggle in goals.py (is_primary im GoalUpdate Model) → Beim Update auf primary werden andere Goals korrekt auf false gesetzt - FIX: lean_mass current_value Berechnung implementiert → weight - (weight * body_fat_pct / 100) - FIX: VO2Max Spaltenname vo2_max (statt vo2max) → Internal Server Error behoben CLAUDE.md: - Version Update: Phase 1 Fixes (27.03.2026) Keine Doppelarbeit: - Alle zukünftigen Phase 0b Platzhalter nutzen get_focus_weights() - v2.0 Redesign = nur eine Funktion ändern, nicht 120+ Platzhalter Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
e3f1e399c2
commit
87464ff138
11
CLAUDE.md
11
CLAUDE.md
|
|
@ -76,9 +76,16 @@ frontend/src/
|
||||||
└── technical/ # MEMBERSHIP_SYSTEM.md
|
└── 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
|
- ✅ **Phase 0a: Minimal Goal System:** Strategic + Tactical Layers implementiert
|
||||||
- ✅ **Migration 022:** goal_mode, goals, training_phases, fitness_tests tables
|
- ✅ **Migration 022:** goal_mode, goals, training_phases, fitness_tests tables
|
||||||
- ✅ **Backend Router:** goals.py mit vollständigem CRUD (490 Zeilen)
|
- ✅ **Backend Router:** goals.py mit vollständigem CRUD (490 Zeilen)
|
||||||
|
|
|
||||||
190
backend/goal_utils.py
Normal file
190
backend/goal_utils.py
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
@ -43,6 +43,7 @@ class GoalUpdate(BaseModel):
|
||||||
target_value: Optional[float] = None
|
target_value: Optional[float] = None
|
||||||
target_date: Optional[date] = None
|
target_date: Optional[date] = None
|
||||||
status: Optional[str] = None # active, reached, abandoned, expired
|
status: Optional[str] = None # active, reached, abandoned, expired
|
||||||
|
is_primary: Optional[bool] = None
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
description: 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():
|
if not cur.fetchone():
|
||||||
raise HTTPException(status_code=404, detail="Ziel nicht gefunden")
|
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
|
# Build update query dynamically
|
||||||
updates = []
|
updates = []
|
||||||
params = []
|
params = []
|
||||||
|
|
@ -222,6 +230,10 @@ def update_goal(goal_id: str, data: GoalUpdate, session: dict = Depends(require_
|
||||||
if data.status == 'reached':
|
if data.status == 'reached':
|
||||||
updates.append("reached_date = CURRENT_DATE")
|
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:
|
if data.name is not None:
|
||||||
updates.append("name = %s")
|
updates.append("name = %s")
|
||||||
params.append(data.name)
|
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()
|
row = cur.fetchone()
|
||||||
return float(row['body_fat_pct']) if row else None
|
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':
|
elif goal_type == 'vo2max':
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT vo2max FROM vitals_baseline
|
SELECT vo2_max FROM vitals_baseline
|
||||||
WHERE profile_id = %s AND vo2max IS NOT NULL
|
WHERE profile_id = %s AND vo2_max IS NOT NULL
|
||||||
ORDER BY date DESC LIMIT 1
|
ORDER BY date DESC LIMIT 1
|
||||||
""", (profile_id,))
|
""", (profile_id,))
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
return float(row['vo2max']) if row else None
|
return float(row['vo2_max']) if row else None
|
||||||
|
|
||||||
elif goal_type == 'rhr':
|
elif goal_type == 'rhr':
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user