""" Vitals Router - Resting HR + HRV Tracking v9d Phase 2: Vitals Module Endpoints: - GET /api/vitals List vitals (with limit) - GET /api/vitals/by-date/{date} Get vitals for specific date - POST /api/vitals Create/update vitals (upsert) - PUT /api/vitals/{id} Update vitals - DELETE /api/vitals/{id} Delete vitals - GET /api/vitals/stats Get vitals statistics """ from fastapi import APIRouter, HTTPException, Depends, Header from pydantic import BaseModel from typing import Optional from datetime import datetime, timedelta import logging from db import get_db, get_cursor, r2d from auth import require_auth router = APIRouter(prefix="/api/vitals", tags=["vitals"]) logger = logging.getLogger(__name__) class VitalsEntry(BaseModel): date: str resting_hr: Optional[int] = None hrv: Optional[int] = None blood_pressure_systolic: Optional[int] = None blood_pressure_diastolic: Optional[int] = None pulse: Optional[int] = None vo2_max: Optional[float] = None spo2: Optional[int] = None respiratory_rate: Optional[float] = None irregular_heartbeat: Optional[bool] = None possible_afib: Optional[bool] = None note: Optional[str] = None class VitalsUpdate(BaseModel): date: Optional[str] = None resting_hr: Optional[int] = None hrv: Optional[int] = None blood_pressure_systolic: Optional[int] = None blood_pressure_diastolic: Optional[int] = None pulse: Optional[int] = None vo2_max: Optional[float] = None spo2: Optional[int] = None respiratory_rate: Optional[float] = None irregular_heartbeat: Optional[bool] = None possible_afib: Optional[bool] = None note: Optional[str] = None def get_pid(x_profile_id: Optional[str], session: dict) -> str: """Extract profile_id from session (never from header for security).""" return session['profile_id'] @router.get("") def list_vitals( limit: int = 90, x_profile_id: Optional[str] = Header(default=None), session: dict = Depends(require_auth) ): """Get vitals entries for current profile.""" pid = get_pid(x_profile_id, session) with get_db() as conn: cur = get_cursor(conn) cur.execute( """ SELECT id, profile_id, date, resting_hr, hrv, blood_pressure_systolic, blood_pressure_diastolic, pulse, vo2_max, spo2, respiratory_rate, irregular_heartbeat, possible_afib, note, source, created_at, updated_at FROM vitals_log WHERE profile_id = %s ORDER BY date DESC LIMIT %s """, (pid, limit) ) return [r2d(r) for r in cur.fetchall()] @router.get("/by-date/{date}") def get_vitals_by_date( date: str, x_profile_id: Optional[str] = Header(default=None), session: dict = Depends(require_auth) ): """Get vitals entry for a specific date.""" pid = get_pid(x_profile_id, session) with get_db() as conn: cur = get_cursor(conn) cur.execute( """ SELECT id, profile_id, date, resting_hr, hrv, blood_pressure_systolic, blood_pressure_diastolic, pulse, vo2_max, spo2, respiratory_rate, irregular_heartbeat, possible_afib, note, source, created_at, updated_at FROM vitals_log WHERE profile_id = %s AND date = %s """, (pid, date) ) row = cur.fetchone() if not row: raise HTTPException(404, "Keine Vitalwerte für dieses Datum gefunden") return r2d(row) @router.post("") def create_vitals( entry: VitalsEntry, x_profile_id: Optional[str] = Header(default=None), session: dict = Depends(require_auth) ): """Create or update vitals entry (upsert).""" pid = get_pid(x_profile_id, session) # Validation: at least one vital must be provided has_data = any([ entry.resting_hr, entry.hrv, entry.blood_pressure_systolic, entry.blood_pressure_diastolic, entry.vo2_max, entry.spo2, entry.respiratory_rate ]) if not has_data: raise HTTPException(400, "Mindestens ein Vitalwert muss angegeben werden") with get_db() as conn: cur = get_cursor(conn) # Upsert: insert or update if date already exists cur.execute( """ INSERT INTO vitals_log ( profile_id, date, resting_hr, hrv, blood_pressure_systolic, blood_pressure_diastolic, pulse, vo2_max, spo2, respiratory_rate, irregular_heartbeat, possible_afib, note, source ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'manual') ON CONFLICT (profile_id, date) DO UPDATE SET resting_hr = COALESCE(EXCLUDED.resting_hr, vitals_log.resting_hr), hrv = COALESCE(EXCLUDED.hrv, vitals_log.hrv), blood_pressure_systolic = COALESCE(EXCLUDED.blood_pressure_systolic, vitals_log.blood_pressure_systolic), blood_pressure_diastolic = COALESCE(EXCLUDED.blood_pressure_diastolic, vitals_log.blood_pressure_diastolic), pulse = COALESCE(EXCLUDED.pulse, vitals_log.pulse), vo2_max = COALESCE(EXCLUDED.vo2_max, vitals_log.vo2_max), spo2 = COALESCE(EXCLUDED.spo2, vitals_log.spo2), respiratory_rate = COALESCE(EXCLUDED.respiratory_rate, vitals_log.respiratory_rate), irregular_heartbeat = COALESCE(EXCLUDED.irregular_heartbeat, vitals_log.irregular_heartbeat), possible_afib = COALESCE(EXCLUDED.possible_afib, vitals_log.possible_afib), note = COALESCE(EXCLUDED.note, vitals_log.note), updated_at = CURRENT_TIMESTAMP RETURNING id, profile_id, date, resting_hr, hrv, blood_pressure_systolic, blood_pressure_diastolic, pulse, vo2_max, spo2, respiratory_rate, irregular_heartbeat, possible_afib, note, source, created_at, updated_at """, (pid, entry.date, entry.resting_hr, entry.hrv, entry.blood_pressure_systolic, entry.blood_pressure_diastolic, entry.pulse, entry.vo2_max, entry.spo2, entry.respiratory_rate, entry.irregular_heartbeat, entry.possible_afib, entry.note) ) row = cur.fetchone() conn.commit() logger.info(f"[VITALS] Upserted vitals for {pid} on {entry.date}") return r2d(row) @router.put("/{vitals_id}") def update_vitals( vitals_id: int, updates: VitalsUpdate, x_profile_id: Optional[str] = Header(default=None), session: dict = Depends(require_auth) ): """Update existing vitals entry.""" pid = get_pid(x_profile_id, session) with get_db() as conn: cur = get_cursor(conn) # Check ownership cur.execute( "SELECT id FROM vitals_log WHERE id = %s AND profile_id = %s", (vitals_id, pid) ) if not cur.fetchone(): raise HTTPException(404, "Eintrag nicht gefunden") # Build update query dynamically fields = [] values = [] if updates.date is not None: fields.append("date = %s") values.append(updates.date) if updates.resting_hr is not None: fields.append("resting_hr = %s") values.append(updates.resting_hr) if updates.hrv is not None: fields.append("hrv = %s") values.append(updates.hrv) if updates.blood_pressure_systolic is not None: fields.append("blood_pressure_systolic = %s") values.append(updates.blood_pressure_systolic) if updates.blood_pressure_diastolic is not None: fields.append("blood_pressure_diastolic = %s") values.append(updates.blood_pressure_diastolic) if updates.pulse is not None: fields.append("pulse = %s") values.append(updates.pulse) if updates.vo2_max is not None: fields.append("vo2_max = %s") values.append(updates.vo2_max) if updates.spo2 is not None: fields.append("spo2 = %s") values.append(updates.spo2) if updates.respiratory_rate is not None: fields.append("respiratory_rate = %s") values.append(updates.respiratory_rate) if updates.irregular_heartbeat is not None: fields.append("irregular_heartbeat = %s") values.append(updates.irregular_heartbeat) if updates.possible_afib is not None: fields.append("possible_afib = %s") values.append(updates.possible_afib) if updates.note is not None: fields.append("note = %s") values.append(updates.note) if not fields: raise HTTPException(400, "Keine Änderungen angegeben") fields.append("updated_at = CURRENT_TIMESTAMP") values.append(vitals_id) query = f""" UPDATE vitals_log SET {', '.join(fields)} WHERE id = %s RETURNING id, profile_id, date, resting_hr, hrv, blood_pressure_systolic, blood_pressure_diastolic, pulse, vo2_max, spo2, respiratory_rate, irregular_heartbeat, possible_afib, note, source, created_at, updated_at """ cur.execute(query, values) row = cur.fetchone() conn.commit() return r2d(row) @router.delete("/{vitals_id}") def delete_vitals( vitals_id: int, x_profile_id: Optional[str] = Header(default=None), session: dict = Depends(require_auth) ): """Delete vitals entry.""" pid = get_pid(x_profile_id, session) with get_db() as conn: cur = get_cursor(conn) # Check ownership and delete cur.execute( "DELETE FROM vitals_log WHERE id = %s AND profile_id = %s RETURNING id", (vitals_id, pid) ) if not cur.fetchone(): raise HTTPException(404, "Eintrag nicht gefunden") conn.commit() logger.info(f"[VITALS] Deleted vitals {vitals_id} for {pid}") return {"message": "Eintrag gelöscht"} @router.get("/stats") def get_vitals_stats( days: int = 30, x_profile_id: Optional[str] = Header(default=None), session: dict = Depends(require_auth) ): """ Get vitals statistics over the last N days. Returns: - avg_resting_hr (7d and 30d) - avg_hrv (7d and 30d) - trend (increasing/decreasing/stable) - latest values """ pid = get_pid(x_profile_id, session) with get_db() as conn: cur = get_cursor(conn) # Get latest entry cur.execute( """ SELECT date, resting_hr, hrv FROM vitals_log WHERE profile_id = %s AND date >= CURRENT_DATE - INTERVAL '%s days' ORDER BY date DESC LIMIT 1 """, (pid, days) ) latest = cur.fetchone() # Get averages (7d and 30d) cur.execute( """ SELECT AVG(CASE WHEN date >= CURRENT_DATE - INTERVAL '7 days' THEN resting_hr END) as avg_hr_7d, AVG(CASE WHEN date >= CURRENT_DATE - INTERVAL '30 days' THEN resting_hr END) as avg_hr_30d, AVG(CASE WHEN date >= CURRENT_DATE - INTERVAL '7 days' THEN hrv END) as avg_hrv_7d, AVG(CASE WHEN date >= CURRENT_DATE - INTERVAL '30 days' THEN hrv END) as avg_hrv_30d, COUNT(*) as total_entries FROM vitals_log WHERE profile_id = %s AND date >= CURRENT_DATE - INTERVAL '%s days' """, (pid, max(days, 30)) ) stats_row = cur.fetchone() # Get entries for trend calculation (last 14 days) cur.execute( """ SELECT date, resting_hr, hrv FROM vitals_log WHERE profile_id = %s AND date >= CURRENT_DATE - INTERVAL '14 days' ORDER BY date ASC """, (pid,) ) entries = [r2d(r) for r in cur.fetchall()] # Simple trend calculation (compare first half vs second half) trend_hr = "stable" trend_hrv = "stable" if len(entries) >= 4: mid = len(entries) // 2 first_half_hr = [e['resting_hr'] for e in entries[:mid] if e['resting_hr']] second_half_hr = [e['resting_hr'] for e in entries[mid:] if e['resting_hr']] if first_half_hr and second_half_hr: avg_first = sum(first_half_hr) / len(first_half_hr) avg_second = sum(second_half_hr) / len(second_half_hr) diff = avg_second - avg_first if diff > 2: trend_hr = "increasing" elif diff < -2: trend_hr = "decreasing" first_half_hrv = [e['hrv'] for e in entries[:mid] if e['hrv']] second_half_hrv = [e['hrv'] for e in entries[mid:] if e['hrv']] if first_half_hrv and second_half_hrv: avg_first_hrv = sum(first_half_hrv) / len(first_half_hrv) avg_second_hrv = sum(second_half_hrv) / len(second_half_hrv) diff_hrv = avg_second_hrv - avg_first_hrv if diff_hrv > 5: trend_hrv = "increasing" elif diff_hrv < -5: trend_hrv = "decreasing" return { "latest": r2d(latest) if latest else None, "avg_resting_hr_7d": round(stats_row['avg_hr_7d'], 1) if stats_row['avg_hr_7d'] else None, "avg_resting_hr_30d": round(stats_row['avg_hr_30d'], 1) if stats_row['avg_hr_30d'] else None, "avg_hrv_7d": round(stats_row['avg_hrv_7d'], 1) if stats_row['avg_hrv_7d'] else None, "avg_hrv_30d": round(stats_row['avg_hrv_30d'], 1) if stats_row['avg_hrv_30d'] else None, "total_entries": stats_row['total_entries'], "trend_resting_hr": trend_hr, "trend_hrv": trend_hrv, "period_days": days }