Compare commits
15 Commits
3ff2a1bf45
...
601fc80178
| Author | SHA1 | Date | |
|---|---|---|---|
| 601fc80178 | |||
| 5adec042a4 | |||
| 9aeb0de936 | |||
| b22481d4ce | |||
| 1644b34d5c | |||
| b52c877367 | |||
| da376a8b18 | |||
| 9a9c597187 | |||
| b1a92c01fc | |||
| b65efd3b71 | |||
| 9e4d6fa715 | |||
| 836bc4294b | |||
| 39d676e5c8 | |||
| ef81c46bc0 | |||
| 40a4739349 |
109
CLAUDE.md
109
CLAUDE.md
|
|
@ -1,5 +1,12 @@
|
||||||
# Mitai Jinkendo – Entwickler-Kontext für Claude Code
|
# 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
|
## Projekt-Übersicht
|
||||||
**Mitai Jinkendo** (身体 Jinkendo) – selbst-gehostete PWA für Körper-Tracking mit KI-Auswertung.
|
**Mitai Jinkendo** (身体 Jinkendo) – selbst-gehostete PWA für Körper-Tracking mit KI-Auswertung.
|
||||||
Teil der **Jinkendo**-App-Familie (人拳道). Domains: jinkendo.de / .com / .life
|
Teil der **Jinkendo**-App-Familie (人拳道). Domains: jinkendo.de / .com / .life
|
||||||
|
|
@ -93,43 +100,49 @@ frontend/src/
|
||||||
- ✅ **Navigation-Fixes:** Alle Login/Verify-Flows funktionieren korrekt
|
- ✅ **Navigation-Fixes:** Alle Login/Verify-Flows funktionieren korrekt
|
||||||
- ✅ **Error-Handling:** JSON-Fehler sauber formatiert, Dashboard robust bei API-Fehlern
|
- ✅ **Error-Handling:** JSON-Fehler sauber formatiert, Dashboard robust bei API-Fehlern
|
||||||
|
|
||||||
### Auf develop (bereit für Prod) 🚀
|
### v9d – Phase 1b ✅ (Deployed to Production 21.03.2026)
|
||||||
**v9d Phase 1b - Feature-komplett, ready for deployment**
|
|
||||||
|
|
||||||
- ✅ **Trainingstypen-System (komplett):**
|
**Trainingstypen-System mit lernendem Mapping:**
|
||||||
- 29 Trainingstypen (7 Kategorien)
|
|
||||||
- Admin-CRUD mit vollständiger UI
|
- ✅ **29 Trainingstypen** in 7 Kategorien (inkl. Geist & Meditation)
|
||||||
- Automatisches Apple Health Mapping (23 Workout-Typen)
|
- ✅ **Lernendes Mapping-System (DB-basiert):**
|
||||||
- Bulk-Kategorisierung für bestehende Aktivitäten
|
- Tabelle `activity_type_mappings` statt hardcoded
|
||||||
- Farbige Typ-Badges in Aktivitätsliste
|
- 40+ Standard-Mappings (Deutsch + English)
|
||||||
- TrainingTypeDistribution Chart in History-Seite
|
- 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
|
- 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:**
|
**Migrations:**
|
||||||
- TrialBanner mailto (Vorbereitung zentrales Abo-System)
|
- Migration 004: training_types Tabelle + 23 Basis-Typen
|
||||||
- Admin-Formular UX-Optimierung (Full-width inputs, größere Textareas)
|
- 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:**
|
**Dokumentation:**
|
||||||
- `.claude/docs/technical/CENTRAL_SUBSCRIPTION_SYSTEM.md`
|
|
||||||
- `.claude/docs/functional/AI_PROMPTS.md` (erweitert um Fähigkeiten-Mapping)
|
- `.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)
|
### v9d – Phase 2 🔲 (Nächster Schritt)
|
||||||
- ✅ **Trainingstypen Basis:** DB-Schema, 23 Typen, API-Endpoints
|
**Vitalwerte & Erholung:**
|
||||||
- ✅ **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)
|
|
||||||
- 🔲 Ruhetage erfassen (rest_days Tabelle)
|
- 🔲 Ruhetage erfassen (rest_days Tabelle)
|
||||||
- 🔲 Ruhepuls erfassen (vitals_log Tabelle)
|
- 🔲 Ruhepuls erfassen (vitals_log Tabelle)
|
||||||
- 🔲 HF-Zonen + Erholungsstatus
|
- 🔲 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`
|
📚 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`
|
> Vollständige CSS-Variablen und Komponenten-Muster: `frontend/src/app.css`
|
||||||
> Responsive Layout-Spec: `.claude/docs/functional/RESPONSIVE_UI.md`
|
> 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
|
## 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 admin, stats, exportdata, importdata
|
||||||
from routers import subscription, coupons, features, tiers_mgmt, tier_limits
|
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 user_restrictions, access_grants, training_types, admin_training_types
|
||||||
from routers import admin_activity_mappings
|
from routers import admin_activity_mappings, sleep
|
||||||
|
|
||||||
# ── App Configuration ─────────────────────────────────────────────────────────
|
# ── App Configuration ─────────────────────────────────────────────────────────
|
||||||
DATA_DIR = Path(os.getenv("DATA_DIR", "./data"))
|
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(user_restrictions.router) # /api/user-restrictions (admin)
|
||||||
app.include_router(access_grants.router) # /api/access-grants (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(training_types.router) # /api/training-types/*
|
||||||
app.include_router(admin_training_types.router) # /api/admin/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(admin_activity_mappings.router) # /api/admin/activity-mappings/*
|
||||||
|
app.include_router(sleep.router) # /api/sleep/* (v9d Phase 2b)
|
||||||
|
|
||||||
# ── Health Check ──────────────────────────────────────────────────────────────
|
# ── Health Check ──────────────────────────────────────────────────────────────
|
||||||
@app.get("/")
|
@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 AdminTrainingTypesPage from './pages/AdminTrainingTypesPage'
|
||||||
import AdminActivityMappingsPage from './pages/AdminActivityMappingsPage'
|
import AdminActivityMappingsPage from './pages/AdminActivityMappingsPage'
|
||||||
import SubscriptionPage from './pages/SubscriptionPage'
|
import SubscriptionPage from './pages/SubscriptionPage'
|
||||||
|
import SleepPage from './pages/SleepPage'
|
||||||
import './app.css'
|
import './app.css'
|
||||||
|
|
||||||
function Nav() {
|
function Nav() {
|
||||||
|
|
@ -164,6 +165,7 @@ function AppShell() {
|
||||||
<Route path="/circum" element={<CircumScreen/>}/>
|
<Route path="/circum" element={<CircumScreen/>}/>
|
||||||
<Route path="/caliper" element={<CaliperScreen/>}/>
|
<Route path="/caliper" element={<CaliperScreen/>}/>
|
||||||
<Route path="/history" element={<History/>}/>
|
<Route path="/history" element={<History/>}/>
|
||||||
|
<Route path="/sleep" element={<SleepPage/>}/>
|
||||||
<Route path="/nutrition" element={<NutritionPage/>}/>
|
<Route path="/nutrition" element={<NutritionPage/>}/>
|
||||||
<Route path="/activity" element={<ActivityPage/>}/>
|
<Route path="/activity" element={<ActivityPage/>}/>
|
||||||
<Route path="/analysis" element={<Analysis/>}/>
|
<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; }
|
.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; }
|
.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 spin { to { transform: rotate(360deg); } }
|
||||||
|
@keyframes slideDown {
|
||||||
|
from { transform: translate(-50%, -20px); opacity: 0; }
|
||||||
|
to { transform: translate(-50%, 0); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
/* Additional vars */
|
/* Additional vars */
|
||||||
:root {
|
: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',
|
to: '/activity',
|
||||||
color: '#D4537E',
|
color: '#D4537E',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: '🌙',
|
||||||
|
label: 'Schlaf',
|
||||||
|
sub: 'Schlafdaten erfassen oder Apple Health importieren',
|
||||||
|
to: '/sleep',
|
||||||
|
color: '#7B68EE',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
icon: '📖',
|
icon: '📖',
|
||||||
label: 'Messanleitung',
|
label: 'Messanleitung',
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { getBfCategory } from '../utils/calc'
|
||||||
import TrialBanner from '../components/TrialBanner'
|
import TrialBanner from '../components/TrialBanner'
|
||||||
import EmailVerificationBanner from '../components/EmailVerificationBanner'
|
import EmailVerificationBanner from '../components/EmailVerificationBanner'
|
||||||
import TrainingTypeDistribution from '../components/TrainingTypeDistribution'
|
import TrainingTypeDistribution from '../components/TrainingTypeDistribution'
|
||||||
|
import SleepWidget from '../components/SleepWidget'
|
||||||
import { getInterpretation, getStatusColor, getStatusBg } from '../utils/interpret'
|
import { getInterpretation, getStatusColor, getStatusBg } from '../utils/interpret'
|
||||||
import Markdown from '../utils/Markdown'
|
import Markdown from '../utils/Markdown'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
|
|
@ -471,6 +472,11 @@ export default function Dashboard() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Sleep Widget */}
|
||||||
|
<div style={{marginBottom:16}}>
|
||||||
|
<SleepWidget/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Training Type Distribution */}
|
{/* Training Type Distribution */}
|
||||||
{activities.length > 0 && (
|
{activities.length > 0 && (
|
||||||
<div className="card section-gap" style={{marginBottom:16}}>
|
<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)),
|
adminUpdateActivityMapping: (id,d) => req(`/admin/activity-mappings/${id}`, jput(d)),
|
||||||
adminDeleteActivityMapping: (id) => req(`/admin/activity-mappings/${id}`, {method:'DELETE'}),
|
adminDeleteActivityMapping: (id) => req(`/admin/activity-mappings/${id}`, {method:'DELETE'}),
|
||||||
adminGetMappingCoverage: () => req('/admin/activity-mappings/stats/coverage'),
|
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