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:
parent
2f51b26418
commit
6a3a782bff
58
backend/migrations/028_goal_categories_priorities.sql
Normal file
58
backend/migrations/028_goal_categories_priorities.sql
Normal 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';
|
||||||
|
|
@ -42,10 +42,12 @@ class FocusAreasUpdate(BaseModel):
|
||||||
class GoalCreate(BaseModel):
|
class GoalCreate(BaseModel):
|
||||||
"""Create or update a concrete goal"""
|
"""Create or update a concrete goal"""
|
||||||
goal_type: str # weight, body_fat, lean_mass, vo2max, strength, flexibility, bp, rhr
|
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
|
target_value: float
|
||||||
unit: str # kg, %, ml/kg/min, bpm, mmHg, cm, reps
|
unit: str # kg, %, ml/kg/min, bpm, mmHg, cm, reps
|
||||||
target_date: Optional[date] = None
|
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
|
name: Optional[str] = None
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
|
|
||||||
|
|
@ -54,7 +56,9 @@ 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
|
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
|
name: Optional[str] = None
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
|
|
||||||
|
|
@ -403,13 +407,13 @@ def create_goal(data: GoalCreate, session: dict = Depends(require_auth)):
|
||||||
INSERT INTO goals (
|
INSERT INTO goals (
|
||||||
profile_id, goal_type, is_primary,
|
profile_id, goal_type, is_primary,
|
||||||
target_value, current_value, start_value, unit,
|
target_value, current_value, start_value, unit,
|
||||||
target_date, name, description
|
target_date, category, priority, name, description
|
||||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
""", (
|
""", (
|
||||||
pid, data.goal_type, data.is_primary,
|
pid, data.goal_type, data.is_primary,
|
||||||
data.target_value, current_value, current_value, data.unit,
|
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']
|
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")
|
updates.append("is_primary = %s")
|
||||||
params.append(data.is_primary)
|
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:
|
if data.name is not None:
|
||||||
updates.append("name = %s")
|
updates.append("name = %s")
|
||||||
params.append(data.name)
|
params.append(data.name)
|
||||||
|
|
@ -499,6 +511,52 @@ def delete_goal(goal_id: str, session: dict = Depends(require_auth)):
|
||||||
|
|
||||||
return {"message": "Ziel gelöscht"}
|
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
|
# Training Phases
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
|
||||||
|
|
@ -340,6 +340,7 @@ export const api = {
|
||||||
updateFocusAreas: (d) => req('/goals/focus-areas', jput(d)),
|
updateFocusAreas: (d) => req('/goals/focus-areas', jput(d)),
|
||||||
|
|
||||||
listGoals: () => req('/goals/list'),
|
listGoals: () => req('/goals/list'),
|
||||||
|
listGoalsGrouped: () => req('/goals/grouped'),
|
||||||
createGoal: (d) => req('/goals/create', json(d)),
|
createGoal: (d) => req('/goals/create', json(d)),
|
||||||
updateGoal: (id,d) => req(`/goals/${id}`, jput(d)),
|
updateGoal: (id,d) => req(`/goals/${id}`, jput(d)),
|
||||||
deleteGoal: (id) => req(`/goals/${id}`, {method:'DELETE'}),
|
deleteGoal: (id) => req(`/goals/${id}`, {method:'DELETE'}),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user