mitai-jinkendo/backend/migrations/015_vitals_refactoring.sql
Lars 1866ff9ce6
Some checks failed
Build Test / lint-backend (push) Waiting to run
Build Test / build-frontend (push) Waiting to run
Deploy Development / deploy (push) Has been cancelled
refactor: vitals architecture - separate baseline vs blood pressure
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>
2026-03-23 16:02:40 +01:00

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
-- ══════════════════════════════════════════════════════════════════════════════