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 */}
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)
Schlafphasen (Minuten)
{plausibilityError && (
)}
{/* Action Buttons */}
)
}
// ── View Mode ──────────────────────────────────────────────────────────────────
return (
{/* Main Info */}
{new Date(entry.date).toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit' })}
{getSourceBadge(entry.source)}
{entry.duration_formatted}
{entry.quality && ` · ${'★'.repeat(entry.quality)}${'☆'.repeat(5 - entry.quality)}`}
{entry.wake_count > 0 && ` · ${entry.wake_count}x aufgewacht`}
{entry.bedtime && entry.wake_time && (
{entry.bedtime} – {entry.wake_time}
)}
{entry.note && (
"{entry.note}"
)}
{/* Action Buttons */}
{entry.sleep_segments && (
)}
{/* Expanded: Sleep Segments Timeline */}
{expanded && entry.sleep_segments && (
Schlafphasen-Timeline
{entry.sleep_segments.map((seg, i) => {
const phaseColors = {
deep: '#1D9E75',
rem: '#7B68EE',
light: '#87CEEB',
awake: '#D85A30'
}
const phaseLabels = {
deep: 'Tiefschlaf',
rem: 'REM',
light: 'Leichtschlaf',
awake: 'Wach'
}
return (
{new Date(seg.start).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}
{' – '}
{new Date(seg.end).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}
{phaseLabels[seg.phase]}
{seg.duration_min} min
)
})}
)}
)
}
// ── New Entry Form Component ──────────────────────────────────────────────────
function NewEntryForm({ onSave, onCancel, formatDuration }) {
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: '',
rem_minutes: '',
light_minutes: '',
awake_minutes: '',
note: '',
source: 'manual'
})
const [saving, setSaving] = useState(false)
const [plausibilityError, setPlausibilityError] = useState(null)
const [showDetail, setShowDetail] = useState(false)
// Live plausibility check
useEffect(() => {
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)
}
}, [formData])
const handleSave = async () => {
if (plausibilityError) {
alert(plausibilityError)
return
}
setSaving(true)
try {
await onSave(formData)
} finally {
setSaving(false)
}
}
return (
➕ Neuer Eintrag
Schlafdauer (Minuten)
setFormData({ ...formData, duration_minutes: parseInt(e.target.value) || 0 })}
style={{ width: '100%' }}
min="1"
/>
= {formatDuration(formData.duration_minutes)}
Qualität
{/* Toggle Detail View */}
{/* Detail Fields */}
{showDetail && (
Schlafphasen (Minuten)
{plausibilityError && (
)}
)}
{/* Action Buttons */}
)
}