temp: placeholder VitalsPage während Frontend-Refactoring
Einfache 3-Tab-Struktur als Platzhalter: - Morgenmessung (Baseline) - Blutdruck (BP) - Import Verhindert Crash durch alte API-Calls. Vollständige UI folgt nach Backend-Test. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
1866ff9ce6
commit
1cc3b05705
|
|
@ -1,868 +1,73 @@
|
||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Pencil, Trash2, X, TrendingUp, TrendingDown, Minus, Upload } from 'lucide-react'
|
|
||||||
import { api } from '../utils/api'
|
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import 'dayjs/locale/de'
|
import 'dayjs/locale/de'
|
||||||
dayjs.locale('de')
|
dayjs.locale('de')
|
||||||
|
|
||||||
function empty() {
|
/**
|
||||||
return {
|
* VitalsPage - Refactored v9d Phase 2d
|
||||||
date: dayjs().format('YYYY-MM-DD'),
|
*
|
||||||
resting_hr: '',
|
* Separated into:
|
||||||
hrv: '',
|
* - Tab 1: Morgenmessung (Baseline) - once daily
|
||||||
blood_pressure_systolic: '',
|
* - Tab 2: Blutdruck (BP) - multiple daily with context
|
||||||
blood_pressure_diastolic: '',
|
* - Tab 3: Import
|
||||||
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 (
|
|
||||||
<div>
|
|
||||||
<div className="form-row">
|
|
||||||
<label className="form-label">Datum</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
className="form-input"
|
|
||||||
style={{ width: 140 }}
|
|
||||||
value={form.date}
|
|
||||||
onChange={e => set('date', e.target.value)}
|
|
||||||
/>
|
|
||||||
<span className="form-unit" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Section: Morgenmessung */}
|
|
||||||
<div style={{ marginTop: 16, marginBottom: 8, fontWeight: 600, fontSize: 13, color: 'var(--text2)' }}>
|
|
||||||
Morgenmessung (vor dem Aufstehen)
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-row">
|
|
||||||
<label className="form-label">Ruhepuls</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className="form-input"
|
|
||||||
min={30}
|
|
||||||
max={120}
|
|
||||||
step={1}
|
|
||||||
placeholder="z.B. 58"
|
|
||||||
value={form.resting_hr || ''}
|
|
||||||
onChange={e => set('resting_hr', e.target.value)}
|
|
||||||
/>
|
|
||||||
<span className="form-unit">bpm</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-row">
|
|
||||||
<label className="form-label">HRV</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className="form-input"
|
|
||||||
min={1}
|
|
||||||
max={300}
|
|
||||||
step={1}
|
|
||||||
placeholder="optional"
|
|
||||||
value={form.hrv || ''}
|
|
||||||
onChange={e => set('hrv', e.target.value)}
|
|
||||||
/>
|
|
||||||
<span className="form-unit">ms</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Section: Blutdruck */}
|
|
||||||
<div style={{ marginTop: 16, marginBottom: 8, fontWeight: 600, fontSize: 13, color: 'var(--text2)' }}>
|
|
||||||
Blutdruck (Omron)
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-row">
|
|
||||||
<label className="form-label">Systolisch</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className="form-input"
|
|
||||||
min={50}
|
|
||||||
max={250}
|
|
||||||
step={1}
|
|
||||||
placeholder="z.B. 125"
|
|
||||||
value={form.blood_pressure_systolic || ''}
|
|
||||||
onChange={e => set('blood_pressure_systolic', e.target.value)}
|
|
||||||
/>
|
|
||||||
<span className="form-unit">mmHg</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-row">
|
|
||||||
<label className="form-label">Diastolisch</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className="form-input"
|
|
||||||
min={30}
|
|
||||||
max={150}
|
|
||||||
step={1}
|
|
||||||
placeholder="z.B. 83"
|
|
||||||
value={form.blood_pressure_diastolic || ''}
|
|
||||||
onChange={e => set('blood_pressure_diastolic', e.target.value)}
|
|
||||||
/>
|
|
||||||
<span className="form-unit">mmHg</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-row">
|
|
||||||
<label className="form-label">Puls</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className="form-input"
|
|
||||||
min={30}
|
|
||||||
max={200}
|
|
||||||
step={1}
|
|
||||||
placeholder="z.B. 61"
|
|
||||||
value={form.pulse || ''}
|
|
||||||
onChange={e => set('pulse', e.target.value)}
|
|
||||||
/>
|
|
||||||
<span className="form-unit">bpm</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Section: Fitness & Sauerstoff */}
|
|
||||||
<div style={{ marginTop: 16, marginBottom: 8, fontWeight: 600, fontSize: 13, color: 'var(--text2)' }}>
|
|
||||||
Fitness & Sauerstoff (Apple Watch)
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-row">
|
|
||||||
<label className="form-label">VO2 Max</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className="form-input"
|
|
||||||
min={10}
|
|
||||||
max={90}
|
|
||||||
step={0.1}
|
|
||||||
placeholder="z.B. 42.5"
|
|
||||||
value={form.vo2_max || ''}
|
|
||||||
onChange={e => set('vo2_max', e.target.value)}
|
|
||||||
/>
|
|
||||||
<span className="form-unit">ml/kg/min</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-row">
|
|
||||||
<label className="form-label">SpO2</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className="form-input"
|
|
||||||
min={70}
|
|
||||||
max={100}
|
|
||||||
step={1}
|
|
||||||
placeholder="z.B. 98"
|
|
||||||
value={form.spo2 || ''}
|
|
||||||
onChange={e => set('spo2', e.target.value)}
|
|
||||||
/>
|
|
||||||
<span className="form-unit">%</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-row">
|
|
||||||
<label className="form-label">Atemfrequenz</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className="form-input"
|
|
||||||
min={1}
|
|
||||||
max={60}
|
|
||||||
step={0.1}
|
|
||||||
placeholder="z.B. 15.5"
|
|
||||||
value={form.respiratory_rate || ''}
|
|
||||||
onChange={e => set('respiratory_rate', e.target.value)}
|
|
||||||
/>
|
|
||||||
<span className="form-unit">/min</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Section: Warnungen */}
|
|
||||||
<div style={{ marginTop: 16, marginBottom: 8 }}>
|
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, marginBottom: 4 }}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={form.irregular_heartbeat || false}
|
|
||||||
onChange={e => set('irregular_heartbeat', e.target.checked)}
|
|
||||||
/>
|
|
||||||
Unregelmäßiger Herzschlag festgestellt
|
|
||||||
</label>
|
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13 }}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={form.possible_afib || false}
|
|
||||||
onChange={e => set('possible_afib', e.target.checked)}
|
|
||||||
/>
|
|
||||||
Mögliches Vorhofflimmern (AFib)
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-row">
|
|
||||||
<label className="form-label">Notiz</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="form-input"
|
|
||||||
placeholder="optional"
|
|
||||||
value={form.note || ''}
|
|
||||||
onChange={e => set('note', e.target.value)}
|
|
||||||
/>
|
|
||||||
<span className="form-unit" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: 6, marginTop: 8 }}>
|
|
||||||
<button
|
|
||||||
className="btn btn-primary"
|
|
||||||
style={{ flex: 1 }}
|
|
||||||
onClick={onSave}
|
|
||||||
disabled={saving}
|
|
||||||
>
|
|
||||||
{saveLabel}
|
|
||||||
</button>
|
|
||||||
{onCancel && (
|
|
||||||
<button className="btn btn-secondary" style={{ flex: 1 }} onClick={onCancel}>
|
|
||||||
<X size={13} /> Abbrechen
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function VitalsPage() {
|
export default function VitalsPage() {
|
||||||
const [entries, setEntries] = useState([])
|
const [tab, setTab] = useState('baseline')
|
||||||
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 <TrendingUp size={14} style={{ color: '#D85A30' }} />
|
|
||||||
if (trend === 'decreasing') return <TrendingDown size={14} style={{ color: '#1D9E75' }} />
|
|
||||||
return <Minus size={14} style={{ color: 'var(--text3)' }} />
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 = ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="page-title">Vitalwerte</h1>
|
<h1 className="page-title">Vitalwerte</h1>
|
||||||
|
|
||||||
<div className="tabs" style={{ overflowX: 'auto', flexWrap: 'nowrap' }}>
|
<div className="tabs" style={{ overflowX: 'auto', flexWrap: 'nowrap' }}>
|
||||||
<button className={'tab' + (tab === 'list' ? ' active' : '')} onClick={() => setTab('list')}>
|
<button className={'tab' + (tab === 'baseline' ? ' active' : '')} onClick={() => setTab('baseline')}>
|
||||||
Verlauf
|
Morgenmessung
|
||||||
</button>
|
</button>
|
||||||
<button className={'tab' + (tab === 'add' ? ' active' : '')} onClick={() => setTab('add')}>
|
<button className={'tab' + (tab === 'bp' ? ' active' : '')} onClick={() => setTab('bp')}>
|
||||||
+ Erfassen
|
Blutdruck
|
||||||
</button>
|
</button>
|
||||||
<button className={'tab' + (tab === 'import' ? ' active' : '')} onClick={() => setTab('import')}>
|
<button className={'tab' + (tab === 'import' ? ' active' : '')} onClick={() => setTab('import')}>
|
||||||
Import
|
Import
|
||||||
</button>
|
</button>
|
||||||
<button className={'tab' + (tab === 'stats' ? ' active' : '')} onClick={() => setTab('stats')}>
|
|
||||||
Statistik
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats Overview */}
|
{tab === 'baseline' && (
|
||||||
{stats && stats.total_entries > 0 && (
|
|
||||||
<div className="card section-gap">
|
<div className="card section-gap">
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(100px, 1fr))', gap: 8 }}>
|
<div className="card-title">Morgenmessung (Baseline-Vitals)</div>
|
||||||
{stats.avg_resting_hr_7d && (
|
<p style={{ fontSize: 13, color: 'var(--text3)' }}>
|
||||||
<div style={{
|
Einmal täglich, morgens vor dem Aufstehen:
|
||||||
background: 'var(--surface2)',
|
Ruhepuls, HRV, VO2 Max, SpO2, Atemfrequenz
|
||||||
borderRadius: 8,
|
</p>
|
||||||
padding: '8px 10px',
|
<div style={{ padding: 20, textAlign: 'center', color: 'var(--text3)' }}>
|
||||||
textAlign: 'center'
|
🚧 Frontend-Refactoring läuft noch...<br />
|
||||||
}}>
|
Nutze vorerst die alten Endpoints oder warte auf Update.
|
||||||
<div style={{ fontSize: 18, fontWeight: 700, color: '#378ADD' }}>
|
|
||||||
{Math.round(stats.avg_resting_hr_7d)}
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 10, color: 'var(--text3)' }}>Ø Ruhepuls 7d</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{stats.avg_hrv_7d && (
|
|
||||||
<div style={{
|
|
||||||
background: 'var(--surface2)',
|
|
||||||
borderRadius: 8,
|
|
||||||
padding: '8px 10px',
|
|
||||||
textAlign: 'center'
|
|
||||||
}}>
|
|
||||||
<div style={{ fontSize: 18, fontWeight: 700, color: '#1D9E75' }}>
|
|
||||||
{Math.round(stats.avg_hrv_7d)}
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 10, color: 'var(--text3)' }}>Ø HRV 7d</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{stats.avg_bp_systolic_7d && (
|
|
||||||
<div style={{
|
|
||||||
background: 'var(--surface2)',
|
|
||||||
borderRadius: 8,
|
|
||||||
padding: '8px 10px',
|
|
||||||
textAlign: 'center'
|
|
||||||
}}>
|
|
||||||
<div style={{ fontSize: 16, fontWeight: 700, color: '#E74C3C' }}>
|
|
||||||
{Math.round(stats.avg_bp_systolic_7d)}/{Math.round(stats.avg_bp_diastolic_7d || 0)}
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 10, color: 'var(--text3)' }}>Ø Blutdruck 7d</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{stats.latest_vo2_max && (
|
|
||||||
<div style={{
|
|
||||||
background: 'var(--surface2)',
|
|
||||||
borderRadius: 8,
|
|
||||||
padding: '8px 10px',
|
|
||||||
textAlign: 'center'
|
|
||||||
}}>
|
|
||||||
<div style={{ fontSize: 18, fontWeight: 700, color: '#1D9E75' }}>
|
|
||||||
{stats.latest_vo2_max.toFixed(1)}
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 10, color: 'var(--text3)' }}>VO2 Max</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{stats.avg_spo2_7d && (
|
|
||||||
<div style={{
|
|
||||||
background: 'var(--surface2)',
|
|
||||||
borderRadius: 8,
|
|
||||||
padding: '8px 10px',
|
|
||||||
textAlign: 'center'
|
|
||||||
}}>
|
|
||||||
<div style={{ fontSize: 18, fontWeight: 700, color: '#7B68EE' }}>
|
|
||||||
{Math.round(stats.avg_spo2_7d)}%
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 10, color: 'var(--text3)' }}>Ø SpO2 7d</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div style={{
|
|
||||||
background: 'var(--surface2)',
|
|
||||||
borderRadius: 8,
|
|
||||||
padding: '8px 10px',
|
|
||||||
textAlign: 'center'
|
|
||||||
}}>
|
|
||||||
<div style={{ fontSize: 18, fontWeight: 700, color: 'var(--text2)' }}>
|
|
||||||
{stats.total_entries}
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 10, color: 'var(--text3)' }}>Einträge</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === 'add' && (
|
{tab === 'bp' && (
|
||||||
<div className="card section-gap">
|
<div className="card section-gap">
|
||||||
<div className="card-title">Vitalwerte erfassen</div>
|
<div className="card-title">Blutdruck (mehrfach täglich)</div>
|
||||||
<p style={{ fontSize: 13, color: 'var(--text2)', marginBottom: 10, lineHeight: 1.6 }}>
|
<p style={{ fontSize: 13, color: 'var(--text3)' }}>
|
||||||
Morgens nach dem Aufwachen, vor dem Aufstehen: Ruhepuls messen (z.B. mit Apple Watch oder Smartwatch).
|
Mehrfach täglich mit Kontext-Tagging:
|
||||||
HRV ist optional.
|
Nüchtern, Nach dem Essen, Vor/Nach Training, Abends, Stress
|
||||||
</p>
|
</p>
|
||||||
{error && (
|
<div style={{ padding: 20, textAlign: 'center', color: 'var(--text3)' }}>
|
||||||
<div style={{
|
🚧 Frontend-Refactoring läuft noch...<br />
|
||||||
padding: '10px',
|
Nutze vorerst die alten Endpoints oder warte auf Update.
|
||||||
background: '#FCEBEB',
|
</div>
|
||||||
border: '1px solid #D85A30',
|
|
||||||
borderRadius: 8,
|
|
||||||
fontSize: 13,
|
|
||||||
color: '#D85A30',
|
|
||||||
marginBottom: 8
|
|
||||||
}}>
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<EntryForm
|
|
||||||
form={form}
|
|
||||||
setForm={setForm}
|
|
||||||
onSave={handleSave}
|
|
||||||
saveLabel={saved ? '✓ Gespeichert!' : 'Speichern'}
|
|
||||||
saving={saving}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === 'import' && (
|
{tab === 'import' && (
|
||||||
<div>
|
|
||||||
{error && (
|
|
||||||
<div style={{
|
|
||||||
padding: '10px',
|
|
||||||
background: '#FCEBEB',
|
|
||||||
border: '1px solid #D85A30',
|
|
||||||
borderRadius: 8,
|
|
||||||
fontSize: 13,
|
|
||||||
color: '#D85A30',
|
|
||||||
marginBottom: 12
|
|
||||||
}}>
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{importResult && (
|
|
||||||
<div style={{
|
|
||||||
padding: '12px',
|
|
||||||
background: '#E8F7F0',
|
|
||||||
border: '1px solid #1D9E75',
|
|
||||||
borderRadius: 8,
|
|
||||||
fontSize: 13,
|
|
||||||
color: '#085041',
|
|
||||||
marginBottom: 12
|
|
||||||
}}>
|
|
||||||
<strong>Import erfolgreich ({importResult.type === 'omron' ? 'Omron' : 'Apple Health'}):</strong><br />
|
|
||||||
{importResult.inserted} neu · {importResult.updated} aktualisiert · {importResult.skipped} übersprungen
|
|
||||||
{importResult.errors > 0 && <> · {importResult.errors} Fehler</>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Omron Import */}
|
|
||||||
<div className="card" style={{ marginBottom: 12 }}>
|
|
||||||
<div className="card-title">Omron Blutdruckmessgerät</div>
|
|
||||||
<p style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 12, lineHeight: 1.5 }}>
|
|
||||||
Exportiere CSV aus der Omron Connect App:<br />
|
|
||||||
• Blutdruck (Systolisch/Diastolisch)<br />
|
|
||||||
• Puls<br />
|
|
||||||
• Unregelmäßiger Herzschlag & AFib-Warnungen
|
|
||||||
</p>
|
|
||||||
<div
|
|
||||||
onDragOver={e => { 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 ? (
|
|
||||||
<>
|
|
||||||
<div className="spinner" style={{ width: 24, height: 24, margin: '0 auto 8px' }} />
|
|
||||||
<div style={{ fontSize: 14, fontWeight: 500, color: 'var(--text2)' }}>
|
|
||||||
Importiere Omron-Daten...
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Upload size={28} style={{ color: draggingOmron ? 'var(--accent)' : 'var(--text3)', marginBottom: 8 }} />
|
|
||||||
<div style={{ fontSize: 14, fontWeight: 500, color: draggingOmron ? 'var(--accent-dark)' : 'var(--text2)' }}>
|
|
||||||
{draggingOmron ? 'CSV loslassen...' : 'Omron CSV hierher ziehen oder tippen'}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
ref={omronFileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept=".csv"
|
|
||||||
onChange={e => {
|
|
||||||
const file = e.target.files[0]
|
|
||||||
if (file) handleOmronImport(file)
|
|
||||||
}}
|
|
||||||
style={{ display: 'none' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Apple Health Import */}
|
|
||||||
<div className="card">
|
|
||||||
<div className="card-title">Apple Health</div>
|
|
||||||
<p style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 12, lineHeight: 1.5 }}>
|
|
||||||
Exportiere Health-Daten von der Health-App:<br />
|
|
||||||
• Ruhepuls (Resting Heart Rate)<br />
|
|
||||||
• HRV (Heart Rate Variability)<br />
|
|
||||||
• VO2 Max<br />
|
|
||||||
• SpO2 (Blutsauerstoffsättigung)<br />
|
|
||||||
• Atemfrequenz
|
|
||||||
</p>
|
|
||||||
<div
|
|
||||||
onDragOver={e => { 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 ? (
|
|
||||||
<>
|
|
||||||
<div className="spinner" style={{ width: 24, height: 24, margin: '0 auto 8px' }} />
|
|
||||||
<div style={{ fontSize: 14, fontWeight: 500, color: 'var(--text2)' }}>
|
|
||||||
Importiere Apple Health-Daten...
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Upload size={28} style={{ color: draggingApple ? 'var(--accent)' : 'var(--text3)', marginBottom: 8 }} />
|
|
||||||
<div style={{ fontSize: 14, fontWeight: 500, color: draggingApple ? 'var(--accent-dark)' : 'var(--text2)' }}>
|
|
||||||
{draggingApple ? 'CSV loslassen...' : 'Apple Health CSV hierher ziehen oder tippen'}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
ref={appleFileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept=".csv"
|
|
||||||
onChange={e => {
|
|
||||||
const file = e.target.files[0]
|
|
||||||
if (file) handleAppleImport(file)
|
|
||||||
}}
|
|
||||||
style={{ display: 'none' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tab === 'stats' && stats && (
|
|
||||||
<div className="card section-gap">
|
<div className="card section-gap">
|
||||||
<div className="card-title">Trend-Analyse (14 Tage)</div>
|
<div className="card-title">CSV Import</div>
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text3)' }}>
|
||||||
<div style={{ marginBottom: 16 }}>
|
Omron (Blutdruck) + Apple Health (Baseline-Vitals)
|
||||||
<div style={{
|
</p>
|
||||||
display: 'flex',
|
<div style={{ padding: 20, textAlign: 'center', color: 'var(--text3)' }}>
|
||||||
justifyContent: 'space-between',
|
🚧 Frontend-Refactoring läuft noch...<br />
|
||||||
alignItems: 'center',
|
Import-Funktionen folgen im nächsten Update.
|
||||||
padding: '8px 0',
|
|
||||||
borderBottom: '1px solid var(--border)'
|
|
||||||
}}>
|
|
||||||
<div style={{ fontSize: 13, fontWeight: 600 }}>Ruhepuls</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
||||||
{getTrendIcon(stats.trend_resting_hr)}
|
|
||||||
<span style={{ fontSize: 13, color: 'var(--text2)' }}>
|
|
||||||
{stats.trend_resting_hr === 'increasing' ? 'Steigend' :
|
|
||||||
stats.trend_resting_hr === 'decreasing' ? 'Sinkend' : 'Stabil'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
padding: '8px 0'
|
|
||||||
}}>
|
|
||||||
<div style={{ fontSize: 13, fontWeight: 600 }}>HRV</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
||||||
{getTrendIcon(stats.trend_hrv)}
|
|
||||||
<span style={{ fontSize: 13, color: 'var(--text2)' }}>
|
|
||||||
{stats.trend_hrv === 'increasing' ? 'Steigend' :
|
|
||||||
stats.trend_hrv === 'decreasing' ? 'Sinkend' : 'Stabil'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{
|
|
||||||
padding: 12,
|
|
||||||
background: 'var(--surface2)',
|
|
||||||
borderRadius: 8,
|
|
||||||
fontSize: 12,
|
|
||||||
color: 'var(--text2)',
|
|
||||||
lineHeight: 1.6
|
|
||||||
}}>
|
|
||||||
<strong>Interpretation:</strong><br />
|
|
||||||
• Ruhepuls sinkend = bessere Fitness 💪<br />
|
|
||||||
• HRV steigend = bessere Erholung ✨<br />
|
|
||||||
• Ruhepuls steigend + HRV sinkend = Übertraining-Signal ⚠️
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tab === 'list' && (
|
|
||||||
<div>
|
|
||||||
{entries.length === 0 && (
|
|
||||||
<div className="empty-state">
|
|
||||||
<h3>Keine Einträge</h3>
|
|
||||||
<p>Erfasse deine ersten Vitalwerte im Tab "Erfassen".</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{entries.map(e => {
|
|
||||||
const isEd = editing?.id === e.id
|
|
||||||
return (
|
|
||||||
<div key={e.id} className="card" style={{ marginBottom: 8 }}>
|
|
||||||
{isEd ? (
|
|
||||||
<EntryForm
|
|
||||||
form={editing}
|
|
||||||
setForm={setEditing}
|
|
||||||
onSave={handleUpdate}
|
|
||||||
onCancel={() => setEditing(null)}
|
|
||||||
saveLabel="Speichern"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div>
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'flex-start'
|
|
||||||
}}>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<div style={{ fontSize: 14, fontWeight: 600, marginBottom: 2 }}>
|
|
||||||
{dayjs(e.date).format('dd, DD. MMMM YYYY')}
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', marginTop: 6 }}>
|
|
||||||
{e.resting_hr && (
|
|
||||||
<span style={{ fontSize: 12, color: 'var(--text2)' }}>
|
|
||||||
❤️ {e.resting_hr} bpm
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{e.hrv && (
|
|
||||||
<span style={{ fontSize: 12, color: 'var(--text2)' }}>
|
|
||||||
📊 HRV {e.hrv} ms
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{e.blood_pressure_systolic && e.blood_pressure_diastolic && (
|
|
||||||
<span style={{ fontSize: 12, color: '#E74C3C', fontWeight: 600 }}>
|
|
||||||
🩸 {e.blood_pressure_systolic}/{e.blood_pressure_diastolic} mmHg
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{e.pulse && (
|
|
||||||
<span style={{ fontSize: 12, color: 'var(--text2)' }}>
|
|
||||||
💓 {e.pulse} bpm
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{e.vo2_max && (
|
|
||||||
<span style={{ fontSize: 12, color: '#1D9E75', fontWeight: 600 }}>
|
|
||||||
🏃 VO2 {e.vo2_max}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{e.spo2 && (
|
|
||||||
<span style={{ fontSize: 12, color: 'var(--text2)' }}>
|
|
||||||
🫁 SpO2 {e.spo2}%
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{e.respiratory_rate && (
|
|
||||||
<span style={{ fontSize: 12, color: 'var(--text2)' }}>
|
|
||||||
💨 {e.respiratory_rate}/min
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{(e.irregular_heartbeat || e.possible_afib) && (
|
|
||||||
<span style={{ fontSize: 12, color: '#D85A30', fontWeight: 600 }}>
|
|
||||||
⚠️ {e.irregular_heartbeat ? 'Unregelmäßig' : ''} {e.possible_afib ? 'AFib?' : ''}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{e.source !== 'manual' && (
|
|
||||||
<span style={{ fontSize: 10, color: 'var(--text3)' }}>
|
|
||||||
{e.source === 'apple_health' ? 'Apple Health' : e.source === 'omron' ? 'Omron' : e.source}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{e.note && (
|
|
||||||
<p style={{
|
|
||||||
fontSize: 12,
|
|
||||||
color: 'var(--text2)',
|
|
||||||
fontStyle: 'italic',
|
|
||||||
marginTop: 4
|
|
||||||
}}>
|
|
||||||
"{e.note}"
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', gap: 6, marginLeft: 8 }}>
|
|
||||||
<button
|
|
||||||
className="btn btn-secondary"
|
|
||||||
style={{ padding: '5px 8px' }}
|
|
||||||
onClick={() => setEditing({ ...e })}
|
|
||||||
>
|
|
||||||
<Pencil size={13} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="btn btn-danger"
|
|
||||||
style={{ padding: '5px 8px' }}
|
|
||||||
onClick={() => handleDelete(e.id)}
|
|
||||||
>
|
|
||||||
<Trash2 size={13} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user