Goalsystem V1 #50

Merged
Lars merged 51 commits from develop into main 2026-03-27 17:40:51 +01:00
4 changed files with 385 additions and 36 deletions
Showing only changes of commit 4a11d20c4d - Show all commits

View File

@ -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
}
}

View 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;

View File

@ -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
# ============================================================================

View File

@ -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)),