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 <noreply@anthropic.com>
This commit is contained in:
parent
40a4739349
commit
ef81c46bc0
|
|
@ -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("/")
|
||||
|
|
|
|||
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 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}, ...]';
|
||||
418
backend/routers/sleep.py
Normal file
418
backend/routers/sleep.py
Normal file
|
|
@ -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
|
||||
]
|
||||
|
|
@ -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:<LayoutDashboard size={20}/>, label:'Übersicht' },
|
||||
{ to:'/capture', icon:<PlusSquare size={20}/>, label:'Erfassen' },
|
||||
{ to:'/history', icon:<TrendingUp size={20}/>, label:'Verlauf' },
|
||||
{ to:'/sleep', icon:<Moon size={20}/>, label:'Schlaf' },
|
||||
{ to:'/analysis', icon:<BarChart2 size={20}/>, label:'Analyse' },
|
||||
{ to:'/settings', icon:<Settings size={20}/>, label:'Einst.' },
|
||||
]
|
||||
|
|
@ -164,6 +166,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/>}/>
|
||||
|
|
|
|||
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)
|
||||
]).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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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}}>
|
||||
|
|
|
|||
466
frontend/src/pages/SleepPage.jsx
Normal file
466
frontend/src/pages/SleepPage.jsx
Normal file
|
|
@ -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 (
|
||||
<div style={{ padding: 20, textAlign: 'center' }}>
|
||||
<div className="spinner" style={{ width: 32, height: 32, margin: '0 auto' }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '16px 16px 80px' }}>
|
||||
{/* Header */}
|
||||
<div style={{ marginBottom: 20, display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<Moon size={24} color="var(--accent)" />
|
||||
<h1 style={{ margin: 0, fontSize: 20, fontWeight: 700 }}>Schlaf</h1>
|
||||
</div>
|
||||
|
||||
{/* Stats Card */}
|
||||
{stats && (
|
||||
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 12, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<TrendingUp size={16} color="var(--accent)" />
|
||||
<span>Übersicht (letzte 7 Tage)</span>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<div style={{ textAlign: 'center', padding: 12, background: 'var(--surface)', borderRadius: 8 }}>
|
||||
<div style={{ fontSize: 18, fontWeight: 700, color: 'var(--accent)' }}>
|
||||
{formatDuration(Math.round(stats.avg_duration_minutes))}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text3)' }}>Ø Schlafdauer</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center', padding: 12, background: 'var(--surface)', borderRadius: 8 }}>
|
||||
<div style={{ fontSize: 18, fontWeight: 700 }}>
|
||||
{stats.avg_quality ? stats.avg_quality.toFixed(1) : '—'}/5
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text3)' }}>Ø Qualität</div>
|
||||
</div>
|
||||
</div>
|
||||
{stats.nights_below_goal > 0 && (
|
||||
<div style={{ marginTop: 12, fontSize: 12, color: '#D85A30', textAlign: 'center' }}>
|
||||
⚠️ {stats.nights_below_goal} Nächte unter Ziel ({formatDuration(stats.sleep_goal_minutes)})
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Button */}
|
||||
{!showForm && (
|
||||
<button
|
||||
onClick={startCreate}
|
||||
className="btn btn-primary btn-full"
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
<Plus size={16} /> Schlaf erfassen
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Entry Form */}
|
||||
{showForm && (
|
||||
<div className="card" style={{ padding: 16, marginBottom: 16, border: '2px solid var(--accent)' }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 12 }}>
|
||||
{editingId ? '✏️ Eintrag bearbeiten' : '➕ Neuer Eintrag'}
|
||||
</div>
|
||||
|
||||
{/* Quick Entry Fields */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div>
|
||||
<div className="form-label">Datum</div>
|
||||
<input
|
||||
type="date"
|
||||
className="form-input"
|
||||
value={formData.date}
|
||||
onChange={e => setFormData({ ...formData, date: e.target.value })}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: 12 }}>
|
||||
<div>
|
||||
<div className="form-label">Schlafdauer (Minuten)</div>
|
||||
<input
|
||||
type="number"
|
||||
className="form-input"
|
||||
value={formData.duration_minutes}
|
||||
onChange={e => setFormData({ ...formData, duration_minutes: parseInt(e.target.value) || 0 })}
|
||||
style={{ width: '100%' }}
|
||||
min="1"
|
||||
/>
|
||||
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 4 }}>
|
||||
= {formatDuration(formData.duration_minutes)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="form-label">Qualität</div>
|
||||
<select
|
||||
className="form-input"
|
||||
value={formData.quality || ''}
|
||||
onChange={e => setFormData({ ...formData, quality: parseInt(e.target.value) || null })}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<option value="">—</option>
|
||||
<option value="1">★☆☆☆☆ 1</option>
|
||||
<option value="2">★★☆☆☆ 2</option>
|
||||
<option value="3">★★★☆☆ 3</option>
|
||||
<option value="4">★★★★☆ 4</option>
|
||||
<option value="5">★★★★★ 5</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="form-label">Notiz (optional)</div>
|
||||
<textarea
|
||||
className="form-input"
|
||||
value={formData.note}
|
||||
onChange={e => setFormData({ ...formData, note: e.target.value })}
|
||||
placeholder="z.B. 'Gut durchgeschlafen', 'Stress', ..."
|
||||
rows={2}
|
||||
style={{ width: '100%', resize: 'vertical' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Toggle Detail View */}
|
||||
<button
|
||||
onClick={() => setShowDetail(!showDetail)}
|
||||
className="btn btn-secondary"
|
||||
style={{ fontSize: 12 }}
|
||||
>
|
||||
{showDetail ? '− Detailansicht ausblenden' : '+ Detailansicht anzeigen'}
|
||||
</button>
|
||||
|
||||
{/* Detail Fields */}
|
||||
{showDetail && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, paddingTop: 12, borderTop: '1px solid var(--border)' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<div>
|
||||
<div className="form-label">Eingeschlafen (HH:MM)</div>
|
||||
<input
|
||||
type="time"
|
||||
className="form-input"
|
||||
value={formData.bedtime}
|
||||
onChange={e => setFormData({ ...formData, bedtime: e.target.value })}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="form-label">Aufgewacht (HH:MM)</div>
|
||||
<input
|
||||
type="time"
|
||||
className="form-input"
|
||||
value={formData.wake_time}
|
||||
onChange={e => setFormData({ ...formData, wake_time: e.target.value })}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="form-label">Aufwachungen</div>
|
||||
<input
|
||||
type="number"
|
||||
className="form-input"
|
||||
value={formData.wake_count || 0}
|
||||
onChange={e => setFormData({ ...formData, wake_count: parseInt(e.target.value) || 0 })}
|
||||
style={{ width: '100%' }}
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ fontWeight: 600, fontSize: 13, marginTop: 8 }}>Schlafphasen (Minuten)</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<div>
|
||||
<div className="form-label">Tiefschlaf</div>
|
||||
<input
|
||||
type="number"
|
||||
className="form-input"
|
||||
value={formData.deep_minutes || ''}
|
||||
onChange={e => setFormData({ ...formData, deep_minutes: parseInt(e.target.value) || null })}
|
||||
placeholder="—"
|
||||
style={{ width: '100%' }}
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="form-label">REM-Schlaf</div>
|
||||
<input
|
||||
type="number"
|
||||
className="form-input"
|
||||
value={formData.rem_minutes || ''}
|
||||
onChange={e => setFormData({ ...formData, rem_minutes: parseInt(e.target.value) || null })}
|
||||
placeholder="—"
|
||||
style={{ width: '100%' }}
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="form-label">Leichtschlaf</div>
|
||||
<input
|
||||
type="number"
|
||||
className="form-input"
|
||||
value={formData.light_minutes || ''}
|
||||
onChange={e => setFormData({ ...formData, light_minutes: parseInt(e.target.value) || null })}
|
||||
placeholder="—"
|
||||
style={{ width: '100%' }}
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="form-label">Wach im Bett</div>
|
||||
<input
|
||||
type="number"
|
||||
className="form-input"
|
||||
value={formData.awake_minutes || ''}
|
||||
onChange={e => setFormData({ ...formData, awake_minutes: parseInt(e.target.value) || null })}
|
||||
placeholder="—"
|
||||
style={{ width: '100%' }}
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="btn btn-primary"
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
{saving ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, justifyContent: 'center' }}>
|
||||
<div className="spinner" style={{ width: 14, height: 14 }} />
|
||||
Speichere...
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Save size={16} /> Speichern
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={cancelEdit}
|
||||
disabled={saving}
|
||||
className="btn btn-secondary"
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<X size={16} /> Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sleep List */}
|
||||
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 12 }}>
|
||||
Letzte 30 Nächte ({sleep.length})
|
||||
</div>
|
||||
|
||||
{sleep.length === 0 ? (
|
||||
<div className="card" style={{ padding: 40, textAlign: 'center', color: 'var(--text3)' }}>
|
||||
Noch keine Einträge erfasst
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{sleep.map(entry => (
|
||||
<div key={entry.id} className="card" style={{ padding: 12 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
||||
{new Date(entry.date).toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit' })}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text2)' }}>
|
||||
{entry.duration_formatted}
|
||||
{entry.quality && ` · ${'★'.repeat(entry.quality)}${'☆'.repeat(5 - entry.quality)}`}
|
||||
</div>
|
||||
{entry.bedtime && entry.wake_time && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 2 }}>
|
||||
{entry.bedtime} – {entry.wake_time}
|
||||
</div>
|
||||
)}
|
||||
{entry.note && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 4, fontStyle: 'italic' }}>
|
||||
"{entry.note}"
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
onClick={() => startEdit(entry)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: 6,
|
||||
color: 'var(--accent)'
|
||||
}}
|
||||
title="Bearbeiten"
|
||||
>
|
||||
<Edit2 size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(entry.id, entry.date)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: 6,
|
||||
color: '#D85A30'
|
||||
}}
|
||||
title="Löschen"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -212,4 +212,15 @@ 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}`),
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user