Merge pull request 'WP 9c Phase 1' (#12) from develop into main
Reviewed-on: #12
This commit is contained in:
commit
601fc80178
109
CLAUDE.md
109
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`
|
||||
**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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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("/")
|
||||
|
|
|
|||
59
backend/migrations/008_vitals_rest_days.sql
Normal file
59
backend/migrations/008_vitals_rest_days.sql
Normal 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)';
|
||||
31
backend/migrations/009_sleep_log.sql
Normal file
31
backend/migrations/009_sleep_log.sql
Normal 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
660
backend/routers/sleep.py
Normal 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: 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)"
|
||||
}
|
||||
|
|
@ -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/>}/>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
111
frontend/src/components/SleepWidget.jsx
Normal file
111
frontend/src/components/SleepWidget.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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}}>
|
||||
|
|
|
|||
1007
frontend/src/pages/SleepPage.jsx
Normal file
1007
frontend/src/pages/SleepPage.jsx
Normal file
File diff suppressed because it is too large
Load Diff
|
|
@ -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})
|
||||
},
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user