feat: extend VitalsPage with all new vital parameters
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:
parent
4f53cfffab
commit
9634ca8909
|
|
@ -10,6 +10,14 @@ function empty() {
|
||||||
date: dayjs().format('YYYY-MM-DD'),
|
date: dayjs().format('YYYY-MM-DD'),
|
||||||
resting_hr: '',
|
resting_hr: '',
|
||||||
hrv: '',
|
hrv: '',
|
||||||
|
blood_pressure_systolic: '',
|
||||||
|
blood_pressure_diastolic: '',
|
||||||
|
pulse: '',
|
||||||
|
vo2_max: '',
|
||||||
|
spo2: '',
|
||||||
|
respiratory_rate: '',
|
||||||
|
irregular_heartbeat: false,
|
||||||
|
possible_afib: false,
|
||||||
note: ''
|
note: ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -31,8 +39,13 @@ function EntryForm({ form, setForm, onSave, onCancel, saving, saveLabel = 'Speic
|
||||||
<span className="form-unit" />
|
<span className="form-unit" />
|
||||||
</div>
|
</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">
|
<div className="form-row">
|
||||||
<label className="form-label">Ruhepuls *</label>
|
<label className="form-label">Ruhepuls</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
className="form-input"
|
className="form-input"
|
||||||
|
|
@ -61,6 +74,126 @@ function EntryForm({ form, setForm, onSave, onCancel, saving, saveLabel = 'Speic
|
||||||
<span className="form-unit">ms</span>
|
<span className="form-unit">ms</span>
|
||||||
</div>
|
</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">
|
<div className="form-row">
|
||||||
<label className="form-label">Notiz</label>
|
<label className="form-label">Notiz</label>
|
||||||
<input
|
<input
|
||||||
|
|
@ -127,10 +260,22 @@ export default function VitalsPage() {
|
||||||
// Only include fields if they have values
|
// Only include fields if they have values
|
||||||
if (form.resting_hr) payload.resting_hr = parseInt(form.resting_hr)
|
if (form.resting_hr) payload.resting_hr = parseInt(form.resting_hr)
|
||||||
if (form.hrv) payload.hrv = parseInt(form.hrv)
|
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 (form.note) payload.note = form.note
|
||||||
|
|
||||||
if (!payload.resting_hr && !payload.hrv) {
|
// Check if at least one vital is provided
|
||||||
setError('Mindestens Ruhepuls oder HRV muss angegeben werden')
|
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)
|
setSaving(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -159,6 +304,14 @@ export default function VitalsPage() {
|
||||||
if (editing.date) payload.date = editing.date
|
if (editing.date) payload.date = editing.date
|
||||||
if (editing.resting_hr) payload.resting_hr = parseInt(editing.resting_hr)
|
if (editing.resting_hr) payload.resting_hr = parseInt(editing.resting_hr)
|
||||||
if (editing.hrv) payload.hrv = parseInt(editing.hrv)
|
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
|
if (editing.note) payload.note = editing.note
|
||||||
|
|
||||||
await api.updateVitals(editing.id, payload)
|
await api.updateVitals(editing.id, payload)
|
||||||
|
|
@ -206,36 +359,73 @@ export default function VitalsPage() {
|
||||||
{/* Stats Overview */}
|
{/* Stats Overview */}
|
||||||
{stats && stats.total_entries > 0 && (
|
{stats && stats.total_entries > 0 && (
|
||||||
<div className="card section-gap">
|
<div className="card section-gap">
|
||||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(100px, 1fr))', gap: 8 }}>
|
||||||
|
{stats.avg_resting_hr_7d && (
|
||||||
<div style={{
|
<div style={{
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
background: 'var(--surface2)',
|
background: 'var(--surface2)',
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
padding: '8px 10px',
|
padding: '8px 10px',
|
||||||
textAlign: 'center'
|
textAlign: 'center'
|
||||||
}}>
|
}}>
|
||||||
<div style={{ fontSize: 18, fontWeight: 700, color: '#378ADD' }}>
|
<div style={{ fontSize: 18, fontWeight: 700, color: '#378ADD' }}>
|
||||||
{stats.avg_resting_hr_7d ? Math.round(stats.avg_resting_hr_7d) : '—'}
|
{Math.round(stats.avg_resting_hr_7d)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 10, color: 'var(--text3)' }}>Ø Ruhepuls 7d</div>
|
<div style={{ fontSize: 10, color: 'var(--text3)' }}>Ø Ruhepuls 7d</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
{stats.avg_hrv_7d && (
|
||||||
<div style={{
|
<div style={{
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
background: 'var(--surface2)',
|
background: 'var(--surface2)',
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
padding: '8px 10px',
|
padding: '8px 10px',
|
||||||
textAlign: 'center'
|
textAlign: 'center'
|
||||||
}}>
|
}}>
|
||||||
<div style={{ fontSize: 18, fontWeight: 700, color: '#1D9E75' }}>
|
<div style={{ fontSize: 18, fontWeight: 700, color: '#1D9E75' }}>
|
||||||
{stats.avg_hrv_7d ? Math.round(stats.avg_hrv_7d) : '—'}
|
{Math.round(stats.avg_hrv_7d)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 10, color: 'var(--text3)' }}>Ø HRV 7d</div>
|
<div style={{ fontSize: 10, color: 'var(--text3)' }}>Ø HRV 7d</div>
|
||||||
</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={{
|
<div style={{
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
background: 'var(--surface2)',
|
background: 'var(--surface2)',
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
padding: '8px 10px',
|
padding: '8px 10px',
|
||||||
|
|
@ -377,9 +567,39 @@ export default function VitalsPage() {
|
||||||
📊 HRV {e.hrv} ms
|
📊 HRV {e.hrv} ms
|
||||||
</span>
|
</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' && (
|
{e.source !== 'manual' && (
|
||||||
<span style={{ fontSize: 10, color: 'var(--text3)' }}>
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user