diff --git a/backend/migrations/014_vitals_extended.sql b/backend/migrations/014_vitals_extended.sql new file mode 100644 index 0000000..5273239 --- /dev/null +++ b/backend/migrations/014_vitals_extended.sql @@ -0,0 +1,29 @@ +-- Migration 014: Extended Vitals (Blood Pressure, VO2 Max, SpO2, Respiratory Rate) +-- v9d Phase 2d: Complete vitals tracking +-- Date: 2026-03-23 + +-- Add new vitals fields +ALTER TABLE vitals_log +ADD COLUMN IF NOT EXISTS blood_pressure_systolic INTEGER CHECK (blood_pressure_systolic > 0 AND blood_pressure_systolic < 300), +ADD COLUMN IF NOT EXISTS blood_pressure_diastolic INTEGER CHECK (blood_pressure_diastolic > 0 AND blood_pressure_diastolic < 200), +ADD COLUMN IF NOT EXISTS pulse INTEGER CHECK (pulse > 0 AND pulse < 250), +ADD COLUMN IF NOT EXISTS vo2_max DECIMAL(4,1) CHECK (vo2_max > 0 AND vo2_max < 100), +ADD COLUMN IF NOT EXISTS spo2 INTEGER CHECK (spo2 >= 70 AND spo2 <= 100), +ADD COLUMN IF NOT EXISTS respiratory_rate DECIMAL(4,1) CHECK (respiratory_rate > 0 AND respiratory_rate < 60), +ADD COLUMN IF NOT EXISTS irregular_heartbeat BOOLEAN DEFAULT false, +ADD COLUMN IF NOT EXISTS possible_afib BOOLEAN DEFAULT false; + +-- Update source check to include omron +ALTER TABLE vitals_log DROP CONSTRAINT IF EXISTS vitals_log_source_check; +ALTER TABLE vitals_log ADD CONSTRAINT vitals_log_source_check + CHECK (source IN ('manual', 'apple_health', 'garmin', 'omron')); + +-- Comments +COMMENT ON COLUMN vitals_log.blood_pressure_systolic IS 'Systolic blood pressure (mmHg) from Omron or manual entry'; +COMMENT ON COLUMN vitals_log.blood_pressure_diastolic IS 'Diastolic blood pressure (mmHg) from Omron or manual entry'; +COMMENT ON COLUMN vitals_log.pulse IS 'Pulse during blood pressure measurement (bpm)'; +COMMENT ON COLUMN vitals_log.vo2_max IS 'VO2 Max from Apple Watch (ml/kg/min)'; +COMMENT ON COLUMN vitals_log.spo2 IS 'Blood oxygen saturation (%) from Apple Watch'; +COMMENT ON COLUMN vitals_log.respiratory_rate IS 'Respiratory rate (breaths/min) from Apple Watch'; +COMMENT ON COLUMN vitals_log.irregular_heartbeat IS 'Irregular heartbeat detected (Omron)'; +COMMENT ON COLUMN vitals_log.possible_afib IS 'Possible atrial fibrillation (Omron)'; diff --git a/backend/routers/vitals.py b/backend/routers/vitals.py index abeb1e6..abc5b15 100644 --- a/backend/routers/vitals.py +++ b/backend/routers/vitals.py @@ -27,6 +27,14 @@ 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 @@ -34,6 +42,14 @@ 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 @@ -54,8 +70,11 @@ def list_vitals( cur = get_cursor(conn) cur.execute( """ - SELECT id, profile_id, date, resting_hr, hrv, note, source, - created_at, updated_at + 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 @@ -78,8 +97,11 @@ def get_vitals_by_date( cur = get_cursor(conn) cur.execute( """ - SELECT id, profile_id, date, resting_hr, hrv, note, source, - created_at, updated_at + 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 """, @@ -100,9 +122,14 @@ def create_vitals( """Create or update vitals entry (upsert).""" pid = get_pid(x_profile_id, session) - # Validation - if entry.resting_hr is None and entry.hrv is None: - raise HTTPException(400, "Mindestens Ruhepuls oder HRV muss angegeben werden") + # 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) @@ -110,17 +137,39 @@ def create_vitals( # Upsert: insert or update if date already exists cur.execute( """ - INSERT INTO vitals_log (profile_id, date, resting_hr, hrv, note, source) - VALUES (%s, %s, %s, %s, %s, 'manual') + 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 = EXCLUDED.resting_hr, - hrv = EXCLUDED.hrv, - note = EXCLUDED.note, + 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, note, source, created_at, updated_at + 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.note) + (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() @@ -163,6 +212,30 @@ def update_vitals( 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) @@ -177,7 +250,11 @@ def update_vitals( UPDATE vitals_log SET {', '.join(fields)} WHERE id = %s - RETURNING id, profile_id, date, resting_hr, hrv, note, source, created_at, updated_at + 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)