feat: goal categories and priorities - backend + API

Implemented multi-dimensional goal priorities (Option B).

**Backend Changes:**
- Migration 028: Added `category` + `priority` columns to goals table
- Auto-migration of existing goals to categories based on goal_type
- GoalCreate/GoalUpdate models extended with category + priority
- New endpoint: GET /api/goals/grouped (returns goals by category)
- Categories: body, training, nutrition, recovery, health, other
- Priorities: 1=high (), 2=medium (), 3=low ()

**API Changes:**
- Added api.listGoalsGrouped() binding

**Frontend (partial):**
- Added GOAL_CATEGORIES + PRIORITY_LEVELS constants
- Extended formData with category + priority fields
- Removed "Gewichtung gesamt" display (useless)
- Load groupedGoals in addition to flat goals list

Next: Complete frontend UI rebuild for category grouping

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lars 2026-03-27 12:30:59 +01:00
parent 2f51b26418
commit 6a3a782bff
3 changed files with 122 additions and 5 deletions

View File

@ -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';

View File

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

View File

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