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 */}
{/* List */}
{entries.length > 0 && (
Letzte Messungen ({entries.length})
{entries.map(e => (
{editingId === e.id ? (
// Edit Mode
) : (
// 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 */}
{/* 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
) : (
// 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' && }
)
}