mitai-jinkendo/frontend/src/pages/SleepPage.jsx

520 lines
18 KiB
React
Raw Normal View History

import { useState, useEffect, useRef } from 'react'
import { Moon, Plus, Edit2, Trash2, X, Save, TrendingUp, Upload } from 'lucide-react'
import { api } from '../utils/api'
/**
* SleepPage - Sleep tracking with quick/detail entry (v9d Phase 2b)
*
* 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
*/
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 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()
}, [])
const load = () => {
setLoading(true)
Promise.all([
api.listSleep(30),
api.getSleepStats(7).catch(() => null) // Stats optional - don't fail if error
]).then(([sleepData, statsData]) => {
setSleep(sleepData)
setStats(statsData)
setLoading(false)
}).catch(err => {
console.error('Failed to load sleep data:', err)
setLoading(false)
})
}
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 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]
if (!file) return
if (!file.name.endsWith('.csv')) {
alert('Bitte eine CSV-Datei auswählen')
return
}
setImporting(true)
try {
const result = await api.importAppleHealthSleep(file)
await load()
alert(result.message || `✅ ${result.imported} Nächte importiert, ${result.skipped} übersprungen`)
} catch (err) {
alert('Import fehlgeschlagen: ' + err.message)
} finally {
setImporting(false)
if (fileInputRef.current) {
fileInputRef.current.value = '' // Reset input
}
}
}
const formatDuration = (minutes) => {
const h = Math.floor(minutes / 60)
const m = minutes % 60
return `${h}h ${m}min`
}
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' }}>
{/* 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 */}
{stats && (
<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>
)}
{/* Action Buttons */}
{!showForm && (
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
<button
onClick={startCreate}
className="btn btn-primary"
style={{ flex: 1 }}
>
<Plus size={16} /> Schlaf erfassen
</button>
<button
onClick={() => fileInputRef.current?.click()}
disabled={importing}
className="btn btn-secondary"
style={{ flex: 1 }}
>
{importing ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, justifyContent: 'center' }}>
<div className="spinner" style={{ width: 14, height: 14 }} />
Importiere...
</div>
) : (
<>
<Upload size={16} /> CSV Import
</>
)}
</button>
<input
ref={fileInputRef}
type="file"
accept=".csv"
onChange={handleImport}
style={{ display: 'none' }}
/>
</div>
)}
{/* Entry Form */}
{showForm && (
<div className="card" style={{ padding: 16, marginBottom: 16, border: '2px solid var(--accent)' }}>
<div style={{ fontWeight: 600, marginBottom: 12 }}>
{editingId ? '✏️ Eintrag bearbeiten' : '➕ Neuer Eintrag'}
</div>
{/* Quick Entry Fields */}
<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) || null })}
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) || null })}
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) || null })}
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) || null })}
placeholder="—"
style={{ width: '100%' }}
min="0"
/>
</div>
</div>
</div>
)}
{/* Action Buttons */}
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<button
onClick={handleSave}
disabled={saving}
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={cancelEdit}
disabled={saving}
className="btn btn-secondary"
style={{ flex: 1 }}
>
<X size={16} /> Abbrechen
</button>
</div>
</div>
</div>
)}
{/* 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 => (
<div key={entry.id} className="card" style={{ padding: 12 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
{new Date(entry.date).toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit' })}
</div>
<div style={{ fontSize: 12, color: 'var(--text2)' }}>
{entry.duration_formatted}
{entry.quality && ` · ${'★'.repeat(entry.quality)}${'☆'.repeat(5 - entry.quality)}`}
</div>
{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>
)}
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button
onClick={() => startEdit(entry)}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 6,
color: 'var(--accent)'
}}
title="Bearbeiten"
>
<Edit2 size={16} />
</button>
<button
onClick={() => handleDelete(entry.id, entry.date)}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 6,
color: '#D85A30'
}}
title="Löschen"
>
<Trash2 size={16} />
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
)
}