diff --git a/backend/routers/vitals.py b/backend/routers/vitals.py deleted file mode 100644 index 36ecb09..0000000 --- a/backend/routers/vitals.py +++ /dev/null @@ -1,684 +0,0 @@ -""" -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 -- POST /api/vitals/import/omron Import Omron CSV -- POST /api/vitals/import/apple-health Import Apple Health CSV -""" -from fastapi import APIRouter, HTTPException, Depends, Header, UploadFile, File -from pydantic import BaseModel -from typing import Optional -from datetime import datetime, timedelta -import logging -import csv -import io -from dateutil import parser as date_parser - -from db import get_db, get_cursor, r2d -from auth import require_auth - -router = APIRouter(prefix="/api/vitals", tags=["vitals"]) -logger = logging.getLogger(__name__) - -# German month mapping for Omron dates -GERMAN_MONTHS = { - 'Januar': '01', 'Jan.': '01', - 'Februar': '02', 'Feb.': '02', - 'März': '03', - 'April': '04', 'Apr.': '04', - 'Mai': '05', - 'Juni': '06', - 'Juli': '07', - 'August': '08', 'Aug.': '08', - 'September': '09', 'Sep.': '09', - 'Oktober': '10', 'Okt.': '10', - 'November': '11', 'Nov.': '11', - 'Dezember': '12', 'Dez.': '12' -} - - -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). - - Post-Migration-015: Routes to vitals_baseline (for RHR, HRV, etc.) - Note: BP measurements should use /api/blood-pressure endpoint instead. - """ - pid = get_pid(x_profile_id, session) - - # Validation: at least one baseline vital must be provided - has_baseline = any([ - entry.resting_hr, entry.hrv, entry.vo2_max, - entry.spo2, entry.respiratory_rate - ]) - - if not has_baseline: - raise HTTPException(400, "Mindestens ein Vitalwert muss angegeben werden (RHR, HRV, VO2Max, SpO2, oder Atemfrequenz)") - - with get_db() as conn: - cur = get_cursor(conn) - - # Upsert into vitals_baseline (Migration 015) - cur.execute( - """ - INSERT INTO vitals_baseline ( - profile_id, date, resting_hr, hrv, - vo2_max, spo2, respiratory_rate, - note, source - ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, 'manual') - ON CONFLICT (profile_id, date) - DO UPDATE SET - resting_hr = COALESCE(EXCLUDED.resting_hr, vitals_baseline.resting_hr), - hrv = COALESCE(EXCLUDED.hrv, vitals_baseline.hrv), - vo2_max = COALESCE(EXCLUDED.vo2_max, vitals_baseline.vo2_max), - spo2 = COALESCE(EXCLUDED.spo2, vitals_baseline.spo2), - respiratory_rate = COALESCE(EXCLUDED.respiratory_rate, vitals_baseline.respiratory_rate), - note = COALESCE(EXCLUDED.note, vitals_baseline.note), - updated_at = CURRENT_TIMESTAMP - RETURNING id, profile_id, date, resting_hr, hrv, - vo2_max, spo2, respiratory_rate, - note, source, created_at, updated_at - """, - (pid, entry.date, entry.resting_hr, entry.hrv, - entry.vo2_max, entry.spo2, entry.respiratory_rate, - entry.note) - ) - row = cur.fetchone() - conn.commit() - - logger.info(f"[VITALS] Upserted baseline vitals for {pid} on {entry.date}") - - # Return in legacy format for backward compatibility - result = r2d(row) - result['blood_pressure_systolic'] = None - result['blood_pressure_diastolic'] = None - result['pulse'] = None - result['irregular_heartbeat'] = None - result['possible_afib'] = None - - return result - - -@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, - AVG(CASE WHEN date >= CURRENT_DATE - INTERVAL '7 days' THEN blood_pressure_systolic END) as avg_bp_sys_7d, - AVG(CASE WHEN date >= CURRENT_DATE - INTERVAL '30 days' THEN blood_pressure_systolic END) as avg_bp_sys_30d, - AVG(CASE WHEN date >= CURRENT_DATE - INTERVAL '7 days' THEN blood_pressure_diastolic END) as avg_bp_dia_7d, - AVG(CASE WHEN date >= CURRENT_DATE - INTERVAL '30 days' THEN blood_pressure_diastolic END) as avg_bp_dia_30d, - AVG(CASE WHEN date >= CURRENT_DATE - INTERVAL '7 days' THEN spo2 END) as avg_spo2_7d, - AVG(CASE WHEN date >= CURRENT_DATE - INTERVAL '30 days' THEN spo2 END) as avg_spo2_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 latest VO2 Max - cur.execute( - """ - SELECT vo2_max - FROM vitals_log - WHERE profile_id = %s AND vo2_max IS NOT NULL - ORDER BY date DESC - LIMIT 1 - """, - (pid,) - ) - vo2_row = cur.fetchone() - latest_vo2 = vo2_row['vo2_max'] if vo2_row else None - - # 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, - "avg_bp_systolic_7d": round(stats_row['avg_bp_sys_7d'], 1) if stats_row['avg_bp_sys_7d'] else None, - "avg_bp_systolic_30d": round(stats_row['avg_bp_sys_30d'], 1) if stats_row['avg_bp_sys_30d'] else None, - "avg_bp_diastolic_7d": round(stats_row['avg_bp_dia_7d'], 1) if stats_row['avg_bp_dia_7d'] else None, - "avg_bp_diastolic_30d": round(stats_row['avg_bp_dia_30d'], 1) if stats_row['avg_bp_dia_30d'] else None, - "avg_spo2_7d": round(stats_row['avg_spo2_7d'], 1) if stats_row['avg_spo2_7d'] else None, - "avg_spo2_30d": round(stats_row['avg_spo2_30d'], 1) if stats_row['avg_spo2_30d'] else None, - "latest_vo2_max": float(latest_vo2) if latest_vo2 else None, - "total_entries": stats_row['total_entries'], - "trend_resting_hr": trend_hr, - "trend_hrv": trend_hrv, - "period_days": days - } - - -# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -# Import Endpoints -# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - - -def parse_omron_date(date_str: str) -> str: - """ - Parse Omron German date format to YYYY-MM-DD. - - Examples: - - "13 März 2026" -> "2026-03-13" - - "28 Feb. 2026" -> "2026-02-28" - """ - parts = date_str.strip().split() - if len(parts) != 3: - raise ValueError(f"Invalid date format: {date_str}") - - day = parts[0].zfill(2) - month_str = parts[1] - year = parts[2] - - # Map German month to number - month = GERMAN_MONTHS.get(month_str) - if not month: - raise ValueError(f"Unknown month: {month_str}") - - return f"{year}-{month}-{day}" - - -@router.post("/import/omron") -async def import_omron_csv( - file: UploadFile = File(...), - x_profile_id: Optional[str] = Header(default=None), - session: dict = Depends(require_auth) -): - """ - Import Omron blood pressure CSV export. - - Expected format: - Datum,Zeit,Systolisch (mmHg),Diastolisch (mmHg),Puls (bpm),... - """ - pid = get_pid(x_profile_id, session) - - # Read file - content = await file.read() - content_str = content.decode('utf-8') - - # Parse CSV - reader = csv.DictReader(io.StringIO(content_str)) - - inserted = 0 - updated = 0 - skipped = 0 - errors = [] - - with get_db() as conn: - cur = get_cursor(conn) - - for row_num, row in enumerate(reader, start=2): - try: - # Parse date - date_str = parse_omron_date(row['Datum']) - - # Parse values - systolic = int(row['Systolisch (mmHg)']) if row['Systolisch (mmHg)'] and row['Systolisch (mmHg)'] != '-' else None - diastolic = int(row['Diastolisch (mmHg)']) if row['Diastolisch (mmHg)'] and row['Diastolisch (mmHg)'] != '-' else None - pulse = int(row['Puls (bpm)']) if row['Puls (bpm)'] and row['Puls (bpm)'] != '-' else None - - # Skip if no data - if not systolic and not diastolic and not pulse: - skipped += 1 - continue - - # Parse flags (optional columns) - irregular = row.get('Unregelmäßiger Herzschlag festgestellt', '').strip() not in ('', '-', ' ') - afib = row.get('Mögliches AFib', '').strip() not in ('', '-', ' ') - - # Upsert - cur.execute( - """ - INSERT INTO vitals_log ( - profile_id, date, blood_pressure_systolic, blood_pressure_diastolic, - pulse, irregular_heartbeat, possible_afib, source - ) - VALUES (%s, %s, %s, %s, %s, %s, %s, 'omron') - ON CONFLICT (profile_id, date) - DO UPDATE SET - 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), - irregular_heartbeat = COALESCE(EXCLUDED.irregular_heartbeat, vitals_log.irregular_heartbeat), - possible_afib = COALESCE(EXCLUDED.possible_afib, vitals_log.possible_afib), - source = CASE WHEN vitals_log.source = 'manual' THEN vitals_log.source ELSE 'omron' END, - updated_at = CURRENT_TIMESTAMP - RETURNING (xmax = 0) AS inserted - """, - (pid, date_str, systolic, diastolic, pulse, irregular, afib) - ) - - result = cur.fetchone() - if result['inserted']: - inserted += 1 - else: - updated += 1 - - except Exception as e: - errors.append(f"Zeile {row_num}: {str(e)}") - logger.error(f"[OMRON-IMPORT] Error at row {row_num}: {e}") - continue - - conn.commit() - - logger.info(f"[OMRON-IMPORT] {pid}: {inserted} inserted, {updated} updated, {skipped} skipped, {len(errors)} errors") - - return { - "message": "Omron CSV Import abgeschlossen", - "inserted": inserted, - "updated": updated, - "skipped": skipped, - "errors": errors[:10] # Limit to first 10 errors - } - - -@router.post("/import/apple-health") -async def import_apple_health_csv( - file: UploadFile = File(...), - x_profile_id: Optional[str] = Header(default=None), - session: dict = Depends(require_auth) -): - """ - Import Apple Health vitals CSV export. - - Expected columns: - - Datum/Uhrzeit - - Ruhepuls (count/min) - - Herzfrequenzvariabilität (ms) - - VO2 max (ml/(kg·min)) - - Blutsauerstoffsättigung (%) - - Atemfrequenz (count/min) - """ - pid = get_pid(x_profile_id, session) - - # Read file - content = await file.read() - content_str = content.decode('utf-8') - - # Parse CSV - reader = csv.DictReader(io.StringIO(content_str)) - - inserted = 0 - updated = 0 - skipped = 0 - errors = [] - - with get_db() as conn: - cur = get_cursor(conn) - - for row_num, row in enumerate(reader, start=2): - try: - # Parse date (format: "2026-02-21 00:00:00") - date_str = row.get('Datum/Uhrzeit', '').split()[0] # Extract date part - if not date_str: - skipped += 1 - continue - - # Parse values (columns might be empty) - resting_hr = None - hrv = None - vo2_max = None - spo2 = None - respiratory_rate = None - - if 'Ruhepuls (count/min)' in row and row['Ruhepuls (count/min)']: - resting_hr = int(float(row['Ruhepuls (count/min)'])) - - if 'Herzfrequenzvariabilität (ms)' in row and row['Herzfrequenzvariabilität (ms)']: - hrv = int(float(row['Herzfrequenzvariabilität (ms)'])) - - if 'VO2 max (ml/(kg·min))' in row and row['VO2 max (ml/(kg·min))']: - vo2_max = float(row['VO2 max (ml/(kg·min))']) - - if 'Blutsauerstoffsättigung (%)' in row and row['Blutsauerstoffsättigung (%)']: - spo2 = int(float(row['Blutsauerstoffsättigung (%)'])) - - if 'Atemfrequenz (count/min)' in row and row['Atemfrequenz (count/min)']: - respiratory_rate = float(row['Atemfrequenz (count/min)']) - - # Skip if no vitals data - if not any([resting_hr, hrv, vo2_max, spo2, respiratory_rate]): - skipped += 1 - continue - - # Upsert - cur.execute( - """ - INSERT INTO vitals_log ( - profile_id, date, resting_hr, hrv, vo2_max, spo2, - respiratory_rate, source - ) - VALUES (%s, %s, %s, %s, %s, %s, %s, 'apple_health') - 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), - 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), - source = CASE WHEN vitals_log.source = 'manual' THEN vitals_log.source ELSE 'apple_health' END, - updated_at = CURRENT_TIMESTAMP - RETURNING (xmax = 0) AS inserted - """, - (pid, date_str, resting_hr, hrv, vo2_max, spo2, respiratory_rate) - ) - - result = cur.fetchone() - if result['inserted']: - inserted += 1 - else: - updated += 1 - - except Exception as e: - errors.append(f"Zeile {row_num}: {str(e)}") - logger.error(f"[APPLE-HEALTH-IMPORT] Error at row {row_num}: {e}") - continue - - conn.commit() - - logger.info(f"[APPLE-HEALTH-IMPORT] {pid}: {inserted} inserted, {updated} updated, {skipped} skipped, {len(errors)} errors") - - return { - "message": "Apple Health CSV Import abgeschlossen", - "inserted": inserted, - "updated": updated, - "skipped": skipped, - "errors": errors[:10] # Limit to first 10 errors - }