mitai-jinkendo/backend/routers/goals.py
Lars 7ffa8f039b
All checks were successful
Deploy Development / deploy (push) Successful in 51s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 15s
fix: PostgreSQL date subtraction in historical value query
**Error:**
function pg_catalog.extract(unknown, integer) does not exist
HINT: No function matches the given name and argument types.

**Problem:**
In PostgreSQL, date - date returns INTEGER (days), not INTERVAL.
EXTRACT(EPOCH FROM integer) fails because EPOCH expects timestamp/interval.

**Solution:**
Changed from:
  ORDER BY ABS(EXTRACT(EPOCH FROM (date - '2026-01-01')))

To:
  ORDER BY ABS(date - '2026-01-01'::date)

This directly uses the day difference (integer) for sorting,
which is exactly what we need to find the closest date.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 13:22:05 +01:00

764 lines
28 KiB
Python

"""
Goals Router - Core Goal CRUD & Focus Areas (Streamlined v2.0)
Endpoints for managing:
- Strategic focus areas (weighted multi-goal system)
- Tactical goal targets (concrete values with deadlines)
- Grouped goal views
Part of v9h Goal System (Phase 0a).
NOTE: Code split complete! Related endpoints moved to:
- goal_types.py → Goal Type Definitions (Admin CRUD)
- goal_progress.py → Progress tracking
- training_phases.py → Training phase management
- fitness_tests.py → Fitness test recording
"""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from typing import Optional, List
from datetime import date, timedelta
from db import get_db, get_cursor, r2d
from auth import require_auth
from goal_utils import get_current_value_for_goal
router = APIRouter(prefix="/api/goals", tags=["goals"])
# ============================================================================
# Pydantic Models
# ============================================================================
class GoalModeUpdate(BaseModel):
"""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 FocusContribution(BaseModel):
"""Focus area contribution (v2.0)"""
focus_area_id: str
contribution_weight: float = 100.0 # 0-100%
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 # Kept for backward compatibility
target_value: float
unit: str # kg, %, ml/kg/min, bpm, mmHg, cm, reps
target_date: Optional[date] = None
start_date: Optional[date] = None # When goal started (defaults to today, can be historical)
start_value: Optional[float] = None # Auto-populated from start_date if not provided
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
focus_contributions: Optional[List[FocusContribution]] = [] # v2.0: Many-to-Many
class GoalUpdate(BaseModel):
"""Update existing goal"""
target_value: Optional[float] = None
target_date: Optional[date] = None
start_date: Optional[date] = None # Change start date (recalculates start_value)
start_value: Optional[float] = None # Manually override start value
status: Optional[str] = None # active, reached, abandoned, expired
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
focus_contributions: Optional[List[FocusContribution]] = None # v2.0: Many-to-Many
# ============================================================================
# Strategic Layer: Goal Modes
# ============================================================================
@router.get("/mode")
def get_goal_mode(session: dict = Depends(require_auth)):
"""Get user's current strategic goal mode"""
pid = session['profile_id']
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"SELECT goal_mode FROM profiles WHERE id = %s",
(pid,)
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Profil nicht gefunden")
return {
"goal_mode": row['goal_mode'] or 'health',
"description": _get_goal_mode_description(row['goal_mode'] or 'health')
}
@router.put("/mode")
def update_goal_mode(data: GoalModeUpdate, session: dict = Depends(require_auth)):
"""Update user's strategic goal mode"""
pid = session['profile_id']
# Validate goal mode
valid_modes = ['weight_loss', 'strength', 'endurance', 'recomposition', 'health']
if data.goal_mode not in valid_modes:
raise HTTPException(
status_code=400,
detail=f"Ungültiger Goal Mode. Erlaubt: {', '.join(valid_modes)}"
)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"UPDATE profiles SET goal_mode = %s WHERE id = %s",
(data.goal_mode, pid)
)
return {
"goal_mode": data.goal_mode,
"description": _get_goal_mode_description(data.goal_mode)
}
def _get_goal_mode_description(mode: str) -> str:
"""Get description for goal mode"""
descriptions = {
'weight_loss': 'Gewichtsreduktion (Kaloriendefizit, Fettabbau)',
'strength': 'Kraftaufbau (Muskelwachstum, progressive Belastung)',
'endurance': 'Ausdauer (VO2Max, aerobe Kapazität)',
'recomposition': 'Körperkomposition (gleichzeitig Fett ab- und Muskeln aufbauen)',
'health': 'Allgemeine Gesundheit (ausgewogen, präventiv)'
}
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 (user_focus_preferences after Migration 031)
try:
cur.execute("""
SELECT weight_loss_pct, muscle_gain_pct, strength_pct,
endurance_pct, flexibility_pct, health_pct,
created_at, updated_at
FROM user_focus_preferences
WHERE profile_id = %s
LIMIT 1
""", (pid,))
row = cur.fetchone()
except Exception as e:
# Migration 031 not applied yet, try old table name
print(f"[WARNING] user_focus_preferences not found, trying old focus_areas: {e}")
try:
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()
except:
row = None
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 - Core CRUD
# ============================================================================
@router.get("/list")
def list_goals(session: dict = Depends(require_auth)):
"""List all goals for current user"""
pid = session['profile_id']
try:
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("""
SELECT id, goal_type, is_primary, status,
target_value, current_value, start_value, unit,
start_date, target_date, reached_date,
name, description,
progress_pct, projection_date, on_track,
created_at, updated_at
FROM goals
WHERE profile_id = %s
ORDER BY is_primary DESC, created_at DESC
""", (pid,))
goals = [r2d(row) for row in cur.fetchall()]
print(f"[DEBUG] Loaded {len(goals)} goals for profile {pid}")
# Update current values for each goal
for goal in goals:
try:
_update_goal_progress(conn, pid, goal)
except Exception as e:
print(f"[ERROR] Failed to update progress for goal {goal.get('id')}: {e}")
# Continue with other goals even if one fails
return goals
except Exception as e:
print(f"[ERROR] list_goals failed: {e}")
import traceback
traceback.print_exc()
raise HTTPException(
status_code=500,
detail=f"Fehler beim Laden der Ziele: {str(e)}"
)
@router.post("/create")
def create_goal(data: GoalCreate, session: dict = Depends(require_auth)):
"""Create new goal"""
pid = session['profile_id']
with get_db() as conn:
cur = get_cursor(conn)
# If this is set as primary, unset other primary goals
if data.is_primary:
cur.execute(
"UPDATE goals SET is_primary = false WHERE profile_id = %s",
(pid,)
)
# Get current value for this goal type
current_value = _get_current_value_for_goal_type(conn, pid, data.goal_type)
# Determine start_date (default to today if not provided)
start_date = data.start_date if data.start_date else date.today()
# Determine start_value
if data.start_value is not None:
# User explicitly provided start_value
start_value = data.start_value
elif start_date < date.today():
# Historical start date - try to get historical value
start_value = _get_historical_value_for_goal_type(conn, pid, data.goal_type, start_date)
if start_value is None:
# No data on that date, fall back to current value
start_value = current_value
print(f"[WARN] No historical data for {data.goal_type} on {start_date}, using current value")
else:
# Start date is today, use current value
start_value = current_value
# Insert goal
cur.execute("""
INSERT INTO goals (
profile_id, goal_type, is_primary,
target_value, current_value, start_value, unit,
start_date, target_date, category, priority, name, description
) VALUES (%s, %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, start_value, data.unit,
start_date, data.target_date, data.category, data.priority, data.name, data.description
))
goal_id = cur.fetchone()['id']
# v2.0: Insert focus area contributions
if data.focus_contributions:
for contrib in data.focus_contributions:
cur.execute("""
INSERT INTO goal_focus_contributions
(goal_id, focus_area_id, contribution_weight)
VALUES (%s, %s, %s)
ON CONFLICT (goal_id, focus_area_id) DO UPDATE
SET contribution_weight = EXCLUDED.contribution_weight
""", (goal_id, contrib.focus_area_id, contrib.contribution_weight))
return {"id": goal_id, "message": "Ziel erstellt"}
@router.put("/{goal_id}")
def update_goal(goal_id: str, data: GoalUpdate, session: dict = Depends(require_auth)):
"""Update existing goal"""
pid = session['profile_id']
with get_db() as conn:
cur = get_cursor(conn)
# Verify ownership
cur.execute(
"SELECT id FROM goals WHERE id = %s AND profile_id = %s",
(goal_id, pid)
)
if not cur.fetchone():
raise HTTPException(status_code=404, detail="Ziel nicht gefunden")
# If setting this goal as primary, unset all other primary goals
if data.is_primary is True:
cur.execute(
"UPDATE goals SET is_primary = false WHERE profile_id = %s AND id != %s",
(pid, goal_id)
)
# Build update query dynamically
updates = []
params = []
if data.target_value is not None:
updates.append("target_value = %s")
params.append(data.target_value)
if data.target_date is not None:
updates.append("target_date = %s")
params.append(data.target_date)
if data.status is not None:
updates.append("status = %s")
params.append(data.status)
if data.status == 'reached':
updates.append("reached_date = CURRENT_DATE")
if data.is_primary is not None:
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)
if data.description is not None:
updates.append("description = %s")
params.append(data.description)
# Handle start_date and start_value
if data.start_date is not None:
updates.append("start_date = %s")
params.append(data.start_date)
# If start_value not explicitly provided, recalculate from historical data
if data.start_value is None:
# Get goal_type for historical lookup
cur.execute("SELECT goal_type FROM goals WHERE id = %s", (goal_id,))
goal_row = cur.fetchone()
if goal_row:
goal_type = goal_row['goal_type']
historical_value = _get_historical_value_for_goal_type(conn, pid, goal_type, data.start_date)
if historical_value is not None:
updates.append("start_value = %s")
params.append(historical_value)
print(f"[INFO] Auto-populated start_value from {data.start_date}: {historical_value}")
if data.start_value is not None:
updates.append("start_value = %s")
params.append(data.start_value)
# Handle focus_contributions separately (can be updated even if no other changes)
if data.focus_contributions is not None:
# Delete existing contributions
cur.execute(
"DELETE FROM goal_focus_contributions WHERE goal_id = %s",
(goal_id,)
)
# Insert new contributions
for contrib in data.focus_contributions:
cur.execute("""
INSERT INTO goal_focus_contributions
(goal_id, focus_area_id, contribution_weight)
VALUES (%s, %s, %s)
""", (goal_id, contrib.focus_area_id, contrib.contribution_weight))
if not updates and data.focus_contributions is None:
raise HTTPException(status_code=400, detail="Keine Änderungen angegeben")
if updates:
updates.append("updated_at = NOW()")
params.extend([goal_id, pid])
cur.execute(
f"UPDATE goals SET {', '.join(updates)} WHERE id = %s AND profile_id = %s",
tuple(params)
)
return {"message": "Ziel aktualisiert"}
@router.delete("/{goal_id}")
def delete_goal(goal_id: str, session: dict = Depends(require_auth)):
"""Delete goal"""
pid = session['profile_id']
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"DELETE FROM goals WHERE id = %s AND profile_id = %s",
(goal_id, pid)
)
if cur.rowcount == 0:
raise HTTPException(status_code=404, detail="Ziel nicht gefunden")
return {"message": "Ziel gelöscht"}
@router.get("/grouped")
def get_goals_grouped(session: dict = Depends(require_auth)):
"""
Get all goals grouped by category.
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.on_track, g.projection_date,
g.created_at, g.updated_at,
gt.label_de, gt.icon, gt.category as type_category,
gt.source_table, gt.source_column
FROM goals g
LEFT JOIN goal_type_definitions gt ON g.goal_type = gt.type_key
WHERE g.profile_id = %s
ORDER BY g.category, g.priority ASC, g.created_at DESC
""", (pid,))
goals = cur.fetchall()
# v2.0: Load focus_contributions for each goal
goal_ids = [g['id'] for g in goals]
focus_map = {} # goal_id → [contributions]
if goal_ids:
try:
placeholders = ','.join(['%s'] * len(goal_ids))
cur.execute(f"""
SELECT
gfc.goal_id, gfc.contribution_weight,
fa.id as focus_area_id, fa.key, fa.name_de, fa.icon, fa.category
FROM goal_focus_contributions gfc
JOIN focus_area_definitions fa ON gfc.focus_area_id = fa.id
WHERE gfc.goal_id IN ({placeholders})
ORDER BY gfc.contribution_weight DESC
""", tuple(goal_ids))
for row in cur.fetchall():
gid = row['goal_id']
if gid not in focus_map:
focus_map[gid] = []
focus_map[gid].append({
'focus_area_id': row['focus_area_id'],
'key': row['key'],
'name_de': row['name_de'],
'icon': row['icon'],
'category': row['category'],
'contribution_weight': float(row['contribution_weight'])
})
except Exception as e:
# Migration 031 not yet applied - focus_contributions tables don't exist
print(f"[WARNING] Could not load focus_contributions: {e}")
# Continue without focus_contributions (backward compatible)
# Group by category and attach focus_contributions
grouped = {}
for goal in goals:
cat = goal['category'] or 'other'
if cat not in grouped:
grouped[cat] = []
goal_dict = r2d(goal)
goal_dict['focus_contributions'] = focus_map.get(goal['id'], [])
grouped[cat].append(goal_dict)
return grouped
# ============================================================================
# Helper Functions
# ============================================================================
def _get_current_value_for_goal_type(conn, profile_id: str, goal_type: str) -> Optional[float]:
"""
Get current value for a goal type.
DEPRECATED: This function now delegates to the universal fetcher in goal_utils.py.
Phase 1.5: All goal types are now defined in goal_type_definitions table.
Args:
conn: Database connection
profile_id: User's profile ID
goal_type: Goal type key (e.g., 'weight', 'meditation_minutes')
Returns:
Current value or None
"""
# Delegate to universal fetcher (Phase 1.5)
return get_current_value_for_goal(conn, profile_id, goal_type)
def _get_historical_value_for_goal_type(conn, profile_id: str, goal_type: str, target_date: date) -> Optional[float]:
"""
Get historical value for a goal type on a specific date.
Looks for closest value within ±7 days window.
Args:
conn: Database connection
profile_id: User's profile ID
goal_type: Goal type key (e.g., 'weight', 'body_fat')
target_date: Date to query (can be historical)
Returns:
Historical value or None if not found
"""
from goal_utils import get_goal_type_config, get_cursor
# Get goal type configuration
config = get_goal_type_config(conn, goal_type)
if not config:
return None
source_table = config.get('source_table')
source_column = config.get('source_column')
if not source_table or not source_column:
return None
# Query for value closest to target_date (±7 days window)
cur = get_cursor(conn)
try:
# Special handling for different tables
if source_table == 'vitals_baseline':
date_col = 'date'
elif source_table == 'blood_pressure_log':
date_col = 'recorded_at::date'
else:
date_col = 'date'
cur.execute(f"""
SELECT {source_column}
FROM {source_table}
WHERE profile_id = %s
AND {date_col} BETWEEN %s AND %s
ORDER BY ABS({date_col} - %s::date)
LIMIT 1
""", (
profile_id,
target_date - timedelta(days=7),
target_date + timedelta(days=7),
target_date
))
row = cur.fetchone()
if row:
value = row[source_column]
# Convert Decimal to float
return float(value) if value is not None else None
return None
except Exception as e:
print(f"[ERROR] Failed to get historical value for {goal_type} on {target_date}: {e}")
return None
def _update_goal_progress(conn, profile_id: str, goal: dict):
"""Update goal progress (modifies goal dict in-place)"""
# Get current value
current = _get_current_value_for_goal_type(conn, profile_id, goal['goal_type'])
if current is not None and goal['start_value'] is not None and goal['target_value'] is not None:
goal['current_value'] = current
# Calculate progress percentage
total_delta = float(goal['target_value']) - float(goal['start_value'])
current_delta = current - float(goal['start_value'])
if total_delta != 0:
progress_pct = (current_delta / total_delta) * 100
goal['progress_pct'] = round(progress_pct, 2)
# Simple linear projection
if goal['start_date'] and current_delta != 0:
days_elapsed = (date.today() - goal['start_date']).days
if days_elapsed > 0:
days_per_unit = days_elapsed / current_delta
remaining_units = float(goal['target_value']) - current
remaining_days = int(days_per_unit * remaining_units)
goal['projection_date'] = date.today() + timedelta(days=remaining_days)
# Check if on track
if goal['target_date'] and goal['projection_date']:
goal['on_track'] = goal['projection_date'] <= goal['target_date']