Goalsystem V1 #50
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
134
backend/migrations/027_focus_areas_system.sql
Normal file
134
backend/migrations/027_focus_areas_system.sql
Normal file
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
# ============================================================================
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user