diff --git a/backend/routers/sleep.py b/backend/routers/sleep.py index 4dd9a1a..6a7f498 100644 --- a/backend/routers/sleep.py +++ b/backend/routers/sleep.py @@ -171,6 +171,16 @@ def create_sleep( bedtime = data.bedtime if data.bedtime 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: cur = get_cursor(conn) @@ -221,6 +231,16 @@ def update_sleep( bedtime = data.bedtime if data.bedtime 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: cur = get_cursor(conn) @@ -545,6 +565,9 @@ async def import_apple_health_sleep( 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 sleep_segments = [ { @@ -571,15 +594,16 @@ async def import_apple_health_sleep( cur.execute(""" INSERT INTO sleep_log ( 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 ) 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 bedtime = EXCLUDED.bedtime, wake_time = EXCLUDED.wake_time, duration_minutes = EXCLUDED.duration_minutes, + wake_count = EXCLUDED.wake_count, deep_minutes = EXCLUDED.deep_minutes, rem_minutes = EXCLUDED.rem_minutes, light_minutes = EXCLUDED.light_minutes, @@ -594,6 +618,7 @@ async def import_apple_health_sleep( night['bedtime'].time(), night['wake_time'].time(), duration_minutes, + wake_count, night['deep_minutes'], night['rem_minutes'], night['light_minutes'], diff --git a/frontend/src/app.css b/frontend/src/app.css index b1bb675..fd8581e 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -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; } .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 slideDown { + from { transform: translate(-50%, -20px); opacity: 0; } + to { transform: translate(-50%, 0); opacity: 1; } +} /* Additional vars */ :root { diff --git a/frontend/src/pages/SleepPage.jsx b/frontend/src/pages/SleepPage.jsx index 538b3a9..0b409dc 100644 --- a/frontend/src/pages/SleepPage.jsx +++ b/frontend/src/pages/SleepPage.jsx @@ -1,43 +1,29 @@ 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' /** - * SleepPage - Sleep tracking with quick/detail entry (v9d Phase 2b) + * SleepPage - Sleep tracking with CSV import (v9d Phase 2c) * * 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 + * - 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 [showForm, setShowForm] = useState(false) - const [showDetail, setShowDetail] = useState(false) - const [editingId, setEditingId] = useState(null) const [loading, setLoading] = useState(true) - const [saving, setSaving] = 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) - // 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() }, []) @@ -46,7 +32,7 @@ export default function SleepPage() { setLoading(true) Promise.all([ api.listSleep(30), - api.getSleepStats(7).catch(() => null) // Stats optional - don't fail if error + api.getSleepStats(7).catch(() => null) ]).then(([sleepData, statsData]) => { setSleep(sleepData) setStats(statsData) @@ -57,90 +43,15 @@ export default function SleepPage() { }) } - 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 showToast = (message, type = 'success') => { + setToast({ message, type }) + setTimeout(() => setToast(null), 4000) } - 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 handleImport = async (e) => { - const file = e.target.files[0] + const handleImport = async (file) => { if (!file) return - if (!file.name.endsWith('.csv')) { - alert('Bitte eine CSV-Datei auswählen') + showToast('Bitte eine CSV-Datei auswählen', 'error') return } @@ -149,23 +60,68 @@ export default function SleepPage() { try { const result = await api.importAppleHealthSleep(file) await load() - alert(result.message || `✅ ${result.imported} Nächte importiert, ${result.skipped} übersprungen`) + showToast(`✅ ${result.imported} Nächte importiert, ${result.skipped} übersprungen`) } catch (err) { - alert('Import fehlgeschlagen: ' + err.message) + showToast('Import fehlgeschlagen: ' + err.message, 'error') } finally { setImporting(false) 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 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 (
@@ -176,6 +132,27 @@ export default function SleepPage() { return (
+ {/* Toast Notification */} + {toast && ( +
+ {toast.message} +
+ )} + {/* Header */}
@@ -183,7 +160,7 @@ export default function SleepPage() {
{/* Stats Card */} - {stats && ( + {stats && stats.total_nights > 0 && (
@@ -211,242 +188,51 @@ export default function SleepPage() {
)} - {/* Action Buttons */} - {!showForm && ( -
- - - + + ) : ( + <> + +
+ {dragging ? 'CSV loslassen...' : 'CSV hierher ziehen oder tippen'} +
+
+ Apple Health Export (.csv) +
+ + )}
- )} - - {/* Entry Form */} - {showForm && ( -
-
- {editingId ? '✏️ Eintrag bearbeiten' : '➕ Neuer Eintrag'} -
- - {/* Quick Entry 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
- -
-
- -
-
Notiz (optional)
-