From 7f10286e0226fbf3acbf87ce333ba57024122e16 Mon Sep 17 00:00:00 2001 From: Lars Date: Mon, 23 Mar 2026 16:10:42 +0100 Subject: [PATCH] feat: complete VitalsPage UI with 3-tab architecture (v9d Phase 2d) - Tab 1: BaselineTab (once daily morning vitals: RHR, HRV, VO2 Max, SpO2, respiratory rate) - Tab 2: BloodPressureTab (multiple daily with context tagging, WHO/ISH classification) - Tab 3: ImportTab (drag & drop for Omron + Apple Health CSV) - Stats display with 7d averages and trends - Context-aware BP measurements (8 context options) - Color-coded BP category classification - Entry lists with delete functionality Co-Authored-By: Claude Opus 4.6 --- frontend/src/pages/VitalsPage.jsx | 673 ++++++++++++++++++++++++++++-- 1 file changed, 628 insertions(+), 45 deletions(-) diff --git a/frontend/src/pages/VitalsPage.jsx b/frontend/src/pages/VitalsPage.jsx index fcfa6f6..d4887d1 100644 --- a/frontend/src/pages/VitalsPage.jsx +++ b/frontend/src/pages/VitalsPage.jsx @@ -1,4 +1,6 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useRef } from 'react' +import { Pencil, Trash2, X, TrendingUp, TrendingDown, Minus, Upload } from 'lucide-react' +import { api } from '../utils/api' import dayjs from 'dayjs' import 'dayjs/locale/de' dayjs.locale('de') @@ -6,13 +8,629 @@ dayjs.locale('de') /** * VitalsPage - Refactored v9d Phase 2d * - * Separated into: - * - Tab 1: Morgenmessung (Baseline) - once daily - * - Tab 2: Blutdruck (BP) - multiple daily with context - * - Tab 3: Import + * Separated vitals tracking: + * - Baseline Vitals: Once daily (morning, fasted) - RHR, HRV, VO2 Max, SpO2 + * - Blood Pressure: Multiple daily with context - Systolic/Diastolic + context tagging + * - Imports: Omron CSV (BP) + Apple Health CSV (Baseline) */ + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// BASELINE VITALS TAB (Once daily, morning) +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +function BaselineTab() { + const [entries, setEntries] = useState([]) + const [stats, setStats] = useState(null) + const [form, setForm] = useState({ + date: dayjs().format('YYYY-MM-DD'), + resting_hr: '', + hrv: '', + vo2_max: '', + spo2: '', + respiratory_rate: '', + note: '' + }) + const [editing, setEditing] = useState(null) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + const [success, setSuccess] = useState(false) + + const load = async () => { + try { + const [e, s] = await Promise.all([ + api.listBaseline(90), + api.getBaselineStats(30) + ]) + setEntries(e) + setStats(s) + } catch (err) { + setError(err.message) + } + } + + useEffect(() => { load() }, []) + + const handleSave = async () => { + setSaving(true) + setError(null) + try { + const payload = { date: form.date } + if (form.resting_hr) payload.resting_hr = parseInt(form.resting_hr) + if (form.hrv) payload.hrv = parseInt(form.hrv) + 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.note) payload.note = form.note + + if (!payload.resting_hr && !payload.hrv && !payload.vo2_max && !payload.spo2 && !payload.respiratory_rate) { + setError('Mindestens ein Wert muss angegeben werden') + setSaving(false) + return + } + + await api.createBaseline(payload) + setSuccess(true) + await load() + setTimeout(() => { + setSuccess(false) + setForm({ date: dayjs().format('YYYY-MM-DD'), resting_hr: '', hrv: '', vo2_max: '', spo2: '', respiratory_rate: '', note: '' }) + }, 1500) + } catch (err) { + setError(err.message) + } finally { + setSaving(false) + } + } + + const handleDelete = async (id) => { + if (!confirm('Eintrag löschen?')) return + try { + await api.deleteBaseline(id) + await load() + } catch (err) { + setError(err.message) + } + } + + const getTrendIcon = (trend) => { + if (trend === 'increasing') return + if (trend === 'decreasing') return + return + } + + return ( +
+ {/* Stats */} + {stats && stats.total_entries > 0 && ( +
+
+ {stats.avg_rhr_7d && ( +
+
{Math.round(stats.avg_rhr_7d)}
+
Ø RHR 7d
+
+ )} + {stats.avg_hrv_7d && ( +
+
{Math.round(stats.avg_hrv_7d)}
+
Ø HRV 7d
+
+ )} + {stats.latest_vo2_max && ( +
+
{stats.latest_vo2_max.toFixed(1)}
+
VO2 Max
+
+ )} +
+
+ )} + + {/* Form */} +
+
Morgenmessung erfassen
+

+ Einmal täglich, morgens vor dem Aufstehen (nüchtern) +

+ + {error &&
{error}
} + +
+ + setForm(f => ({ ...f, date: e.target.value }))} /> + +
+ +
+ + setForm(f => ({ ...f, resting_hr: e.target.value }))} /> + bpm +
+ +
+ + setForm(f => ({ ...f, hrv: e.target.value }))} /> + ms +
+ +
+ + setForm(f => ({ ...f, vo2_max: e.target.value }))} /> + ml/kg/min +
+ +
+ + setForm(f => ({ ...f, spo2: e.target.value }))} /> + % +
+ +
+ + setForm(f => ({ ...f, respiratory_rate: e.target.value }))} /> + /min +
+ +
+ + setForm(f => ({ ...f, note: e.target.value }))} /> + +
+ + +
+ + {/* List */} + {entries.length > 0 && ( +
+
Letzte Messungen ({entries.length})
+ {entries.map(e => ( +
+
+
+
+ {dayjs(e.date).format('dd, DD. MMM YYYY')} +
+
+ {e.resting_hr && ❤️ {e.resting_hr} bpm} + {e.hrv && 📊 HRV {e.hrv} ms} + {e.vo2_max && 🏃 VO2 {e.vo2_max}} + {e.spo2 && 🫁 SpO2 {e.spo2}%} + {e.respiratory_rate && 💨 {e.respiratory_rate}/min} +
+ {e.note &&

"{e.note}"

} +
+ +
+
+ ))} +
+ )} +
+ ) +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// BLOOD PRESSURE TAB (Multiple daily with context) +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +const CONTEXT_OPTIONS = [ + { value: 'morning_fasted', label: 'Nüchtern (morgens)' }, + { value: 'after_meal', label: 'Nach dem Essen' }, + { value: 'before_training', label: 'Vor dem Training' }, + { value: 'after_training', label: 'Nach dem Training' }, + { value: 'evening', label: 'Abends' }, + { value: 'stress', label: 'Bei Stress' }, + { value: 'resting', label: 'Ruhemessung' }, + { value: 'other', label: 'Sonstiges' }, +] + +function BloodPressureTab() { + const [entries, setEntries] = useState([]) + const [stats, setStats] = useState(null) + const [form, setForm] = useState({ + date: dayjs().format('YYYY-MM-DD'), + time: dayjs().format('HH:mm'), + systolic: '', + diastolic: '', + pulse: '', + context: 'morning_fasted', + irregular_heartbeat: false, + possible_afib: false, + note: '' + }) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + const [success, setSuccess] = useState(false) + + const load = async () => { + try { + const [e, s] = await Promise.all([ + api.listBloodPressure(90), + api.getBPStats(30) + ]) + setEntries(e) + setStats(s) + } catch (err) { + setError(err.message) + } + } + + useEffect(() => { load() }, []) + + const handleSave = async () => { + if (!form.systolic || !form.diastolic) { + setError('Systolisch und Diastolisch sind Pflichtfelder') + return + } + + setSaving(true) + setError(null) + try { + const measured_at = `${form.date} ${form.time}:00` + const payload = { + measured_at, + systolic: parseInt(form.systolic), + diastolic: parseInt(form.diastolic), + pulse: form.pulse ? parseInt(form.pulse) : null, + context: form.context, + irregular_heartbeat: form.irregular_heartbeat, + possible_afib: form.possible_afib, + note: form.note || null + } + + await api.createBloodPressure(payload) + setSuccess(true) + await load() + setTimeout(() => { + setSuccess(false) + setForm({ + date: dayjs().format('YYYY-MM-DD'), + time: dayjs().format('HH:mm'), + systolic: '', + diastolic: '', + pulse: '', + context: 'morning_fasted', + irregular_heartbeat: false, + possible_afib: false, + note: '' + }) + }, 1500) + } catch (err) { + setError(err.message) + } finally { + setSaving(false) + } + } + + const handleDelete = async (id) => { + if (!confirm('Messung löschen?')) return + try { + await api.deleteBloodPressure(id) + await load() + } catch (err) { + setError(err.message) + } + } + + const getBPCategory = (sys, dia) => { + if (sys < 120 && dia < 80) return { label: 'Optimal', color: '#1D9E75' } + if (sys < 130 && dia < 85) return { label: 'Normal', color: '#1D9E75' } + if (sys < 140 && dia < 90) return { label: 'Hochnormal', color: '#EF9F27' } + if (sys < 160 && dia < 100) return { label: 'Hypertonie Grad 1', color: '#D85A30' } + if (sys < 180 && dia < 110) return { label: 'Hypertonie Grad 2', color: '#D85A30' } + return { label: 'Hypertonie Grad 3', color: '#C41E3A' } + } + + return ( +
+ {/* Stats */} + {stats && stats.total_measurements > 0 && ( +
+
+ {stats.avg_systolic && ( +
+
+ {Math.round(stats.avg_systolic)}/{Math.round(stats.avg_diastolic)} +
+
Ø Blutdruck 30d
+
+ )} + {stats.bp_category && ( +
+
+ {getBPCategory(stats.avg_systolic, stats.avg_diastolic).label} +
+
Kategorie
+
+ )} +
+
{stats.total_measurements}
+
Messungen
+
+
+
+ )} + + {/* Form */} +
+
Blutdruck messen
+

+ Mehrfach täglich mit Kontext-Tagging +

+ + {error &&
{error}
} + +
+
+ + setForm(f => ({ ...f, date: e.target.value }))} /> +
+
+ + setForm(f => ({ ...f, time: e.target.value }))} /> +
+
+ +
+ + setForm(f => ({ ...f, systolic: e.target.value }))} /> + mmHg +
+ +
+ + setForm(f => ({ ...f, diastolic: e.target.value }))} /> + mmHg +
+ +
+ + setForm(f => ({ ...f, pulse: e.target.value }))} /> + bpm +
+ +
+ + + +
+ +
+ + +
+ +
+ + setForm(f => ({ ...f, note: e.target.value }))} /> + +
+ + +
+ + {/* List */} + {entries.length > 0 && ( +
+
Letzte Messungen ({entries.length})
+ {entries.map(e => { + const cat = getBPCategory(e.systolic, e.diastolic) + const ctx = CONTEXT_OPTIONS.find(o => o.value === e.context) + return ( +
+
+
+
+ {dayjs(e.measured_at).format('dd, DD. MMM • HH:mm')} Uhr +
+
+ {e.systolic}/{e.diastolic} mmHg + {e.pulse && 💓 {e.pulse} bpm} +
+
+ {ctx?.label} · {cat.label} + {(e.irregular_heartbeat || e.possible_afib) && ⚠️ {e.irregular_heartbeat ? 'Unregelmäßig' : ''} {e.possible_afib ? 'AFib?' : ''}} +
+ {e.note &&

"{e.note}"

} +
+ +
+
+ ) + })} +
+ )} +
+ ) +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// IMPORT TAB (CSV imports) +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +function ImportTab({ onImportComplete }) { + const [importingOmron, setImportingOmron] = useState(false) + const [importingApple, setImportingApple] = useState(false) + const [draggingOmron, setDraggingOmron] = useState(false) + const [draggingApple, setDraggingApple] = useState(false) + const [result, setResult] = useState(null) + const [error, setError] = useState(null) + const omronRef = useRef(null) + const appleRef = useRef(null) + + const handleOmronImport = async (file) => { + if (!file || !file.name.endsWith('.csv')) { + setError('Bitte eine CSV-Datei auswählen') + return + } + + setImportingOmron(true) + setResult(null) + setError(null) + + try { + const res = await api.importBPOmron(file) + setResult({ type: 'omron', ...res }) + if (onImportComplete) onImportComplete() + } catch (err) { + setError('Omron Import fehlgeschlagen: ' + err.message) + } finally { + setImportingOmron(false) + if (omronRef.current) omronRef.current.value = '' + } + } + + const handleAppleImport = async (file) => { + if (!file || !file.name.endsWith('.csv')) { + setError('Bitte eine CSV-Datei auswählen') + return + } + + setImportingApple(true) + setResult(null) + setError(null) + + try { + const res = await api.importBaselineAppleHealth(file) + setResult({ type: 'apple_health', ...res }) + if (onImportComplete) onImportComplete() + } catch (err) { + setError('Apple Health Import fehlgeschlagen: ' + err.message) + } finally { + setImportingApple(false) + if (appleRef.current) appleRef.current.value = '' + } + } + + return ( +
+ {error &&
{error}
} + + {result && ( +
+ Import erfolgreich ({result.type === 'omron' ? 'Omron' : 'Apple Health'}):
+ {result.inserted} neu · {result.updated} aktualisiert · {result.skipped} übersprungen + {result.errors > 0 && <> · {result.errors} Fehler} +
+ )} + + {/* Omron */} +
+
Omron Blutdruckmessgerät
+

+ Exportiere CSV aus der Omron Connect App (Blutdruck + Puls + Warnungen) +

+
{ e.preventDefault(); setDraggingOmron(true) }} + onDragLeave={() => setDraggingOmron(false)} + onDrop={e => { + e.preventDefault() + setDraggingOmron(false) + const file = e.dataTransfer.files[0] + if (file) handleOmronImport(file) + }} + onClick={() => omronRef.current?.click()} + style={{ + border: `2px dashed ${draggingOmron ? 'var(--accent)' : 'var(--border)'}`, + borderRadius: 10, + padding: '20px 16px', + textAlign: 'center', + background: draggingOmron ? 'var(--accent)14' : 'var(--surface2)', + cursor: importingOmron ? 'not-allowed' : 'pointer', + transition: 'all 0.15s', + opacity: importingOmron ? 0.6 : 1 + }}> + {importingOmron ? ( + <> +
+
Importiere Omron-Daten...
+ + ) : ( + <> + +
+ {draggingOmron ? 'CSV loslassen...' : 'Omron CSV hierher ziehen oder tippen'} +
+ + )} +
+ { const file = e.target.files[0]; if (file) handleOmronImport(file) }} style={{ display: 'none' }} /> +
+ + {/* Apple Health */} +
+
Apple Health
+

+ Exportiere Health-Daten (Ruhepuls, HRV, VO2 Max, SpO2, Atemfrequenz) +

+
{ e.preventDefault(); setDraggingApple(true) }} + onDragLeave={() => setDraggingApple(false)} + onDrop={e => { + e.preventDefault() + setDraggingApple(false) + const file = e.dataTransfer.files[0] + if (file) handleAppleImport(file) + }} + onClick={() => appleRef.current?.click()} + style={{ + border: `2px dashed ${draggingApple ? 'var(--accent)' : 'var(--border)'}`, + borderRadius: 10, + padding: '20px 16px', + textAlign: 'center', + background: draggingApple ? 'var(--accent)14' : 'var(--surface2)', + cursor: importingApple ? 'not-allowed' : 'pointer', + transition: 'all 0.15s', + opacity: importingApple ? 0.6 : 1 + }}> + {importingApple ? ( + <> +
+
Importiere Apple Health-Daten...
+ + ) : ( + <> + +
+ {draggingApple ? 'CSV loslassen...' : 'Apple Health CSV hierher ziehen oder tippen'} +
+ + )} +
+ { const file = e.target.files[0]; if (file) handleAppleImport(file) }} style={{ display: 'none' }} /> +
+
+ ) +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// MAIN COMPONENT +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + export default function VitalsPage() { const [tab, setTab] = useState('baseline') + const [refreshKey, setRefreshKey] = useState(0) + + const handleImportComplete = () => { + setRefreshKey(k => k + 1) + } return (
@@ -30,46 +648,11 @@ export default function VitalsPage() {
- {tab === 'baseline' && ( -
-
Morgenmessung (Baseline-Vitals)
-

- Einmal täglich, morgens vor dem Aufstehen: - Ruhepuls, HRV, VO2 Max, SpO2, Atemfrequenz -

-
- 🚧 Frontend-Refactoring läuft noch...
- Nutze vorerst die alten Endpoints oder warte auf Update. -
-
- )} - - {tab === 'bp' && ( -
-
Blutdruck (mehrfach täglich)
-

- Mehrfach täglich mit Kontext-Tagging: - Nüchtern, Nach dem Essen, Vor/Nach Training, Abends, Stress -

-
- 🚧 Frontend-Refactoring läuft noch...
- Nutze vorerst die alten Endpoints oder warte auf Update. -
-
- )} - - {tab === 'import' && ( -
-
CSV Import
-

- Omron (Blutdruck) + Apple Health (Baseline-Vitals) -

-
- 🚧 Frontend-Refactoring läuft noch...
- Import-Funktionen folgen im nächsten Update. -
-
- )} +
+ {tab === 'baseline' && } + {tab === 'bp' && } + {tab === 'import' && } +
) }