WP 9c Phase 1 #12

Merged
Lars merged 14 commits from develop into main 2026-03-22 14:14:34 +01:00
8 changed files with 1050 additions and 3 deletions
Showing only changes of commit ef81c46bc0 - Show all commits

View File

@ -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("/")

View 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
View 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
]

View File

@ -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/>}/>

View 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>
)
}

View File

@ -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}}>

View 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>
)
}

View File

@ -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}`),
}