diff --git a/frontend/src/pages/VitalsPage.jsx b/frontend/src/pages/VitalsPage.jsx index ef21f4f..fcfa6f6 100644 --- a/frontend/src/pages/VitalsPage.jsx +++ b/frontend/src/pages/VitalsPage.jsx @@ -1,868 +1,73 @@ -import { useState, useEffect, useRef } from 'react' -import { Pencil, Trash2, X, TrendingUp, TrendingDown, Minus, Upload } from 'lucide-react' -import { api } from '../utils/api' +import { useState, useEffect } from 'react' import dayjs from 'dayjs' import 'dayjs/locale/de' dayjs.locale('de') -function empty() { - return { - 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: '' - } -} - -function EntryForm({ form, setForm, onSave, onCancel, saving, saveLabel = 'Speichern' }) { - const set = (k, v) => setForm(f => ({ ...f, [k]: v })) - - return ( -
-
- - set('date', e.target.value)} - /> - -
- - {/* Section: Morgenmessung */} -
- Morgenmessung (vor dem Aufstehen) -
- -
- - set('resting_hr', e.target.value)} - /> - bpm -
- -
- - set('hrv', e.target.value)} - /> - ms -
- - {/* Section: Blutdruck */} -
- Blutdruck (Omron) -
- -
- - set('blood_pressure_systolic', e.target.value)} - /> - mmHg -
- -
- - set('blood_pressure_diastolic', e.target.value)} - /> - mmHg -
- -
- - set('pulse', e.target.value)} - /> - bpm -
- - {/* Section: Fitness & Sauerstoff */} -
- Fitness & Sauerstoff (Apple Watch) -
- -
- - set('vo2_max', e.target.value)} - /> - ml/kg/min -
- -
- - set('spo2', e.target.value)} - /> - % -
- -
- - set('respiratory_rate', e.target.value)} - /> - /min -
- - {/* Section: Warnungen */} -
- - -
- -
- - set('note', e.target.value)} - /> - -
- -
- - {onCancel && ( - - )} -
-
- ) -} - +/** + * VitalsPage - Refactored v9d Phase 2d + * + * Separated into: + * - Tab 1: Morgenmessung (Baseline) - once daily + * - Tab 2: Blutdruck (BP) - multiple daily with context + * - Tab 3: Import + */ export default function VitalsPage() { - const [entries, setEntries] = useState([]) - const [stats, setStats] = useState(null) - const [tab, setTab] = useState('list') - const [form, setForm] = useState(empty()) - const [editing, setEditing] = useState(null) - const [saving, setSaving] = useState(false) - const [saved, setSaved] = useState(false) - const [error, setError] = useState(null) - - // Import states - const [importingOmron, setImportingOmron] = useState(false) - const [importingApple, setImportingApple] = useState(false) - const [draggingOmron, setDraggingOmron] = useState(false) - const [draggingApple, setDraggingApple] = useState(false) - const [importResult, setImportResult] = useState(null) - const omronFileInputRef = useRef(null) - const appleFileInputRef = useRef(null) - - const load = async () => { - try { - const [e, s] = await Promise.all([api.listVitals(90), api.getVitalsStats(30)]) - setEntries(e) - setStats(s) - } catch (err) { - console.error('Load failed:', err) - setError(err.message) - } - } - - useEffect(() => { - load() - }, []) - - const handleSave = async () => { - setSaving(true) - setError(null) - - try { - const payload = { date: form.date } - - // 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 - - // 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 - } - - await api.createVitals(payload) - setSaved(true) - await load() - setTimeout(() => { - setSaved(false) - setForm(empty()) - }, 1500) - } catch (err) { - console.error('Save failed:', err) - setError(err.message || 'Fehler beim Speichern') - setTimeout(() => setError(null), 5000) - } finally { - setSaving(false) - } - } - - const handleUpdate = async () => { - try { - const payload = {} - - // Only include fields if they have values - 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) - setEditing(null) - await load() - } catch (err) { - console.error('Update failed:', err) - setError(err.message) - } - } - - const handleDelete = async (id) => { - if (!confirm('Eintrag löschen?')) return - try { - await api.deleteVitals(id) - await load() - } catch (err) { - console.error('Delete failed:', err) - setError(err.message) - } - } - - const getTrendIcon = (trend) => { - if (trend === 'increasing') return - if (trend === 'decreasing') return - return - } - - // Import handlers - const handleOmronImport = async (file) => { - if (!file || !file.name.endsWith('.csv')) { - setError('Bitte eine CSV-Datei auswählen') - setTimeout(() => setError(null), 3000) - return - } - - setImportingOmron(true) - setImportResult(null) - setError(null) - - try { - const result = await api.importVitalsOmron(file) - setImportResult({ - type: 'omron', - inserted: result.inserted || 0, - updated: result.updated || 0, - skipped: result.skipped || 0, - errors: result.errors || 0 - }) - await load() - } catch (err) { - setError('Omron Import fehlgeschlagen: ' + err.message) - setTimeout(() => setError(null), 5000) - } finally { - setImportingOmron(false) - if (omronFileInputRef.current) { - omronFileInputRef.current.value = '' - } - } - } - - const handleAppleImport = async (file) => { - if (!file || !file.name.endsWith('.csv')) { - setError('Bitte eine CSV-Datei auswählen') - setTimeout(() => setError(null), 3000) - return - } - - setImportingApple(true) - setImportResult(null) - setError(null) - - try { - const result = await api.importVitalsAppleHealth(file) - setImportResult({ - type: 'apple_health', - inserted: result.inserted || 0, - updated: result.updated || 0, - skipped: result.skipped || 0, - errors: result.errors || 0 - }) - await load() - } catch (err) { - setError('Apple Health Import fehlgeschlagen: ' + err.message) - setTimeout(() => setError(null), 5000) - } finally { - setImportingApple(false) - if (appleFileInputRef.current) { - appleFileInputRef.current.value = '' - } - } - } + const [tab, setTab] = useState('baseline') return (

Vitalwerte

- - -
- {/* Stats Overview */} - {stats && stats.total_entries > 0 && ( + {tab === 'baseline' && (
-
- {stats.avg_resting_hr_7d && ( -
-
- {Math.round(stats.avg_resting_hr_7d)} -
-
Ø Ruhepuls 7d
-
- )} - {stats.avg_hrv_7d && ( -
-
- {Math.round(stats.avg_hrv_7d)} -
-
Ø HRV 7d
-
- )} - {stats.avg_bp_systolic_7d && ( -
-
- {Math.round(stats.avg_bp_systolic_7d)}/{Math.round(stats.avg_bp_diastolic_7d || 0)} -
-
Ø Blutdruck 7d
-
- )} - {stats.latest_vo2_max && ( -
-
- {stats.latest_vo2_max.toFixed(1)} -
-
VO2 Max
-
- )} - {stats.avg_spo2_7d && ( -
-
- {Math.round(stats.avg_spo2_7d)}% -
-
Ø SpO2 7d
-
- )} -
-
- {stats.total_entries} -
-
Einträge
-
+
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 === 'add' && ( + {tab === 'bp' && (
-
Vitalwerte erfassen
-

- Morgens nach dem Aufwachen, vor dem Aufstehen: Ruhepuls messen (z.B. mit Apple Watch oder Smartwatch). - HRV ist optional. +

Blutdruck (mehrfach täglich)
+

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

- {error && ( -
- {error} -
- )} - +
+ 🚧 Frontend-Refactoring läuft noch...
+ Nutze vorerst die alten Endpoints oder warte auf Update. +
)} {tab === 'import' && ( -
- {error && ( -
- {error} -
- )} - - {importResult && ( -
- Import erfolgreich ({importResult.type === 'omron' ? 'Omron' : 'Apple Health'}):
- {importResult.inserted} neu · {importResult.updated} aktualisiert · {importResult.skipped} übersprungen - {importResult.errors > 0 && <> · {importResult.errors} Fehler} -
- )} - - {/* Omron Import */} -
-
Omron Blutdruckmessgerät
-

- Exportiere CSV aus der Omron Connect App:
- • Blutdruck (Systolisch/Diastolisch)
- • Puls
- • Unregelmäßiger Herzschlag & AFib-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={() => omronFileInputRef.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 Import */} -
-
Apple Health
-

- Exportiere Health-Daten von der Health-App:
- • Ruhepuls (Resting Heart Rate)
- • HRV (Heart Rate Variability)
- • VO2 Max
- • SpO2 (Blutsauerstoffsättigung)
- • 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={() => appleFileInputRef.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' }} - /> -
-
- )} - - {tab === 'stats' && stats && (
-
Trend-Analyse (14 Tage)
- -
-
-
Ruhepuls
-
- {getTrendIcon(stats.trend_resting_hr)} - - {stats.trend_resting_hr === 'increasing' ? 'Steigend' : - stats.trend_resting_hr === 'decreasing' ? 'Sinkend' : 'Stabil'} - -
-
- -
-
HRV
-
- {getTrendIcon(stats.trend_hrv)} - - {stats.trend_hrv === 'increasing' ? 'Steigend' : - stats.trend_hrv === 'decreasing' ? 'Sinkend' : 'Stabil'} - -
-
+
CSV Import
+

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

+
+ 🚧 Frontend-Refactoring läuft noch...
+ Import-Funktionen folgen im nächsten Update.
- -
- Interpretation:
- • Ruhepuls sinkend = bessere Fitness 💪
- • HRV steigend = bessere Erholung ✨
- • Ruhepuls steigend + HRV sinkend = Übertraining-Signal ⚠️ -
-
- )} - - {tab === 'list' && ( -
- {entries.length === 0 && ( -
-

Keine Einträge

-

Erfasse deine ersten Vitalwerte im Tab "Erfassen".

-
- )} - {entries.map(e => { - const isEd = editing?.id === e.id - return ( -
- {isEd ? ( - setEditing(null)} - saveLabel="Speichern" - /> - ) : ( -
-
-
-
- {dayjs(e.date).format('dd, DD. MMMM YYYY')} -
-
- {e.resting_hr && ( - - ❤️ {e.resting_hr} bpm - - )} - {e.hrv && ( - - 📊 HRV {e.hrv} ms - - )} - {e.blood_pressure_systolic && e.blood_pressure_diastolic && ( - - 🩸 {e.blood_pressure_systolic}/{e.blood_pressure_diastolic} mmHg - - )} - {e.pulse && ( - - 💓 {e.pulse} bpm - - )} - {e.vo2_max && ( - - 🏃 VO2 {e.vo2_max} - - )} - {e.spo2 && ( - - 🫁 SpO2 {e.spo2}% - - )} - {e.respiratory_rate && ( - - 💨 {e.respiratory_rate}/min - - )} - {(e.irregular_heartbeat || e.possible_afib) && ( - - ⚠️ {e.irregular_heartbeat ? 'Unregelmäßig' : ''} {e.possible_afib ? 'AFib?' : ''} - - )} - {e.source !== 'manual' && ( - - {e.source === 'apple_health' ? 'Apple Health' : e.source === 'omron' ? 'Omron' : e.source} - - )} -
- {e.note && ( -

- "{e.note}" -

- )} -
-
- - -
-
-
- )} -
- ) - })}
)}