""" Goals Router - Goal System (Strategic + Tactical) Endpoints for managing: - Strategic goal modes (weight_loss, strength, etc.) - Tactical goal targets (concrete values with deadlines) - Training phase detection - Fitness tests Part of v9e Goal System implementation. """ from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from typing import Optional, List from datetime import date, datetime, timedelta from decimal import Decimal from db import get_db, get_cursor, r2d from auth import require_auth router = APIRouter(prefix="/api/goals", tags=["goals"]) # ============================================================================ # Pydantic Models # ============================================================================ class GoalModeUpdate(BaseModel): """Update strategic goal mode""" goal_mode: str # weight_loss, strength, endurance, recomposition, health 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 target_value: float unit: str # kg, %, ml/kg/min, bpm, mmHg, cm, reps target_date: Optional[date] = None name: Optional[str] = None description: Optional[str] = None class GoalUpdate(BaseModel): """Update existing goal""" target_value: Optional[float] = None target_date: Optional[date] = None status: Optional[str] = None # active, reached, abandoned, expired is_primary: Optional[bool] = None name: Optional[str] = None description: Optional[str] = None class TrainingPhaseCreate(BaseModel): """Create training phase (manual or auto-detected)""" phase_type: str # calorie_deficit, calorie_surplus, deload, maintenance, periodization start_date: date end_date: Optional[date] = None notes: Optional[str] = None class FitnessTestCreate(BaseModel): """Record fitness test result""" test_type: str result_value: float result_unit: str test_date: date test_conditions: Optional[str] = None # ============================================================================ # 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') # ============================================================================ # Tactical Layer: Concrete Goals # ============================================================================ @router.get("/list") def list_goals(session: dict = Depends(require_auth)): """List all goals for current user""" pid = session['profile_id'] 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()] # Update current values for each goal for goal in goals: _update_goal_progress(conn, pid, goal) return goals @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) # Insert goal cur.execute(""" 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) 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 )) goal_id = cur.fetchone()['id'] 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.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) if not updates: raise HTTPException(status_code=400, detail="Keine Änderungen angegeben") 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"} # ============================================================================ # Training Phases # ============================================================================ @router.get("/phases") def list_training_phases(session: dict = Depends(require_auth)): """List training phases""" pid = session['profile_id'] with get_db() as conn: cur = get_cursor(conn) cur.execute(""" SELECT id, phase_type, detected_automatically, confidence_score, status, start_date, end_date, duration_days, detection_params, notes, created_at FROM training_phases WHERE profile_id = %s ORDER BY start_date DESC """, (pid,)) return [r2d(row) for row in cur.fetchall()] @router.post("/phases") def create_training_phase(data: TrainingPhaseCreate, session: dict = Depends(require_auth)): """Create training phase (manual)""" pid = session['profile_id'] with get_db() as conn: cur = get_cursor(conn) duration = None if data.end_date: duration = (data.end_date - data.start_date).days cur.execute(""" INSERT INTO training_phases ( profile_id, phase_type, detected_automatically, status, start_date, end_date, duration_days, notes ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s) RETURNING id """, ( pid, data.phase_type, False, 'active', data.start_date, data.end_date, duration, data.notes )) phase_id = cur.fetchone()['id'] return {"id": phase_id, "message": "Trainingsphase erstellt"} @router.put("/phases/{phase_id}/status") def update_phase_status( phase_id: str, status: str, session: dict = Depends(require_auth) ): """Update training phase status (accept/reject auto-detected phases)""" pid = session['profile_id'] valid_statuses = ['suggested', 'accepted', 'active', 'completed', 'rejected'] if status not in valid_statuses: raise HTTPException( status_code=400, detail=f"Ungültiger Status. Erlaubt: {', '.join(valid_statuses)}" ) with get_db() as conn: cur = get_cursor(conn) cur.execute( "UPDATE training_phases SET status = %s WHERE id = %s AND profile_id = %s", (status, phase_id, pid) ) if cur.rowcount == 0: raise HTTPException(status_code=404, detail="Trainingsphase nicht gefunden") return {"message": "Status aktualisiert"} # ============================================================================ # Fitness Tests # ============================================================================ @router.get("/tests") def list_fitness_tests(session: dict = Depends(require_auth)): """List all fitness tests""" pid = session['profile_id'] with get_db() as conn: cur = get_cursor(conn) cur.execute(""" SELECT id, test_type, result_value, result_unit, test_date, test_conditions, norm_category, created_at FROM fitness_tests WHERE profile_id = %s ORDER BY test_date DESC """, (pid,)) return [r2d(row) for row in cur.fetchall()] @router.post("/tests") def create_fitness_test(data: FitnessTestCreate, session: dict = Depends(require_auth)): """Record fitness test result""" pid = session['profile_id'] with get_db() as conn: cur = get_cursor(conn) # Calculate norm category (simplified for now) norm_category = _calculate_norm_category( data.test_type, data.result_value, data.result_unit ) cur.execute(""" INSERT INTO fitness_tests ( profile_id, test_type, result_value, result_unit, test_date, test_conditions, norm_category ) VALUES (%s, %s, %s, %s, %s, %s, %s) RETURNING id """, ( pid, data.test_type, data.result_value, data.result_unit, data.test_date, data.test_conditions, norm_category )) test_id = cur.fetchone()['id'] return {"id": test_id, "norm_category": norm_category} # ============================================================================ # 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 from latest data""" cur = get_cursor(conn) if goal_type == 'weight': cur.execute(""" SELECT weight FROM weight_log WHERE profile_id = %s ORDER BY date DESC LIMIT 1 """, (profile_id,)) row = cur.fetchone() return float(row['weight']) if row else None elif goal_type == 'body_fat': cur.execute(""" SELECT body_fat_pct FROM caliper_log WHERE profile_id = %s ORDER BY date DESC LIMIT 1 """, (profile_id,)) row = cur.fetchone() return float(row['body_fat_pct']) if row else None elif goal_type == 'lean_mass': # Calculate lean mass: weight - (weight * body_fat_pct / 100) # Need both latest weight and latest body fat percentage cur.execute(""" SELECT weight FROM weight_log WHERE profile_id = %s ORDER BY date DESC LIMIT 1 """, (profile_id,)) weight_row = cur.fetchone() cur.execute(""" SELECT body_fat_pct FROM caliper_log WHERE profile_id = %s ORDER BY date DESC LIMIT 1 """, (profile_id,)) bf_row = cur.fetchone() if weight_row and bf_row: weight = float(weight_row['weight']) bf_pct = float(bf_row['body_fat_pct']) lean_mass = weight - (weight * bf_pct / 100.0) return round(lean_mass, 2) return None elif goal_type == 'vo2max': cur.execute(""" SELECT vo2_max FROM vitals_baseline WHERE profile_id = %s AND vo2_max IS NOT NULL ORDER BY date DESC LIMIT 1 """, (profile_id,)) row = cur.fetchone() return float(row['vo2_max']) if row else None elif goal_type == 'rhr': cur.execute(""" SELECT resting_hr FROM vitals_baseline WHERE profile_id = %s AND resting_hr IS NOT NULL ORDER BY date DESC LIMIT 1 """, (profile_id,)) row = cur.fetchone() return float(row['resting_hr']) if row else None 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'] def _calculate_norm_category(test_type: str, value: float, unit: str) -> Optional[str]: """ Calculate norm category for fitness test (Simplified - would need age/gender-specific norms) """ # Placeholder - should use proper norm tables return None