feat: extend vitals with blood pressure, VO2 max, SpO2, respiratory rate
All checks were successful
Deploy Development / deploy (push) Successful in 42s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s

Migration 014:
- blood_pressure_systolic/diastolic (mmHg)
- pulse (bpm) - during BP measurement
- vo2_max (ml/kg/min) - from Apple Watch
- spo2 (%) - blood oxygen saturation
- respiratory_rate (breaths/min)
- irregular_heartbeat, possible_afib (boolean flags from Omron)
- Added 'omron' to source enum

Backend:
- Updated Pydantic models (VitalsEntry, VitalsUpdate)
- Updated all SELECT queries to include new fields
- Updated INSERT/UPDATE with COALESCE for partial updates
- Validation: at least one vital must be provided

Preparation for Omron + Apple Health imports

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lars 2026-03-23 15:14:34 +01:00
parent 7433b19b7e
commit 4f53cfffab
2 changed files with 121 additions and 15 deletions

View File

@ -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)';

View File

@ -27,6 +27,14 @@ class VitalsEntry(BaseModel):
date: str date: str
resting_hr: Optional[int] = None resting_hr: Optional[int] = None
hrv: 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 note: Optional[str] = None
@ -34,6 +42,14 @@ class VitalsUpdate(BaseModel):
date: Optional[str] = None date: Optional[str] = None
resting_hr: Optional[int] = None resting_hr: Optional[int] = None
hrv: 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 note: Optional[str] = None
@ -54,8 +70,11 @@ def list_vitals(
cur = get_cursor(conn) cur = get_cursor(conn)
cur.execute( cur.execute(
""" """
SELECT id, profile_id, date, resting_hr, hrv, note, source, SELECT id, profile_id, date, resting_hr, hrv,
created_at, updated_at 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 FROM vitals_log
WHERE profile_id = %s WHERE profile_id = %s
ORDER BY date DESC ORDER BY date DESC
@ -78,8 +97,11 @@ def get_vitals_by_date(
cur = get_cursor(conn) cur = get_cursor(conn)
cur.execute( cur.execute(
""" """
SELECT id, profile_id, date, resting_hr, hrv, note, source, SELECT id, profile_id, date, resting_hr, hrv,
created_at, updated_at 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 FROM vitals_log
WHERE profile_id = %s AND date = %s WHERE profile_id = %s AND date = %s
""", """,
@ -100,9 +122,14 @@ def create_vitals(
"""Create or update vitals entry (upsert).""" """Create or update vitals entry (upsert)."""
pid = get_pid(x_profile_id, session) pid = get_pid(x_profile_id, session)
# Validation # Validation: at least one vital must be provided
if entry.resting_hr is None and entry.hrv is None: has_data = any([
raise HTTPException(400, "Mindestens Ruhepuls oder HRV muss angegeben werden") 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: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
@ -110,17 +137,39 @@ def create_vitals(
# Upsert: insert or update if date already exists # Upsert: insert or update if date already exists
cur.execute( cur.execute(
""" """
INSERT INTO vitals_log (profile_id, date, resting_hr, hrv, note, source) INSERT INTO vitals_log (
VALUES (%s, %s, %s, %s, %s, 'manual') 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) ON CONFLICT (profile_id, date)
DO UPDATE SET DO UPDATE SET
resting_hr = EXCLUDED.resting_hr, resting_hr = COALESCE(EXCLUDED.resting_hr, vitals_log.resting_hr),
hrv = EXCLUDED.hrv, hrv = COALESCE(EXCLUDED.hrv, vitals_log.hrv),
note = EXCLUDED.note, 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 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() row = cur.fetchone()
conn.commit() conn.commit()
@ -163,6 +212,30 @@ def update_vitals(
if updates.hrv is not None: if updates.hrv is not None:
fields.append("hrv = %s") fields.append("hrv = %s")
values.append(updates.hrv) 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: if updates.note is not None:
fields.append("note = %s") fields.append("note = %s")
values.append(updates.note) values.append(updates.note)
@ -177,7 +250,11 @@ def update_vitals(
UPDATE vitals_log UPDATE vitals_log
SET {', '.join(fields)} SET {', '.join(fields)}
WHERE id = %s 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) cur.execute(query, values)