From 1644b34d5ccc3d8f72c6981f4fddee2ae2885648 Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 22 Mar 2026 13:43:02 +0100 Subject: [PATCH] fix: manual sleep entry creation + import overwrite protection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Critical fixes: 1. Added "+ Schlaf erfassen" button back (was missing!) - Opens NewEntryForm component inline - Default: 450 min (7h 30min), quality 3 - Collapsible detail view - Live plausibility check 2. Fixed import overwriting manual entries - Problem: ON CONFLICT WHERE clause didn't prevent updates - Solution: Explicit if/else logic - If manual entry exists → skip (don't touch) - If non-manual entry exists → UPDATE - If no entry exists → INSERT - Properly counts imported vs skipped Test results: ✅ CSV import with drag & drop ✅ Inline editing ✅ Segment timeline view with colors ✅ Source badges (Manual/Apple Health) ✅ Plausibility check (backend + frontend) Co-Authored-By: Claude Opus 4.6 --- backend/routers/sleep.py | 94 ++++++---- frontend/src/pages/SleepPage.jsx | 283 +++++++++++++++++++++++++++++++ 2 files changed, 340 insertions(+), 37 deletions(-) diff --git a/backend/routers/sleep.py b/backend/routers/sleep.py index 6a7f498..e346a94 100644 --- a/backend/routers/sleep.py +++ b/backend/routers/sleep.py @@ -579,7 +579,7 @@ async def import_apple_health_sleep( for seg in night['segments'] ] - # Check if manual entry exists + # Check if manual entry exists - do NOT overwrite cur.execute(""" SELECT id, source FROM sleep_log WHERE profile_id = %s AND date = %s @@ -588,43 +588,63 @@ async def import_apple_health_sleep( if existing and existing['source'] == 'manual': skipped += 1 - continue # Don't overwrite manual entries + continue # Skip - don't overwrite manual entries - # Upsert - cur.execute(""" - INSERT INTO sleep_log ( - profile_id, date, bedtime, wake_time, duration_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, %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, - awake_minutes = EXCLUDED.awake_minutes, - sleep_segments = EXCLUDED.sleep_segments, - source = EXCLUDED.source, - updated_at = CURRENT_TIMESTAMP - WHERE sleep_log.source != 'manual' - """, ( - pid, - date, - night['bedtime'].time(), - night['wake_time'].time(), - duration_minutes, - wake_count, - night['deep_minutes'], - night['rem_minutes'], - night['light_minutes'], - night['awake_minutes'], - json.dumps(sleep_segments) - )) + # Upsert (only if not manual) + # If entry exists and is NOT manual → update + # If entry doesn't exist → insert + if existing: + # Update existing non-manual entry + cur.execute(""" + UPDATE sleep_log SET + bedtime = %s, + wake_time = %s, + duration_minutes = %s, + wake_count = %s, + deep_minutes = %s, + rem_minutes = %s, + light_minutes = %s, + awake_minutes = %s, + sleep_segments = %s, + source = 'apple_health', + updated_at = CURRENT_TIMESTAMP + WHERE id = %s AND profile_id = %s + """, ( + night['bedtime'].time(), + night['wake_time'].time(), + duration_minutes, + wake_count, + night['deep_minutes'], + night['rem_minutes'], + night['light_minutes'], + night['awake_minutes'], + json.dumps(sleep_segments), + existing['id'], + pid + )) + else: + # Insert new entry + cur.execute(""" + INSERT INTO sleep_log ( + profile_id, date, bedtime, wake_time, duration_minutes, + wake_count, deep_minutes, rem_minutes, light_minutes, awake_minutes, + sleep_segments, source, created_at, updated_at + ) VALUES ( + %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'apple_health', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + ) + """, ( + pid, + date, + night['bedtime'].time(), + night['wake_time'].time(), + duration_minutes, + wake_count, + night['deep_minutes'], + night['rem_minutes'], + night['light_minutes'], + night['awake_minutes'], + json.dumps(sleep_segments) + )) imported += 1 diff --git a/frontend/src/pages/SleepPage.jsx b/frontend/src/pages/SleepPage.jsx index 0b409dc..9f43767 100644 --- a/frontend/src/pages/SleepPage.jsx +++ b/frontend/src/pages/SleepPage.jsx @@ -234,6 +234,33 @@ export default function SleepPage() { /> + {/* 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}) @@ -641,3 +668,259 @@ function SleepEntry({ entry, expanded, editing, onToggleExpand, onEdit, onCancel
) } + +// ── 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
+ +
+
+
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)
+