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

424 lines
14 KiB
React
Raw Normal View History

import { useState, useEffect } from 'react'
import { Pencil, Trash2, X, TrendingUp, TrendingDown, Minus } from 'lucide-react'
import { api } from '../utils/api'
import dayjs from 'dayjs'
import 'dayjs/locale/de'
dayjs.locale('de')
function empty() {
return {
date: dayjs().format('YYYY-MM-DD'),
resting_hr: '',
hrv: '',
note: ''
}
}
function EntryForm({ form, setForm, onSave, onCancel, saving, saveLabel = 'Speichern' }) {
const set = (k, v) => setForm(f => ({ ...f, [k]: v }))
return (
<div>
<div className="form-row">
<label className="form-label">Datum</label>
<input
type="date"
className="form-input"
style={{ width: 140 }}
value={form.date}
onChange={e => set('date', e.target.value)}
/>
<span className="form-unit" />
</div>
<div className="form-row">
<label className="form-label">Ruhepuls *</label>
<input
type="number"
className="form-input"
min={30}
max={120}
step={1}
placeholder="z.B. 58"
value={form.resting_hr || ''}
onChange={e => set('resting_hr', e.target.value)}
/>
<span className="form-unit">bpm</span>
</div>
<div className="form-row">
<label className="form-label">HRV</label>
<input
type="number"
className="form-input"
min={1}
max={300}
step={1}
placeholder="optional"
value={form.hrv || ''}
onChange={e => set('hrv', e.target.value)}
/>
<span className="form-unit">ms</span>
</div>
<div className="form-row">
<label className="form-label">Notiz</label>
<input
type="text"
className="form-input"
placeholder="optional"
value={form.note || ''}
onChange={e => set('note', e.target.value)}
/>
<span className="form-unit" />
</div>
<div style={{ display: 'flex', gap: 6, marginTop: 8 }}>
<button
className="btn btn-primary"
style={{ flex: 1 }}
onClick={onSave}
disabled={saving}
>
{saveLabel}
</button>
{onCancel && (
<button className="btn btn-secondary" style={{ flex: 1 }} onClick={onCancel}>
<X size={13} /> Abbrechen
</button>
)}
</div>
</div>
)
}
export default function VitalsPage() {
const [entries, setEntries] = useState([])
const [stats, setStats] = useState(null)
const [tab, setTab] = useState('list')
const [form, setForm] = useState(empty())
const [editing, setEditing] = useState(null)
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [error, setError] = useState(null)
const load = async () => {
try {
const [e, s] = await Promise.all([api.listVitals(90), api.getVitalsStats(30)])
setEntries(e)
setStats(s)
} catch (err) {
console.error('Load failed:', err)
setError(err.message)
}
}
useEffect(() => {
load()
}, [])
const handleSave = async () => {
setSaving(true)
setError(null)
try {
const payload = { date: form.date }
// Only include fields if they have values
if (form.resting_hr) payload.resting_hr = parseInt(form.resting_hr)
if (form.hrv) payload.hrv = parseInt(form.hrv)
if (form.note) payload.note = form.note
if (!payload.resting_hr && !payload.hrv) {
setError('Mindestens Ruhepuls oder HRV muss angegeben werden')
setSaving(false)
return
}
await api.createVitals(payload)
setSaved(true)
await load()
setTimeout(() => {
setSaved(false)
setForm(empty())
}, 1500)
} catch (err) {
console.error('Save failed:', err)
setError(err.message || 'Fehler beim Speichern')
setTimeout(() => setError(null), 5000)
} finally {
setSaving(false)
}
}
const handleUpdate = async () => {
try {
const payload = {}
// Only include fields if they have values
if (editing.date) payload.date = editing.date
if (editing.resting_hr) payload.resting_hr = parseInt(editing.resting_hr)
if (editing.hrv) payload.hrv = parseInt(editing.hrv)
if (editing.note) payload.note = editing.note
await api.updateVitals(editing.id, payload)
setEditing(null)
await load()
} catch (err) {
console.error('Update failed:', err)
setError(err.message)
}
}
const handleDelete = async (id) => {
if (!confirm('Eintrag löschen?')) return
try {
await api.deleteVitals(id)
await load()
} catch (err) {
console.error('Delete failed:', err)
setError(err.message)
}
}
const getTrendIcon = (trend) => {
if (trend === 'increasing') return <TrendingUp size={14} style={{ color: '#D85A30' }} />
if (trend === 'decreasing') return <TrendingDown size={14} style={{ color: '#1D9E75' }} />
return <Minus size={14} style={{ color: 'var(--text3)' }} />
}
return (
<div>
<h1 className="page-title">Vitalwerte</h1>
<div className="tabs" style={{ overflowX: 'auto', flexWrap: 'nowrap' }}>
<button className={'tab' + (tab === 'list' ? ' active' : '')} onClick={() => setTab('list')}>
Verlauf
</button>
<button className={'tab' + (tab === 'add' ? ' active' : '')} onClick={() => setTab('add')}>
+ Erfassen
</button>
<button className={'tab' + (tab === 'stats' ? ' active' : '')} onClick={() => setTab('stats')}>
Statistik
</button>
</div>
{/* Stats Overview */}
{stats && stats.total_entries > 0 && (
<div className="card section-gap">
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<div style={{
flex: 1,
minWidth: 120,
background: 'var(--surface2)',
borderRadius: 8,
padding: '8px 10px',
textAlign: 'center'
}}>
<div style={{ fontSize: 18, fontWeight: 700, color: '#378ADD' }}>
{stats.avg_resting_hr_7d ? Math.round(stats.avg_resting_hr_7d) : '—'}
</div>
<div style={{ fontSize: 10, color: 'var(--text3)' }}>Ø Ruhepuls 7d</div>
</div>
<div style={{
flex: 1,
minWidth: 120,
background: 'var(--surface2)',
borderRadius: 8,
padding: '8px 10px',
textAlign: 'center'
}}>
<div style={{ fontSize: 18, fontWeight: 700, color: '#1D9E75' }}>
{stats.avg_hrv_7d ? Math.round(stats.avg_hrv_7d) : '—'}
</div>
<div style={{ fontSize: 10, color: 'var(--text3)' }}>Ø HRV 7d</div>
</div>
<div style={{
flex: 1,
minWidth: 120,
background: 'var(--surface2)',
borderRadius: 8,
padding: '8px 10px',
textAlign: 'center'
}}>
<div style={{ fontSize: 18, fontWeight: 700, color: 'var(--text2)' }}>
{stats.total_entries}
</div>
<div style={{ fontSize: 10, color: 'var(--text3)' }}>Einträge</div>
</div>
</div>
</div>
)}
{tab === 'add' && (
<div className="card section-gap">
<div className="card-title">Vitalwerte erfassen</div>
<p style={{ fontSize: 13, color: 'var(--text2)', marginBottom: 10, lineHeight: 1.6 }}>
Morgens nach dem Aufwachen, vor dem Aufstehen: Ruhepuls messen (z.B. mit Apple Watch oder Smartwatch).
HRV ist optional.
</p>
{error && (
<div style={{
padding: '10px',
background: '#FCEBEB',
border: '1px solid #D85A30',
borderRadius: 8,
fontSize: 13,
color: '#D85A30',
marginBottom: 8
}}>
{error}
</div>
)}
<EntryForm
form={form}
setForm={setForm}
onSave={handleSave}
saveLabel={saved ? '✓ Gespeichert!' : 'Speichern'}
saving={saving}
/>
</div>
)}
{tab === 'stats' && stats && (
<div className="card section-gap">
<div className="card-title">Trend-Analyse (14 Tage)</div>
<div style={{ marginBottom: 16 }}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '8px 0',
borderBottom: '1px solid var(--border)'
}}>
<div style={{ fontSize: 13, fontWeight: 600 }}>Ruhepuls</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{getTrendIcon(stats.trend_resting_hr)}
<span style={{ fontSize: 13, color: 'var(--text2)' }}>
{stats.trend_resting_hr === 'increasing' ? 'Steigend' :
stats.trend_resting_hr === 'decreasing' ? 'Sinkend' : 'Stabil'}
</span>
</div>
</div>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '8px 0'
}}>
<div style={{ fontSize: 13, fontWeight: 600 }}>HRV</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{getTrendIcon(stats.trend_hrv)}
<span style={{ fontSize: 13, color: 'var(--text2)' }}>
{stats.trend_hrv === 'increasing' ? 'Steigend' :
stats.trend_hrv === 'decreasing' ? 'Sinkend' : 'Stabil'}
</span>
</div>
</div>
</div>
<div style={{
padding: 12,
background: 'var(--surface2)',
borderRadius: 8,
fontSize: 12,
color: 'var(--text2)',
lineHeight: 1.6
}}>
<strong>Interpretation:</strong><br />
• Ruhepuls sinkend = bessere Fitness 💪<br />
• HRV steigend = bessere Erholung ✨<br />
• Ruhepuls steigend + HRV sinkend = Übertraining-Signal ⚠️
</div>
</div>
)}
{tab === 'list' && (
<div>
{entries.length === 0 && (
<div className="empty-state">
<h3>Keine Einträge</h3>
<p>Erfasse deine ersten Vitalwerte im Tab "Erfassen".</p>
</div>
)}
{entries.map(e => {
const isEd = editing?.id === e.id
return (
<div key={e.id} className="card" style={{ marginBottom: 8 }}>
{isEd ? (
<EntryForm
form={editing}
setForm={setEditing}
onSave={handleUpdate}
onCancel={() => setEditing(null)}
saveLabel="Speichern"
/>
) : (
<div>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start'
}}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 14, fontWeight: 600, marginBottom: 2 }}>
{dayjs(e.date).format('dd, DD. MMMM YYYY')}
</div>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', marginTop: 6 }}>
{e.resting_hr && (
<span style={{ fontSize: 12, color: 'var(--text2)' }}>
❤️ {e.resting_hr} bpm
</span>
)}
{e.hrv && (
<span style={{ fontSize: 12, color: 'var(--text2)' }}>
📊 HRV {e.hrv} ms
</span>
)}
{e.source !== 'manual' && (
<span style={{ fontSize: 10, color: 'var(--text3)' }}>
{e.source === 'apple_health' ? 'Apple Health' : e.source}
</span>
)}
</div>
{e.note && (
<p style={{
fontSize: 12,
color: 'var(--text2)',
fontStyle: 'italic',
marginTop: 4
}}>
"{e.note}"
</p>
)}
</div>
<div style={{ display: 'flex', gap: 6, marginLeft: 8 }}>
<button
className="btn btn-secondary"
style={{ padding: '5px 8px' }}
onClick={() => setEditing({ ...e })}
>
<Pencil size={13} />
</button>
<button
className="btn btn-danger"
style={{ padding: '5px 8px' }}
onClick={() => handleDelete(e.id)}
>
<Trash2 size={13} />
</button>
</div>
</div>
</div>
)}
</div>
)
})}
</div>
)}
</div>
)
}