diff --git a/CLAUDE.md b/CLAUDE.md index 166b058..51c236e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` - - `.claude/docs/functional/AI_PROMPTS.md` (erweitert um Fähigkeiten-Mapping) +**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 ``` diff --git a/backend/main.py b/backend/main.py index e67e3b9..229ea16 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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("/") diff --git a/backend/migrations/008_vitals_rest_days.sql b/backend/migrations/008_vitals_rest_days.sql new file mode 100644 index 0000000..80dbfdb --- /dev/null +++ b/backend/migrations/008_vitals_rest_days.sql @@ -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)'; diff --git a/backend/migrations/009_sleep_log.sql b/backend/migrations/009_sleep_log.sql new file mode 100644 index 0000000..9f4edeb --- /dev/null +++ b/backend/migrations/009_sleep_log.sql @@ -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}, ...]'; diff --git a/backend/routers/sleep.py b/backend/routers/sleep.py new file mode 100644 index 0000000..dfdf8e6 --- /dev/null +++ b/backend/routers/sleep.py @@ -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: Kern→light, REM→rem, Tief→deep, Wach→awake + - 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)" + } diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index d65ebac..3df5521 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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() { }/> }/> }/> + }/> }/> }/> }/> diff --git a/frontend/src/app.css b/frontend/src/app.css index b1bb675..fd8581e 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -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 { diff --git a/frontend/src/components/SleepWidget.jsx b/frontend/src/components/SleepWidget.jsx new file mode 100644 index 0000000..32cb323 --- /dev/null +++ b/frontend/src/components/SleepWidget.jsx @@ -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 ( +
+
+ +
Schlaf
+
+
+
+
+
+ ) + } + + return ( +
+
+ +
Schlaf
+
+ + {lastNight ? ( +
+
Letzte Nacht:
+
+ {lastNight.duration_formatted} {lastNight.quality && `· ${renderStars(lastNight.quality)}`} +
+
+ {new Date(lastNight.date).toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit' })} +
+ + {stats && stats.total_nights > 0 && ( +
+
Ø 7 Tage:
+
+ {formatDuration(Math.round(stats.avg_duration_minutes))} + {stats.avg_quality && ` · ${stats.avg_quality.toFixed(1)}/5`} +
+
+ )} +
+ ) : ( +
+
Noch keine Einträge erfasst
+
+ )} + + +
+ ) +} diff --git a/frontend/src/pages/CaptureHub.jsx b/frontend/src/pages/CaptureHub.jsx index 92cee18..1ce7dcd 100644 --- a/frontend/src/pages/CaptureHub.jsx +++ b/frontend/src/pages/CaptureHub.jsx @@ -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', diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index 1649544..7c86135 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -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() { )}
+ {/* Sleep Widget */} +
+ +
+ {/* Training Type Distribution */} {activities.length > 0 && (
diff --git a/frontend/src/pages/SleepPage.jsx b/frontend/src/pages/SleepPage.jsx new file mode 100644 index 0000000..1237880 --- /dev/null +++ b/frontend/src/pages/SleepPage.jsx @@ -0,0 +1,1007 @@ +import { useState, useEffect, useRef } from 'react' +import { Moon, Plus, Edit2, Trash2, X, Save, TrendingUp, Upload, ChevronDown, ChevronUp, AlertCircle } from 'lucide-react' +import { api } from '../utils/api' + +/** + * SleepPage - Sleep tracking with CSV import (v9d Phase 2c) + * + * Features: + * - Drag & Drop CSV import + * - Inline editing (no scroll to top) + * - Source badges (manual/Apple Health/Garmin) + * - Expandable segment view (JSONB sleep_segments) + * - Plausibility check (phases sum = duration) + * - Toast notifications (no alerts) + */ +export default function SleepPage() { + const [sleep, setSleep] = useState([]) + const [stats, setStats] = useState(null) + const [loading, setLoading] = useState(true) + const [importing, setImporting] = useState(false) + const [dragging, setDragging] = useState(false) + const [toast, setToast] = useState(null) + const [expandedId, setExpandedId] = useState(null) + const [editingId, setEditingId] = useState(null) + const fileInputRef = useRef(null) + + useEffect(() => { + load() + }, []) + + const load = () => { + setLoading(true) + Promise.all([ + api.listSleep(30), + api.getSleepStats(7).catch(() => null) + ]).then(([sleepData, statsData]) => { + setSleep(sleepData) + setStats(statsData) + setLoading(false) + }).catch(err => { + console.error('Failed to load sleep data:', err) + setLoading(false) + }) + } + + const showToast = (message, type = 'success') => { + setToast({ message, type }) + setTimeout(() => setToast(null), 4000) + } + + // Clean data: convert empty strings to null for optional integer fields + const cleanSleepData = (data) => ({ + ...data, + quality: data.quality === '' ? null : data.quality, + wake_count: data.wake_count === '' ? 0 : data.wake_count, + deep_minutes: data.deep_minutes === '' ? null : data.deep_minutes, + rem_minutes: data.rem_minutes === '' ? null : data.rem_minutes, + light_minutes: data.light_minutes === '' ? null : data.light_minutes, + awake_minutes: data.awake_minutes === '' ? null : data.awake_minutes, + }) + + const handleImport = async (file) => { + if (!file) return + if (!file.name.endsWith('.csv')) { + showToast('Bitte eine CSV-Datei auswählen', 'error') + return + } + + setImporting(true) + + try { + const result = await api.importAppleHealthSleep(file) + await load() + showToast(`✅ ${result.imported} Nächte importiert, ${result.skipped} übersprungen`) + } catch (err) { + showToast('Import fehlgeschlagen: ' + err.message, 'error') + } finally { + setImporting(false) + if (fileInputRef.current) { + fileInputRef.current.value = '' + } + } + } + + const handleDrop = async (e) => { + e.preventDefault() + setDragging(false) + const file = e.dataTransfer.files[0] + if (file) await handleImport(file) + } + + const handleFileSelect = async (e) => { + const file = e.target.files[0] + if (file) await handleImport(file) + } + + const handleDelete = async (id, date) => { + if (!confirm(`Schlaf-Eintrag vom ${date} wirklich löschen?`)) return + + try { + await api.deleteSleep(id) + await load() + showToast('Eintrag gelöscht') + } catch (err) { + showToast('Löschen fehlgeschlagen: ' + err.message, 'error') + } + } + + const formatDuration = (minutes) => { + const h = Math.floor(minutes / 60) + const m = minutes % 60 + return `${h}h ${m}min` + } + + const getSourceBadge = (source) => { + const colors = { + manual: { bg: 'var(--surface2)', color: 'var(--text2)', label: 'Manuell' }, + apple_health: { bg: '#1D9E75', color: 'white', label: 'Apple Health' }, + garmin: { bg: '#007DB7', color: 'white', label: 'Garmin' } + } + const s = colors[source] || colors.manual + return ( + + {s.label} + + ) + } + + if (loading) { + return ( +
+
+
+ ) + } + + return ( +
+ {/* Toast Notification */} + {toast && ( +
+ {toast.message} +
+ )} + + {/* Header */} +
+ +

Schlaf

+
+ + {/* Stats Card */} + {stats && stats.total_nights > 0 && ( +
+
+ + Übersicht (letzte 7 Tage) +
+
+
+
+ {formatDuration(Math.round(stats.avg_duration_minutes))} +
+
Ø Schlafdauer
+
+
+
+ {stats.avg_quality ? stats.avg_quality.toFixed(1) : '—'}/5 +
+
Ø Qualität
+
+
+ {stats.nights_below_goal > 0 && ( +
+ ⚠️ {stats.nights_below_goal} Nächte unter Ziel ({formatDuration(stats.sleep_goal_minutes)}) +
+ )} +
+ )} + + {/* CSV Import Drag & Drop */} +
+
CSV Import
+
{ e.preventDefault(); setDragging(true) }} + onDragLeave={() => setDragging(false)} + onDrop={handleDrop} + onClick={() => fileInputRef.current?.click()} + style={{ + border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border2)'}`, + borderRadius: 10, + padding: '24px 16px', + textAlign: 'center', + background: dragging ? 'var(--accent-light)' : 'var(--surface2)', + cursor: importing ? 'not-allowed' : 'pointer', + transition: 'all 0.15s', + opacity: importing ? 0.6 : 1 + }}> + {importing ? ( + <> +
+
+ Importiere Schlaf-Daten... +
+ + ) : ( + <> + +
+ {dragging ? 'CSV loslassen...' : 'CSV hierher ziehen oder tippen'} +
+
+ Apple Health Export (.csv) +
+ + )} +
+ +
+ + {/* Manual Entry Button */} + + + {/* New Entry Form (if creating) */} + {editingId === 'new' && ( + { + try { + await api.createSleep(cleanSleepData(data)) + await load() + setEditingId(null) + showToast('Gespeichert') + } catch (err) { + showToast(err.message, 'error') + } + }} + onCancel={() => setEditingId(null)} + formatDuration={formatDuration} + /> + )} + + {/* Sleep List */} +
+ Letzte 30 Nächte ({sleep.length}) +
+ + {sleep.length === 0 ? ( +
+ Noch keine Einträge erfasst +
+ ) : ( +
+ {sleep.map(entry => ( + setExpandedId(expandedId === entry.id ? null : entry.id)} + onEdit={() => setEditingId(entry.id)} + onCancelEdit={() => setEditingId(null)} + onSave={async (data) => { + try { + await api.updateSleep(entry.id, cleanSleepData(data)) + await load() + setEditingId(null) + showToast('Gespeichert') + } catch (err) { + showToast(err.message, 'error') + } + }} + cleanSleepData={cleanSleepData} + onDelete={() => handleDelete(entry.id, entry.date)} + formatDuration={formatDuration} + getSourceBadge={getSourceBadge} + /> + ))} +
+ )} +
+ ) +} + +// ── Sleep Entry Component (Inline Editing) ──────────────────────────────────── + +function SleepEntry({ entry, expanded, editing, onToggleExpand, onEdit, onCancelEdit, onSave, onDelete, formatDuration, getSourceBadge }) { + const [formData, setFormData] = useState({ + date: entry.date, + duration_minutes: entry.duration_minutes, + quality: entry.quality, + bedtime: entry.bedtime || '', + wake_time: entry.wake_time || '', + wake_count: entry.wake_count || 0, + deep_minutes: entry.deep_minutes || '', + rem_minutes: entry.rem_minutes || '', + light_minutes: entry.light_minutes || '', + awake_minutes: entry.awake_minutes || '', + note: entry.note || '', + source: entry.source || 'manual' + }) + + const [saving, setSaving] = useState(false) + const [plausibilityError, setPlausibilityError] = useState(null) + const [suggestedDuration, setSuggestedDuration] = useState(null) + + // Auto-calculate duration from bedtime + wake_time (minus awake time) + useEffect(() => { + if (editing && formData.bedtime && formData.wake_time) { + const [bedH, bedM] = formData.bedtime.split(':').map(Number) + const [wakeH, wakeM] = formData.wake_time.split(':').map(Number) + + let bedMinutes = bedH * 60 + bedM + let wakeMinutes = wakeH * 60 + wakeM + + if (wakeMinutes < bedMinutes) { + wakeMinutes += 24 * 60 + } + + let duration = wakeMinutes - bedMinutes + + // Subtract awake time to get actual sleep duration + const awake = parseInt(formData.awake_minutes) || 0 + if (awake > 0) { + duration = Math.max(0, duration - awake) + } + + setSuggestedDuration(duration) + } else { + setSuggestedDuration(null) + } + }, [editing, formData.bedtime, formData.wake_time, formData.awake_minutes]) + + // Live plausibility check (sleep phases only, awake not counted) + useEffect(() => { + if (!editing) return + const sleepPhases = [formData.deep_minutes, formData.rem_minutes, formData.light_minutes] + if (sleepPhases.some(p => p !== '' && p !== null)) { + const sum = sleepPhases.reduce((a, b) => a + (parseInt(b) || 0), 0) + const diff = Math.abs(formData.duration_minutes - sum) + if (diff > 5) { + setPlausibilityError(`Schlafphasen-Summe (${sum} min) weicht um ${diff} min ab (Toleranz: 5 min). Wachphasen nicht mitgezählt.`) + } else { + setPlausibilityError(null) + } + } else { + setPlausibilityError(null) + } + }, [editing, formData]) + + const handleSave = async () => { + if (plausibilityError) { + alert(plausibilityError) + return + } + + setSaving(true) + try { + await onSave(formData) + } finally { + setSaving(false) + } + } + + if (editing) { + // ── Edit Mode ──────────────────────────────────────────────────────────────── + return ( +
+
✏️ Bearbeiten
+ + {/* Basic Fields */} +
+
+
Datum
+ setFormData({ ...formData, date: e.target.value })} + style={{ width: '100%' }} + /> +
+ +
+
+
Schlafdauer (reine Schlafzeit, Minuten)
+ setFormData({ ...formData, duration_minutes: parseInt(e.target.value) || 0 })} + style={{ width: '100%' }} + min="1" + /> +
+ = {formatDuration(formData.duration_minutes)} + {suggestedDuration && suggestedDuration !== formData.duration_minutes && ( + setFormData({ ...formData, duration_minutes: suggestedDuration })}> + 💡 Vorschlag: {formatDuration(suggestedDuration)} (übernehmen?) + + )} +
+
+ +
+
Qualität
+ +
+
+ + {/* Detail Fields */} +
+
Details (optional)
+ +
+
+
Eingeschlafen
+ setFormData({ ...formData, bedtime: e.target.value })} + style={{ width: '100%' }} + /> +
+
+
Aufgewacht
+ setFormData({ ...formData, wake_time: e.target.value })} + style={{ width: '100%' }} + /> +
+
+ +
+
Aufwachungen
+ setFormData({ ...formData, wake_count: parseInt(e.target.value) || 0 })} + style={{ width: '100%' }} + min="0" + /> +
+ +
Schlafphasen (Minuten)
+
+
+
Tiefschlaf
+ setFormData({ ...formData, deep_minutes: parseInt(e.target.value) || '' })} + style={{ width: '100%' }} + min="0" + /> +
+
+
REM-Schlaf
+ setFormData({ ...formData, rem_minutes: parseInt(e.target.value) || '' })} + style={{ width: '100%' }} + min="0" + /> +
+
+
Leichtschlaf
+ setFormData({ ...formData, light_minutes: parseInt(e.target.value) || '' })} + style={{ width: '100%' }} + min="0" + /> +
+
+
Wach im Bett
+ setFormData({ ...formData, awake_minutes: parseInt(e.target.value) || '' })} + style={{ width: '100%' }} + min="0" + /> +
+
+ + {plausibilityError && ( +
+ + {plausibilityError} +
+ )} +
+ +
+
Notiz
+