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, 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' && }
+
)
}