2026-03-22 11:49:09 +01:00
|
|
|
|
import { useState, useEffect, useRef } from 'react'
|
2026-03-22 13:09:34 +01:00
|
|
|
|
import { Moon, Plus, Edit2, Trash2, X, Save, TrendingUp, Upload, ChevronDown, ChevronUp, AlertCircle } from 'lucide-react'
|
2026-03-22 08:17:11 +01:00
|
|
|
|
import { api } from '../utils/api'
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-03-22 13:09:34 +01:00
|
|
|
|
* SleepPage - Sleep tracking with CSV import (v9d Phase 2c)
|
2026-03-22 08:17:11 +01:00
|
|
|
|
*
|
|
|
|
|
|
* Features:
|
2026-03-22 13:09:34 +01:00
|
|
|
|
* - 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)
|
2026-03-22 08:17:11 +01:00
|
|
|
|
*/
|
|
|
|
|
|
export default function SleepPage() {
|
|
|
|
|
|
const [sleep, setSleep] = useState([])
|
|
|
|
|
|
const [stats, setStats] = useState(null)
|
|
|
|
|
|
const [loading, setLoading] = useState(true)
|
2026-03-22 11:49:09 +01:00
|
|
|
|
const [importing, setImporting] = useState(false)
|
2026-03-22 13:09:34 +01:00
|
|
|
|
const [dragging, setDragging] = useState(false)
|
|
|
|
|
|
const [toast, setToast] = useState(null)
|
|
|
|
|
|
const [expandedId, setExpandedId] = useState(null)
|
|
|
|
|
|
const [editingId, setEditingId] = useState(null)
|
2026-03-22 11:49:09 +01:00
|
|
|
|
const fileInputRef = useRef(null)
|
2026-03-22 08:17:11 +01:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
load()
|
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
|
|
const load = () => {
|
|
|
|
|
|
setLoading(true)
|
|
|
|
|
|
Promise.all([
|
|
|
|
|
|
api.listSleep(30),
|
2026-03-22 13:09:34 +01:00
|
|
|
|
api.getSleepStats(7).catch(() => null)
|
2026-03-22 08:17:11 +01:00
|
|
|
|
]).then(([sleepData, statsData]) => {
|
|
|
|
|
|
setSleep(sleepData)
|
|
|
|
|
|
setStats(statsData)
|
|
|
|
|
|
setLoading(false)
|
|
|
|
|
|
}).catch(err => {
|
|
|
|
|
|
console.error('Failed to load sleep data:', err)
|
|
|
|
|
|
setLoading(false)
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 13:09:34 +01:00
|
|
|
|
const showToast = (message, type = 'success') => {
|
|
|
|
|
|
setToast({ message, type })
|
|
|
|
|
|
setTimeout(() => setToast(null), 4000)
|
2026-03-22 08:17:11 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 13:53:13 +01:00
|
|
|
|
// Clean data: convert empty strings to null for optional integer fields
|
|
|
|
|
|
const cleanSleepData = (data) => ({
|
|
|
|
|
|
...data,
|
|
|
|
|
|
quality: data.quality === '' ? null : data.quality,
|
|
|
|
|
|
wake_count: data.wake_count === '' ? 0 : data.wake_count,
|
|
|
|
|
|
deep_minutes: data.deep_minutes === '' ? null : data.deep_minutes,
|
|
|
|
|
|
rem_minutes: data.rem_minutes === '' ? null : data.rem_minutes,
|
|
|
|
|
|
light_minutes: data.light_minutes === '' ? null : data.light_minutes,
|
|
|
|
|
|
awake_minutes: data.awake_minutes === '' ? null : data.awake_minutes,
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-03-22 13:09:34 +01:00
|
|
|
|
const handleImport = async (file) => {
|
|
|
|
|
|
if (!file) return
|
|
|
|
|
|
if (!file.name.endsWith('.csv')) {
|
|
|
|
|
|
showToast('Bitte eine CSV-Datei auswählen', 'error')
|
2026-03-22 08:17:11 +01:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 13:09:34 +01:00
|
|
|
|
setImporting(true)
|
2026-03-22 08:17:11 +01:00
|
|
|
|
|
|
|
|
|
|
try {
|
2026-03-22 13:09:34 +01:00
|
|
|
|
const result = await api.importAppleHealthSleep(file)
|
2026-03-22 08:17:11 +01:00
|
|
|
|
await load()
|
2026-03-22 13:09:34 +01:00
|
|
|
|
showToast(`✅ ${result.imported} Nächte importiert, ${result.skipped} übersprungen`)
|
2026-03-22 08:17:11 +01:00
|
|
|
|
} catch (err) {
|
2026-03-22 13:09:34 +01:00
|
|
|
|
showToast('Import fehlgeschlagen: ' + err.message, 'error')
|
2026-03-22 08:17:11 +01:00
|
|
|
|
} finally {
|
2026-03-22 13:09:34 +01:00
|
|
|
|
setImporting(false)
|
|
|
|
|
|
if (fileInputRef.current) {
|
|
|
|
|
|
fileInputRef.current.value = ''
|
|
|
|
|
|
}
|
2026-03-22 08:17:11 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 13:09:34 +01:00
|
|
|
|
const handleDrop = async (e) => {
|
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
|
setDragging(false)
|
|
|
|
|
|
const file = e.dataTransfer.files[0]
|
|
|
|
|
|
if (file) await handleImport(file)
|
2026-03-22 08:17:11 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 13:09:34 +01:00
|
|
|
|
const handleFileSelect = async (e) => {
|
2026-03-22 11:49:09 +01:00
|
|
|
|
const file = e.target.files[0]
|
2026-03-22 13:09:34 +01:00
|
|
|
|
if (file) await handleImport(file)
|
|
|
|
|
|
}
|
2026-03-22 11:49:09 +01:00
|
|
|
|
|
2026-03-22 13:09:34 +01:00
|
|
|
|
const handleDelete = async (id, date) => {
|
|
|
|
|
|
if (!confirm(`Schlaf-Eintrag vom ${date} wirklich löschen?`)) return
|
2026-03-22 11:49:09 +01:00
|
|
|
|
|
|
|
|
|
|
try {
|
2026-03-22 13:09:34 +01:00
|
|
|
|
await api.deleteSleep(id)
|
2026-03-22 11:49:09 +01:00
|
|
|
|
await load()
|
2026-03-22 13:09:34 +01:00
|
|
|
|
showToast('Eintrag gelöscht')
|
2026-03-22 11:49:09 +01:00
|
|
|
|
} catch (err) {
|
2026-03-22 13:09:34 +01:00
|
|
|
|
showToast('Löschen fehlgeschlagen: ' + err.message, 'error')
|
2026-03-22 11:49:09 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 08:17:11 +01:00
|
|
|
|
const formatDuration = (minutes) => {
|
|
|
|
|
|
const h = Math.floor(minutes / 60)
|
|
|
|
|
|
const m = minutes % 60
|
|
|
|
|
|
return `${h}h ${m}min`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 13:09:34 +01:00
|
|
|
|
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>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 08:17:11 +01:00
|
|
|
|
if (loading) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div style={{ padding: 20, textAlign: 'center' }}>
|
|
|
|
|
|
<div className="spinner" style={{ width: 32, height: 32, margin: '0 auto' }} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div style={{ padding: '16px 16px 80px' }}>
|
2026-03-22 13:09:34 +01:00
|
|
|
|
{/* 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>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-03-22 08:17:11 +01:00
|
|
|
|
{/* Header */}
|
|
|
|
|
|
<div style={{ marginBottom: 20, display: 'flex', alignItems: 'center', gap: 12 }}>
|
|
|
|
|
|
<Moon size={24} color="var(--accent)" />
|
|
|
|
|
|
<h1 style={{ margin: 0, fontSize: 20, fontWeight: 700 }}>Schlaf</h1>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Stats Card */}
|
2026-03-22 13:09:34 +01:00
|
|
|
|
{stats && stats.total_nights > 0 && (
|
2026-03-22 08:17:11 +01:00
|
|
|
|
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
|
|
|
|
|
|
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 12, display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
|
|
|
|
<TrendingUp size={16} color="var(--accent)" />
|
|
|
|
|
|
<span>Übersicht (letzte 7 Tage)</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
|
|
|
|
|
<div style={{ textAlign: 'center', padding: 12, background: 'var(--surface)', borderRadius: 8 }}>
|
|
|
|
|
|
<div style={{ fontSize: 18, fontWeight: 700, color: 'var(--accent)' }}>
|
|
|
|
|
|
{formatDuration(Math.round(stats.avg_duration_minutes))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div style={{ fontSize: 11, color: 'var(--text3)' }}>Ø Schlafdauer</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div style={{ textAlign: 'center', padding: 12, background: 'var(--surface)', borderRadius: 8 }}>
|
|
|
|
|
|
<div style={{ fontSize: 18, fontWeight: 700 }}>
|
|
|
|
|
|
{stats.avg_quality ? stats.avg_quality.toFixed(1) : '—'}/5
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div style={{ fontSize: 11, color: 'var(--text3)' }}>Ø Qualität</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{stats.nights_below_goal > 0 && (
|
|
|
|
|
|
<div style={{ marginTop: 12, fontSize: 12, color: '#D85A30', textAlign: 'center' }}>
|
|
|
|
|
|
⚠️ {stats.nights_below_goal} Nächte unter Ziel ({formatDuration(stats.sleep_goal_minutes)})
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-03-22 13:09:34 +01:00
|
|
|
|
{/* CSV Import Drag & Drop */}
|
|
|
|
|
|
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
|
|
|
|
|
|
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 12 }}>CSV Import</div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
onDragOver={e => { 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 ? (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<div className="spinner" style={{ width: 24, height: 24, margin: '0 auto 8px' }} />
|
|
|
|
|
|
<div style={{ fontSize: 14, fontWeight: 500, color: 'var(--text2)' }}>
|
|
|
|
|
|
Importiere Schlaf-Daten...
|
2026-03-22 11:49:09 +01:00
|
|
|
|
</div>
|
2026-03-22 13:09:34 +01:00
|
|
|
|
</>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<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>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<input
|
|
|
|
|
|
ref={fileInputRef}
|
|
|
|
|
|
type="file"
|
|
|
|
|
|
accept=".csv"
|
|
|
|
|
|
onChange={handleFileSelect}
|
|
|
|
|
|
style={{ display: 'none' }}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-22 13:43:02 +01:00
|
|
|
|
{/* 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 {
|
2026-03-22 13:53:13 +01:00
|
|
|
|
await api.createSleep(cleanSleepData(data))
|
2026-03-22 13:43:02 +01:00
|
|
|
|
await load()
|
|
|
|
|
|
setEditingId(null)
|
|
|
|
|
|
showToast('Gespeichert')
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
showToast(err.message, 'error')
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
onCancel={() => setEditingId(null)}
|
|
|
|
|
|
formatDuration={formatDuration}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-03-22 13:09:34 +01:00
|
|
|
|
{/* Sleep List */}
|
|
|
|
|
|
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 12 }}>
|
|
|
|
|
|
Letzte 30 Nächte ({sleep.length})
|
|
|
|
|
|
</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 => (
|
|
|
|
|
|
<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 {
|
2026-03-22 13:53:13 +01:00
|
|
|
|
await api.updateSleep(entry.id, cleanSleepData(data))
|
2026-03-22 13:09:34 +01:00
|
|
|
|
await load()
|
|
|
|
|
|
setEditingId(null)
|
|
|
|
|
|
showToast('Gespeichert')
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
showToast(err.message, 'error')
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
2026-03-22 13:53:13 +01:00
|
|
|
|
cleanSleepData={cleanSleepData}
|
2026-03-22 13:09:34 +01:00
|
|
|
|
onDelete={() => handleDelete(entry.id, entry.date)}
|
|
|
|
|
|
formatDuration={formatDuration}
|
|
|
|
|
|
getSourceBadge={getSourceBadge}
|
|
|
|
|
|
/>
|
|
|
|
|
|
))}
|
2026-03-22 11:49:09 +01:00
|
|
|
|
</div>
|
2026-03-22 08:17:11 +01:00
|
|
|
|
)}
|
2026-03-22 13:09:34 +01:00
|
|
|
|
</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)
|
2026-03-22 13:53:13 +01:00
|
|
|
|
const [suggestedDuration, setSuggestedDuration] = useState(null)
|
|
|
|
|
|
|
2026-03-22 14:01:47 +01:00
|
|
|
|
// Auto-calculate duration from bedtime + wake_time (minus awake time)
|
2026-03-22 13:53:13 +01:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (editing && formData.bedtime && formData.wake_time) {
|
|
|
|
|
|
const [bedH, bedM] = formData.bedtime.split(':').map(Number)
|
|
|
|
|
|
const [wakeH, wakeM] = formData.wake_time.split(':').map(Number)
|
|
|
|
|
|
|
|
|
|
|
|
let bedMinutes = bedH * 60 + bedM
|
|
|
|
|
|
let wakeMinutes = wakeH * 60 + wakeM
|
|
|
|
|
|
|
|
|
|
|
|
if (wakeMinutes < bedMinutes) {
|
|
|
|
|
|
wakeMinutes += 24 * 60
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 14:01:47 +01:00
|
|
|
|
let duration = wakeMinutes - bedMinutes
|
|
|
|
|
|
|
|
|
|
|
|
// Subtract awake time to get actual sleep duration
|
|
|
|
|
|
const awake = parseInt(formData.awake_minutes) || 0
|
|
|
|
|
|
if (awake > 0) {
|
|
|
|
|
|
duration = Math.max(0, duration - awake)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 13:53:13 +01:00
|
|
|
|
setSuggestedDuration(duration)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setSuggestedDuration(null)
|
|
|
|
|
|
}
|
2026-03-22 14:01:47 +01:00
|
|
|
|
}, [editing, formData.bedtime, formData.wake_time, formData.awake_minutes])
|
2026-03-22 08:17:11 +01:00
|
|
|
|
|
2026-03-22 14:01:47 +01:00
|
|
|
|
// Live plausibility check (sleep phases only, awake not counted)
|
2026-03-22 13:09:34 +01:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!editing) return
|
2026-03-22 14:01:47 +01:00
|
|
|
|
const sleepPhases = [formData.deep_minutes, formData.rem_minutes, formData.light_minutes]
|
|
|
|
|
|
if (sleepPhases.some(p => p !== '' && p !== null)) {
|
|
|
|
|
|
const sum = sleepPhases.reduce((a, b) => a + (parseInt(b) || 0), 0)
|
2026-03-22 13:09:34 +01:00
|
|
|
|
const diff = Math.abs(formData.duration_minutes - sum)
|
|
|
|
|
|
if (diff > 5) {
|
2026-03-22 14:01:47 +01:00
|
|
|
|
setPlausibilityError(`Schlafphasen-Summe (${sum} min) weicht um ${diff} min ab (Toleranz: 5 min). Wachphasen nicht mitgezählt.`)
|
2026-03-22 13:09:34 +01:00
|
|
|
|
} 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 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%' }}
|
|
|
|
|
|
/>
|
2026-03-22 08:17:11 +01:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-22 13:09:34 +01:00
|
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: 10 }}>
|
2026-03-22 08:17:11 +01:00
|
|
|
|
<div>
|
2026-03-22 14:01:47 +01:00
|
|
|
|
<div className="form-label">Schlafdauer (reine Schlafzeit, Minuten)</div>
|
2026-03-22 08:17:11 +01:00
|
|
|
|
<input
|
2026-03-22 13:09:34 +01:00
|
|
|
|
type="number"
|
2026-03-22 08:17:11 +01:00
|
|
|
|
className="form-input"
|
2026-03-22 13:09:34 +01:00
|
|
|
|
value={formData.duration_minutes}
|
|
|
|
|
|
onChange={e => setFormData({ ...formData, duration_minutes: parseInt(e.target.value) || 0 })}
|
2026-03-22 08:17:11 +01:00
|
|
|
|
style={{ width: '100%' }}
|
2026-03-22 13:09:34 +01:00
|
|
|
|
min="1"
|
2026-03-22 08:17:11 +01:00
|
|
|
|
/>
|
2026-03-22 13:09:34 +01:00
|
|
|
|
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 2 }}>
|
|
|
|
|
|
= {formatDuration(formData.duration_minutes)}
|
2026-03-22 13:53:13 +01:00
|
|
|
|
{suggestedDuration && suggestedDuration !== formData.duration_minutes && (
|
|
|
|
|
|
<span style={{ marginLeft: 8, color: 'var(--accent)', cursor: 'pointer' }}
|
|
|
|
|
|
onClick={() => setFormData({ ...formData, duration_minutes: suggestedDuration })}>
|
|
|
|
|
|
💡 Vorschlag: {formatDuration(suggestedDuration)} (übernehmen?)
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
2026-03-22 13:09:34 +01:00
|
|
|
|
</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>
|
2026-03-22 08:17:11 +01:00
|
|
|
|
</div>
|
2026-03-22 13:09:34 +01:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Detail Fields */}
|
|
|
|
|
|
<div style={{ paddingTop: 10, borderTop: '1px solid var(--border)' }}>
|
|
|
|
|
|
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 8 }}>Details (optional)</div>
|
2026-03-22 08:17:11 +01:00
|
|
|
|
|
2026-03-22 13:09:34 +01:00
|
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10, marginBottom: 10 }}>
|
2026-03-22 08:17:11 +01:00
|
|
|
|
<div>
|
2026-03-22 13:09:34 +01:00
|
|
|
|
<div className="form-label">Eingeschlafen</div>
|
2026-03-22 08:17:11 +01:00
|
|
|
|
<input
|
2026-03-22 13:09:34 +01:00
|
|
|
|
type="time"
|
2026-03-22 08:17:11 +01:00
|
|
|
|
className="form-input"
|
2026-03-22 13:09:34 +01:00
|
|
|
|
value={formData.bedtime}
|
|
|
|
|
|
onChange={e => setFormData({ ...formData, bedtime: e.target.value })}
|
2026-03-22 08:17:11 +01:00
|
|
|
|
style={{ width: '100%' }}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
2026-03-22 13:09:34 +01:00
|
|
|
|
<div className="form-label">Aufgewacht</div>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="time"
|
2026-03-22 08:17:11 +01:00
|
|
|
|
className="form-input"
|
2026-03-22 13:09:34 +01:00
|
|
|
|
value={formData.wake_time}
|
|
|
|
|
|
onChange={e => setFormData({ ...formData, wake_time: e.target.value })}
|
2026-03-22 08:17:11 +01:00
|
|
|
|
style={{ width: '100%' }}
|
2026-03-22 13:09:34 +01:00
|
|
|
|
/>
|
2026-03-22 08:17:11 +01:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-22 13:09:34 +01:00
|
|
|
|
<div style={{ marginBottom: 10 }}>
|
|
|
|
|
|
<div className="form-label">Aufwachungen</div>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="number"
|
2026-03-22 08:17:11 +01:00
|
|
|
|
className="form-input"
|
2026-03-22 13:09:34 +01:00
|
|
|
|
value={formData.wake_count}
|
|
|
|
|
|
onChange={e => setFormData({ ...formData, wake_count: parseInt(e.target.value) || 0 })}
|
|
|
|
|
|
style={{ width: '100%' }}
|
|
|
|
|
|
min="0"
|
2026-03-22 08:17:11 +01:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-22 13:09:34 +01:00
|
|
|
|
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 8 }}>Schlafphasen (Minuten)</div>
|
|
|
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
|
|
|
|
|
|
<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) || '' })}
|
|
|
|
|
|
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) || '' })}
|
|
|
|
|
|
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) || '' })}
|
|
|
|
|
|
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) || '' })}
|
|
|
|
|
|
style={{ width: '100%' }}
|
|
|
|
|
|
min="0"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-03-22 08:17:11 +01:00
|
|
|
|
|
2026-03-22 13:09:34 +01:00
|
|
|
|
{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>
|
2026-03-22 08:17:11 +01:00
|
|
|
|
|
2026-03-22 13:09:34 +01:00
|
|
|
|
<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>
|
2026-03-22 08:17:11 +01:00
|
|
|
|
|
2026-03-22 13:09:34 +01:00
|
|
|
|
{/* Action Buttons */}
|
|
|
|
|
|
<div style={{ display: 'flex', gap: 8 }}>
|
|
|
|
|
|
<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...
|
2026-03-22 08:17:11 +01:00
|
|
|
|
</div>
|
2026-03-22 13:09:34 +01:00
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<Save size={16} /> Speichern
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={onCancelEdit}
|
|
|
|
|
|
disabled={saving}
|
|
|
|
|
|
className="btn btn-secondary"
|
|
|
|
|
|
style={{ flex: 1 }}
|
|
|
|
|
|
>
|
|
|
|
|
|
<X size={16} /> Abbrechen
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
2026-03-22 08:17:11 +01:00
|
|
|
|
|
2026-03-22 13:09:34 +01:00
|
|
|
|
// ── View Mode ──────────────────────────────────────────────────────────────────
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="card" style={{ padding: 12 }}>
|
|
|
|
|
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
|
|
|
|
|
|
{/* Main Info */}
|
|
|
|
|
|
<div style={{ flex: 1 }}>
|
|
|
|
|
|
<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' })}
|
2026-03-22 08:17:11 +01:00
|
|
|
|
</div>
|
2026-03-22 13:09:34 +01:00
|
|
|
|
{getSourceBadge(entry.source)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div style={{ fontSize: 12, color: 'var(--text2)' }}>
|
|
|
|
|
|
{entry.duration_formatted}
|
|
|
|
|
|
{entry.quality && ` · ${'★'.repeat(entry.quality)}${'☆'.repeat(5 - entry.quality)}`}
|
|
|
|
|
|
{entry.wake_count > 0 && ` · ${entry.wake_count}x aufgewacht`}
|
2026-03-22 08:17:11 +01:00
|
|
|
|
</div>
|
2026-03-22 13:09:34 +01:00
|
|
|
|
{entry.bedtime && entry.wake_time && (
|
|
|
|
|
|
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 2 }}>
|
|
|
|
|
|
{entry.bedtime} – {entry.wake_time}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{entry.note && (
|
|
|
|
|
|
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 4, fontStyle: 'italic' }}>
|
|
|
|
|
|
"{entry.note}"
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-03-22 08:17:11 +01:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-22 13:09:34 +01:00
|
|
|
|
{/* Action Buttons */}
|
|
|
|
|
|
<div style={{ display: 'flex', gap: 6 }}>
|
|
|
|
|
|
{entry.sleep_segments && (
|
|
|
|
|
|
<button
|
|
|
|
|
|
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={{
|
|
|
|
|
|
background: 'none',
|
|
|
|
|
|
border: 'none',
|
|
|
|
|
|
cursor: 'pointer',
|
|
|
|
|
|
padding: 6,
|
|
|
|
|
|
color: 'var(--accent)'
|
|
|
|
|
|
}}
|
|
|
|
|
|
title="Bearbeiten"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Edit2 size={16} />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={onDelete}
|
|
|
|
|
|
style={{
|
|
|
|
|
|
background: 'none',
|
|
|
|
|
|
border: 'none',
|
|
|
|
|
|
cursor: 'pointer',
|
|
|
|
|
|
padding: 6,
|
|
|
|
|
|
color: '#D85A30'
|
|
|
|
|
|
}}
|
|
|
|
|
|
title="Löschen"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Trash2 size={16} />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
2026-03-22 08:17:11 +01:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-22 13:09:34 +01:00
|
|
|
|
{/* 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' })}
|
2026-03-22 08:17:11 +01:00
|
|
|
|
</div>
|
2026-03-22 13:09:34 +01:00
|
|
|
|
<div style={{ fontWeight: 600, minWidth: 80 }}>
|
|
|
|
|
|
{phaseLabels[seg.phase]}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div style={{ color: 'var(--text3)', minWidth: 50, textAlign: 'right' }}>
|
|
|
|
|
|
{seg.duration_min} min
|
2026-03-22 08:17:11 +01:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-03-22 13:09:34 +01:00
|
|
|
|
)
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
2026-03-22 08:17:11 +01:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
2026-03-22 13:43:02 +01:00
|
|
|
|
|
|
|
|
|
|
// ── 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)
|
2026-03-22 13:53:13 +01:00
|
|
|
|
const [suggestedDuration, setSuggestedDuration] = useState(null)
|
|
|
|
|
|
|
2026-03-22 14:01:47 +01:00
|
|
|
|
// Auto-calculate duration from bedtime + wake_time (minus awake time)
|
2026-03-22 13:53:13 +01:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (formData.bedtime && formData.wake_time) {
|
|
|
|
|
|
const [bedH, bedM] = formData.bedtime.split(':').map(Number)
|
|
|
|
|
|
const [wakeH, wakeM] = formData.wake_time.split(':').map(Number)
|
|
|
|
|
|
|
|
|
|
|
|
let bedMinutes = bedH * 60 + bedM
|
|
|
|
|
|
let wakeMinutes = wakeH * 60 + wakeM
|
|
|
|
|
|
|
|
|
|
|
|
// If wake time < bed time, add 24 hours (crossed midnight)
|
|
|
|
|
|
if (wakeMinutes < bedMinutes) {
|
|
|
|
|
|
wakeMinutes += 24 * 60
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 14:01:47 +01:00
|
|
|
|
let duration = wakeMinutes - bedMinutes
|
|
|
|
|
|
|
|
|
|
|
|
// Subtract awake time to get actual sleep duration
|
|
|
|
|
|
const awake = parseInt(formData.awake_minutes) || 0
|
|
|
|
|
|
if (awake > 0) {
|
|
|
|
|
|
duration = Math.max(0, duration - awake)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 13:53:13 +01:00
|
|
|
|
setSuggestedDuration(duration)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setSuggestedDuration(null)
|
|
|
|
|
|
}
|
2026-03-22 14:01:47 +01:00
|
|
|
|
}, [formData.bedtime, formData.wake_time, formData.awake_minutes])
|
2026-03-22 13:43:02 +01:00
|
|
|
|
|
2026-03-22 14:01:47 +01:00
|
|
|
|
// Live plausibility check (sleep phases only, awake not counted)
|
2026-03-22 13:43:02 +01:00
|
|
|
|
useEffect(() => {
|
2026-03-22 14:01:47 +01:00
|
|
|
|
const sleepPhases = [formData.deep_minutes, formData.rem_minutes, formData.light_minutes]
|
|
|
|
|
|
if (sleepPhases.some(p => p !== '' && p !== null)) {
|
|
|
|
|
|
const sum = sleepPhases.reduce((a, b) => a + (parseInt(b) || 0), 0)
|
2026-03-22 13:43:02 +01:00
|
|
|
|
const diff = Math.abs(formData.duration_minutes - sum)
|
|
|
|
|
|
if (diff > 5) {
|
2026-03-22 14:01:47 +01:00
|
|
|
|
setPlausibilityError(`Schlafphasen-Summe (${sum} min) weicht um ${diff} min ab (Toleranz: 5 min). Wachphasen nicht mitgezählt.`)
|
2026-03-22 13:43:02 +01:00
|
|
|
|
} 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)}
|
2026-03-22 13:53:13 +01:00
|
|
|
|
{suggestedDuration && suggestedDuration !== formData.duration_minutes && (
|
|
|
|
|
|
<span style={{ marginLeft: 8, color: 'var(--accent)', cursor: 'pointer' }}
|
|
|
|
|
|
onClick={() => setFormData({ ...formData, duration_minutes: suggestedDuration })}>
|
|
|
|
|
|
💡 Vorschlag: {formatDuration(suggestedDuration)} (übernehmen?)
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
2026-03-22 13:43:02 +01:00
|
|
|
|
</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>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|