feat: extend VitalsPage with all new vital parameters
Some checks failed
Build Test / lint-backend (push) Waiting to run
Build Test / build-frontend (push) Waiting to run
Deploy Development / deploy (push) Has been cancelled

Form sections:
- Morgenmessung: Ruhepuls, HRV
- Blutdruck (Omron): Systolisch, Diastolisch, Puls
- Fitness & Sauerstoff (Apple Watch): VO2 Max, SpO2, Atemfrequenz
- Warnungen: Unregelmäßiger Herzschlag, Mögliches AFib (checkboxes)

Display:
- All vitals shown in entry list with icons
- Blood pressure highlighted in red (🩸)
- VO2 Max in green (🏃)
- Warnings in orange (⚠️)

Stats overview:
- Dynamic grid showing available metrics
- Avg blood pressure 7d
- Latest VO2 Max
- Avg SpO2 7d

Save/Update:
- Only non-empty fields included in payload
- At least one vital must be provided

Ready for manual testing + import implementation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lars 2026-03-23 15:17:36 +01:00
parent 4f53cfffab
commit 9634ca8909

View File

@ -10,6 +10,14 @@ function empty() {
date: dayjs().format('YYYY-MM-DD'),
resting_hr: '',
hrv: '',
blood_pressure_systolic: '',
blood_pressure_diastolic: '',
pulse: '',
vo2_max: '',
spo2: '',
respiratory_rate: '',
irregular_heartbeat: false,
possible_afib: false,
note: ''
}
}
@ -31,8 +39,13 @@ function EntryForm({ form, setForm, onSave, onCancel, saving, saveLabel = 'Speic
<span className="form-unit" />
</div>
{/* Section: Morgenmessung */}
<div style={{ marginTop: 16, marginBottom: 8, fontWeight: 600, fontSize: 13, color: 'var(--text2)' }}>
Morgenmessung (vor dem Aufstehen)
</div>
<div className="form-row">
<label className="form-label">Ruhepuls *</label>
<label className="form-label">Ruhepuls</label>
<input
type="number"
className="form-input"
@ -61,6 +74,126 @@ function EntryForm({ form, setForm, onSave, onCancel, saving, saveLabel = 'Speic
<span className="form-unit">ms</span>
</div>
{/* Section: Blutdruck */}
<div style={{ marginTop: 16, marginBottom: 8, fontWeight: 600, fontSize: 13, color: 'var(--text2)' }}>
Blutdruck (Omron)
</div>
<div className="form-row">
<label className="form-label">Systolisch</label>
<input
type="number"
className="form-input"
min={50}
max={250}
step={1}
placeholder="z.B. 125"
value={form.blood_pressure_systolic || ''}
onChange={e => set('blood_pressure_systolic', e.target.value)}
/>
<span className="form-unit">mmHg</span>
</div>
<div className="form-row">
<label className="form-label">Diastolisch</label>
<input
type="number"
className="form-input"
min={30}
max={150}
step={1}
placeholder="z.B. 83"
value={form.blood_pressure_diastolic || ''}
onChange={e => set('blood_pressure_diastolic', e.target.value)}
/>
<span className="form-unit">mmHg</span>
</div>
<div className="form-row">
<label className="form-label">Puls</label>
<input
type="number"
className="form-input"
min={30}
max={200}
step={1}
placeholder="z.B. 61"
value={form.pulse || ''}
onChange={e => set('pulse', e.target.value)}
/>
<span className="form-unit">bpm</span>
</div>
{/* Section: Fitness & Sauerstoff */}
<div style={{ marginTop: 16, marginBottom: 8, fontWeight: 600, fontSize: 13, color: 'var(--text2)' }}>
Fitness & Sauerstoff (Apple Watch)
</div>
<div className="form-row">
<label className="form-label">VO2 Max</label>
<input
type="number"
className="form-input"
min={10}
max={90}
step={0.1}
placeholder="z.B. 42.5"
value={form.vo2_max || ''}
onChange={e => set('vo2_max', e.target.value)}
/>
<span className="form-unit">ml/kg/min</span>
</div>
<div className="form-row">
<label className="form-label">SpO2</label>
<input
type="number"
className="form-input"
min={70}
max={100}
step={1}
placeholder="z.B. 98"
value={form.spo2 || ''}
onChange={e => set('spo2', e.target.value)}
/>
<span className="form-unit">%</span>
</div>
<div className="form-row">
<label className="form-label">Atemfrequenz</label>
<input
type="number"
className="form-input"
min={1}
max={60}
step={0.1}
placeholder="z.B. 15.5"
value={form.respiratory_rate || ''}
onChange={e => set('respiratory_rate', e.target.value)}
/>
<span className="form-unit">/min</span>
</div>
{/* Section: Warnungen */}
<div style={{ marginTop: 16, marginBottom: 8 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, marginBottom: 4 }}>
<input
type="checkbox"
checked={form.irregular_heartbeat || false}
onChange={e => set('irregular_heartbeat', e.target.checked)}
/>
Unregelmäßiger Herzschlag festgestellt
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13 }}>
<input
type="checkbox"
checked={form.possible_afib || false}
onChange={e => set('possible_afib', e.target.checked)}
/>
Mögliches Vorhofflimmern (AFib)
</label>
</div>
<div className="form-row">
<label className="form-label">Notiz</label>
<input
@ -127,10 +260,22 @@ export default function VitalsPage() {
// 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.blood_pressure_systolic) payload.blood_pressure_systolic = parseInt(form.blood_pressure_systolic)
if (form.blood_pressure_diastolic) payload.blood_pressure_diastolic = parseInt(form.blood_pressure_diastolic)
if (form.pulse) payload.pulse = parseInt(form.pulse)
if (form.vo2_max) payload.vo2_max = parseFloat(form.vo2_max)
if (form.spo2) payload.spo2 = parseInt(form.spo2)
if (form.respiratory_rate) payload.respiratory_rate = parseFloat(form.respiratory_rate)
if (form.irregular_heartbeat) payload.irregular_heartbeat = form.irregular_heartbeat
if (form.possible_afib) payload.possible_afib = form.possible_afib
if (form.note) payload.note = form.note
if (!payload.resting_hr && !payload.hrv) {
setError('Mindestens Ruhepuls oder HRV muss angegeben werden')
// Check if at least one vital is provided
const hasData = payload.resting_hr || payload.hrv || payload.blood_pressure_systolic ||
payload.blood_pressure_diastolic || payload.vo2_max || payload.spo2 ||
payload.respiratory_rate
if (!hasData) {
setError('Mindestens ein Vitalwert muss angegeben werden')
setSaving(false)
return
}
@ -159,6 +304,14 @@ export default function VitalsPage() {
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.blood_pressure_systolic) payload.blood_pressure_systolic = parseInt(editing.blood_pressure_systolic)
if (editing.blood_pressure_diastolic) payload.blood_pressure_diastolic = parseInt(editing.blood_pressure_diastolic)
if (editing.pulse) payload.pulse = parseInt(editing.pulse)
if (editing.vo2_max) payload.vo2_max = parseFloat(editing.vo2_max)
if (editing.spo2) payload.spo2 = parseInt(editing.spo2)
if (editing.respiratory_rate) payload.respiratory_rate = parseFloat(editing.respiratory_rate)
if (editing.irregular_heartbeat !== undefined) payload.irregular_heartbeat = editing.irregular_heartbeat
if (editing.possible_afib !== undefined) payload.possible_afib = editing.possible_afib
if (editing.note) payload.note = editing.note
await api.updateVitals(editing.id, payload)
@ -206,36 +359,73 @@ export default function VitalsPage() {
{/* 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 style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(100px, 1fr))', gap: 8 }}>
{stats.avg_resting_hr_7d && (
<div style={{
background: 'var(--surface2)',
borderRadius: 8,
padding: '8px 10px',
textAlign: 'center'
}}>
<div style={{ fontSize: 18, fontWeight: 700, color: '#378ADD' }}>
{Math.round(stats.avg_resting_hr_7d)}
</div>
<div style={{ fontSize: 10, color: 'var(--text3)' }}>Ø Ruhepuls 7d</div>
</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) : '—'}
)}
{stats.avg_hrv_7d && (
<div style={{
background: 'var(--surface2)',
borderRadius: 8,
padding: '8px 10px',
textAlign: 'center'
}}>
<div style={{ fontSize: 18, fontWeight: 700, color: '#1D9E75' }}>
{Math.round(stats.avg_hrv_7d)}
</div>
<div style={{ fontSize: 10, color: 'var(--text3)' }}>Ø HRV 7d</div>
</div>
<div style={{ fontSize: 10, color: 'var(--text3)' }}>Ø HRV 7d</div>
</div>
)}
{stats.avg_bp_systolic_7d && (
<div style={{
background: 'var(--surface2)',
borderRadius: 8,
padding: '8px 10px',
textAlign: 'center'
}}>
<div style={{ fontSize: 16, fontWeight: 700, color: '#E74C3C' }}>
{Math.round(stats.avg_bp_systolic_7d)}/{Math.round(stats.avg_bp_diastolic_7d || 0)}
</div>
<div style={{ fontSize: 10, color: 'var(--text3)' }}>Ø Blutdruck 7d</div>
</div>
)}
{stats.latest_vo2_max && (
<div style={{
background: 'var(--surface2)',
borderRadius: 8,
padding: '8px 10px',
textAlign: 'center'
}}>
<div style={{ fontSize: 18, fontWeight: 700, color: '#1D9E75' }}>
{stats.latest_vo2_max.toFixed(1)}
</div>
<div style={{ fontSize: 10, color: 'var(--text3)' }}>VO2 Max</div>
</div>
)}
{stats.avg_spo2_7d && (
<div style={{
background: 'var(--surface2)',
borderRadius: 8,
padding: '8px 10px',
textAlign: 'center'
}}>
<div style={{ fontSize: 18, fontWeight: 700, color: '#7B68EE' }}>
{Math.round(stats.avg_spo2_7d)}%
</div>
<div style={{ fontSize: 10, color: 'var(--text3)' }}>Ø SpO2 7d</div>
</div>
)}
<div style={{
flex: 1,
minWidth: 120,
background: 'var(--surface2)',
borderRadius: 8,
padding: '8px 10px',
@ -377,9 +567,39 @@ export default function VitalsPage() {
📊 HRV {e.hrv} ms
</span>
)}
{e.blood_pressure_systolic && e.blood_pressure_diastolic && (
<span style={{ fontSize: 12, color: '#E74C3C', fontWeight: 600 }}>
🩸 {e.blood_pressure_systolic}/{e.blood_pressure_diastolic} mmHg
</span>
)}
{e.pulse && (
<span style={{ fontSize: 12, color: 'var(--text2)' }}>
💓 {e.pulse} bpm
</span>
)}
{e.vo2_max && (
<span style={{ fontSize: 12, color: '#1D9E75', fontWeight: 600 }}>
🏃 VO2 {e.vo2_max}
</span>
)}
{e.spo2 && (
<span style={{ fontSize: 12, color: 'var(--text2)' }}>
🫁 SpO2 {e.spo2}%
</span>
)}
{e.respiratory_rate && (
<span style={{ fontSize: 12, color: 'var(--text2)' }}>
💨 {e.respiratory_rate}/min
</span>
)}
{(e.irregular_heartbeat || e.possible_afib) && (
<span style={{ fontSize: 12, color: '#D85A30', fontWeight: 600 }}>
{e.irregular_heartbeat ? 'Unregelmäßig' : ''} {e.possible_afib ? 'AFib?' : ''}
</span>
)}
{e.source !== 'manual' && (
<span style={{ fontSize: 10, color: 'var(--text3)' }}>
{e.source === 'apple_health' ? 'Apple Health' : e.source}
{e.source === 'apple_health' ? 'Apple Health' : e.source === 'omron' ? 'Omron' : e.source}
</span>
)}
</div>