Merge pull request 'WP 9c Phase 1' (#12) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 49s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s

Reviewed-on: #12
This commit is contained in:
Lars 2026-03-22 14:14:34 +01:00
commit 601fc80178
12 changed files with 1973 additions and 48 deletions

109
CLAUDE.md
View File

@ -1,5 +1,12 @@
# Mitai Jinkendo Entwickler-Kontext für Claude Code
## Pflicht-Lektüre für Claude Code
> VOR jeder Implementierung lesen:
> | Architektur-Regeln | `.claude/rules/ARCHITECTURE.md` |
> | Coding-Regeln | `.claude/rules/CODING_RULES.md` |
> | Lessons Learned | `.claude/rules/LESSONS_LEARNED.md` |
## Projekt-Übersicht
**Mitai Jinkendo** (身体 Jinkendo) selbst-gehostete PWA für Körper-Tracking mit KI-Auswertung.
Teil der **Jinkendo**-App-Familie (人拳道). Domains: jinkendo.de / .com / .life
@ -93,43 +100,49 @@ frontend/src/
- ✅ **Navigation-Fixes:** Alle Login/Verify-Flows funktionieren korrekt
- ✅ **Error-Handling:** JSON-Fehler sauber formatiert, Dashboard robust bei API-Fehlern
### Auf develop (bereit für Prod) 🚀
**v9d Phase 1b - Feature-komplett, ready for deployment**
### v9d Phase 1b ✅ (Deployed to Production 21.03.2026)
- ✅ **Trainingstypen-System (komplett):**
- 29 Trainingstypen (7 Kategorien)
- Admin-CRUD mit vollständiger UI
- Automatisches Apple Health Mapping (23 Workout-Typen)
- Bulk-Kategorisierung für bestehende Aktivitäten
- Farbige Typ-Badges in Aktivitätsliste
- TrainingTypeDistribution Chart in History-Seite
**Trainingstypen-System mit lernendem Mapping:**
- ✅ **29 Trainingstypen** in 7 Kategorien (inkl. Geist & Meditation)
- ✅ **Lernendes Mapping-System (DB-basiert):**
- Tabelle `activity_type_mappings` statt hardcoded
- 40+ Standard-Mappings (Deutsch + English)
- Auto-Learning: Bulk-Kategorisierung speichert Mappings
- User-spezifische + globale Mappings
- Admin-UI für Mapping-Verwaltung (inline editing)
- Coverage-Stats (% zugeordnet vs. unkategorisiert)
- ✅ **Apple Health Import:**
- Deutsche Workout-Namen unterstützt
- Automatisches Mapping via DB
- Duplikat-Erkennung (date + start_time)
- Update statt Insert bei Reimport
- ✅ **UI-Features:**
- TrainingTypeSelect in ActivityPage
- Farbige Typ-Badges in Aktivitätsliste
- TrainingTypeDistribution Chart in History
- Bulk-Kategorisierung (selbstlernend)
- Admin-CRUD für Trainingstypen
- Admin-CRUD für Activity-Mappings (inline editing)
- ✅ **Weitere Verbesserungen:**
- TrialBanner mailto (Vorbereitung zentrales Abo-System)
- Admin-Formular UX-Optimierung (Full-width inputs, größere Textareas)
**Migrations:**
- Migration 004: training_types Tabelle + 23 Basis-Typen
- Migration 005: Extended types (Gehen, Tanzen, Geist & Meditation)
- Migration 006: abilities JSONB column (Platzhalter für v9f)
- Migration 007: activity_type_mappings (lernendes System)
- 📚 **Dokumentation:**
- `.claude/docs/technical/CENTRAL_SUBSCRIPTION_SYSTEM.md`
**Dokumentation:**
- `.claude/docs/functional/AI_PROMPTS.md` (erweitert um Fähigkeiten-Mapping)
- `.claude/docs/technical/CENTRAL_SUBSCRIPTION_SYSTEM.md`
### v9d Phase 1 ✅ (Deployed 21.03.2026)
- ✅ **Trainingstypen Basis:** DB-Schema, 23 Typen, API-Endpoints
- ✅ **Logout-Button:** Im Header neben Avatar, mit Bestätigung
- ✅ **Components:** TrainingTypeSelect, TrainingTypeDistribution
### v9d Phase 1b ✅ (Abgeschlossen, auf develop)
- ✅ ActivityPage: TrainingTypeSelect eingebunden
- ✅ History: TrainingTypeDistribution Chart + Typ-Badges bei Aktivitäten
- ✅ Apple Health Import: Automatisches Mapping (29 Typen)
- ✅ Bulk-Kategorisierung: UI + Endpoints
- ✅ Admin-CRUD: Vollständige Verwaltung inkl. UX-Optimierungen
### v9d Phase 2+ 🔲 (Später)
### v9d Phase 2 🔲 (Nächster Schritt)
**Vitalwerte & Erholung:**
- 🔲 Ruhetage erfassen (rest_days Tabelle)
- 🔲 Ruhepuls erfassen (vitals_log Tabelle)
- 🔲 HF-Zonen + Erholungsstatus
- 🔲 Schlaf-Modul
- 🔲 Schlaf-Modul (Basis)
📚 Details: `.claude/docs/functional/TRAINING_TYPES.md`
📚 Details: `.claude/docs/technical/MEMBERSHIP_SYSTEM.md` · `.claude/docs/architecture/FEATURE_ENFORCEMENT.md`
@ -277,25 +290,31 @@ Bottom-Padding Mobile: 80px (Navigation)
> Vollständige CSS-Variablen und Komponenten-Muster: `frontend/src/app.css`
> Responsive Layout-Spec: `.claude/docs/functional/RESPONSIVE_UI.md`
## Dokumentations-Referenzen
## Dokumentations-Struktur
```
.claude/
├── BACKLOG.md ← Feature-Übersicht
├── commands/ ← Slash-Commands (/deploy, /document etc.)
├── docs/
│ ├── functional/ ← Fachliche Specs (WAS soll gebaut werden)
│ ├── technical/ ← Technische Specs (WIE wird es gebaut)
│ └── rules/ ← Verbindliche Regeln
└── library/ ← Ergebnis-Dokumentation (WAS wurde gebaut)
```
|Bereich|Pfad|Inhalt|
|-|-|-|
|Architektur-Übersicht|`.claude/library/ARCHITECTURE.md`|Gesamt-Überblick|
|Frontend-Dokumentation|`.claude/library/FRONTEND.md`|Seiten + Komponenten|
|Auth-Flow|`.claude/library/AUTH.md`|Sicherheit + Sessions|
|API-Referenz|`.claude/library/API\_REFERENCE.md`|Alle Endpoints|
|Datenbankschema|`.claude/library/DATABASE.md`|Tabellen + Beziehungen|
> Library-Dateien werden mit `/document` generiert und nach größeren
> Änderungen aktualisiert.
> **Für Claude Code:** Beim Arbeiten an einem Thema die entsprechende Datei lesen:
| Thema | Datei |
|-------|-------|
| Backend-Architektur, Router, DB-Zugriff | `.claude/docs/architecture/BACKEND.md` |
| Frontend-Architektur, api.js, Komponenten | `.claude/docs/architecture/FRONTEND.md` |
| **Feature-Enforcement (neue Features hinzufügen)** | `.claude/docs/architecture/FEATURE_ENFORCEMENT.md` |
| **Database Migrations (Schema-Änderungen)** | `.claude/docs/technical/MIGRATIONS.md` |
| Coding Rules (Pflichtregeln) | `.claude/docs/rules/CODING_RULES.md` |
| Lessons Learned (Fehler vermeiden) | `.claude/docs/rules/LESSONS_LEARNED.md` |
| Feature Backlog (Übersicht) | `.claude/docs/BACKLOG.md` |
| **Pending Features (noch nicht enforced)** | `.claude/docs/PENDING_FEATURES.md` |
| **Known Issues (Bugs & Tech Debt)** | `.claude/docs/KNOWN_ISSUES.md` |
| Membership-System (v9c, technisch) | `.claude/docs/technical/MEMBERSHIP_SYSTEM.md` |
| Trainingstypen + HF (v9d, fachlich) | `.claude/docs/functional/TRAINING_TYPES.md` |
| KI-Prompt Flexibilisierung (v9f, fachlich) | `.claude/docs/functional/AI_PROMPTS.md` |
| Responsive UI (fachlich) | `.claude/docs/functional/RESPONSIVE_UI.md` |
## Jinkendo App-Familie
```

View File

@ -20,7 +20,7 @@ from routers import activity, nutrition, photos, insights, prompts
from routers import admin, stats, exportdata, importdata
from routers import subscription, coupons, features, tiers_mgmt, tier_limits
from routers import user_restrictions, access_grants, training_types, admin_training_types
from routers import admin_activity_mappings
from routers import admin_activity_mappings, sleep
# ── App Configuration ─────────────────────────────────────────────────────────
DATA_DIR = Path(os.getenv("DATA_DIR", "./data"))
@ -86,10 +86,11 @@ app.include_router(tier_limits.router) # /api/tier-limits (admin)
app.include_router(user_restrictions.router) # /api/user-restrictions (admin)
app.include_router(access_grants.router) # /api/access-grants (admin)
# v9d Training Types
# v9d Training Types & Sleep Module
app.include_router(training_types.router) # /api/training-types/*
app.include_router(admin_training_types.router) # /api/admin/training-types/*
app.include_router(admin_activity_mappings.router) # /api/admin/activity-mappings/*
app.include_router(sleep.router) # /api/sleep/* (v9d Phase 2b)
# ── Health Check ──────────────────────────────────────────────────────────────
@app.get("/")

View File

@ -0,0 +1,59 @@
-- Migration 008: Vitals, Rest Days, Weekly Goals
-- v9d Phase 2: Sleep & Vitals Module
-- Date: 2026-03-22
-- Rest Days
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);
-- Vitals (Resting HR + HRV)
CREATE TABLE IF NOT EXISTS vitals_log (
id SERIAL PRIMARY KEY,
profile_id UUID 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 UUID 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);
-- Comments for documentation
COMMENT ON TABLE rest_days IS 'v9d Phase 2: Rest days tracking (full rest or active recovery)';
COMMENT ON TABLE vitals_log IS 'v9d Phase 2: Daily vitals (resting HR, HRV)';
COMMENT ON TABLE weekly_goals IS 'v9d Phase 2: Weekly training goals (Soll/Ist planning)';
COMMENT ON COLUMN profiles.hf_max IS 'Maximum heart rate for HR zone calculation';
COMMENT ON COLUMN profiles.sleep_goal_minutes IS 'Sleep goal in minutes (default: 450 = 7h 30min)';

View File

@ -0,0 +1,31 @@
-- Migration 009: Sleep Log Table
-- v9d Phase 2b: Sleep Module Core
-- Date: 2026-03-22
CREATE TABLE IF NOT EXISTS sleep_log (
id SERIAL PRIMARY KEY,
profile_id UUID 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,
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);
-- Comments for documentation
COMMENT ON TABLE sleep_log IS 'v9d Phase 2b: Daily sleep tracking with phase data';
COMMENT ON COLUMN sleep_log.date IS 'Date of the night (wake date, not bedtime date)';
COMMENT ON COLUMN sleep_log.sleep_segments IS 'Raw phase segments: [{"phase": "deep", "start": "23:44", "duration_min": 42}, ...]';

660
backend/routers/sleep.py Normal file
View File

@ -0,0 +1,660 @@
"""
Sleep Module Router (v9d Phase 2b)
Endpoints:
- CRUD: list, create/upsert, update, delete
- Stats: 7-day average, trends, phase distribution, sleep debt
- Correlations: sleep resting HR, training, weight (Phase 2e)
"""
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel
from typing import Literal
from datetime import datetime, timedelta
import csv
import io
import json
from auth import require_auth
from db import get_db, get_cursor
router = APIRouter(prefix="/api/sleep", tags=["sleep"])
# ── Models ────────────────────────────────────────────────────────────────────
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
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 | None
sleep_efficiency: float | None
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
class SleepDebtResponse(BaseModel):
sleep_debt_minutes: int
sleep_debt_formatted: str
days_analyzed: int
sleep_goal_minutes: int
# ── Helper Functions ──────────────────────────────────────────────────────────
def format_duration(minutes: int) -> str:
"""Convert minutes to 'Xh Ymin' format."""
hours = minutes // 60
mins = minutes % 60
return f"{hours}h {mins}min"
def calculate_sleep_efficiency(duration_min: int, awake_min: int | None) -> float | None:
"""Sleep efficiency = duration / (duration + awake) * 100."""
if awake_min is None or awake_min == 0:
return None
total = duration_min + awake_min
return round((duration_min / total) * 100, 1) if total > 0 else None
def calculate_phase_percent(phase_min: int | None, duration_min: int) -> float | None:
"""Calculate phase percentage of total duration."""
if phase_min is None or duration_min == 0:
return None
return round((phase_min / duration_min) * 100, 1)
def row_to_sleep_response(row: dict) -> SleepResponse:
"""Convert DB row to SleepResponse."""
return SleepResponse(
id=row['id'],
profile_id=row['profile_id'],
date=str(row['date']),
bedtime=str(row['bedtime']) if row['bedtime'] else None,
wake_time=str(row['wake_time']) if row['wake_time'] else None,
duration_minutes=row['duration_minutes'],
duration_formatted=format_duration(row['duration_minutes']),
quality=row['quality'],
wake_count=row['wake_count'],
deep_minutes=row['deep_minutes'],
rem_minutes=row['rem_minutes'],
light_minutes=row['light_minutes'],
awake_minutes=row['awake_minutes'],
sleep_segments=row['sleep_segments'],
sleep_efficiency=calculate_sleep_efficiency(row['duration_minutes'], row['awake_minutes']),
deep_percent=calculate_phase_percent(row['deep_minutes'], row['duration_minutes']),
rem_percent=calculate_phase_percent(row['rem_minutes'], row['duration_minutes']),
note=row['note'] or "",
source=row['source'],
created_at=str(row['created_at'])
)
# ── CRUD Endpoints ────────────────────────────────────────────────────────────
@router.get("")
def list_sleep(
limit: int = 90,
session: dict = Depends(require_auth)
):
"""List sleep entries for current profile (last N days)."""
pid = session['profile_id']
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("""
SELECT * FROM sleep_log
WHERE profile_id = %s
ORDER BY date DESC
LIMIT %s
""", (pid, limit))
rows = cur.fetchall()
return [row_to_sleep_response(row) for row in rows]
@router.get("/by-date/{date}")
def get_sleep_by_date(
date: str,
session: dict = Depends(require_auth)
):
"""Get sleep entry for specific date."""
pid = session['profile_id']
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("""
SELECT * FROM sleep_log
WHERE profile_id = %s AND date = %s
""", (pid, date))
row = cur.fetchone()
if not row:
raise HTTPException(404, "No sleep entry for this date")
return row_to_sleep_response(row)
@router.post("")
def create_sleep(
data: SleepCreate,
session: dict = Depends(require_auth)
):
"""Create or update sleep entry (upsert by date)."""
pid = session['profile_id']
# Convert empty strings to None for TIME fields
bedtime = data.bedtime if data.bedtime else None
wake_time = data.wake_time if data.wake_time else None
# Plausibility check: sleep phases (deep+rem+light) should sum to duration
# Note: awake_minutes is NOT part of sleep duration (tracked separately)
if any([data.deep_minutes, data.rem_minutes, data.light_minutes]):
sleep_phase_sum = (data.deep_minutes or 0) + (data.rem_minutes or 0) + (data.light_minutes or 0)
diff = abs(data.duration_minutes - sleep_phase_sum)
if diff > 5:
raise HTTPException(
400,
f"Plausibilitätsprüfung fehlgeschlagen: Schlafphasen-Summe ({sleep_phase_sum} min) weicht um {diff} min von Schlafdauer ({data.duration_minutes} min) ab. Max. Toleranz: 5 min. Hinweis: Wachphasen werden nicht zur Schlafdauer gezählt."
)
with get_db() as conn:
cur = get_cursor(conn)
# Upsert: INSERT ... ON CONFLICT DO UPDATE
cur.execute("""
INSERT INTO sleep_log (
profile_id, date, bedtime, wake_time, duration_minutes,
quality, wake_count, deep_minutes, rem_minutes, light_minutes,
awake_minutes, note, source, updated_at
) VALUES (
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, CURRENT_TIMESTAMP
)
ON CONFLICT (profile_id, date) DO UPDATE SET
bedtime = EXCLUDED.bedtime,
wake_time = EXCLUDED.wake_time,
duration_minutes = EXCLUDED.duration_minutes,
quality = EXCLUDED.quality,
wake_count = EXCLUDED.wake_count,
deep_minutes = EXCLUDED.deep_minutes,
rem_minutes = EXCLUDED.rem_minutes,
light_minutes = EXCLUDED.light_minutes,
awake_minutes = EXCLUDED.awake_minutes,
note = EXCLUDED.note,
source = EXCLUDED.source,
updated_at = CURRENT_TIMESTAMP
RETURNING *
""", (
pid, data.date, bedtime, wake_time, data.duration_minutes,
data.quality, data.wake_count, data.deep_minutes, data.rem_minutes,
data.light_minutes, data.awake_minutes, data.note, data.source
))
row = cur.fetchone()
conn.commit()
return row_to_sleep_response(row)
@router.put("/{id}")
def update_sleep(
id: int,
data: SleepCreate,
session: dict = Depends(require_auth)
):
"""Update existing sleep entry by ID."""
pid = session['profile_id']
# Convert empty strings to None for TIME fields
bedtime = data.bedtime if data.bedtime else None
wake_time = data.wake_time if data.wake_time else None
# Plausibility check: sleep phases (deep+rem+light) should sum to duration
# Note: awake_minutes is NOT part of sleep duration (tracked separately)
if any([data.deep_minutes, data.rem_minutes, data.light_minutes]):
sleep_phase_sum = (data.deep_minutes or 0) + (data.rem_minutes or 0) + (data.light_minutes or 0)
diff = abs(data.duration_minutes - sleep_phase_sum)
if diff > 5:
raise HTTPException(
400,
f"Plausibilitätsprüfung fehlgeschlagen: Schlafphasen-Summe ({sleep_phase_sum} min) weicht um {diff} min von Schlafdauer ({data.duration_minutes} min) ab. Max. Toleranz: 5 min. Hinweis: Wachphasen werden nicht zur Schlafdauer gezählt."
)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("""
UPDATE sleep_log SET
date = %s,
bedtime = %s,
wake_time = %s,
duration_minutes = %s,
quality = %s,
wake_count = %s,
deep_minutes = %s,
rem_minutes = %s,
light_minutes = %s,
awake_minutes = %s,
note = %s,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s AND profile_id = %s
RETURNING *
""", (
data.date, bedtime, wake_time, data.duration_minutes,
data.quality, data.wake_count, data.deep_minutes, data.rem_minutes,
data.light_minutes, data.awake_minutes, data.note, id, pid
))
row = cur.fetchone()
if not row:
raise HTTPException(404, "Sleep entry not found")
conn.commit()
return row_to_sleep_response(row)
@router.delete("/{id}")
def delete_sleep(
id: int,
session: dict = Depends(require_auth)
):
"""Delete sleep entry."""
pid = session['profile_id']
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("""
DELETE FROM sleep_log
WHERE id = %s AND profile_id = %s
""", (id, pid))
conn.commit()
return {"deleted": id}
# ── Stats Endpoints ───────────────────────────────────────────────────────────
@router.get("/stats")
def get_sleep_stats(
days: int = 7,
session: dict = Depends(require_auth)
):
"""Get sleep statistics (average duration, quality, nights below goal)."""
pid = session['profile_id']
with get_db() as conn:
cur = get_cursor(conn)
# Get sleep goal from profile
cur.execute("SELECT sleep_goal_minutes FROM profiles WHERE id = %s", (pid,))
profile = cur.fetchone()
sleep_goal = profile['sleep_goal_minutes'] if profile and profile['sleep_goal_minutes'] else 450
# Calculate stats
cur.execute("""
SELECT
AVG(duration_minutes)::FLOAT as avg_duration,
AVG(quality)::FLOAT as avg_quality,
COUNT(*) as total_nights,
COUNT(CASE WHEN duration_minutes < %s THEN 1 END) as nights_below_goal
FROM sleep_log
WHERE profile_id = %s
AND date >= CURRENT_DATE - INTERVAL '%s days'
""", (sleep_goal, pid, days))
stats = cur.fetchone()
return SleepStatsResponse(
avg_duration_minutes=round(stats['avg_duration'], 1) if stats['avg_duration'] else 0,
avg_quality=round(stats['avg_quality'], 1) if stats['avg_quality'] else None,
total_nights=stats['total_nights'],
nights_below_goal=stats['nights_below_goal'],
sleep_goal_minutes=sleep_goal
)
@router.get("/debt")
def get_sleep_debt(
days: int = 14,
session: dict = Depends(require_auth)
):
"""Calculate sleep debt over last N days."""
pid = session['profile_id']
with get_db() as conn:
cur = get_cursor(conn)
# Get sleep goal
cur.execute("SELECT sleep_goal_minutes FROM profiles WHERE id = %s", (pid,))
profile = cur.fetchone()
sleep_goal = profile['sleep_goal_minutes'] if profile and profile['sleep_goal_minutes'] else 450
# Calculate debt
cur.execute("""
SELECT
SUM(%s - duration_minutes) as debt_minutes,
COUNT(*) as nights_analyzed
FROM sleep_log
WHERE profile_id = %s
AND date >= CURRENT_DATE - INTERVAL '%s days'
""", (sleep_goal, pid, days))
result = cur.fetchone()
debt_min = int(result['debt_minutes']) if result['debt_minutes'] else 0
nights = result['nights_analyzed'] if result['nights_analyzed'] else 0
# Format debt
if debt_min == 0:
formatted = "0 kein Defizit"
elif debt_min > 0:
formatted = f"+{format_duration(debt_min)}"
else:
formatted = f"{format_duration(abs(debt_min))}"
return SleepDebtResponse(
sleep_debt_minutes=debt_min,
sleep_debt_formatted=formatted,
days_analyzed=nights,
sleep_goal_minutes=sleep_goal
)
@router.get("/trend")
def get_sleep_trend(
days: int = 30,
session: dict = Depends(require_auth)
):
"""Get sleep duration and quality trend over time."""
pid = session['profile_id']
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("""
SELECT
date,
duration_minutes,
quality
FROM sleep_log
WHERE profile_id = %s
AND date >= CURRENT_DATE - INTERVAL '%s days'
ORDER BY date ASC
""", (pid, days))
rows = cur.fetchall()
return [
{
"date": str(row['date']),
"duration_minutes": row['duration_minutes'],
"quality": row['quality']
}
for row in rows
]
@router.get("/phases")
def get_sleep_phases(
days: int = 30,
session: dict = Depends(require_auth)
):
"""Get sleep phase distribution (deep, REM, light, awake) over time."""
pid = session['profile_id']
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("""
SELECT
date,
deep_minutes,
rem_minutes,
light_minutes,
awake_minutes,
duration_minutes
FROM sleep_log
WHERE profile_id = %s
AND date >= CURRENT_DATE - INTERVAL '%s days'
AND (deep_minutes IS NOT NULL OR rem_minutes IS NOT NULL)
ORDER BY date ASC
""", (pid, days))
rows = cur.fetchall()
return [
{
"date": str(row['date']),
"deep_minutes": row['deep_minutes'],
"rem_minutes": row['rem_minutes'],
"light_minutes": row['light_minutes'],
"awake_minutes": row['awake_minutes'],
"deep_percent": calculate_phase_percent(row['deep_minutes'], row['duration_minutes']),
"rem_percent": calculate_phase_percent(row['rem_minutes'], row['duration_minutes'])
}
for row in rows
]
# ── Import Endpoints ──────────────────────────────────────────────────────────
@router.post("/import/apple-health")
async def import_apple_health_sleep(
file: UploadFile = File(...),
session: dict = Depends(require_auth)
):
"""
Import sleep data from Apple Health CSV export.
Expected 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
- Aggregates segments by night (wake date)
- Maps German phase names: Kernlight, REMrem, Tiefdeep, Wachawake
- Stores raw segments in JSONB
- Does NOT overwrite manual entries (source='manual')
"""
pid = session['profile_id']
# Read CSV
content = await file.read()
csv_text = content.decode('utf-8-sig') # Handle BOM
reader = csv.DictReader(io.StringIO(csv_text))
# Phase mapping (German → English)
phase_map = {
'Kern': 'light',
'REM': 'rem',
'Tief': 'deep',
'Wach': 'awake',
'Schlafend': None # Ignore initial sleep entry
}
# Parse segments
segments = []
for row in reader:
phase_de = row['Value'].strip()
phase_en = phase_map.get(phase_de)
if phase_en is None: # Skip "Schlafend"
continue
start_dt = datetime.strptime(row['Start'], '%Y-%m-%d %H:%M:%S')
end_dt = datetime.strptime(row['End'], '%Y-%m-%d %H:%M:%S')
duration_hr = float(row['Duration (hr)'])
duration_min = int(duration_hr * 60)
segments.append({
'start': start_dt,
'end': end_dt,
'duration_min': duration_min,
'phase': phase_en
})
# Sort segments chronologically
segments.sort(key=lambda s: s['start'])
# Group segments into nights (gap-based)
# If gap between segments > 2 hours → new night
nights = []
current_night = None
for seg in segments:
# Start new night if:
# 1. First segment
# 2. Gap > 2 hours since last segment
if current_night is None or (seg['start'] - current_night['wake_time']).total_seconds() > 7200:
current_night = {
'bedtime': seg['start'],
'wake_time': seg['end'],
'segments': [],
'deep_minutes': 0,
'rem_minutes': 0,
'light_minutes': 0,
'awake_minutes': 0
}
nights.append(current_night)
# Add segment to current night
current_night['segments'].append(seg)
current_night['wake_time'] = max(current_night['wake_time'], seg['end'])
current_night['bedtime'] = min(current_night['bedtime'], seg['start'])
# Sum phases
if seg['phase'] == 'deep':
current_night['deep_minutes'] += seg['duration_min']
elif seg['phase'] == 'rem':
current_night['rem_minutes'] += seg['duration_min']
elif seg['phase'] == 'light':
current_night['light_minutes'] += seg['duration_min']
elif seg['phase'] == 'awake':
current_night['awake_minutes'] += seg['duration_min']
# Convert nights list to dict with wake_date as key
nights_dict = {}
for night in nights:
wake_date = night['wake_time'].date() # Date when you woke up
nights_dict[wake_date] = night
# Insert nights
imported = 0
skipped = 0
with get_db() as conn:
cur = get_cursor(conn)
for date, night in nights_dict.items():
# Calculate sleep duration (deep + rem + light, WITHOUT awake)
# Note: awake_minutes tracked separately, not part of sleep duration
duration_minutes = (
night['deep_minutes'] +
night['rem_minutes'] +
night['light_minutes']
)
# Calculate wake_count (number of awake segments)
wake_count = sum(1 for seg in night['segments'] if seg['phase'] == 'awake')
# Prepare JSONB segments with full datetime
sleep_segments = [
{
'phase': seg['phase'],
'start': seg['start'].isoformat(), # Full datetime: 2026-03-21T22:30:00
'end': seg['end'].isoformat(), # Full datetime: 2026-03-21T23:15:00
'duration_min': seg['duration_min']
}
for seg in night['segments']
]
# Check if manual entry exists - do NOT overwrite
cur.execute("""
SELECT id, source FROM sleep_log
WHERE profile_id = %s AND date = %s
""", (pid, date))
existing = cur.fetchone()
if existing and existing['source'] == 'manual':
skipped += 1
continue # Skip - don't overwrite manual entries
# Upsert (only if not manual)
# If entry exists and is NOT manual → update
# If entry doesn't exist → insert
if existing:
# Update existing non-manual entry
cur.execute("""
UPDATE sleep_log SET
bedtime = %s,
wake_time = %s,
duration_minutes = %s,
wake_count = %s,
deep_minutes = %s,
rem_minutes = %s,
light_minutes = %s,
awake_minutes = %s,
sleep_segments = %s,
source = 'apple_health',
updated_at = CURRENT_TIMESTAMP
WHERE id = %s AND profile_id = %s
""", (
night['bedtime'].time(),
night['wake_time'].time(),
duration_minutes,
wake_count,
night['deep_minutes'],
night['rem_minutes'],
night['light_minutes'],
night['awake_minutes'],
json.dumps(sleep_segments),
existing['id'],
pid
))
else:
# Insert new entry
cur.execute("""
INSERT INTO sleep_log (
profile_id, date, bedtime, wake_time, duration_minutes,
wake_count, deep_minutes, rem_minutes, light_minutes, awake_minutes,
sleep_segments, source, created_at, updated_at
) VALUES (
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'apple_health', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
)
""", (
pid,
date,
night['bedtime'].time(),
night['wake_time'].time(),
duration_minutes,
wake_count,
night['deep_minutes'],
night['rem_minutes'],
night['light_minutes'],
night['awake_minutes'],
json.dumps(sleep_segments)
))
imported += 1
conn.commit()
return {
"imported": imported,
"skipped": skipped,
"total_nights": len(nights_dict),
"message": f"{imported} Nächte importiert, {skipped} übersprungen (manuelle Einträge)"
}

View File

@ -30,6 +30,7 @@ import AdminUserRestrictionsPage from './pages/AdminUserRestrictionsPage'
import AdminTrainingTypesPage from './pages/AdminTrainingTypesPage'
import AdminActivityMappingsPage from './pages/AdminActivityMappingsPage'
import SubscriptionPage from './pages/SubscriptionPage'
import SleepPage from './pages/SleepPage'
import './app.css'
function Nav() {
@ -164,6 +165,7 @@ function AppShell() {
<Route path="/circum" element={<CircumScreen/>}/>
<Route path="/caliper" element={<CaliperScreen/>}/>
<Route path="/history" element={<History/>}/>
<Route path="/sleep" element={<SleepPage/>}/>
<Route path="/nutrition" element={<NutritionPage/>}/>
<Route path="/activity" element={<ActivityPage/>}/>
<Route path="/analysis" element={<Analysis/>}/>

View File

@ -130,6 +130,10 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we
.empty-state h3 { font-size: 16px; color: var(--text2); margin-bottom: 6px; }
.spinner { width: 20px; height: 20px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.7s linear infinite; display: inline-block; }
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes slideDown {
from { transform: translate(-50%, -20px); opacity: 0; }
to { transform: translate(-50%, 0); opacity: 1; }
}
/* Additional vars */
:root {

View File

@ -0,0 +1,111 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { Moon, Plus } from 'lucide-react'
import { api } from '../utils/api'
/**
* SleepWidget - Dashboard widget for sleep tracking (v9d Phase 2b)
*
* Shows:
* - Last night's sleep (if exists)
* - 7-day average
* - Quick action button to add entry or view details
*/
export default function SleepWidget() {
const nav = useNavigate()
const [loading, setLoading] = useState(true)
const [lastNight, setLastNight] = useState(null)
const [stats, setStats] = useState(null)
useEffect(() => {
load()
}, [])
const load = () => {
Promise.all([
api.listSleep(1), // Get last entry
api.getSleepStats(7).catch(() => null) // Stats optional
]).then(([sleepData, statsData]) => {
setLastNight(sleepData[0] || null)
setStats(statsData)
setLoading(false)
}).catch(err => {
console.error('Failed to load sleep widget:', err)
setLoading(false)
})
}
const formatDuration = (minutes) => {
const h = Math.floor(minutes / 60)
const m = minutes % 60
return `${h}h ${m}min`
}
const renderStars = (quality) => {
if (!quality) return '—'
return '★'.repeat(quality) + '☆'.repeat(5 - quality)
}
if (loading) {
return (
<div className="card" style={{ padding: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<Moon size={16} color="var(--accent)" />
<div style={{ fontWeight: 600, fontSize: 14 }}>Schlaf</div>
</div>
<div style={{ textAlign: 'center', padding: 20 }}>
<div className="spinner" style={{ width: 20, height: 20, margin: '0 auto' }} />
</div>
</div>
)
}
return (
<div className="card" style={{ padding: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<Moon size={16} color="var(--accent)" />
<div style={{ fontWeight: 600, fontSize: 14 }}>Schlaf</div>
</div>
{lastNight ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ fontSize: 12, color: 'var(--text3)' }}>Letzte Nacht:</div>
<div style={{ fontSize: 16, fontWeight: 700 }}>
{lastNight.duration_formatted} {lastNight.quality && `· ${renderStars(lastNight.quality)}`}
</div>
<div style={{ fontSize: 11, color: 'var(--text3)' }}>
{new Date(lastNight.date).toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit' })}
</div>
{stats && stats.total_nights > 0 && (
<div style={{ marginTop: 8, paddingTop: 8, borderTop: '1px solid var(--border)' }}>
<div style={{ fontSize: 11, color: 'var(--text3)' }}>Ø 7 Tage:</div>
<div style={{ fontSize: 13, fontWeight: 600 }}>
{formatDuration(Math.round(stats.avg_duration_minutes))}
{stats.avg_quality && ` · ${stats.avg_quality.toFixed(1)}/5`}
</div>
</div>
)}
</div>
) : (
<div style={{ textAlign: 'center', padding: '12px 0', color: 'var(--text3)' }}>
<div style={{ fontSize: 12, marginBottom: 12 }}>Noch keine Einträge erfasst</div>
</div>
)}
<button
onClick={() => nav('/sleep')}
className="btn btn-secondary btn-full"
style={{ marginTop: 12, fontSize: 12 }}
>
{lastNight ? (
<>Zur Übersicht</>
) : (
<>
<Plus size={14} /> Schlaf erfassen
</>
)}
</button>
</div>
)
}

View File

@ -45,6 +45,13 @@ const ENTRIES = [
to: '/activity',
color: '#D4537E',
},
{
icon: '🌙',
label: 'Schlaf',
sub: 'Schlafdaten erfassen oder Apple Health importieren',
to: '/sleep',
color: '#7B68EE',
},
{
icon: '📖',
label: 'Messanleitung',

View File

@ -11,6 +11,7 @@ import { getBfCategory } from '../utils/calc'
import TrialBanner from '../components/TrialBanner'
import EmailVerificationBanner from '../components/EmailVerificationBanner'
import TrainingTypeDistribution from '../components/TrainingTypeDistribution'
import SleepWidget from '../components/SleepWidget'
import { getInterpretation, getStatusColor, getStatusBg } from '../utils/interpret'
import Markdown from '../utils/Markdown'
import dayjs from 'dayjs'
@ -471,6 +472,11 @@ export default function Dashboard() {
)}
</div>
{/* Sleep Widget */}
<div style={{marginBottom:16}}>
<SleepWidget/>
</div>
{/* Training Type Distribution */}
{activities.length > 0 && (
<div className="card section-gap" style={{marginBottom:16}}>

File diff suppressed because it is too large Load Diff

View File

@ -212,4 +212,22 @@ export const api = {
adminUpdateActivityMapping: (id,d) => req(`/admin/activity-mappings/${id}`, jput(d)),
adminDeleteActivityMapping: (id) => req(`/admin/activity-mappings/${id}`, {method:'DELETE'}),
adminGetMappingCoverage: () => req('/admin/activity-mappings/stats/coverage'),
// Sleep Module (v9d Phase 2b)
listSleep: (l=90) => req(`/sleep?limit=${l}`),
getSleepByDate: (date) => req(`/sleep/by-date/${date}`),
createSleep: (d) => req('/sleep', json(d)),
updateSleep: (id,d) => req(`/sleep/${id}`, jput(d)),
deleteSleep: (id) => req(`/sleep/${id}`, {method:'DELETE'}),
getSleepStats: (days=7) => req(`/sleep/stats?days=${days}`),
getSleepDebt: (days=14) => req(`/sleep/debt?days=${days}`),
getSleepTrend: (days=30) => req(`/sleep/trend?days=${days}`),
getSleepPhases: (days=30) => req(`/sleep/phases?days=${days}`),
// Sleep Import (v9d Phase 2c)
importAppleHealthSleep: (file) => {
const fd = new FormData()
fd.append('file', file)
return req('/sleep/import/apple-health', {method:'POST', body:fd})
},
}