BREAKING CHANGE: vitals_log split into vitals_baseline + blood_pressure_log
**Architektur-Änderung:**
- Baseline-Vitals (langsam veränderlich, 1x täglich morgens)
→ vitals_baseline (RHR, HRV, VO2 Max, SpO2, Atemfrequenz)
- Kontext-abhängige Vitals (mehrfach täglich, situativ)
→ blood_pressure_log (Blutdruck + Kontext-Tagging)
**Migration 015:**
- CREATE TABLE vitals_baseline (once daily, morning measurements)
- CREATE TABLE blood_pressure_log (multiple daily, context-aware)
- Migrate data from vitals_log → new tables
- Rename vitals_log → vitals_log_backup_pre_015 (safety)
- Prepared for future: glucose_log, temperature_log (commented)
**Backend:**
- NEW: routers/vitals_baseline.py (CRUD + Apple Health import)
- NEW: routers/blood_pressure.py (CRUD + Omron import + context)
- UPDATED: main.py (register new routers, remove old vitals)
- UPDATED: insights.py (query new tables, split template vars)
**Frontend:**
- UPDATED: api.js (new endpoints für baseline + BP)
- UPDATED: Analysis.jsx (add {{bp_summary}} variable)
**Nächster Schritt:**
- Frontend: VitalsPage.jsx refactoren (3 Tabs: Morgenmessung, Blutdruck, Import)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
185 lines
9.4 KiB
SQL
185 lines
9.4 KiB
SQL
-- 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
|
|
-- ══════════════════════════════════════════════════════════════════════════════
|