# Technische Spezifikation: v9d Phase 2 – Vitalwerte & Schlaf **Version:** 1.1 **Status:** Review-Änderungen eingearbeitet **Datum:** 22.03.2026 **Basis:** TRAINING_TYPES.md + SLEEP_MODULE.md **Änderungen v1.1:** - Wochenplanung → DB-persistiert (weekly_goals Tabelle) - Schlafphasen-Segmente → JSONB-Spalte hinzugefügt (sleep_segments) - Erholungsstatus-Gewichtung → 50% HRV / 30% Ruhepuls / 20% Training - Schlafschulden-Zeitraum → 14 Tage (statt 7) - Ruhepuls-Warnung → beide Baselines (7d + 30d) - Vitalwerte-Seite → eigener Nav-Eintrag - Implementierungs-Reihenfolge → Schlaf zuerst (2b → 2c → 2d → 2a → 2e) --- ## 1. Entscheidungen zu offenen Fragen ### 1.1 TRAINING_TYPES.md **Q1: HRV-Erfassung – eigene Tabelle oder Spalte in vitals_log?** ✅ **Entscheidung:** Spalte in `vitals_log` (nullable) **Begründung:** HRV und Ruhepuls werden beide morgens gemessen, gehören konzeptionell zusammen. Separate Tabelle würde Joins und Korrelationen komplizieren. **Q2: Wochenplanung – DB persistieren oder Session-Only?** ✅ **Entscheidung:** DB-persistiert (Tabelle `weekly_goals`) **Begründung:** localStorage geht bei Browser-Wechsel verloren. Einfaches Soll/Ist-Tracking für Wochenplanung wird in Phase 2 implementiert. Erweiterte Ziele-Features kommen in v9f. **Q3: HF-Kurven aus Apple Health oder nur Avg/Max?** ✅ **Entscheidung:** Nur Avg/Max für Phase 2 **Begründung:** HF-Kurven (Zeit × HF) benötigen komplexe Zeitserien-Struktur (JSONB oder separate Tabelle). Avg/Max deckt 80% der Use Cases ab. Kurven kommen in v9h (Connectoren). ### 1.2 SLEEP_MODULE.md **Q4: sleep_log oder Erweiterung von activity_log?** ✅ **Entscheidung:** Separate Tabelle `sleep_log` **Begründung:** Schlaf ist konzeptionell keine Aktivität. Unterschiedliche Felder (bedtime, wake_time, quality, phases) passen nicht in activity_log Schema. Separate Tabelle ermöglicht saubere Korrelations-Queries. **Q5: Schlafphasen – separate Spalten oder JSONB?** ✅ **Entscheidung:** Beides – Summen-Spalten + JSONB für Segmente **Begründung:** - **Summen-Spalten** (`deep_minutes`, `rem_minutes`, `light_minutes`, `awake_minutes`): Einfach zu querien, performant für Aggregationen - **JSONB-Spalte** (`sleep_segments`, nullable): Rohdaten der Phasen-Übergänge aus Apple Health Import - Format: `[{"phase": "deep", "start": "23:44", "duration_min": 42}, ...]` - Nur bei Import befüllt, bei manueller Eingabe NULL - Basis für spätere Analyse (Zykluslänge, Unterbrechungsmuster) in v9e/f **Q6: Morgendlicher Check-in – Frontend oder Backend?** ✅ **Entscheidung:** Frontend-State **Begründung:** Frontend prüft beim Dashboard-Load ob Schlaf-Eintrag für letzte Nacht existiert (`GET /api/sleep?date={yesterday}`). Kein Backend-State nötig, keine zusätzliche Komplexität. **Q7: Korrelationsberechnung – Backend oder Frontend?** ✅ **Entscheidung:** Backend (Python) **Begründung:** Konsistent mit bestehenden Correlation-Endpoints (`/api/nutrition/correlations`). Python ist besser für statistisches Computing (numpy). Frontend rendert nur Ergebnis. **Q8: Apple Health CSV-Format für Schlaf?** ✅ **Analysiert:** Apple Health Schlaf-Export hat folgendes Format: ```csv Start,End,Duration (hr),Value,Source 2026-03-14 22:44:23,2026-03-14 23:00:19,0.266,Kern,Apple Watch von Lars 2026-03-14 23:00:19,2026-03-14 23:12:16,0.199,REM,Apple Watch von Lars 2026-03-14 23:12:16,2026-03-14 23:34:40,0.373,Kern,Apple Watch von Lars 2026-03-14 23:34:40,2026-03-14 23:44:38,0.166,Tief,Apple Watch von Lars ``` **Import-Logik:** - Value-Mapping: `Kern` → light_minutes, `REM` → rem_minutes, `Tief` → deep_minutes, `Wach` → awake_minutes - `Schlafend` (initial entry) wird ignoriert, nur Phasen werden gezählt - Aggregation nach Datum: Alle Segmente einer Nacht gruppieren (Einschlafzeit = erster Start, Aufwachzeit = letztes End) - Duration: Summe aller Phasen in Minuten (`Duration (hr) * 60`) - Datum-Zuordnung: Wenn Schlaf über Mitternacht geht → Datum der Aufwachzeit verwenden - **Segmente:** Raw-Daten werden zusätzlich in `sleep_segments` JSONB gespeichert für spätere Analyse - Qualität: Bei Import nicht verfügbar (NULL), User kann nachträglich setzen --- ## 2. Datenbankschema ### 2.1 Neue Tabellen #### `rest_days` ```sql CREATE TABLE rest_days ( id SERIAL PRIMARY KEY, profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, date DATE NOT NULL, rest_config JSONB NOT NULL, note TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, CONSTRAINT unique_rest_day_per_profile UNIQUE(profile_id, date) ); CREATE INDEX idx_rest_days_profile_date ON rest_days(profile_id, date DESC); ``` **JSONB Schema (`rest_config`):** ```typescript interface RestConfig { focus: 'muscle_recovery' | 'cardio_recovery' | 'mental_rest' | 'deload' | 'injury' rest_from: string[] // Training type IDs (z.B. ['strength', 'hiit']) allows: string[] // Erlaubte Aktivitäten (z.B. ['cardio_low', 'meditation']) intensity_max?: number // Max HF% (1-100), optional note?: string // Zusätzliche Info, optional } ``` **Beispiele:** ```json // Krafttraining-Ruhetag { "focus": "muscle_recovery", "rest_from": ["strength", "hiit"], "allows": ["cardio_low", "meditation", "mobility"], "intensity_max": 60 } // Kompletter Entspannungstag { "focus": "mental_rest", "rest_from": ["strength", "cardio", "hiit", "power"], "allows": ["meditation", "walk"], "intensity_max": 50 } // Deload-Woche { "focus": "deload", "rest_from": [], "allows": ["strength", "cardio", "mobility"], "intensity_max": 70, "note": "Deload Week 4 - 70% Volumen" } ``` **Erklärung:** - **Flexibles Modell** statt binärem `full_rest`/`active_recovery` - **Kontext-spezifische Ruhetage**: Ruhe von Kraft, aber Cardio erlaubt - **Integration mit Wochenplanung**: Regelbasierte Validierung möglich - **Intensitätsbegrenzung**: Max HF% für erlaubte Aktivitäten - UNIQUE Constraint verhindert Duplikate pro Tag #### `vitals_log` ```sql CREATE TABLE vitals_log ( id SERIAL PRIMARY KEY, profile_id VARCHAR(36) NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, date DATE NOT NULL, resting_hr INTEGER CHECK (resting_hr > 0 AND resting_hr < 200), hrv INTEGER CHECK (hrv > 0), -- nullable, in Millisekunden note TEXT, source VARCHAR(20) DEFAULT 'manual' CHECK (source IN ('manual', 'apple_health', 'garmin')), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, CONSTRAINT unique_vitals_per_day UNIQUE(profile_id, date) ); CREATE INDEX idx_vitals_profile_date ON vitals_log(profile_id, date DESC); ``` **Erklärung:** - `resting_hr` = Ruhepuls in bpm (Schläge pro Minute) - `hrv` = Herzratenvariabilität in ms (optional, nicht alle Geräte messen das) - Upsert-Logik: Bei Reimport überschreiben wenn source = 'manual' Vorrang hat #### `weekly_goals` ```sql CREATE TABLE weekly_goals ( id SERIAL PRIMARY KEY, profile_id VARCHAR(36) NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, week_start DATE NOT NULL, -- Montag der Woche (ISO Week) goals JSONB NOT NULL, -- {"strength": 3, "cardio": 2, "mobility": 1, "rest": 1} created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, CONSTRAINT unique_weekly_goal_per_profile UNIQUE(profile_id, week_start) ); CREATE INDEX idx_weekly_goals_profile_week ON weekly_goals(profile_id, week_start DESC); ``` **Erklärung:** - `week_start` = Montag der Woche (z.B. 2026-03-17 für KW 12) - `goals` = JSONB mit Soll-Werten pro Trainingstyp-Kategorie - Beispiel: `{"strength": 3, "cardio": 2, "mobility": 1, "rest": 1}` = 3x Kraft, 2x Cardio, 1x Mobility, 1 Ruhetag - Flexible Struktur, kann um weitere Kategorien erweitert werden - Dashboard zeigt Soll/Ist-Vergleich für aktuelle Woche #### `sleep_log` ```sql CREATE TABLE sleep_log ( id SERIAL PRIMARY KEY, profile_id VARCHAR(36) NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, date DATE NOT NULL, -- Datum der Nacht (= Aufwachdatum) bedtime TIME, -- Einschlafzeit (nullable, z.B. 23:15) wake_time TIME, -- Aufwachzeit (nullable, z.B. 06:45) duration_minutes INTEGER NOT NULL CHECK (duration_minutes > 0), -- Gesamtschlafdauer quality INTEGER CHECK (quality >= 1 AND quality <= 5), -- nullable, subjektiv 1-5 wake_count INTEGER CHECK (wake_count >= 0), -- nullable, wie oft aufgewacht deep_minutes INTEGER CHECK (deep_minutes >= 0), -- Tiefschlaf rem_minutes INTEGER CHECK (rem_minutes >= 0), -- REM-Schlaf light_minutes INTEGER CHECK (light_minutes >= 0), -- Leichtschlaf (Kern) awake_minutes INTEGER CHECK (awake_minutes >= 0), -- Wachphasen im Bett sleep_segments JSONB, -- Rohdaten: [{"phase": "deep", "start": "23:44", "duration_min": 42}, ...] note TEXT, source VARCHAR(20) DEFAULT 'manual' CHECK (source IN ('manual', 'apple_health', 'garmin')), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, CONSTRAINT unique_sleep_per_day UNIQUE(profile_id, date) ); CREATE INDEX idx_sleep_profile_date ON sleep_log(profile_id, date DESC); ``` **Erklärung:** - `date` = Datum der Nacht (wichtig: Aufwachdatum, nicht Einschlafdatum!) - `bedtime` + `wake_time` = nullable, nur wenn bekannt - `duration_minutes` = Pflichtfeld, kann aus bedtime/wake_time berechnet oder direkt eingegeben werden - Phasen-Summen (deep/rem/light/awake_minutes) = nullable (nur wenn Smartwatch-Daten vorhanden) - `sleep_segments` = JSONB nullable, enthält Rohdaten der Phasen-Übergänge aus Import - Format: `[{"phase": "deep|rem|light|awake", "start": "HH:MM", "duration_min": N}, ...]` - Nur bei Import befüllt, bei manueller Eingabe NULL - Basis für spätere Zyklen-Analyse (v9e/f) ### 2.2 Erweiterte Tabellen #### `activity_log` – Neue Spalten für Herzfrequenz ```sql ALTER TABLE activity_log ADD COLUMN avg_hr INTEGER CHECK (avg_hr > 0 AND avg_hr < 250), ADD COLUMN max_hr INTEGER CHECK (max_hr > 0 AND max_hr < 250); ``` **Erklärung:** - `avg_hr` = Durchschnittliche Herzfrequenz während Training (bpm) - `max_hr` = Maximale Herzfrequenz während Training (bpm) - Beide nullable (manuelle Einträge haben oft keine HF-Daten) #### `profiles` – HF-Max für Zonen-Berechnung ```sql ALTER TABLE profiles ADD COLUMN hf_max INTEGER CHECK (hf_max > 0 AND hf_max < 250), ADD COLUMN sleep_goal_minutes INTEGER DEFAULT 450 CHECK (sleep_goal_minutes > 0); -- 7h 30min ``` **Erklärung:** - `hf_max` = Maximale Herzfrequenz (nullable, Standard: 220 - Alter) - `sleep_goal_minutes` = Schlafziel in Minuten (Standard: 450 = 7h 30min) --- ## 3. Migrationen ### Migration 008: Vitalwerte & Ruhetage (initial) **Datei:** `backend/migrations/008_vitals_rest_days.sql` **Status:** Deployed to production (mit einfachem type-Schema) ```sql -- Rest Days (initial simple schema) CREATE TABLE IF NOT EXISTS rest_days ( id SERIAL PRIMARY KEY, profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, date DATE NOT NULL, type VARCHAR(20) NOT NULL CHECK (type IN ('full_rest', 'active_recovery')), note TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, CONSTRAINT unique_rest_day_per_profile UNIQUE(profile_id, date) ); CREATE INDEX idx_rest_days_profile_date ON rest_days(profile_id, date DESC); ``` ### Migration 010: Rest Days Refactoring zu JSONB **Datei:** `backend/migrations/010_rest_days_jsonb.sql` **Status:** In Entwicklung ```sql -- Refactor rest_days to JSONB config for flexible rest day types ALTER TABLE rest_days DROP COLUMN IF EXISTS type; ALTER TABLE rest_days ADD COLUMN rest_config JSONB NOT NULL DEFAULT '{}'::jsonb; -- Validation function for rest_config CREATE OR REPLACE FUNCTION validate_rest_config(config JSONB) RETURNS BOOLEAN AS $$ BEGIN -- Must have focus field IF NOT (config ? 'focus') THEN RETURN FALSE; END IF; -- rest_from must be array if present IF (config ? 'rest_from') AND jsonb_typeof(config->'rest_from') != 'array' THEN RETURN FALSE; END IF; -- allows must be array if present IF (config ? 'allows') AND jsonb_typeof(config->'allows') != 'array' THEN RETURN FALSE; END IF; RETURN TRUE; END; $$ LANGUAGE plpgsql; -- Add check constraint ALTER TABLE rest_days ADD CONSTRAINT valid_rest_config CHECK (validate_rest_config(rest_config)); COMMENT ON COLUMN rest_days.rest_config IS 'JSONB: {focus, rest_from[], allows[], intensity_max, note}'; ``` -- Vitals (Resting HR + HRV) CREATE TABLE IF NOT EXISTS vitals_log ( id SERIAL PRIMARY KEY, profile_id VARCHAR(36) NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, date DATE NOT NULL, resting_hr INTEGER CHECK (resting_hr > 0 AND resting_hr < 200), hrv INTEGER CHECK (hrv > 0), note TEXT, source VARCHAR(20) DEFAULT 'manual' CHECK (source IN ('manual', 'apple_health', 'garmin')), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, CONSTRAINT unique_vitals_per_day UNIQUE(profile_id, date) ); CREATE INDEX idx_vitals_profile_date ON vitals_log(profile_id, date DESC); -- Extend activity_log for heart rate data ALTER TABLE activity_log ADD COLUMN IF NOT EXISTS avg_hr INTEGER CHECK (avg_hr > 0 AND avg_hr < 250), ADD COLUMN IF NOT EXISTS max_hr INTEGER CHECK (max_hr > 0 AND max_hr < 250); -- Extend profiles for HF max and sleep goal ALTER TABLE profiles ADD COLUMN IF NOT EXISTS hf_max INTEGER CHECK (hf_max > 0 AND hf_max < 250), ADD COLUMN IF NOT EXISTS sleep_goal_minutes INTEGER DEFAULT 450 CHECK (sleep_goal_minutes > 0); -- Weekly Goals (Soll/Ist Wochenplanung) CREATE TABLE IF NOT EXISTS weekly_goals ( id SERIAL PRIMARY KEY, profile_id VARCHAR(36) NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, week_start DATE NOT NULL, goals JSONB NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, CONSTRAINT unique_weekly_goal_per_profile UNIQUE(profile_id, week_start) ); CREATE INDEX idx_weekly_goals_profile_week ON weekly_goals(profile_id, week_start DESC); ``` ### Migration 009: Schlaf-Modul **Datei:** `backend/migrations/009_sleep_log.sql` ```sql CREATE TABLE IF NOT EXISTS sleep_log ( id SERIAL PRIMARY KEY, profile_id VARCHAR(36) NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, date DATE NOT NULL, bedtime TIME, wake_time TIME, duration_minutes INTEGER NOT NULL CHECK (duration_minutes > 0), quality INTEGER CHECK (quality >= 1 AND quality <= 5), wake_count INTEGER CHECK (wake_count >= 0), deep_minutes INTEGER CHECK (deep_minutes >= 0), rem_minutes INTEGER CHECK (rem_minutes >= 0), light_minutes INTEGER CHECK (light_minutes >= 0), awake_minutes INTEGER CHECK (awake_minutes >= 0), sleep_segments JSONB, -- Rohdaten: [{"phase": "deep", "start": "23:44", "duration_min": 42}, ...] note TEXT, source VARCHAR(20) DEFAULT 'manual' CHECK (source IN ('manual', 'apple_health', 'garmin')), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, CONSTRAINT unique_sleep_per_day UNIQUE(profile_id, date) ); CREATE INDEX idx_sleep_profile_date ON sleep_log(profile_id, date DESC); ``` --- ## 4. Backend API-Endpoints ### 4.1 Rest Days (`routers/rest_days.py`) ```python router = APIRouter(prefix="/api/rest-days", tags=["rest-days"]) # CRUD GET /api/rest-days?limit=90 # List rest days (last 90 days) POST /api/rest-days # Create rest day (with config validation) PUT /api/rest-days/{id} # Update rest day DELETE /api/rest-days/{id} # Delete rest day # Stats & Validation GET /api/rest-days/stats # Count per week, last 4 weeks POST /api/rest-days/validate-activity # Check if activity conflicts with rest day ``` **Request/Response Models:** ```python class RestConfig(BaseModel): focus: Literal['muscle_recovery', 'cardio_recovery', 'mental_rest', 'deload', 'injury'] rest_from: list[str] # Training type IDs allows: list[str] # Allowed activity type IDs intensity_max: int | None = None # Max HF% (1-100) note: str = "" class RestDayCreate(BaseModel): date: str # YYYY-MM-DD rest_config: RestConfig note: str = "" class RestDayResponse(BaseModel): id: int profile_id: str date: str rest_config: dict # JSONB as dict note: str created_at: str class ActivityConflictResponse(BaseModel): conflict: bool severity: Literal['warning', 'info', 'none'] message: str ``` **Validation Logic:** ```python def check_rest_day_conflict(profile_id: str, date: str, activity_type: str) -> ActivityConflictResponse: """ Check if activity conflicts with rest day configuration. Returns: - conflict=True, severity='warning': Activity is in rest_from - conflict=True, severity='info': Activity not in allows list - conflict=False: No conflict """ rest_day = get_rest_day(profile_id, date) if not rest_day: return {"conflict": False, "severity": "none", "message": ""} config = rest_day["rest_config"] # Check if activity is in rest_from if activity_type in config.get("rest_from", []): return { "conflict": True, "severity": "warning", "message": f"Ruhetag für {activity_type}. Trotzdem erfassen?" } # Check if activity is allowed if config.get("allows") and activity_type not in config.get("allows", []): return { "conflict": True, "severity": "info", "message": "Aktivität nicht in erlaubten Aktivitäten." } return {"conflict": False, "severity": "none", "message": ""} ``` ### 4.2 Vitals (`routers/vitals.py`) ```python router = APIRouter(prefix="/api/vitals", tags=["vitals"]) # CRUD GET /api/vitals?limit=90 # List vitals (last 90 days) POST /api/vitals # Upsert (by date) PUT /api/vitals/{id} # Update DELETE /api/vitals/{id} # Delete GET /api/vitals/by-date/{date} # Get entry for specific date # Stats & Trends GET /api/vitals/trend?days=30 # Resting HR trend (avg, baseline, warnings) GET /api/vitals/hrv-baseline?days=30 # HRV baseline + deviation ``` **Request/Response Models:** ```python class VitalsCreate(BaseModel): date: str # YYYY-MM-DD resting_hr: int hrv: int | None = None note: str = "" source: Literal['manual', 'apple_health', 'garmin'] = 'manual' class VitalsResponse(BaseModel): id: int profile_id: str date: str resting_hr: int hrv: int | None note: str source: str created_at: str updated_at: str class VitalsTrendResponse(BaseModel): current_hr: int | None # letzter Eintrag avg_hr_7d: float # 7-Tage-Durchschnitt baseline_hr_30d: float # 30-Tage-Baseline is_elevated_7d: bool # >+5 bpm über 7d-Baseline (akut) is_elevated_30d: bool # >+5 bpm über 30d-Baseline (Trend) warning_7d: str | None # "Ruhepuls heute erhöht – Ruhetag?" warning_30d: str | None # "Ruhepuls im Aufwärtstrend – Übertraining?" ``` ### 4.3 Sleep (`routers/sleep.py`) ```python router = APIRouter(prefix="/api/sleep", tags=["sleep"]) # CRUD GET /api/sleep?limit=90 # List sleep entries POST /api/sleep # Create/Upsert sleep entry PUT /api/sleep/{id} # Update DELETE /api/sleep/{id} # Delete GET /api/sleep/by-date/{date} # Get entry for specific date # Import POST /api/sleep/import-csv # Apple Health sleep CSV import # Stats & Trends GET /api/sleep/stats?days=7 # 7-day average (duration, quality) GET /api/sleep/trend?days=30 # Duration + quality trend GET /api/sleep/phases?days=30 # Phase distribution (deep, REM, light, awake) GET /api/sleep/debt?days=14 # Sleep debt calculation (14 days default) GET /api/sleep/optimal-window # Optimal bedtime window (min 14 entries) # Correlations GET /api/sleep/correlations/resting-hr # Sleep ↔ Resting HR correlation GET /api/sleep/correlations/training # Sleep ↔ Training performance GET /api/sleep/correlations/weight # Sleep ↔ Weight (weekly) ``` **Request/Response Models:** ```python class SleepCreate(BaseModel): date: str # YYYY-MM-DD bedtime: str | None = None # HH:MM wake_time: str | None = None # HH:MM duration_minutes: int quality: int | None = None # 1-5 wake_count: int | None = None deep_minutes: int | None = None rem_minutes: int | None = None light_minutes: int | None = None awake_minutes: int | None = None note: str = "" source: Literal['manual', 'apple_health', 'garmin'] = 'manual' class SleepResponse(BaseModel): id: int profile_id: str date: str bedtime: str | None wake_time: str | None duration_minutes: int duration_formatted: str # "7h 30min" quality: int | None wake_count: int | None deep_minutes: int | None rem_minutes: int | None light_minutes: int | None awake_minutes: int | None sleep_segments: list[dict] | None # [{"phase": "deep", "start": "23:44", "duration_min": 42}, ...] sleep_efficiency: float | None # calculated if phases present deep_percent: float | None rem_percent: float | None note: str source: str created_at: str class SleepStatsResponse(BaseModel): avg_duration_minutes: float avg_quality: float | None total_nights: int nights_below_goal: int sleep_goal_minutes: int # from profile class SleepDebtResponse(BaseModel): sleep_debt_minutes: int # positive = deficit, negative = surplus sleep_debt_formatted: str # "+2h 15min" or "0 – kein Defizit" days_analyzed: int sleep_goal_minutes: int ``` **CSV Import Logic (`/api/sleep/import-csv`):** ```python def import_sleep_csv(file: UploadFile, profile_id: str): """ Import Apple Health sleep CSV with phases. CSV Format: Start,End,Duration (hr),Value,Source 2026-03-14 22:44:23,2026-03-14 23:00:19,0.266,Kern,Apple Watch Logic: 1. Group by date (use wake_time date as date key) 2. Sum durations per phase type 3. Map German labels: Kern→light, REM→rem, Tief→deep, Wach→awake 4. Ignore "Schlafend" entries (initial marker, no phase info) 5. Calculate total duration_minutes 6. bedtime = min(Start), wake_time = max(End) 7. Build sleep_segments JSONB: [{"phase": "deep", "start": "23:44", "duration_min": 42}, ...] 8. Upsert by (profile_id, date) """ phases_map = { 'Kern': 'light', 'REM': 'rem', 'Tief': 'deep', 'Wach': 'awake', 'Schlafend': None # ignore } # Parse CSV, group by date # For each segment (not "Schlafend"): # - Extract start time (HH:MM from Start timestamp) # - Calculate duration_min (Duration (hr) * 60) # - Map phase using phases_map # - Append to segments list: {"phase": phase, "start": start_time, "duration_min": duration} # # Sum durations by phase for aggregated columns (deep_minutes, etc.) # Store segments array in sleep_segments JSONB column # # Upsert: # INSERT ... (sleep_segments = json_segments) # ON CONFLICT (profile_id, date) DO UPDATE ... return {"imported": count, "updated": updated_count} ``` ### 4.4 Activity Extension (HF Zones) **Extend:** `routers/activity.py` ```python # New endpoint GET /api/activity/hr-zones?days=30 # HF zones distribution class ActivityCreateExtended(BaseModel): # ... existing fields avg_hr: int | None = None max_hr: int | None = None def calculate_hr_zone(avg_hr: int, hf_max: int) -> int: """ Calculate HR zone (1-5) based on avg_hr and hf_max. Zones: 1: 50-60% HFmax (Regeneration) 2: 60-70% HFmax (Grundlagenausdauer) 3: 70-80% HFmax (Aerobe Ausdauer) 4: 80-90% HFmax (Anaerobe Schwelle) 5: 90-100% HFmax (Maximale Intensität) """ percent = (avg_hr / hf_max) * 100 if percent < 60: return 1 elif percent < 70: return 2 elif percent < 80: return 3 elif percent < 90: return 4 else: return 5 class HRZonesResponse(BaseModel): zone_1_count: int zone_2_count: int zone_3_count: int zone_4_count: int zone_5_count: int total_activities: int hf_max_used: int # which hf_max was used for calculation ``` ### 4.5 Recovery Status **New endpoint in:** `routers/stats.py` ```python GET /api/stats/recovery-status # Recovery status (traffic light) class RecoveryStatusResponse(BaseModel): status: Literal['good', 'partial', 'poor'] # 🟢 🟡 🔴 color: str # '#1D9E75', '#E67E22', '#D85A30' recommendation: str # "Intensives Training möglich" / "Leicht" / "Ruhetag" factors: dict # breakdown: hrv_status, resting_hr_status, training_load def calculate_recovery_status(profile_id: str) -> RecoveryStatusResponse: """ Calculate recovery status based on: 1. HRV deviation from baseline (30d) - weight: 50% 2. Resting HR trend (7d vs 30d baseline) - weight: 30% 3. Training load last 3 days - weight: 20% Logic: - HRV < 10% below baseline → poor - Resting HR > +5 bpm above baseline → poor - >3 intense trainings in last 3 days → poor - Weighted score calculation: score = (hrv_score * 0.5) + (hr_score * 0.3) + (training_score * 0.2) - score >= 0.7 → good (🟢) - score >= 0.4 → partial (🟡) - score < 0.4 → poor (🔴) """ ``` --- ## 5. Frontend-Komponenten ### 5.1 Navigation **Erweitere:** `frontend/src/App.jsx` ```javascript // Add to nav links const links = [ { to: '/', icon: , label: 'Übersicht' }, { to: '/capture', icon: , label: 'Erfassen' }, { to: '/history', icon: , label: 'Verlauf' }, { to: '/sleep', icon: , label: 'Schlaf' }, // NEU { to: '/vitals', icon: , label: 'Vital' }, // NEU { to: '/analysis', icon: , label: 'Analyse' }, { to: '/settings', icon: , label: 'Einst.' }, ] // Add routes }/> }/> }/> ``` ### 5.2 Neue Seiten #### `frontend/src/pages/SleepPage.jsx` **Struktur:** ``` ┌─────────────────────────────────────┐ │ Schlaf 🌙 │ ├─────────────────────────────────────┤ │ [ Letzte Nacht erfassen ] (Button) │ ├─────────────────────────────────────┤ │ 📊 Übersicht (letzte 7 Tage) │ │ Ø 7h 12min | Qualität 3.8/5 │ ├─────────────────────────────────────┤ │ 📈 Schlafdauer-Trend (Chart) │ │ - Linie: Tägliche Dauer │ │ - Referenzlinie: Schlafziel │ │ - 7-Tage-Durchschnitt │ ├─────────────────────────────────────┤ │ 📊 Schlafphasen (Stacked Bar) │ │ - Tief | REM | Leicht | Wach │ ├─────────────────────────────────────┤ │ 💤 Schlafschulden │ │ +2h 15min (letzte 7 Tage) │ ├─────────────────────────────────────┤ │ 🔗 Korrelationen │ │ - Schlaf ↔ Ruhepuls │ │ - Schlaf ↔ Training │ │ - Schlaf ↔ Gewicht │ └─────────────────────────────────────┘ Modal: Schlaf erfassen ┌─────────────────────────────────────┐ │ Schlaf erfassen │ ├─────────────────────────────────────┤ │ Datum: [22.03.2026] │ │ Schlafdauer: [7] h [30] min │ │ Qualität: ★★★★☆ │ │ Notiz: [...] │ ├─────────────────────────────────────┤ │ [ Detailansicht ] (Link) │ ├─────────────────────────────────────┤ │ [ Speichern ] [ Abbrechen ] │ └─────────────────────────────────────┘ Detailansicht: │ Eingeschlafen: [23:15] │ │ Aufgewacht: [06:45] │ │ Aufwachungen: [1] │ │ Tiefschlaf: [95] min │ │ REM-Schlaf: [110] min │ │ Leichtschlaf: [230] min │ │ Wach im Bett: [15] min │ ``` **Features:** - Schnelleingabe (3 Felder) vs. Detaileingabe (11 Felder) - Chart-Zeitraum wählbar: 7 / 30 / 90 Tage - CSV-Import-Button (Apple Health) - Inline-Edit bestehender Einträge #### `frontend/src/pages/VitalsPage.jsx` **Struktur:** ``` ┌─────────────────────────────────────┐ │ Vitalwerte 💓 │ ├─────────────────────────────────────┤ │ [ Heute erfassen ] (Button) │ ├─────────────────────────────────────┤ │ 📊 Aktuell │ │ Ruhepuls: 58 bpm │ │ HRV: 52 ms │ │ Trend: ✅ Stabil │ ├─────────────────────────────────────┤ │ 📈 Ruhepuls-Trend (Chart) │ │ - Linie: Täglicher Wert │ │ - Baseline: 30-Tage-Ø │ │ - Warnzone: >+5 bpm │ ├─────────────────────────────────────┤ │ 📊 HRV-Trend (Chart) │ │ - Linie: Täglicher Wert │ │ - Baseline: 30-Tage-Ø │ │ - Warnzone: <-10% │ └─────────────────────────────────────┘ Modal: Vitalwerte erfassen ┌─────────────────────────────────────┐ │ Vitalwerte erfassen │ ├─────────────────────────────────────┤ │ Datum: [22.03.2026] │ │ Ruhepuls (bpm): [58] │ │ HRV (ms): [52] (optional) │ │ Notiz: [...] │ ├─────────────────────────────────────┤ │ [ Speichern ] [ Abbrechen ] │ └─────────────────────────────────────┘ ``` #### `frontend/src/pages/RestDaysPage.jsx` **Oder Integration in ActivityPage:** ``` ┌─────────────────────────────────────┐ │ Aktivitäten & Ruhetage │ ├─────────────────────────────────────┤ │ [ Tabs: Training | Ruhetage ] │ ├─────────────────────────────────────┤ │ Ruhetag Tab: │ │ [ + Ruhetag erfassen ] │ │ │ │ 22.03.2026 - Vollständige Ruhe │ │ "Muskelkater Beine" │ │ │ │ 20.03.2026 - Aktive Erholung │ │ "Spaziergang" │ └─────────────────────────────────────┘ ``` ### 5.3 Dashboard-Widgets **Erweitere:** `frontend/src/pages/Dashboard.jsx` ```javascript // New widgets // Letzte Nacht + 7-Tage-Ø // Ruhepuls + Trend // 🟢 Gut erholt | Empfehlung ``` **SleepWidget:** ``` ┌─────────────────────────────────────┐ │ 🌙 Schlaf │ │ Letzte Nacht: 7h 30min | ★★★★☆ │ │ Ø 7 Tage: 7h 12min | ★★★★☆ │ │ [ Erfassen ] │ └─────────────────────────────────────┘ ``` **RecoveryWidget:** ``` ┌─────────────────────────────────────┐ │ 🔋 Erholungsstatus │ │ 🟢 Gut erholt │ │ "Intensives Training möglich" │ │ [ Details ] │ └─────────────────────────────────────┘ ``` ### 5.4 Activity-Liste Erweiterung **In:** `frontend/src/pages/ActivityPage.jsx` ```javascript // Add HR zone badge {e.avg_hr && (
Zone {e.hr_zone}
)} function getZoneColor(zone) { const colors = { 1: '#1D9E75', // Regeneration (grün) 2: '#3498DB', // Grundlage (blau) 3: '#E67E22', // Aerob (orange) 4: '#E74C3C', // Anaerob (rot) 5: '#8E44AD', // Maximal (lila) } return colors[zone] || '#95A5A6' } ``` --- ## 6. Implementierungs-Phasen **Reihenfolge:** 2b → 2c → 2d → 2a → 2e → 2f (Schlaf-Modul zuerst, dann Vitalwerte) ### Phase 2b – Schlaf-Modul Kern (3-4h) ⭐ START HIER **Ziel:** Schlaf erfassen + Trends sehen 1. ✅ Migration 009 erstellen + deployen (sleep_log + sleep_segments) 2. ✅ Backend Router: `sleep.py` (CRUD + Stats) 3. ✅ Frontend: `SleepPage.jsx` (Schnell + Detail-Eingabe) 4. ✅ Charts: Schlafdauer-Trend, Qualität-Trend 5. ✅ Dashboard: `SleepWidget.jsx` 6. ✅ Navigation: Schlaf-Icon 🌙 hinzufügen 7. ✅ Test: Manuelle Eingabe + Charts funktionieren **Deliverable:** User kann Schlaf erfassen, sieht 7-Tage-Trends. ### Phase 2c – Apple Health Import (2h) **Ziel:** Automatischer Schlaf-Import aus CSV 1. ✅ Backend: CSV-Parser für Apple Health Schlaf (inkl. sleep_segments JSONB) 2. ✅ Frontend: Import-Button auf SleepPage 3. ✅ Test: CSV-Import aggregiert Phasen korrekt, speichert Segmente 4. ✅ Validierung: Mehrfach-Import überschreibt nicht manual-Einträge **Deliverable:** User kann Apple Health Schlaf-CSV importieren, Phasen werden gespeichert. ### Phase 2d – HF-Zonen & Erholung (2-3h) **Ziel:** HF-Zonen-Badges + Erholungsampel 1. ✅ Migration 008 anwenden (`activity_log` avg_hr/max_hr erweitern) 2. ✅ Backend: HF-Zonen-Berechnung (Endpoint + Badge-Logik) 3. ✅ Backend: Recovery-Status-Berechnung (50% HRV / 30% Ruhepuls / 20% Training) 4. ✅ Frontend: HF-Zonen-Badge in ActivityPage 5. ✅ Frontend: `RecoveryWidget.jsx` im Dashboard 6. ✅ Test: Zonen korrekt berechnet, Erholungsampel funktioniert **Deliverable:** User sieht HF-Zonen in Aktivitäten, Erholungsstatus im Dashboard. ### Phase 2a – Basis-Vitalwerte (1-2h) **Ziel:** Ruhetage + Ruhepuls erfassen können 1. ✅ Migration 008 komplett (rest_days, vitals_log, weekly_goals) 2. ✅ Backend Router: `rest_days.py`, `vitals.py`, `weekly_goals.py` (CRUD) 3. ✅ Frontend: `VitalsPage.jsx` (mit 7d + 30d Warnungen) 4. ✅ Frontend: Ruhetage-Tab in ActivityPage integrieren 5. ✅ Dashboard: `VitalsWidget.jsx` 6. ✅ Navigation: Vital-Icon ❤️ hinzufügen 7. ✅ Test: Manuelle Eingabe funktioniert, beide Warnungen angezeigt **Deliverable:** User kann Ruhepuls + Ruhetage erfassen, sieht Trend-Grafik mit Warnungen. ### Phase 2e – Korrelationen (1-2h) **Ziel:** Schlaf ↔ Ruhepuls, Training, Gewicht 1. ✅ Backend: Korrelations-Endpoints (`/api/sleep/correlations/*`) 2. ✅ Frontend: Korrelations-Section auf SleepPage 3. ✅ Charts: Streudiagramme + narrative KI-Aussagen 4. ✅ Test: Mindestens 14 Datenpunkte erforderlich **Deliverable:** User sieht Korrelationen zwischen Schlaf und anderen Metriken. ### Phase 2f – KI-Integration (1-2h, optional) **Ziel:** Neue KI-Platzhalter + Analyse-Funktionen 1. ✅ Backend: KI-Platzhalter erweitern (21 neue, siehe Abschnitt 7) 2. ✅ Backend: Analyse-Funktionen (Schlafmangel, Übertraining) 3. ✅ Test: KI-Prompts nutzen neue Platzhalter **Deliverable:** KI-Analyse berücksichtigt Schlaf + Vitalwerte. --- ## 7. KI-Integration (neue Platzhalter) ### 7.1 Training-Platzhalter (aus TRAINING_TYPES.md) ```python {{trainingstyp_verteilung}} # "60% Kraft, 30% Cardio, 10% Mobility (letzte 4 Wochen)" {{ruhetage_letzte_woche}} # Anzahl Ruhetage letzte 7 Tage {{ruhepuls_aktuell}} # heutiger / letzter Ruhepuls {{ruhepuls_trend}} # "sinkend / stabil / steigend" {{hrv_aktuell}} # letzter HRV-Wert {{hrv_baseline}} # persönlicher HRV-Durchschnitt (30 Tage) {{erholungsstatus}} # "gut / teilweise / schlecht" {{vo2max}} # aktueller VO2Max-Wert (optional, später) {{trainingsphase}} # "Aufbau / Erholung / Plateau / unbekannt" {{hf_zonen_verteilung}} # "Zone 2: 45%, Zone 3: 35%, Zone 4: 20%" ``` ### 7.2 Schlaf-Platzhalter (aus SLEEP_MODULE.md) ```python {{schlaf_avg_dauer}} # "7h 12min (Ø 7 Tage)" {{schlaf_avg_qualitaet}} # "3,8/5 (Ø 7 Tage)" {{schlaf_trend_dauer}} # "sinkend / stabil / steigend" {{schlaf_trend_qualitaet}} # "sinkend / stabil / steigend" {{schlaf_schulden}} # "+2h 15min (letzte 14 Tage)" {{schlaf_ziel}} # "7h 30min" {{schlaf_optimum_fenster}} # "22:00–23:00 Uhr (beste Qualität)" {{schlaf_tiefschlaf_anteil}} # "18% (Ø letzte 14 Tage)" {{schlaf_rem_anteil}} # "22% (Ø letzte 14 Tage)" {{schlaf_aufwachungen}} # "1,2x pro Nacht (Ø 7 Tage)" {{schlaf_korrelation_puls}} # "Ruhepuls +4 bpm nach <7h Schlaf" {{schlaf_detail}} # tabellarische Übersicht letzte 7 Nächte ``` ### 7.3 KI-Analyse-Funktionen (neu) **Übertraining erkennen:** ```python def detect_overtraining(profile_id): # Check: # - >5 Krafteinheiten in 7 Tagen ohne Ruhetag # - Ruhepuls >+7 bpm über Baseline + hohe Trainingsbelastung # - HRV <-15% unter Baseline # Return: warning message or None ``` **Schlafmangel warnen:** ```python def detect_sleep_deprivation(profile_id): # Check: # - Schlafdurchschnitt letzte 3 Tage < (Schlafziel - 60min) # - Schlafschulden >2h (letzte 7 Tage) # Return: warning + recommendation ``` **Ruhetag empfehlen:** ```python def recommend_rest_day(profile_id): # Based on: # - Erholungsstatus = poor # - Kein Ruhetag in letzten 5 Tagen # - Trainingsbelastung hoch # Return: recommendation or None ``` --- ## 8. Testing-Strategie ### 8.1 Unit Tests (Backend) **Test:** `backend/tests/test_vitals.py` ```python def test_upsert_vitals_by_date(): # Create entry for 2026-03-22 # Upsert same date with different HR # Assert: updated, not duplicated def test_resting_hr_trend(): # Insert 30 days of data # Call /api/vitals/trend # Assert: baseline calculated, warning if elevated ``` **Test:** `backend/tests/test_sleep.py` ```python def test_apple_health_csv_import(): # Import sample CSV (from user) # Assert: phases aggregated correctly # Assert: date = wake date, not sleep date # Assert: German labels mapped def test_sleep_debt_calculation(): # Insert 7 days with varying durations # Set sleep_goal_minutes = 450 # Call /api/sleep/debt # Assert: debt calculated correctly ``` **Test:** `backend/tests/test_recovery_status.py` ```python def test_recovery_status_calculation(): # Create mock data: low HRV, high resting HR, high training load # Call /api/stats/recovery-status # Assert: status = 'poor', recommendation includes "Ruhetag" ``` ### 8.2 Integration Tests (Frontend) **Test:** Manual testing checklist - [ ] Vitalwerte erfassen → sichtbar in Trend-Chart - [ ] Ruhetag erfassen → sichtbar in Liste - [ ] Schlaf erfassen (Schnell) → Dashboard-Widget aktualisiert - [ ] Schlaf erfassen (Detail) → Phasen-Chart zeigt Daten - [ ] Apple Health CSV Import → Einträge korrekt aggregiert - [ ] HF-Zonen-Badge → korrekte Farbe basierend auf avg_hr - [ ] Erholungsampel → ändert Farbe bei schlechten Werten - [ ] Korrelationen → Charts zeigen bei >14 Datenpunkten ### 8.3 End-to-End Test **Scenario:** Kompletter User-Flow 1. User importiert Apple Health Schlaf-CSV (7 Tage) 2. User erfasst Ruhepuls für 7 Tage 3. User trainiert 3x intensiv (avg_hr >80% HFmax) 4. User schläft schlecht (quality 2/5, <6h) 5. Dashboard zeigt Erholungsampel 🔴 "Erholung nötig" 6. KI-Analyse empfiehlt Ruhetag **Validierung:** - Schlaf korrekt importiert und aggregiert - Ruhepuls-Trend zeigt Erhöhung - HF-Zonen korrekt berechnet - Erholungsstatus = poor - KI-Platzhalter befüllt, Empfehlung korrekt --- ## 9. Rollout-Plan ### Development (develop Branch) 1. **Phase 2a:** Vitalwerte-Basis → Test auf dev.mitai.jinkendo.de 2. **Phase 2b:** Schlaf-Modul Kern → Test auf dev 3. **Phase 2c:** Apple Health Import → Test mit echten CSV-Daten 4. **Phase 2d:** HF-Zonen + Erholung → Validierung Berechnungslogik 5. **Phase 2e:** Korrelationen → Test mit Mindestdatenbasis 6. **(Optional) Phase 2f:** KI-Integration **Validierung:** Jede Phase einzeln testen, Bug-Fixes vor nächster Phase. ### Production (main Branch) **Merge:** Nach erfolgreicher End-to-End-Validierung auf develop **Deployment:** Gitea Action deploy-prod.yml → mitai.jinkendo.de **Post-Deployment:** - CLAUDE.md aktualisieren: v9d Phase 2 ✅ - Nutzer-Kommunikation: "Neue Features: Schlaf, Vitalwerte, Erholungsstatus" --- ## 10. Offene TODOs (für spätere Versionen) ### Nicht in Phase 2: - [ ] VO2Max-Schätzung (v9f) - [ ] Wochenplanung persistieren (v9f, mit Ziele-Modul) - [ ] HF-Kurven (Zeit × HF) aus Apple Health (v9h) - [ ] Live-Sync Garmin/Apple Watch (v9h, Connectoren) - [ ] Push-Notifications "Schlafenszeit" (optional) - [ ] Schlaf-Coaching / Tipps (optional) - [ ] SpO2 / Schnarchen (v9e, Vitalwerte erweitert) ### Edge Cases / Nice-to-haves: - [ ] Schlaf-Import: Mittagsschlaf (Naps) erkennen und separieren - [ ] Ruhepuls: Automatische Benachrichtigung bei 3+ Tagen Erhöhung - [ ] HRV: Baseline-Berechnung nach Alter/Geschlecht normiert - [ ] Schlafphasen: Referenzwerte nach Altersgruppe --- ## 11. Anhang ### 11.1 Apple Health CSV-Struktur (Schlaf) **Beispiel:** Siehe User-Bereitstellung oben **Mapping:** ``` Value (Deutsch) → DB-Spalte Kern → light_minutes REM → rem_minutes Tief → deep_minutes Wach → awake_minutes Schlafend → (ignorieren) ``` **Aggregations-Logik:** - Eine Nacht = alle Einträge zwischen erstem "Schlafend"/Start und letztem End derselben Schlafperiode - Datum = Aufwachdatum (End des letzten Segments) - Wenn Nacht über 2 Tage geht (z.B. 14.03. 22:00 bis 15.03. 06:00) → Datum = 15.03. ### 11.2 Berechnungsformeln **Schlafeffizienz:** ``` efficiency = (duration_minutes / (duration_minutes + awake_minutes)) * 100 Nur berechnen wenn awake_minutes vorhanden. ``` **Tiefschlaf-Anteil:** ``` deep_percent = (deep_minutes / duration_minutes) * 100 ``` **REM-Anteil:** ``` rem_percent = (rem_minutes / duration_minutes) * 100 ``` **Schlafschulden:** ``` sleep_debt = sum(sleep_goal - actual_duration for each day in last 14 days) Positiv = Defizit, Negativ = Überschuss Zeitraum: 14 Tage (weniger anfällig für Ausreißer als 7 Tage) ``` **HFmax Standard:** ``` hf_max = 220 - age Überschreibbar durch profiles.hf_max ``` **HF-Zone:** ``` zone = calculate_hr_zone(avg_hr, hf_max) Siehe Abschnitt 4.4 ``` **VO2Max-Schätzung (optional, später):** ``` vo2max ≈ 15 × (hf_max / resting_hr) Cooper-Formel, grobe Schätzung ``` --- ## 12. Datenbankgröße & Performance **Schätzung:** - `rest_days`: ~50 Einträge/Jahr/User → ~200 Bytes/Eintrag → 10 KB/Jahr - `vitals_log`: ~250 Einträge/Jahr/User → ~200 Bytes/Eintrag → 50 KB/Jahr - `sleep_log`: ~300 Einträge/Jahr/User → ~400 Bytes/Eintrag → 120 KB/Jahr **Total Phase 2:** ~180 KB/Jahr/User **Performance:** - Indizes auf `(profile_id, date DESC)` → schnelle Bereichsabfragen - Korrelations-Queries: JOIN über date → <100ms bei <5000 Einträgen - Trend-Berechnung: AVG() über 7-30 Tage → <50ms **Empfehlung:** Keine zusätzlichen Optimierungen nötig für <10.000 User. --- **Ende der Spezifikation** **Nächste Schritte:** 1. Spec-Review mit User 2. Start Phase 2a Implementierung 3. Iteratives Deployment (develop → test → main)