import { useState, useEffect, useRef } from 'react' import { Moon, Plus, Edit2, Trash2, X, Save, TrendingUp, Upload, ChevronDown, ChevronUp, AlertCircle } from 'lucide-react' import { api } from '../utils/api' /** * SleepPage - Sleep tracking with CSV import (v9d Phase 2c) * * Features: * - Drag & Drop CSV import * - Inline editing (no scroll to top) * - Source badges (manual/Apple Health/Garmin) * - Expandable segment view (JSONB sleep_segments) * - Plausibility check (phases sum = duration) * - Toast notifications (no alerts) */ export default function SleepPage() { const [sleep, setSleep] = useState([]) const [stats, setStats] = useState(null) const [loading, setLoading] = useState(true) const [importing, setImporting] = useState(false) const [dragging, setDragging] = useState(false) const [toast, setToast] = useState(null) const [expandedId, setExpandedId] = useState(null) const [editingId, setEditingId] = useState(null) const fileInputRef = useRef(null) useEffect(() => { load() }, []) const load = () => { setLoading(true) Promise.all([ api.listSleep(30), api.getSleepStats(7).catch(() => null) ]).then(([sleepData, statsData]) => { setSleep(sleepData) setStats(statsData) setLoading(false) }).catch(err => { console.error('Failed to load sleep data:', err) setLoading(false) }) } const showToast = (message, type = 'success') => { setToast({ message, type }) setTimeout(() => setToast(null), 4000) } const handleImport = async (file) => { if (!file) return if (!file.name.endsWith('.csv')) { showToast('Bitte eine CSV-Datei auswählen', 'error') return } setImporting(true) try { const result = await api.importAppleHealthSleep(file) await load() showToast(`✅ ${result.imported} Nächte importiert, ${result.skipped} übersprungen`) } catch (err) { showToast('Import fehlgeschlagen: ' + err.message, 'error') } finally { setImporting(false) if (fileInputRef.current) { fileInputRef.current.value = '' } } } const handleDrop = async (e) => { e.preventDefault() setDragging(false) const file = e.dataTransfer.files[0] if (file) await handleImport(file) } const handleFileSelect = async (e) => { const file = e.target.files[0] if (file) await handleImport(file) } const handleDelete = async (id, date) => { if (!confirm(`Schlaf-Eintrag vom ${date} wirklich löschen?`)) return try { await api.deleteSleep(id) await load() showToast('Eintrag gelöscht') } catch (err) { showToast('Löschen fehlgeschlagen: ' + err.message, 'error') } } const formatDuration = (minutes) => { const h = Math.floor(minutes / 60) const m = minutes % 60 return `${h}h ${m}min` } const getSourceBadge = (source) => { const colors = { manual: { bg: 'var(--surface2)', color: 'var(--text2)', label: 'Manuell' }, apple_health: { bg: '#1D9E75', color: 'white', label: 'Apple Health' }, garmin: { bg: '#007DB7', color: 'white', label: 'Garmin' } } const s = colors[source] || colors.manual return ( {s.label} ) } if (loading) { return (
) } return (
{/* Toast Notification */} {toast && (
{toast.message}
)} {/* Header */}

Schlaf

{/* Stats Card */} {stats && stats.total_nights > 0 && (
Ü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)})
)}
)} {/* CSV Import Drag & Drop */}
CSV Import
{ e.preventDefault(); setDragging(true) }} onDragLeave={() => setDragging(false)} onDrop={handleDrop} onClick={() => fileInputRef.current?.click()} style={{ border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border2)'}`, borderRadius: 10, padding: '24px 16px', textAlign: 'center', background: dragging ? 'var(--accent-light)' : 'var(--surface2)', cursor: importing ? 'not-allowed' : 'pointer', transition: 'all 0.15s', opacity: importing ? 0.6 : 1 }}> {importing ? ( <>
Importiere Schlaf-Daten...
) : ( <>
{dragging ? 'CSV loslassen...' : 'CSV hierher ziehen oder tippen'}
Apple Health Export (.csv)
)}
{/* Manual Entry Button */} {/* New Entry Form (if creating) */} {editingId === 'new' && ( { try { await api.createSleep(data) await load() setEditingId(null) showToast('Gespeichert') } catch (err) { showToast(err.message, 'error') } }} onCancel={() => setEditingId(null)} formatDuration={formatDuration} /> )} {/* Sleep List */}
Letzte 30 Nächte ({sleep.length})
{sleep.length === 0 ? (
Noch keine Einträge erfasst
) : (
{sleep.map(entry => ( setExpandedId(expandedId === entry.id ? null : entry.id)} onEdit={() => setEditingId(entry.id)} onCancelEdit={() => setEditingId(null)} onSave={async (data) => { try { await api.updateSleep(entry.id, data) await load() setEditingId(null) showToast('Gespeichert') } catch (err) { showToast(err.message, 'error') } }} onDelete={() => handleDelete(entry.id, entry.date)} formatDuration={formatDuration} getSourceBadge={getSourceBadge} /> ))}
)}
) } // ── Sleep Entry Component (Inline Editing) ──────────────────────────────────── function SleepEntry({ entry, expanded, editing, onToggleExpand, onEdit, onCancelEdit, onSave, onDelete, formatDuration, getSourceBadge }) { const [formData, setFormData] = useState({ 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 || '', source: entry.source || 'manual' }) const [saving, setSaving] = useState(false) const [plausibilityError, setPlausibilityError] = useState(null) // Live plausibility check useEffect(() => { if (!editing) return const phases = [formData.deep_minutes, formData.rem_minutes, formData.light_minutes, formData.awake_minutes] if (phases.some(p => p !== '' && p !== null)) { const sum = phases.reduce((a, b) => a + (parseInt(b) || 0), 0) const diff = Math.abs(formData.duration_minutes - sum) if (diff > 5) { setPlausibilityError(`Phasen-Summe (${sum} min) weicht um ${diff} min ab (Toleranz: 5 min)`) } else { setPlausibilityError(null) } } else { setPlausibilityError(null) } }, [editing, formData]) const handleSave = async () => { if (plausibilityError) { alert(plausibilityError) return } setSaving(true) try { await onSave(formData) } finally { setSaving(false) } } if (editing) { // ── Edit Mode ──────────────────────────────────────────────────────────────── return (
✏️ Bearbeiten
{/* Basic 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
{/* Detail Fields */}
Details (optional)
Eingeschlafen
setFormData({ ...formData, bedtime: e.target.value })} style={{ width: '100%' }} />
Aufgewacht
setFormData({ ...formData, wake_time: e.target.value })} style={{ width: '100%' }} />
Aufwachungen
setFormData({ ...formData, wake_count: parseInt(e.target.value) || 0 })} style={{ width: '100%' }} min="0" />
Schlafphasen (Minuten)
Tiefschlaf
setFormData({ ...formData, deep_minutes: parseInt(e.target.value) || '' })} style={{ width: '100%' }} min="0" />
REM-Schlaf
setFormData({ ...formData, rem_minutes: parseInt(e.target.value) || '' })} style={{ width: '100%' }} min="0" />
Leichtschlaf
setFormData({ ...formData, light_minutes: parseInt(e.target.value) || '' })} style={{ width: '100%' }} min="0" />
Wach im Bett
setFormData({ ...formData, awake_minutes: parseInt(e.target.value) || '' })} style={{ width: '100%' }} min="0" />
{plausibilityError && (
{plausibilityError}
)}
Notiz