- .gitignore: .claude/docs, rules, commands tracken; settings.local weiter ignorieren - DOCUMENTATION.md: verbindliche Ablage functional/technical/working/issues - .claude/README.md: Agent-Einstieg; GITEA_ISSUES_INDEX aus MCP (Stand 2026-04-08) - Arbeitspapiere von docs/ nach .claude/docs/working/ verschoben - docs/MEMBERSHIP_SYSTEM.md als Stub; kanonisch technical/MEMBERSHIP_SYSTEM.md - CLAUDE.md Pflichtlektüre und Links angepasst; docs/README.md vereinfacht Made-with: Cursor
1258 lines
46 KiB
Markdown
1258 lines
46 KiB
Markdown
# 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: <LayoutDashboard size={20}/>, label: 'Übersicht' },
|
||
{ to: '/capture', icon: <PlusSquare size={20}/>, label: 'Erfassen' },
|
||
{ to: '/history', icon: <TrendingUp size={20}/>, label: 'Verlauf' },
|
||
{ to: '/sleep', icon: <Moon size={20}/>, label: 'Schlaf' }, // NEU
|
||
{ to: '/vitals', icon: <Heart size={20}/>, label: 'Vital' }, // NEU
|
||
{ to: '/analysis', icon: <BarChart2 size={20}/>, label: 'Analyse' },
|
||
{ to: '/settings', icon: <Settings size={20}/>, label: 'Einst.' },
|
||
]
|
||
|
||
// Add routes
|
||
<Route path="/sleep" element={<SleepPage/>}/>
|
||
<Route path="/vitals" element={<VitalsPage/>}/>
|
||
<Route path="/rest-days" element={<RestDaysPage/>}/>
|
||
```
|
||
|
||
### 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
|
||
<SleepWidget /> // Letzte Nacht + 7-Tage-Ø
|
||
<VitalsWidget /> // Ruhepuls + Trend
|
||
<RecoveryWidget /> // 🟢 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 && (
|
||
<div style={{
|
||
display: 'inline-flex',
|
||
alignItems: 'center',
|
||
gap: 3,
|
||
padding: '2px 6px',
|
||
background: getZoneColor(e.hr_zone) + '22',
|
||
border: `1px solid ${getZoneColor(e.hr_zone)}`,
|
||
borderRadius: 4,
|
||
fontSize: 10,
|
||
fontWeight: 600,
|
||
color: getZoneColor(e.hr_zone)
|
||
}}>
|
||
<Heart size={10} />
|
||
<span>Zone {e.hr_zone}</span>
|
||
</div>
|
||
)}
|
||
|
||
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)
|