feat: extend vitals with blood pressure, VO2 max, SpO2, respiratory rate
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:
parent
7433b19b7e
commit
4f53cfffab
29
backend/migrations/014_vitals_extended.sql
Normal file
29
backend/migrations/014_vitals_extended.sql
Normal 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)';
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user