fix: manual sleep entry creation + import overwrite protection
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 <noreply@anthropic.com>
This commit is contained in:
parent
b52c877367
commit
1644b34d5c
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -234,6 +234,33 @@ export default function SleepPage() {
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* Manual Entry Button */}
|
||||
<button
|
||||
onClick={() => setEditingId('new')}
|
||||
className="btn btn-primary btn-full"
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
<Plus size={16} /> Schlaf erfassen
|
||||
</button>
|
||||
|
||||
{/* New Entry Form (if creating) */}
|
||||
{editingId === 'new' && (
|
||||
<NewEntryForm
|
||||
onSave={async (data) => {
|
||||
try {
|
||||
await api.createSleep(data)
|
||||
await load()
|
||||
setEditingId(null)
|
||||
showToast('Gespeichert')
|
||||
} catch (err) {
|
||||
showToast(err.message, 'error')
|
||||
}
|
||||
}}
|
||||
onCancel={() => setEditingId(null)}
|
||||
formatDuration={formatDuration}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sleep List */}
|
||||
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 12 }}>
|
||||
Letzte 30 Nächte ({sleep.length})
|
||||
|
|
@ -641,3 +668,259 @@ function SleepEntry({ entry, expanded, editing, onToggleExpand, onEdit, onCancel
|
|||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<div className="card" style={{ padding: 16, marginBottom: 16, border: '2px solid var(--accent)' }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 12 }}>➕ Neuer Eintrag</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div>
|
||||
<div className="form-label">Datum</div>
|
||||
<input
|
||||
type="date"
|
||||
className="form-input"
|
||||
value={formData.date}
|
||||
onChange={e => setFormData({ ...formData, date: e.target.value })}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: 12 }}>
|
||||
<div>
|
||||
<div className="form-label">Schlafdauer (Minuten)</div>
|
||||
<input
|
||||
type="number"
|
||||
className="form-input"
|
||||
value={formData.duration_minutes}
|
||||
onChange={e => setFormData({ ...formData, duration_minutes: parseInt(e.target.value) || 0 })}
|
||||
style={{ width: '100%' }}
|
||||
min="1"
|
||||
/>
|
||||
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 4 }}>
|
||||
= {formatDuration(formData.duration_minutes)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="form-label">Qualität</div>
|
||||
<select
|
||||
className="form-input"
|
||||
value={formData.quality || ''}
|
||||
onChange={e => setFormData({ ...formData, quality: parseInt(e.target.value) || null })}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<option value="">—</option>
|
||||
<option value="1">★☆☆☆☆ 1</option>
|
||||
<option value="2">★★☆☆☆ 2</option>
|
||||
<option value="3">★★★☆☆ 3</option>
|
||||
<option value="4">★★★★☆ 4</option>
|
||||
<option value="5">★★★★★ 5</option>
|
||||
</select>
|
||||
</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 */}
|
||||
{showDetail && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, paddingTop: 12, borderTop: '1px solid var(--border)' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<div>
|
||||
<div className="form-label">Eingeschlafen (HH:MM)</div>
|
||||
<input
|
||||
type="time"
|
||||
className="form-input"
|
||||
value={formData.bedtime}
|
||||
onChange={e => setFormData({ ...formData, bedtime: e.target.value })}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="form-label">Aufgewacht (HH:MM)</div>
|
||||
<input
|
||||
type="time"
|
||||
className="form-input"
|
||||
value={formData.wake_time}
|
||||
onChange={e => setFormData({ ...formData, wake_time: e.target.value })}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="form-label">Aufwachungen</div>
|
||||
<input
|
||||
type="number"
|
||||
className="form-input"
|
||||
value={formData.wake_count || 0}
|
||||
onChange={e => setFormData({ ...formData, wake_count: parseInt(e.target.value) || 0 })}
|
||||
style={{ width: '100%' }}
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ fontWeight: 600, fontSize: 13, marginTop: 8 }}>Schlafphasen (Minuten)</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<div>
|
||||
<div className="form-label">Tiefschlaf</div>
|
||||
<input
|
||||
type="number"
|
||||
className="form-input"
|
||||
value={formData.deep_minutes}
|
||||
onChange={e => setFormData({ ...formData, deep_minutes: parseInt(e.target.value) || '' })}
|
||||
placeholder="—"
|
||||
style={{ width: '100%' }}
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="form-label">REM-Schlaf</div>
|
||||
<input
|
||||
type="number"
|
||||
className="form-input"
|
||||
value={formData.rem_minutes}
|
||||
onChange={e => setFormData({ ...formData, rem_minutes: parseInt(e.target.value) || '' })}
|
||||
placeholder="—"
|
||||
style={{ width: '100%' }}
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="form-label">Leichtschlaf</div>
|
||||
<input
|
||||
type="number"
|
||||
className="form-input"
|
||||
value={formData.light_minutes}
|
||||
onChange={e => setFormData({ ...formData, light_minutes: parseInt(e.target.value) || '' })}
|
||||
placeholder="—"
|
||||
style={{ width: '100%' }}
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="form-label">Wach im Bett</div>
|
||||
<input
|
||||
type="number"
|
||||
className="form-input"
|
||||
value={formData.awake_minutes}
|
||||
onChange={e => setFormData({ ...formData, awake_minutes: parseInt(e.target.value) || '' })}
|
||||
placeholder="—"
|
||||
style={{ width: '100%' }}
|
||||
min="0"
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || !!plausibilityError}
|
||||
className="btn btn-primary"
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
{saving ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, justifyContent: 'center' }}>
|
||||
<div className="spinner" style={{ width: 14, height: 14 }} />
|
||||
Speichere...
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Save size={16} /> Speichern
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
disabled={saving}
|
||||
className="btn btn-secondary"
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<X size={16} /> Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user