From 40a4739349ad35876ec2d8372e8ebd3ca0a40e7e Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 21 Mar 2026 21:25:18 +0100 Subject: [PATCH 01/14] docs: mark v9d Phase 1b as deployed to production - Successful production deployment confirmed (21.03.2026) - Document complete learnable mapping system - List all 4 migrations (004-007) - Update roadmap: Phase 2 next v9d Phase 1b complete: - 29 training types - DB-based learnable mapping system - Apple Health import with German support - Inline editing UX - Auto-learning from bulk categorization Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 64 ++++++++++++++++++++++++++++++------------------------- 1 file changed, 35 insertions(+), 29 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 166b058..75e5320 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -93,43 +93,49 @@ frontend/src/ - βœ… **Navigation-Fixes:** Alle Login/Verify-Flows funktionieren korrekt - βœ… **Error-Handling:** JSON-Fehler sauber formatiert, Dashboard robust bei API-Fehlern -### Auf develop (bereit fΓΌr Prod) πŸš€ -**v9d Phase 1b - Feature-komplett, ready for deployment** +### v9d – Phase 1b βœ… (Deployed to Production 21.03.2026) -- βœ… **Trainingstypen-System (komplett):** - - 29 Trainingstypen (7 Kategorien) - - Admin-CRUD mit vollstΓ€ndiger UI - - Automatisches Apple Health Mapping (23 Workout-Typen) - - Bulk-Kategorisierung fΓΌr bestehende AktivitΓ€ten - - Farbige Typ-Badges in AktivitΓ€tsliste - - TrainingTypeDistribution Chart in History-Seite +**Trainingstypen-System mit lernendem Mapping:** + +- βœ… **29 Trainingstypen** in 7 Kategorien (inkl. Geist & Meditation) +- βœ… **Lernendes Mapping-System (DB-basiert):** + - Tabelle `activity_type_mappings` statt hardcoded + - 40+ Standard-Mappings (Deutsch + English) + - Auto-Learning: Bulk-Kategorisierung speichert Mappings + - User-spezifische + globale Mappings + - Admin-UI fΓΌr Mapping-Verwaltung (inline editing) + - Coverage-Stats (% zugeordnet vs. unkategorisiert) +- βœ… **Apple Health Import:** + - Deutsche Workout-Namen unterstΓΌtzt + - Automatisches Mapping via DB + - Duplikat-Erkennung (date + start_time) + - Update statt Insert bei Reimport +- βœ… **UI-Features:** - TrainingTypeSelect in ActivityPage + - Farbige Typ-Badges in AktivitΓ€tsliste + - TrainingTypeDistribution Chart in History + - Bulk-Kategorisierung (selbstlernend) + - Admin-CRUD fΓΌr Trainingstypen + - Admin-CRUD fΓΌr Activity-Mappings (inline editing) -- βœ… **Weitere Verbesserungen:** - - TrialBanner mailto (Vorbereitung zentrales Abo-System) - - Admin-Formular UX-Optimierung (Full-width inputs, grâßere Textareas) +**Migrations:** +- Migration 004: training_types Tabelle + 23 Basis-Typen +- Migration 005: Extended types (Gehen, Tanzen, Geist & Meditation) +- Migration 006: abilities JSONB column (Platzhalter fΓΌr v9f) +- Migration 007: activity_type_mappings (lernendes System) -- πŸ“š **Dokumentation:** - - `.claude/docs/technical/CENTRAL_SUBSCRIPTION_SYSTEM.md` - - `.claude/docs/functional/AI_PROMPTS.md` (erweitert um FΓ€higkeiten-Mapping) +**Dokumentation:** +- `.claude/docs/functional/AI_PROMPTS.md` (erweitert um FΓ€higkeiten-Mapping) +- `.claude/docs/technical/CENTRAL_SUBSCRIPTION_SYSTEM.md` -### v9d – Phase 1 βœ… (Deployed 21.03.2026) -- βœ… **Trainingstypen Basis:** DB-Schema, 23 Typen, API-Endpoints -- βœ… **Logout-Button:** Im Header neben Avatar, mit BestΓ€tigung -- βœ… **Components:** TrainingTypeSelect, TrainingTypeDistribution - -### v9d – Phase 1b βœ… (Abgeschlossen, auf develop) -- βœ… ActivityPage: TrainingTypeSelect eingebunden -- βœ… History: TrainingTypeDistribution Chart + Typ-Badges bei AktivitΓ€ten -- βœ… Apple Health Import: Automatisches Mapping (29 Typen) -- βœ… Bulk-Kategorisierung: UI + Endpoints -- βœ… Admin-CRUD: VollstΓ€ndige Verwaltung inkl. UX-Optimierungen - -### v9d – Phase 2+ πŸ”² (SpΓ€ter) +### v9d – Phase 2 πŸ”² (NΓ€chster Schritt) +**Vitalwerte & Erholung:** - πŸ”² Ruhetage erfassen (rest_days Tabelle) - πŸ”² Ruhepuls erfassen (vitals_log Tabelle) - πŸ”² HF-Zonen + Erholungsstatus -- πŸ”² Schlaf-Modul +- πŸ”² Schlaf-Modul (Basis) + +πŸ“š Details: `.claude/docs/functional/TRAINING_TYPES.md` πŸ“š Details: `.claude/docs/technical/MEMBERSHIP_SYSTEM.md` Β· `.claude/docs/architecture/FEATURE_ENFORCEMENT.md` From ef81c46bc0b15af0b9ebae1e298011d0e5570c25 Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 22 Mar 2026 08:17:11 +0100 Subject: [PATCH 02/14] feat: v9d Phase 2b - Sleep Module Core (Schlaf-Modul) - Add sleep_log table with JSONB sleep_segments (Migration 009) - Add sleep router with CRUD + stats endpoints (7d avg, 14d debt, trend, phases) - Add SleepPage with quick/detail entry forms and inline edit - Add SleepWidget to Dashboard showing last night + 7d average - Add sleep navigation entry with Moon icon - Register sleep router in main.py - Add 9 new API methods in api.js Phase 2b complete - ready for testing on dev Co-Authored-By: Claude Opus 4.6 --- backend/main.py | 5 +- backend/migrations/009_sleep_log.sql | 31 ++ backend/routers/sleep.py | 418 +++++++++++++++++++++ frontend/src/App.jsx | 5 +- frontend/src/components/SleepWidget.jsx | 111 ++++++ frontend/src/pages/Dashboard.jsx | 6 + frontend/src/pages/SleepPage.jsx | 466 ++++++++++++++++++++++++ frontend/src/utils/api.js | 11 + 8 files changed, 1050 insertions(+), 3 deletions(-) create mode 100644 backend/migrations/009_sleep_log.sql create mode 100644 backend/routers/sleep.py create mode 100644 frontend/src/components/SleepWidget.jsx create mode 100644 frontend/src/pages/SleepPage.jsx diff --git a/backend/main.py b/backend/main.py index e67e3b9..229ea16 100644 --- a/backend/main.py +++ b/backend/main.py @@ -20,7 +20,7 @@ from routers import activity, nutrition, photos, insights, prompts from routers import admin, stats, exportdata, importdata from routers import subscription, coupons, features, tiers_mgmt, tier_limits from routers import user_restrictions, access_grants, training_types, admin_training_types -from routers import admin_activity_mappings +from routers import admin_activity_mappings, sleep # ── App Configuration ───────────────────────────────────────────────────────── DATA_DIR = Path(os.getenv("DATA_DIR", "./data")) @@ -86,10 +86,11 @@ app.include_router(tier_limits.router) # /api/tier-limits (admin) app.include_router(user_restrictions.router) # /api/user-restrictions (admin) app.include_router(access_grants.router) # /api/access-grants (admin) -# v9d Training Types +# v9d Training Types & Sleep Module app.include_router(training_types.router) # /api/training-types/* app.include_router(admin_training_types.router) # /api/admin/training-types/* app.include_router(admin_activity_mappings.router) # /api/admin/activity-mappings/* +app.include_router(sleep.router) # /api/sleep/* (v9d Phase 2b) # ── Health Check ────────────────────────────────────────────────────────────── @app.get("/") diff --git a/backend/migrations/009_sleep_log.sql b/backend/migrations/009_sleep_log.sql new file mode 100644 index 0000000..3c77247 --- /dev/null +++ b/backend/migrations/009_sleep_log.sql @@ -0,0 +1,31 @@ +-- Migration 009: Sleep Log Table +-- v9d Phase 2b: Sleep Module Core +-- Date: 2026-03-22 + +CREATE TABLE IF NOT EXISTS sleep_log ( + id SERIAL PRIMARY KEY, + profile_id VARCHAR(36) NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + date DATE NOT NULL, + bedtime TIME, + wake_time TIME, + duration_minutes INTEGER NOT NULL CHECK (duration_minutes > 0), + quality INTEGER CHECK (quality >= 1 AND quality <= 5), + wake_count INTEGER CHECK (wake_count >= 0), + deep_minutes INTEGER CHECK (deep_minutes >= 0), + rem_minutes INTEGER CHECK (rem_minutes >= 0), + light_minutes INTEGER CHECK (light_minutes >= 0), + awake_minutes INTEGER CHECK (awake_minutes >= 0), + sleep_segments JSONB, + note TEXT, + source VARCHAR(20) DEFAULT 'manual' CHECK (source IN ('manual', 'apple_health', 'garmin')), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT unique_sleep_per_day UNIQUE(profile_id, date) +); + +CREATE INDEX idx_sleep_profile_date ON sleep_log(profile_id, date DESC); + +-- Comments for documentation +COMMENT ON TABLE sleep_log IS 'v9d Phase 2b: Daily sleep tracking with phase data'; +COMMENT ON COLUMN sleep_log.date IS 'Date of the night (wake date, not bedtime date)'; +COMMENT ON COLUMN sleep_log.sleep_segments IS 'Raw phase segments: [{"phase": "deep", "start": "23:44", "duration_min": 42}, ...]'; diff --git a/backend/routers/sleep.py b/backend/routers/sleep.py new file mode 100644 index 0000000..2c84480 --- /dev/null +++ b/backend/routers/sleep.py @@ -0,0 +1,418 @@ +""" +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 + +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'] + + 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, data.bedtime, data.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'] + + 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, data.bedtime, data.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 + ] diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index d65ebac..ce9e19b 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,6 +1,6 @@ import { useEffect } from 'react' import { BrowserRouter, Routes, Route, NavLink, useNavigate } from 'react-router-dom' -import { LayoutDashboard, PlusSquare, TrendingUp, BarChart2, Settings, LogOut } from 'lucide-react' +import { LayoutDashboard, PlusSquare, TrendingUp, BarChart2, Settings, LogOut, Moon } from 'lucide-react' import { ProfileProvider, useProfile } from './context/ProfileContext' import { AuthProvider, useAuth } from './context/AuthContext' import { setProfileId } from './utils/api' @@ -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() { @@ -37,6 +38,7 @@ function Nav() { { to:'/', icon:, label:'Übersicht' }, { to:'/capture', icon:, label:'Erfassen' }, { to:'/history', icon:, label:'Verlauf' }, + { to:'/sleep', icon:, label:'Schlaf' }, { to:'/analysis', icon:, label:'Analyse' }, { to:'/settings', icon:, label:'Einst.' }, ] @@ -164,6 +166,7 @@ function AppShell() { }/> }/> }/> + }/> }/> }/> }/> diff --git a/frontend/src/components/SleepWidget.jsx b/frontend/src/components/SleepWidget.jsx new file mode 100644 index 0000000..a6353f3 --- /dev/null +++ b/frontend/src/components/SleepWidget.jsx @@ -0,0 +1,111 @@ +import { useState, useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import { Moon, Plus } from 'lucide-react' +import { api } from '../utils/api' + +/** + * SleepWidget - Dashboard widget for sleep tracking (v9d Phase 2b) + * + * Shows: + * - Last night's sleep (if exists) + * - 7-day average + * - Quick action button to add entry or view details + */ +export default function SleepWidget() { + const nav = useNavigate() + const [loading, setLoading] = useState(true) + const [lastNight, setLastNight] = useState(null) + const [stats, setStats] = useState(null) + + useEffect(() => { + load() + }, []) + + const load = () => { + Promise.all([ + api.listSleep(1), // Get last entry + api.getSleepStats(7) + ]).then(([sleepData, statsData]) => { + setLastNight(sleepData[0] || null) + setStats(statsData) + setLoading(false) + }).catch(err => { + console.error('Failed to load sleep widget:', err) + setLoading(false) + }) + } + + const formatDuration = (minutes) => { + const h = Math.floor(minutes / 60) + const m = minutes % 60 + return `${h}h ${m}min` + } + + const renderStars = (quality) => { + if (!quality) return 'β€”' + return 'β˜…'.repeat(quality) + 'β˜†'.repeat(5 - quality) + } + + if (loading) { + return ( +
+
+ +
Schlaf
+
+
+
+
+
+ ) + } + + return ( +
+
+ +
Schlaf
+
+ + {lastNight ? ( +
+
Letzte Nacht:
+
+ {lastNight.duration_formatted} {lastNight.quality && `Β· ${renderStars(lastNight.quality)}`} +
+
+ {new Date(lastNight.date).toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit' })} +
+ + {stats && stats.total_nights > 0 && ( +
+
Ø 7 Tage:
+
+ {formatDuration(Math.round(stats.avg_duration_minutes))} + {stats.avg_quality && ` Β· ${stats.avg_quality.toFixed(1)}/5`} +
+
+ )} +
+ ) : ( +
+
Noch keine EintrΓ€ge erfasst
+
+ )} + + +
+ ) +} diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index 1649544..7c86135 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -11,6 +11,7 @@ import { getBfCategory } from '../utils/calc' import TrialBanner from '../components/TrialBanner' import EmailVerificationBanner from '../components/EmailVerificationBanner' import TrainingTypeDistribution from '../components/TrainingTypeDistribution' +import SleepWidget from '../components/SleepWidget' import { getInterpretation, getStatusColor, getStatusBg } from '../utils/interpret' import Markdown from '../utils/Markdown' import dayjs from 'dayjs' @@ -471,6 +472,11 @@ export default function Dashboard() { )}
+ {/* Sleep Widget */} +
+ +
+ {/* Training Type Distribution */} {activities.length > 0 && (
diff --git a/frontend/src/pages/SleepPage.jsx b/frontend/src/pages/SleepPage.jsx new file mode 100644 index 0000000..41cccd7 --- /dev/null +++ b/frontend/src/pages/SleepPage.jsx @@ -0,0 +1,466 @@ +import { useState, useEffect } from 'react' +import { Moon, Plus, Edit2, Trash2, X, Save, TrendingUp } from 'lucide-react' +import { api } from '../utils/api' + +/** + * SleepPage - Sleep tracking with quick/detail entry (v9d Phase 2b) + * + * Features: + * - Quick entry: date, duration, quality + * - Detail entry: bedtime, wake time, phases, wake count + * - 7-day stats overview + * - Sleep duration trend chart + * - List with inline edit/delete + */ +export default function SleepPage() { + const [sleep, setSleep] = useState([]) + const [stats, setStats] = useState(null) + const [showForm, setShowForm] = useState(false) + const [showDetail, setShowDetail] = useState(false) + const [editingId, setEditingId] = useState(null) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + + // Form state + const [formData, setFormData] = useState({ + date: new Date().toISOString().split('T')[0], + duration_minutes: 450, // 7h 30min default + quality: 3, + bedtime: '', + wake_time: '', + wake_count: 0, + deep_minutes: null, + rem_minutes: null, + light_minutes: null, + awake_minutes: null, + note: '' + }) + + useEffect(() => { + load() + }, []) + + const load = () => { + setLoading(true) + Promise.all([ + api.listSleep(30), + api.getSleepStats(7) + ]).then(([sleepData, statsData]) => { + setSleep(sleepData) + setStats(statsData) + setLoading(false) + }).catch(err => { + console.error('Failed to load sleep data:', err) + setLoading(false) + }) + } + + const startCreate = () => { + setFormData({ + date: new Date().toISOString().split('T')[0], + duration_minutes: 450, + quality: 3, + bedtime: '', + wake_time: '', + wake_count: 0, + deep_minutes: null, + rem_minutes: null, + light_minutes: null, + awake_minutes: null, + note: '' + }) + setShowForm(true) + setShowDetail(false) + setEditingId(null) + } + + const startEdit = (entry) => { + setFormData({ + date: entry.date, + duration_minutes: entry.duration_minutes, + quality: entry.quality, + bedtime: entry.bedtime || '', + wake_time: entry.wake_time || '', + wake_count: entry.wake_count || 0, + deep_minutes: entry.deep_minutes, + rem_minutes: entry.rem_minutes, + light_minutes: entry.light_minutes, + awake_minutes: entry.awake_minutes, + note: entry.note || '' + }) + setEditingId(entry.id) + setShowForm(true) + setShowDetail(true) // Show detail if phases exist + } + + const cancelEdit = () => { + setShowForm(false) + setEditingId(null) + setShowDetail(false) + } + + const handleSave = async () => { + if (!formData.date || formData.duration_minutes <= 0) { + alert('Datum und Schlafdauer sind Pflichtfelder') + return + } + + setSaving(true) + + try { + if (editingId) { + await api.updateSleep(editingId, formData) + } else { + await api.createSleep(formData) + } + await load() + cancelEdit() + } catch (err) { + alert('Speichern fehlgeschlagen: ' + err.message) + } finally { + setSaving(false) + } + } + + const handleDelete = async (id, date) => { + if (!confirm(`Schlaf-Eintrag vom ${date} wirklich lΓΆschen?`)) return + + try { + await api.deleteSleep(id) + await load() + } catch (err) { + alert('LΓΆschen fehlgeschlagen: ' + err.message) + } + } + + const formatDuration = (minutes) => { + const h = Math.floor(minutes / 60) + const m = minutes % 60 + return `${h}h ${m}min` + } + + if (loading) { + return ( +
+
+
+ ) + } + + return ( +
+ {/* Header */} +
+ +

Schlaf

+
+ + {/* Stats Card */} + {stats && ( +
+
+ + Übersicht (letzte 7 Tage) +
+
+
+
+ {formatDuration(Math.round(stats.avg_duration_minutes))} +
+
Ø Schlafdauer
+
+
+
+ {stats.avg_quality ? stats.avg_quality.toFixed(1) : 'β€”'}/5 +
+
Ø QualitÀt
+
+
+ {stats.nights_below_goal > 0 && ( +
+ ⚠️ {stats.nights_below_goal} NÀchte unter Ziel ({formatDuration(stats.sleep_goal_minutes)}) +
+ )} +
+ )} + + {/* Add Button */} + {!showForm && ( + + )} + + {/* Entry Form */} + {showForm && ( +
+
+ {editingId ? '✏️ Eintrag bearbeiten' : 'βž• Neuer Eintrag'} +
+ + {/* Quick Entry Fields */} +
+
+
Datum
+ setFormData({ ...formData, date: e.target.value })} + style={{ width: '100%' }} + /> +
+ +
+
+
Schlafdauer (Minuten)
+ setFormData({ ...formData, duration_minutes: parseInt(e.target.value) || 0 })} + style={{ width: '100%' }} + min="1" + /> +
+ = {formatDuration(formData.duration_minutes)} +
+
+ +
+
QualitΓ€t
+ +
+
+ +
+
Notiz (optional)
+