diff --git a/backend/migrations/028_goal_categories_priorities.sql b/backend/migrations/028_goal_categories_priorities.sql new file mode 100644 index 0000000..5126bc4 --- /dev/null +++ b/backend/migrations/028_goal_categories_priorities.sql @@ -0,0 +1,58 @@ +-- Migration 028: Goal Categories and Priorities +-- Date: 2026-03-27 +-- Purpose: Multi-dimensional goal priorities (one primary goal per category) + +-- ============================================================================ +-- Add category and priority columns +-- ============================================================================ + +ALTER TABLE goals +ADD COLUMN category VARCHAR(50), +ADD COLUMN priority INTEGER DEFAULT 2 CHECK (priority >= 1 AND priority <= 3); + +COMMENT ON COLUMN goals.category IS 'Goal category: body, training, nutrition, recovery, health, other'; +COMMENT ON COLUMN goals.priority IS 'Priority level: 1=high, 2=medium, 3=low'; + +-- ============================================================================ +-- Migrate existing goals to categories based on goal_type +-- ============================================================================ + +UPDATE goals SET category = CASE + -- Body composition goals + WHEN goal_type IN ('weight', 'body_fat', 'lean_mass') THEN 'body' + + -- Training goals + WHEN goal_type IN ('strength', 'flexibility', 'training_frequency') THEN 'training' + + -- Health/cardio goals + WHEN goal_type IN ('vo2max', 'rhr', 'bp', 'hrv') THEN 'health' + + -- Recovery goals + WHEN goal_type IN ('sleep_quality', 'sleep_duration', 'rest_days') THEN 'recovery' + + -- Nutrition goals + WHEN goal_type IN ('calories', 'protein', 'healthy_eating') THEN 'nutrition' + + -- Default + ELSE 'other' +END +WHERE category IS NULL; + +-- ============================================================================ +-- Set priority based on is_primary +-- ============================================================================ + +UPDATE goals SET priority = CASE + WHEN is_primary = true THEN 1 -- Primary goals get priority 1 + ELSE 2 -- Others get priority 2 (medium) +END; + +-- ============================================================================ +-- Create index for category-based queries +-- ============================================================================ + +CREATE INDEX IF NOT EXISTS idx_goals_category_priority +ON goals(profile_id, category, priority) +WHERE is_active = true; + +COMMENT ON INDEX idx_goals_category_priority IS 'Fast lookup for category-grouped goals sorted by priority'; diff --git a/backend/routers/goals.py b/backend/routers/goals.py index 8856aa7..816ec5f 100644 --- a/backend/routers/goals.py +++ b/backend/routers/goals.py @@ -42,10 +42,12 @@ class FocusAreasUpdate(BaseModel): class GoalCreate(BaseModel): """Create or update a concrete goal""" goal_type: str # weight, body_fat, lean_mass, vo2max, strength, flexibility, bp, rhr - is_primary: bool = False + is_primary: bool = False # Kept for backward compatibility target_value: float unit: str # kg, %, ml/kg/min, bpm, mmHg, cm, reps target_date: Optional[date] = None + category: Optional[str] = 'other' # body, training, nutrition, recovery, health, other + priority: Optional[int] = 2 # 1=high, 2=medium, 3=low name: Optional[str] = None description: Optional[str] = None @@ -54,7 +56,9 @@ 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 + is_primary: Optional[bool] = None # Kept for backward compatibility + category: Optional[str] = None # body, training, nutrition, recovery, health, other + priority: Optional[int] = None # 1=high, 2=medium, 3=low name: Optional[str] = None description: Optional[str] = None @@ -403,13 +407,13 @@ def create_goal(data: GoalCreate, session: dict = Depends(require_auth)): INSERT INTO goals ( profile_id, goal_type, is_primary, target_value, current_value, start_value, unit, - target_date, name, description - ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + target_date, category, priority, name, description + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id """, ( pid, data.goal_type, data.is_primary, data.target_value, current_value, current_value, data.unit, - data.target_date, data.name, data.description + data.target_date, data.category, data.priority, data.name, data.description )) goal_id = cur.fetchone()['id'] @@ -461,6 +465,14 @@ def update_goal(goal_id: str, data: GoalUpdate, session: dict = Depends(require_ updates.append("is_primary = %s") params.append(data.is_primary) + if data.category is not None: + updates.append("category = %s") + params.append(data.category) + + if data.priority is not None: + updates.append("priority = %s") + params.append(data.priority) + if data.name is not None: updates.append("name = %s") params.append(data.name) @@ -499,6 +511,52 @@ def delete_goal(goal_id: str, session: dict = Depends(require_auth)): return {"message": "Ziel gelöscht"} +@router.get("/grouped") +def get_goals_grouped(session: dict = Depends(require_auth)): + """ + Get goals grouped by category, sorted by priority. + + Returns structure: + { + "body": [{"id": "...", "goal_type": "weight", "priority": 1, ...}, ...], + "training": [...], + "nutrition": [...], + "recovery": [...], + "health": [...], + "other": [...] + } + """ + pid = session['profile_id'] + + with get_db() as conn: + cur = get_cursor(conn) + + # Get all active goals with type definitions + cur.execute(""" + SELECT + g.id, g.goal_type, g.target_value, g.current_value, g.start_value, + g.unit, g.target_date, g.status, g.is_primary, g.category, g.priority, + g.name, g.description, g.progress_pct, g.linear_projection, + g.created_at, g.updated_at, + gt.label_de, gt.icon, gt.category as type_category + FROM goals g + LEFT JOIN goal_type_definitions gt ON g.goal_type = gt.type_key + WHERE g.profile_id = %s AND g.is_active = true + ORDER BY g.category, g.priority ASC, g.created_at DESC + """, (pid,)) + + goals = cur.fetchall() + + # Group by category + grouped = {} + for goal in goals: + cat = goal['category'] or 'other' + if cat not in grouped: + grouped[cat] = [] + grouped[cat].append(r2d(goal)) + + return grouped + # ============================================================================ # Training Phases # ============================================================================ diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 142830b..a25567e 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -340,6 +340,7 @@ export const api = { updateFocusAreas: (d) => req('/goals/focus-areas', jput(d)), listGoals: () => req('/goals/list'), + listGoalsGrouped: () => req('/goals/grouped'), createGoal: (d) => req('/goals/create', json(d)), updateGoal: (id,d) => req(`/goals/${id}`, jput(d)), deleteGoal: (id) => req(`/goals/${id}`, {method:'DELETE'}),