import { useState, useEffect, useRef } from 'react' import { Pencil, Trash2, X, Save, TrendingUp, TrendingDown, Minus, Upload } from 'lucide-react' import { api } from '../utils/api' import dayjs from 'dayjs' import 'dayjs/locale/de' dayjs.locale('de') /** * VitalsPage - Refactored v9d Phase 2d (Mobile-optimized, Inline Editing, Smart Upsert) * * 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({ id: null, // Für Update date: dayjs().format('YYYY-MM-DD'), resting_hr: '', hrv: '', vo2_max: '', spo2: '', respiratory_rate: '', note: '' }) const [editingId, setEditingId] = useState(null) const [editForm, setEditForm] = useState({}) 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() }, []) // Smart Upsert: Beim Datum-Wechsel existierenden Eintrag laden useEffect(() => { const loadExisting = async () => { if (!form.date) return try { const existing = await api.getBaselineByDate(form.date) if (existing && existing.id) { // Eintrag gefunden → Formular vorausfüllen setForm({ id: existing.id, date: existing.date, resting_hr: existing.resting_hr || '', hrv: existing.hrv || '', vo2_max: existing.vo2_max || '', spo2: existing.spo2 || '', respiratory_rate: existing.respiratory_rate || '', note: existing.note || '' }) } else { // Kein Eintrag → leeres Formular (nur Datum behalten) setForm(f => ({ id: null, date: f.date, resting_hr: '', hrv: '', vo2_max: '', spo2: '', respiratory_rate: '', note: '' })) } } catch (err) { // 404 ist ok (kein Eintrag vorhanden) if (!err.message.includes('404')) { console.error('Fehler beim Laden:', err) } } } loadExisting() }, [form.date]) 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 } if (form.id) { await api.updateBaseline(form.id, payload) } else { await api.createBaseline(payload) } setSuccess(true) await load() setTimeout(() => { setSuccess(false) setForm({ id: null, 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 startEdit = (entry) => { setEditingId(entry.id) setEditForm({ resting_hr: entry.resting_hr || '', hrv: entry.hrv || '', vo2_max: entry.vo2_max || '', spo2: entry.spo2 || '', respiratory_rate: entry.respiratory_rate || '', note: entry.note || '' }) } const cancelEdit = () => { setEditingId(null) setEditForm({}) } const saveEdit = async (id) => { try { const entry = entries.find(e => e.id === id) const payload = { date: entry.date } if (editForm.resting_hr) payload.resting_hr = parseInt(editForm.resting_hr) if (editForm.hrv) payload.hrv = parseInt(editForm.hrv) if (editForm.vo2_max) payload.vo2_max = parseFloat(editForm.vo2_max) if (editForm.spo2) payload.spo2 = parseInt(editForm.spo2) if (editForm.respiratory_rate) payload.respiratory_rate = parseFloat(editForm.respiratory_rate) if (editForm.note) payload.note = editForm.note await api.updateBaseline(id, payload) setEditingId(null) setEditForm({}) await load() } catch (err) { setError(err.message) } } 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). {form.id && Eintrag wird aktualisiert.}

{error &&
{error}
} {/* Datum - volle Breite */}
setForm(f => ({ ...f, date: e.target.value }))} />
{/* Sektion: Herzfunktion */}
❤️ Herzfunktion
setForm(f => ({ ...f, resting_hr: e.target.value }))} />
setForm(f => ({ ...f, hrv: e.target.value }))} />
{/* Sektion: Fitness & Atmung */}
🏃 Fitness & Atmung
setForm(f => ({ ...f, vo2_max: e.target.value }))} />
setForm(f => ({ ...f, spo2: e.target.value }))} />
setForm(f => ({ ...f, respiratory_rate: e.target.value }))} />
{/* Notiz */}
setForm(f => ({ ...f, note: e.target.value }))} />
{/* List */} {entries.length > 0 && (
Letzte Messungen ({entries.length})
{entries.map(e => (
{editingId === e.id ? ( // Edit Mode
{dayjs(e.date).format('dd, DD. MMM YYYY')}
setEditForm(f => ({ ...f, resting_hr: e.target.value }))} />
setEditForm(f => ({ ...f, hrv: e.target.value }))} />
setEditForm(f => ({ ...f, vo2_max: e.target.value }))} />
setEditForm(f => ({ ...f, spo2: e.target.value }))} />
setEditForm(f => ({ ...f, respiratory_rate: e.target.value }))} />
setEditForm(f => ({ ...f, note: e.target.value }))} />
) : ( // View Mode
{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 [editingId, setEditingId] = useState(null) const [editForm, setEditForm] = useState({}) 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 startEdit = (entry) => { const dt = dayjs(entry.measured_at) setEditingId(entry.id) setEditForm({ date: dt.format('YYYY-MM-DD'), time: dt.format('HH:mm'), systolic: entry.systolic, diastolic: entry.diastolic, pulse: entry.pulse || '', context: entry.context, irregular_heartbeat: entry.irregular_heartbeat, possible_afib: entry.possible_afib, note: entry.note || '' }) } const cancelEdit = () => { setEditingId(null) setEditForm({}) } const saveEdit = async (id) => { try { const measured_at = `${editForm.date} ${editForm.time}:00` const payload = { measured_at, systolic: parseInt(editForm.systolic), diastolic: parseInt(editForm.diastolic), pulse: editForm.pulse ? parseInt(editForm.pulse) : null, context: editForm.context, irregular_heartbeat: editForm.irregular_heartbeat, possible_afib: editForm.possible_afib, note: editForm.note || null } await api.updateBloodPressure(id, payload) setEditingId(null) setEditForm({}) 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}
} {/* Datum + Uhrzeit - volle Breite */}
setForm(f => ({ ...f, date: e.target.value }))} />
setForm(f => ({ ...f, time: e.target.value }))} />
{/* Sektion: Blutdruck */}
🩸 Blutdruck
setForm(f => ({ ...f, systolic: e.target.value }))} />
setForm(f => ({ ...f, diastolic: e.target.value }))} />
setForm(f => ({ ...f, pulse: e.target.value }))} />
{/* Kontext */}
{/* Checkboxen */}
{/* Notiz */}
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 (
{editingId === e.id ? ( // Edit Mode
setEditForm(f => ({ ...f, date: ev.target.value }))} />
setEditForm(f => ({ ...f, time: ev.target.value }))} />
setEditForm(f => ({ ...f, systolic: ev.target.value }))} />
setEditForm(f => ({ ...f, diastolic: ev.target.value }))} />
setEditForm(f => ({ ...f, pulse: ev.target.value }))} />
setEditForm(f => ({ ...f, note: ev.target.value }))} />
) : ( // View Mode
{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 && (
0 ? '#FCEBEB' : '#E8F7F0', border: `1px solid ${result.errors > 0 ? '#D85A30' : '#1D9E75'}`, borderRadius: 8, fontSize: 13, color: result.errors > 0 ? '#D85A30' : '#085041', marginBottom: 12 }}> Import {result.errors > 0 ? 'mit Fehlern' : 'erfolgreich'} ({result.type === 'omron' ? 'Omron' : 'Apple Health'}):
{result.inserted} neu · {result.updated} aktualisiert · {result.skipped} übersprungen {result.errors > 0 && <> · {result.errors} Fehler} {result.error_details && result.error_details.length > 0 && (
Fehler-Details: {result.error_details.map((err, i) => (
• {err}
))}
)}
)} {/* 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 (

Vitalwerte

{tab === 'baseline' && } {tab === 'bp' && } {tab === 'import' && }
) }