417 lines
14 KiB
React
417 lines
14 KiB
React
|
|
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 = { ...form }
|
|||
|
|
if (payload.resting_hr) payload.resting_hr = parseInt(payload.resting_hr)
|
|||
|
|
if (payload.hrv) payload.hrv = parseInt(payload.hrv)
|
|||
|
|
|
|||
|
|
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 = { ...editing }
|
|||
|
|
if (payload.resting_hr) payload.resting_hr = parseInt(payload.resting_hr)
|
|||
|
|
if (payload.hrv) payload.hrv = parseInt(payload.hrv)
|
|||
|
|
|
|||
|
|
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>
|
|||
|
|
)
|
|||
|
|
}
|