-- Migration 015: Vitals Refactoring - Trennung Baseline vs. Context-Dependent -- v9d Phase 2d: Architektur-Verbesserung für bessere Datenqualität -- Date: 2026-03-23 -- ══════════════════════════════════════════════════════════════════════════════ -- STEP 1: Create new tables -- ══════════════════════════════════════════════════════════════════════════════ -- Baseline Vitals (slow-changing, once daily, morning measurement) CREATE TABLE IF NOT EXISTS vitals_baseline ( id SERIAL PRIMARY KEY, profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, date DATE NOT NULL, -- Core baseline vitals resting_hr INTEGER CHECK (resting_hr > 0 AND resting_hr < 120), hrv INTEGER CHECK (hrv > 0 AND hrv < 300), vo2_max DECIMAL(4,1) CHECK (vo2_max > 0 AND vo2_max < 100), spo2 INTEGER CHECK (spo2 >= 70 AND spo2 <= 100), respiratory_rate DECIMAL(4,1) CHECK (respiratory_rate > 0 AND respiratory_rate < 60), -- Future baseline vitals (prepared for expansion) body_temperature DECIMAL(3,1) CHECK (body_temperature > 30 AND body_temperature < 45), resting_metabolic_rate INTEGER CHECK (resting_metabolic_rate > 0), -- Metadata note TEXT, source VARCHAR(20) DEFAULT 'manual' CHECK (source IN ('manual', 'apple_health', 'garmin', 'withings')), created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW(), CONSTRAINT unique_baseline_per_day UNIQUE(profile_id, date) ); CREATE INDEX idx_vitals_baseline_profile_date ON vitals_baseline(profile_id, date DESC); COMMENT ON TABLE vitals_baseline IS 'v9d Phase 2d: Baseline vitals measured once daily (morning, fasted)'; COMMENT ON COLUMN vitals_baseline.resting_hr IS 'Resting heart rate (bpm) - measured in the morning before getting up'; COMMENT ON COLUMN vitals_baseline.hrv IS 'Heart rate variability (ms) - higher is better'; COMMENT ON COLUMN vitals_baseline.vo2_max IS 'VO2 Max (ml/kg/min) - estimated by Apple Watch or lab test'; COMMENT ON COLUMN vitals_baseline.spo2 IS 'Blood oxygen saturation (%) - baseline measurement'; COMMENT ON COLUMN vitals_baseline.respiratory_rate IS 'Respiratory rate (breaths/min) - baseline measurement'; -- ══════════════════════════════════════════════════════════════════════════════ -- Blood Pressure Log (context-dependent, multiple times per day) CREATE TABLE IF NOT EXISTS blood_pressure_log ( id SERIAL PRIMARY KEY, profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, measured_at TIMESTAMP NOT NULL, -- Blood pressure measurements systolic INTEGER NOT NULL CHECK (systolic > 0 AND systolic < 300), diastolic INTEGER NOT NULL CHECK (diastolic > 0 AND diastolic < 200), pulse INTEGER CHECK (pulse > 0 AND pulse < 250), -- Context tagging for correlation analysis context VARCHAR(30) CHECK (context IN ( 'morning_fasted', -- Morgens nüchtern 'after_meal', -- Nach dem Essen 'before_training', -- Vor dem Training 'after_training', -- Nach dem Training 'evening', -- Abends 'stress', -- Bei Stress 'resting', -- Ruhemessung 'other' -- Sonstiges )), -- Warning flags (Omron) irregular_heartbeat BOOLEAN DEFAULT false, possible_afib BOOLEAN DEFAULT false, -- Metadata note TEXT, source VARCHAR(20) DEFAULT 'manual' CHECK (source IN ('manual', 'omron', 'apple_health', 'withings')), created_at TIMESTAMP DEFAULT NOW(), CONSTRAINT unique_bp_measurement UNIQUE(profile_id, measured_at) ); CREATE INDEX idx_blood_pressure_profile_datetime ON blood_pressure_log(profile_id, measured_at DESC); CREATE INDEX idx_blood_pressure_context ON blood_pressure_log(context) WHERE context IS NOT NULL; COMMENT ON TABLE blood_pressure_log IS 'v9d Phase 2d: Blood pressure measurements (multiple per day, context-aware)'; COMMENT ON COLUMN blood_pressure_log.context IS 'Measurement context for correlation analysis'; COMMENT ON COLUMN blood_pressure_log.irregular_heartbeat IS 'Irregular heartbeat detected (Omron device)'; COMMENT ON COLUMN blood_pressure_log.possible_afib IS 'Possible atrial fibrillation (Omron device)'; -- ══════════════════════════════════════════════════════════════════════════════ -- STEP 2: Migrate existing data from vitals_log -- ══════════════════════════════════════════════════════════════════════════════ -- Migrate baseline vitals (RHR, HRV, VO2 Max, SpO2, Respiratory Rate) INSERT INTO vitals_baseline ( profile_id, date, resting_hr, hrv, vo2_max, spo2, respiratory_rate, note, source, created_at, updated_at ) SELECT profile_id, date, resting_hr, hrv, vo2_max, spo2, respiratory_rate, note, source, created_at, updated_at FROM vitals_log WHERE resting_hr IS NOT NULL OR hrv IS NOT NULL OR vo2_max IS NOT NULL OR spo2 IS NOT NULL OR respiratory_rate IS NOT NULL ON CONFLICT (profile_id, date) DO NOTHING; -- Migrate blood pressure measurements -- Note: Use date + 08:00 as default timestamp (morning measurement) INSERT INTO blood_pressure_log ( profile_id, measured_at, systolic, diastolic, pulse, irregular_heartbeat, possible_afib, note, source, created_at ) SELECT profile_id, (date + TIME '08:00:00')::timestamp AS measured_at, blood_pressure_systolic, blood_pressure_diastolic, pulse, irregular_heartbeat, possible_afib, note, CASE WHEN source = 'manual' THEN 'manual' WHEN source = 'omron' THEN 'omron' ELSE 'manual' END AS source, created_at FROM vitals_log WHERE blood_pressure_systolic IS NOT NULL AND blood_pressure_diastolic IS NOT NULL ON CONFLICT (profile_id, measured_at) DO NOTHING; -- ══════════════════════════════════════════════════════════════════════════════ -- STEP 3: Drop old vitals_log table (backup first) -- ══════════════════════════════════════════════════════════════════════════════ -- Rename old table as backup (keep for safety, can be dropped later) ALTER TABLE vitals_log RENAME TO vitals_log_backup_pre_015; -- Drop old index (it's on the renamed table now) DROP INDEX IF EXISTS idx_vitals_profile_date; -- ══════════════════════════════════════════════════════════════════════════════ -- STEP 4: Prepared for future vitals types -- ══════════════════════════════════════════════════════════════════════════════ -- Future tables (commented out, create when needed): -- Glucose Log (for blood sugar tracking) -- CREATE TABLE glucose_log ( -- id SERIAL PRIMARY KEY, -- profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, -- measured_at TIMESTAMP NOT NULL, -- glucose_mg_dl INTEGER NOT NULL CHECK (glucose_mg_dl > 0 AND glucose_mg_dl < 500), -- context VARCHAR(30) CHECK (context IN ( -- 'fasted', 'before_meal', 'after_meal_1h', 'after_meal_2h', 'before_training', 'after_training', 'other' -- )), -- note TEXT, -- source VARCHAR(20) DEFAULT 'manual', -- created_at TIMESTAMP DEFAULT NOW(), -- CONSTRAINT unique_glucose_measurement UNIQUE(profile_id, measured_at) -- ); -- Temperature Log (for illness tracking) -- CREATE TABLE temperature_log ( -- id SERIAL PRIMARY KEY, -- profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, -- measured_at TIMESTAMP NOT NULL, -- temperature_celsius DECIMAL(3,1) NOT NULL CHECK (temperature_celsius > 30 AND temperature_celsius < 45), -- measurement_location VARCHAR(20) CHECK (measurement_location IN ('oral', 'ear', 'forehead', 'armpit')), -- note TEXT, -- created_at TIMESTAMP DEFAULT NOW(), -- CONSTRAINT unique_temperature_measurement UNIQUE(profile_id, measured_at) -- ); -- ══════════════════════════════════════════════════════════════════════════════ -- Migration complete -- ══════════════════════════════════════════════════════════════════════════════