WP 9c Phase 1 #12

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

View File

@ -171,6 +171,16 @@ def create_sleep(
bedtime = data.bedtime if data.bedtime else None bedtime = data.bedtime if data.bedtime else None
wake_time = data.wake_time if data.wake_time else None wake_time = data.wake_time if data.wake_time else None
# Plausibility check: phases should sum to duration (tolerance: 5 min)
if any([data.deep_minutes, data.rem_minutes, data.light_minutes, data.awake_minutes]):
phase_sum = (data.deep_minutes or 0) + (data.rem_minutes or 0) + (data.light_minutes or 0) + (data.awake_minutes or 0)
diff = abs(data.duration_minutes - phase_sum)
if diff > 5:
raise HTTPException(
400,
f"Plausibilitätsprüfung fehlgeschlagen: Phasen-Summe ({phase_sum} min) weicht um {diff} min von Gesamtdauer ({data.duration_minutes} min) ab. Max. Toleranz: 5 min."
)
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
@ -221,6 +231,16 @@ def update_sleep(
bedtime = data.bedtime if data.bedtime else None bedtime = data.bedtime if data.bedtime else None
wake_time = data.wake_time if data.wake_time else None wake_time = data.wake_time if data.wake_time else None
# Plausibility check: phases should sum to duration (tolerance: 5 min)
if any([data.deep_minutes, data.rem_minutes, data.light_minutes, data.awake_minutes]):
phase_sum = (data.deep_minutes or 0) + (data.rem_minutes or 0) + (data.light_minutes or 0) + (data.awake_minutes or 0)
diff = abs(data.duration_minutes - phase_sum)
if diff > 5:
raise HTTPException(
400,
f"Plausibilitätsprüfung fehlgeschlagen: Phasen-Summe ({phase_sum} min) weicht um {diff} min von Gesamtdauer ({data.duration_minutes} min) ab. Max. Toleranz: 5 min."
)
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
@ -545,6 +565,9 @@ async def import_apple_health_sleep(
night['awake_minutes'] night['awake_minutes']
) )
# Calculate wake_count (number of awake segments)
wake_count = sum(1 for seg in night['segments'] if seg['phase'] == 'awake')
# Prepare JSONB segments with full datetime # Prepare JSONB segments with full datetime
sleep_segments = [ sleep_segments = [
{ {
@ -571,15 +594,16 @@ async def import_apple_health_sleep(
cur.execute(""" cur.execute("""
INSERT INTO sleep_log ( INSERT INTO sleep_log (
profile_id, date, bedtime, wake_time, duration_minutes, profile_id, date, bedtime, wake_time, duration_minutes,
deep_minutes, rem_minutes, light_minutes, awake_minutes, wake_count, deep_minutes, rem_minutes, light_minutes, awake_minutes,
sleep_segments, source, updated_at sleep_segments, source, updated_at
) VALUES ( ) VALUES (
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'apple_health', CURRENT_TIMESTAMP %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'apple_health', CURRENT_TIMESTAMP
) )
ON CONFLICT (profile_id, date) DO UPDATE SET ON CONFLICT (profile_id, date) DO UPDATE SET
bedtime = EXCLUDED.bedtime, bedtime = EXCLUDED.bedtime,
wake_time = EXCLUDED.wake_time, wake_time = EXCLUDED.wake_time,
duration_minutes = EXCLUDED.duration_minutes, duration_minutes = EXCLUDED.duration_minutes,
wake_count = EXCLUDED.wake_count,
deep_minutes = EXCLUDED.deep_minutes, deep_minutes = EXCLUDED.deep_minutes,
rem_minutes = EXCLUDED.rem_minutes, rem_minutes = EXCLUDED.rem_minutes,
light_minutes = EXCLUDED.light_minutes, light_minutes = EXCLUDED.light_minutes,
@ -594,6 +618,7 @@ async def import_apple_health_sleep(
night['bedtime'].time(), night['bedtime'].time(),
night['wake_time'].time(), night['wake_time'].time(),
duration_minutes, duration_minutes,
wake_count,
night['deep_minutes'], night['deep_minutes'],
night['rem_minutes'], night['rem_minutes'],
night['light_minutes'], night['light_minutes'],

View File

@ -130,6 +130,10 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we
.empty-state h3 { font-size: 16px; color: var(--text2); margin-bottom: 6px; } .empty-state h3 { font-size: 16px; color: var(--text2); margin-bottom: 6px; }
.spinner { width: 20px; height: 20px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.7s linear infinite; display: inline-block; } .spinner { width: 20px; height: 20px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.7s linear infinite; display: inline-block; }
@keyframes spin { to { transform: rotate(360deg); } } @keyframes spin { to { transform: rotate(360deg); } }
@keyframes slideDown {
from { transform: translate(-50%, -20px); opacity: 0; }
to { transform: translate(-50%, 0); opacity: 1; }
}
/* Additional vars */ /* Additional vars */
:root { :root {

View File

@ -1,43 +1,29 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import { Moon, Plus, Edit2, Trash2, X, Save, TrendingUp, Upload } from 'lucide-react' import { Moon, Plus, Edit2, Trash2, X, Save, TrendingUp, Upload, ChevronDown, ChevronUp, AlertCircle } from 'lucide-react'
import { api } from '../utils/api' import { api } from '../utils/api'
/** /**
* SleepPage - Sleep tracking with quick/detail entry (v9d Phase 2b) * SleepPage - Sleep tracking with CSV import (v9d Phase 2c)
* *
* Features: * Features:
* - Quick entry: date, duration, quality * - Drag & Drop CSV import
* - Detail entry: bedtime, wake time, phases, wake count * - Inline editing (no scroll to top)
* - 7-day stats overview * - Source badges (manual/Apple Health/Garmin)
* - Sleep duration trend chart * - Expandable segment view (JSONB sleep_segments)
* - List with inline edit/delete * - Plausibility check (phases sum = duration)
* - Toast notifications (no alerts)
*/ */
export default function SleepPage() { export default function SleepPage() {
const [sleep, setSleep] = useState([]) const [sleep, setSleep] = useState([])
const [stats, setStats] = useState(null) 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 [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [importing, setImporting] = useState(false) 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) const fileInputRef = useRef(null)
// 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(() => { useEffect(() => {
load() load()
}, []) }, [])
@ -46,7 +32,7 @@ export default function SleepPage() {
setLoading(true) setLoading(true)
Promise.all([ Promise.all([
api.listSleep(30), api.listSleep(30),
api.getSleepStats(7).catch(() => null) // Stats optional - don't fail if error api.getSleepStats(7).catch(() => null)
]).then(([sleepData, statsData]) => { ]).then(([sleepData, statsData]) => {
setSleep(sleepData) setSleep(sleepData)
setStats(statsData) setStats(statsData)
@ -57,90 +43,15 @@ export default function SleepPage() {
}) })
} }
const startCreate = () => { const showToast = (message, type = 'success') => {
setFormData({ setToast({ message, type })
date: new Date().toISOString().split('T')[0], setTimeout(() => setToast(null), 4000)
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) => { const handleImport = async (file) => {
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 handleImport = async (e) => {
const file = e.target.files[0]
if (!file) return if (!file) return
if (!file.name.endsWith('.csv')) { if (!file.name.endsWith('.csv')) {
alert('Bitte eine CSV-Datei auswählen') showToast('Bitte eine CSV-Datei auswählen', 'error')
return return
} }
@ -149,23 +60,68 @@ export default function SleepPage() {
try { try {
const result = await api.importAppleHealthSleep(file) const result = await api.importAppleHealthSleep(file)
await load() await load()
alert(result.message || `${result.imported} Nächte importiert, ${result.skipped} übersprungen`) showToast(`${result.imported} Nächte importiert, ${result.skipped} übersprungen`)
} catch (err) { } catch (err) {
alert('Import fehlgeschlagen: ' + err.message) showToast('Import fehlgeschlagen: ' + err.message, 'error')
} finally { } finally {
setImporting(false) setImporting(false)
if (fileInputRef.current) { if (fileInputRef.current) {
fileInputRef.current.value = '' // Reset input 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 formatDuration = (minutes) => {
const h = Math.floor(minutes / 60) const h = Math.floor(minutes / 60)
const m = minutes % 60 const m = minutes % 60
return `${h}h ${m}min` 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 (
<span style={{
fontSize: 10,
padding: '2px 6px',
borderRadius: 4,
background: s.bg,
color: s.color,
fontWeight: 600
}}>
{s.label}
</span>
)
}
if (loading) { if (loading) {
return ( return (
<div style={{ padding: 20, textAlign: 'center' }}> <div style={{ padding: 20, textAlign: 'center' }}>
@ -176,6 +132,27 @@ export default function SleepPage() {
return ( return (
<div style={{ padding: '16px 16px 80px' }}> <div style={{ padding: '16px 16px 80px' }}>
{/* Toast Notification */}
{toast && (
<div style={{
position: 'fixed',
top: 16,
left: '50%',
transform: 'translateX(-50%)',
zIndex: 9999,
background: toast.type === 'error' ? '#D85A30' : 'var(--accent)',
color: 'white',
padding: '12px 20px',
borderRadius: 8,
fontSize: 14,
fontWeight: 600,
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
animation: 'slideDown 0.3s ease'
}}>
{toast.message}
</div>
)}
{/* Header */} {/* Header */}
<div style={{ marginBottom: 20, display: 'flex', alignItems: 'center', gap: 12 }}> <div style={{ marginBottom: 20, display: 'flex', alignItems: 'center', gap: 12 }}>
<Moon size={24} color="var(--accent)" /> <Moon size={24} color="var(--accent)" />
@ -183,7 +160,7 @@ export default function SleepPage() {
</div> </div>
{/* Stats Card */} {/* Stats Card */}
{stats && ( {stats && stats.total_nights > 0 && (
<div className="card" style={{ padding: 16, marginBottom: 16 }}> <div className="card" style={{ padding: 16, marginBottom: 16 }}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 12, display: 'flex', alignItems: 'center', gap: 6 }}> <div style={{ fontWeight: 600, fontSize: 14, marginBottom: 12, display: 'flex', alignItems: 'center', gap: 6 }}>
<TrendingUp size={16} color="var(--accent)" /> <TrendingUp size={16} color="var(--accent)" />
@ -211,52 +188,153 @@ export default function SleepPage() {
</div> </div>
)} )}
{/* Action Buttons */} {/* CSV Import Drag & Drop */}
{!showForm && ( <div className="card" style={{ padding: 16, marginBottom: 16 }}>
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}> <div style={{ fontWeight: 600, fontSize: 14, marginBottom: 12 }}>CSV Import</div>
<button <div
onClick={startCreate} onDragOver={e => { e.preventDefault(); setDragging(true) }}
className="btn btn-primary" onDragLeave={() => setDragging(false)}
style={{ flex: 1 }} onDrop={handleDrop}
>
<Plus size={16} /> Schlaf erfassen
</button>
<button
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
disabled={importing} style={{
className="btn btn-secondary" border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border2)'}`,
style={{ flex: 1 }} 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 ? ( {importing ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, justifyContent: 'center' }}> <>
<div className="spinner" style={{ width: 14, height: 14 }} /> <div className="spinner" style={{ width: 24, height: 24, margin: '0 auto 8px' }} />
Importiere... <div style={{ fontSize: 14, fontWeight: 500, color: 'var(--text2)' }}>
Importiere Schlaf-Daten...
</div> </div>
</>
) : ( ) : (
<> <>
<Upload size={16} /> CSV Import <Upload size={28} style={{ color: dragging ? 'var(--accent)' : 'var(--text3)', marginBottom: 8 }} />
<div style={{ fontSize: 14, fontWeight: 500, color: dragging ? 'var(--accent-dark)' : 'var(--text2)' }}>
{dragging ? 'CSV loslassen...' : 'CSV hierher ziehen oder tippen'}
</div>
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 4 }}>
Apple Health Export (.csv)
</div>
</> </>
)} )}
</button> </div>
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
accept=".csv" accept=".csv"
onChange={handleImport} onChange={handleFileSelect}
style={{ display: 'none' }} style={{ display: 'none' }}
/> />
</div> </div>
)}
{/* Entry Form */} {/* Sleep List */}
{showForm && ( <div style={{ fontWeight: 600, fontSize: 14, marginBottom: 12 }}>
<div className="card" style={{ padding: 16, marginBottom: 16, border: '2px solid var(--accent)' }}> Letzte 30 Nächte ({sleep.length})
<div style={{ fontWeight: 600, marginBottom: 12 }}>
{editingId ? '✏️ Eintrag bearbeiten' : ' Neuer Eintrag'}
</div> </div>
{/* Quick Entry Fields */} {sleep.length === 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}> <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 => (
<SleepEntry
key={entry.id}
entry={entry}
expanded={expandedId === entry.id}
editing={editingId === entry.id}
onToggleExpand={() => 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}
/>
))}
</div>
)}
</div>
)
}
// 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 (
<div className="card" style={{ padding: 12, border: '2px solid var(--accent)' }}>
<div style={{ fontWeight: 600, marginBottom: 12, fontSize: 13 }}> Bearbeiten</div>
{/* Basic Fields */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<div> <div>
<div className="form-label">Datum</div> <div className="form-label">Datum</div>
<input <input
@ -268,7 +346,7 @@ export default function SleepPage() {
/> />
</div> </div>
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: 12 }}> <div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: 10 }}>
<div> <div>
<div className="form-label">Schlafdauer (Minuten)</div> <div className="form-label">Schlafdauer (Minuten)</div>
<input <input
@ -279,7 +357,7 @@ export default function SleepPage() {
style={{ width: '100%' }} style={{ width: '100%' }}
min="1" min="1"
/> />
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 4 }}> <div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 2 }}>
= {formatDuration(formData.duration_minutes)} = {formatDuration(formData.duration_minutes)}
</div> </div>
</div> </div>
@ -302,33 +380,13 @@ export default function SleepPage() {
</div> </div>
</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 */} {/* Detail Fields */}
{showDetail && ( <div style={{ paddingTop: 10, borderTop: '1px solid var(--border)' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, paddingTop: 12, borderTop: '1px solid var(--border)' }}> <div style={{ fontSize: 12, fontWeight: 600, marginBottom: 8 }}>Details (optional)</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10, marginBottom: 10 }}>
<div> <div>
<div className="form-label">Eingeschlafen (HH:MM)</div> <div className="form-label">Eingeschlafen</div>
<input <input
type="time" type="time"
className="form-input" className="form-input"
@ -338,7 +396,7 @@ export default function SleepPage() {
/> />
</div> </div>
<div> <div>
<div className="form-label">Aufgewacht (HH:MM)</div> <div className="form-label">Aufgewacht</div>
<input <input
type="time" type="time"
className="form-input" className="form-input"
@ -349,29 +407,27 @@ export default function SleepPage() {
</div> </div>
</div> </div>
<div> <div style={{ marginBottom: 10 }}>
<div className="form-label">Aufwachungen</div> <div className="form-label">Aufwachungen</div>
<input <input
type="number" type="number"
className="form-input" className="form-input"
value={formData.wake_count || 0} value={formData.wake_count}
onChange={e => setFormData({ ...formData, wake_count: parseInt(e.target.value) || 0 })} onChange={e => setFormData({ ...formData, wake_count: parseInt(e.target.value) || 0 })}
style={{ width: '100%' }} style={{ width: '100%' }}
min="0" min="0"
/> />
</div> </div>
<div style={{ fontWeight: 600, fontSize: 13, marginTop: 8 }}>Schlafphasen (Minuten)</div> <div style={{ fontSize: 12, fontWeight: 600, marginBottom: 8 }}>Schlafphasen (Minuten)</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div> <div>
<div className="form-label">Tiefschlaf</div> <div className="form-label">Tiefschlaf</div>
<input <input
type="number" type="number"
className="form-input" className="form-input"
value={formData.deep_minutes || ''} value={formData.deep_minutes}
onChange={e => setFormData({ ...formData, deep_minutes: parseInt(e.target.value) || null })} onChange={e => setFormData({ ...formData, deep_minutes: parseInt(e.target.value) || '' })}
placeholder="—"
style={{ width: '100%' }} style={{ width: '100%' }}
min="0" min="0"
/> />
@ -381,9 +437,8 @@ export default function SleepPage() {
<input <input
type="number" type="number"
className="form-input" className="form-input"
value={formData.rem_minutes || ''} value={formData.rem_minutes}
onChange={e => setFormData({ ...formData, rem_minutes: parseInt(e.target.value) || null })} onChange={e => setFormData({ ...formData, rem_minutes: parseInt(e.target.value) || '' })}
placeholder="—"
style={{ width: '100%' }} style={{ width: '100%' }}
min="0" min="0"
/> />
@ -393,9 +448,8 @@ export default function SleepPage() {
<input <input
type="number" type="number"
className="form-input" className="form-input"
value={formData.light_minutes || ''} value={formData.light_minutes}
onChange={e => setFormData({ ...formData, light_minutes: parseInt(e.target.value) || null })} onChange={e => setFormData({ ...formData, light_minutes: parseInt(e.target.value) || '' })}
placeholder="—"
style={{ width: '100%' }} style={{ width: '100%' }}
min="0" min="0"
/> />
@ -405,22 +459,39 @@ export default function SleepPage() {
<input <input
type="number" type="number"
className="form-input" className="form-input"
value={formData.awake_minutes || ''} value={formData.awake_minutes}
onChange={e => setFormData({ ...formData, awake_minutes: parseInt(e.target.value) || null })} onChange={e => setFormData({ ...formData, awake_minutes: parseInt(e.target.value) || '' })}
placeholder="—"
style={{ width: '100%' }} style={{ width: '100%' }}
min="0" min="0"
/> />
</div> </div>
</div> </div>
{plausibilityError && (
<div style={{ marginTop: 8, padding: 8, background: '#D85A30', color: 'white', borderRadius: 6, fontSize: 11, display: 'flex', alignItems: 'center', gap: 6 }}>
<AlertCircle size={14} />
{plausibilityError}
</div> </div>
)} )}
</div>
<div>
<div className="form-label">Notiz</div>
<textarea
className="form-input"
value={formData.note}
onChange={e => setFormData({ ...formData, note: e.target.value })}
rows={2}
style={{ width: '100%', resize: 'vertical' }}
placeholder="z.B. 'Gut durchgeschlafen', 'Stress', ..."
/>
</div>
{/* Action Buttons */} {/* Action Buttons */}
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}> <div style={{ display: 'flex', gap: 8 }}>
<button <button
onClick={handleSave} onClick={handleSave}
disabled={saving} disabled={saving || !!plausibilityError}
className="btn btn-primary" className="btn btn-primary"
style={{ flex: 1 }} style={{ flex: 1 }}
> >
@ -436,7 +507,7 @@ export default function SleepPage() {
)} )}
</button> </button>
<button <button
onClick={cancelEdit} onClick={onCancelEdit}
disabled={saving} disabled={saving}
className="btn btn-secondary" className="btn btn-secondary"
style={{ flex: 1 }} style={{ flex: 1 }}
@ -446,29 +517,25 @@ export default function SleepPage() {
</div> </div>
</div> </div>
</div> </div>
)} )
}
{/* Sleep List */} // View Mode
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 12 }}> return (
Letzte 30 Nächte ({sleep.length}) <div className="card" style={{ padding: 12 }}>
</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={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
{/* Main Info */}
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 4 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<div style={{ fontSize: 13, fontWeight: 600 }}>
{new Date(entry.date).toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit' })} {new Date(entry.date).toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit' })}
</div> </div>
{getSourceBadge(entry.source)}
</div>
<div style={{ fontSize: 12, color: 'var(--text2)' }}> <div style={{ fontSize: 12, color: 'var(--text2)' }}>
{entry.duration_formatted} {entry.duration_formatted}
{entry.quality && ` · ${'★'.repeat(entry.quality)}${'☆'.repeat(5 - entry.quality)}`} {entry.quality && ` · ${'★'.repeat(entry.quality)}${'☆'.repeat(5 - entry.quality)}`}
{entry.wake_count > 0 && ` · ${entry.wake_count}x aufgewacht`}
</div> </div>
{entry.bedtime && entry.wake_time && ( {entry.bedtime && entry.wake_time && (
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 2 }}> <div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 2 }}>
@ -481,9 +548,26 @@ export default function SleepPage() {
</div> </div>
)} )}
</div> </div>
<div style={{ display: 'flex', gap: 8 }}>
{/* Action Buttons */}
<div style={{ display: 'flex', gap: 6 }}>
{entry.sleep_segments && (
<button <button
onClick={() => startEdit(entry)} onClick={onToggleExpand}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 6,
color: 'var(--accent)'
}}
title={expanded ? 'Details ausblenden' : 'Details anzeigen'}
>
{expanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</button>
)}
<button
onClick={onEdit}
style={{ style={{
background: 'none', background: 'none',
border: 'none', border: 'none',
@ -496,7 +580,7 @@ export default function SleepPage() {
<Edit2 size={16} /> <Edit2 size={16} />
</button> </button>
<button <button
onClick={() => handleDelete(entry.id, entry.date)} onClick={onDelete}
style={{ style={{
background: 'none', background: 'none',
border: 'none', border: 'none',
@ -510,8 +594,48 @@ export default function SleepPage() {
</button> </button>
</div> </div>
</div> </div>
{/* Expanded: Sleep Segments Timeline */}
{expanded && entry.sleep_segments && (
<div style={{ marginTop: 12, paddingTop: 12, borderTop: '1px solid var(--border)' }}>
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 8 }}>Schlafphasen-Timeline</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{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 (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 11 }}>
<div style={{
width: 10,
height: 10,
borderRadius: '50%',
background: phaseColors[seg.phase] || 'var(--border)'
}} />
<div style={{ flex: 1, color: 'var(--text2)' }}>
{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' })}
</div>
<div style={{ fontWeight: 600, minWidth: 80 }}>
{phaseLabels[seg.phase]}
</div>
<div style={{ color: 'var(--text3)', minWidth: 50, textAlign: 'right' }}>
{seg.duration_min} min
</div>
</div>
)
})}
</div> </div>
))}
</div> </div>
)} )}
</div> </div>