feat: add CSV import for Vitals (Omron + Apple Health)
All checks were successful
Deploy Development / deploy (push) Successful in 49s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 14s

- Import endpoints for Omron blood pressure CSV (German date format)
- Import endpoints for Apple Health vitals CSV
- Import UI tab in VitalsPage with drag & drop for both sources
- German month mapping for Omron date parsing ("13 März 2026")
- Upsert logic preserves manual entries (source != 'manual')
- Import result feedback (inserted/updated/skipped/errors)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lars 2026-03-23 15:26:51 +01:00
parent a55f11bc96
commit 548a5a481d
3 changed files with 501 additions and 3 deletions

View File

@ -9,12 +9,17 @@ Endpoints:
- PUT /api/vitals/{id} Update vitals - PUT /api/vitals/{id} Update vitals
- DELETE /api/vitals/{id} Delete vitals - DELETE /api/vitals/{id} Delete vitals
- GET /api/vitals/stats Get vitals statistics - GET /api/vitals/stats Get vitals statistics
- POST /api/vitals/import/omron Import Omron CSV
- POST /api/vitals/import/apple-health Import Apple Health CSV
""" """
from fastapi import APIRouter, HTTPException, Depends, Header from fastapi import APIRouter, HTTPException, Depends, Header, UploadFile, File
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional from typing import Optional
from datetime import datetime, timedelta from datetime import datetime, timedelta
import logging import logging
import csv
import io
from dateutil import parser as date_parser
from db import get_db, get_cursor, r2d from db import get_db, get_cursor, r2d
from auth import require_auth from auth import require_auth
@ -22,6 +27,22 @@ from auth import require_auth
router = APIRouter(prefix="/api/vitals", tags=["vitals"]) router = APIRouter(prefix="/api/vitals", tags=["vitals"])
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# German month mapping for Omron dates
GERMAN_MONTHS = {
'Januar': '01', 'Jan.': '01',
'Februar': '02', 'Feb.': '02',
'März': '03',
'April': '04', 'Apr.': '04',
'Mai': '05',
'Juni': '06',
'Juli': '07',
'August': '08', 'Aug.': '08',
'September': '09', 'Sep.': '09',
'Oktober': '10', 'Okt.': '10',
'November': '11', 'Nov.': '11',
'Dezember': '12', 'Dez.': '12'
}
class VitalsEntry(BaseModel): class VitalsEntry(BaseModel):
date: str date: str
@ -420,3 +441,241 @@ def get_vitals_stats(
"trend_hrv": trend_hrv, "trend_hrv": trend_hrv,
"period_days": days "period_days": days
} }
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Import Endpoints
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
def parse_omron_date(date_str: str) -> str:
"""
Parse Omron German date format to YYYY-MM-DD.
Examples:
- "13 März 2026" -> "2026-03-13"
- "28 Feb. 2026" -> "2026-02-28"
"""
parts = date_str.strip().split()
if len(parts) != 3:
raise ValueError(f"Invalid date format: {date_str}")
day = parts[0].zfill(2)
month_str = parts[1]
year = parts[2]
# Map German month to number
month = GERMAN_MONTHS.get(month_str)
if not month:
raise ValueError(f"Unknown month: {month_str}")
return f"{year}-{month}-{day}"
@router.post("/import/omron")
async def import_omron_csv(
file: UploadFile = File(...),
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth)
):
"""
Import Omron blood pressure CSV export.
Expected format:
Datum,Zeit,Systolisch (mmHg),Diastolisch (mmHg),Puls (bpm),...
"""
pid = get_pid(x_profile_id, session)
# Read file
content = await file.read()
content_str = content.decode('utf-8')
# Parse CSV
reader = csv.DictReader(io.StringIO(content_str))
inserted = 0
updated = 0
skipped = 0
errors = []
with get_db() as conn:
cur = get_cursor(conn)
for row_num, row in enumerate(reader, start=2):
try:
# Parse date
date_str = parse_omron_date(row['Datum'])
# Parse values
systolic = int(row['Systolisch (mmHg)']) if row['Systolisch (mmHg)'] and row['Systolisch (mmHg)'] != '-' else None
diastolic = int(row['Diastolisch (mmHg)']) if row['Diastolisch (mmHg)'] and row['Diastolisch (mmHg)'] != '-' else None
pulse = int(row['Puls (bpm)']) if row['Puls (bpm)'] and row['Puls (bpm)'] != '-' else None
# Skip if no data
if not systolic and not diastolic and not pulse:
skipped += 1
continue
# Parse flags (optional columns)
irregular = row.get('Unregelmäßiger Herzschlag festgestellt', '').strip() not in ('', '-', ' ')
afib = row.get('Mögliches AFib', '').strip() not in ('', '-', ' ')
# Upsert
cur.execute(
"""
INSERT INTO vitals_log (
profile_id, date, blood_pressure_systolic, blood_pressure_diastolic,
pulse, irregular_heartbeat, possible_afib, source
)
VALUES (%s, %s, %s, %s, %s, %s, %s, 'omron')
ON CONFLICT (profile_id, date)
DO UPDATE SET
blood_pressure_systolic = COALESCE(EXCLUDED.blood_pressure_systolic, vitals_log.blood_pressure_systolic),
blood_pressure_diastolic = COALESCE(EXCLUDED.blood_pressure_diastolic, vitals_log.blood_pressure_diastolic),
pulse = COALESCE(EXCLUDED.pulse, vitals_log.pulse),
irregular_heartbeat = COALESCE(EXCLUDED.irregular_heartbeat, vitals_log.irregular_heartbeat),
possible_afib = COALESCE(EXCLUDED.possible_afib, vitals_log.possible_afib),
source = CASE WHEN vitals_log.source = 'manual' THEN vitals_log.source ELSE 'omron' END,
updated_at = CURRENT_TIMESTAMP
RETURNING (xmax = 0) AS inserted
""",
(pid, date_str, systolic, diastolic, pulse, irregular, afib)
)
result = cur.fetchone()
if result['inserted']:
inserted += 1
else:
updated += 1
except Exception as e:
errors.append(f"Zeile {row_num}: {str(e)}")
logger.error(f"[OMRON-IMPORT] Error at row {row_num}: {e}")
continue
conn.commit()
logger.info(f"[OMRON-IMPORT] {pid}: {inserted} inserted, {updated} updated, {skipped} skipped, {len(errors)} errors")
return {
"message": "Omron CSV Import abgeschlossen",
"inserted": inserted,
"updated": updated,
"skipped": skipped,
"errors": errors[:10] # Limit to first 10 errors
}
@router.post("/import/apple-health")
async def import_apple_health_csv(
file: UploadFile = File(...),
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth)
):
"""
Import Apple Health vitals CSV export.
Expected columns:
- Datum/Uhrzeit
- Ruhepuls (count/min)
- Herzfrequenzvariabilität (ms)
- VO2 max (ml/(kg·min))
- Blutsauerstoffsättigung (%)
- Atemfrequenz (count/min)
"""
pid = get_pid(x_profile_id, session)
# Read file
content = await file.read()
content_str = content.decode('utf-8')
# Parse CSV
reader = csv.DictReader(io.StringIO(content_str))
inserted = 0
updated = 0
skipped = 0
errors = []
with get_db() as conn:
cur = get_cursor(conn)
for row_num, row in enumerate(reader, start=2):
try:
# Parse date (format: "2026-02-21 00:00:00")
date_str = row.get('Datum/Uhrzeit', '').split()[0] # Extract date part
if not date_str:
skipped += 1
continue
# Parse values (columns might be empty)
resting_hr = None
hrv = None
vo2_max = None
spo2 = None
respiratory_rate = None
if 'Ruhepuls (count/min)' in row and row['Ruhepuls (count/min)']:
resting_hr = int(float(row['Ruhepuls (count/min)']))
if 'Herzfrequenzvariabilität (ms)' in row and row['Herzfrequenzvariabilität (ms)']:
hrv = int(float(row['Herzfrequenzvariabilität (ms)']))
if 'VO2 max (ml/(kg·min))' in row and row['VO2 max (ml/(kg·min))']:
vo2_max = float(row['VO2 max (ml/(kg·min))'])
if 'Blutsauerstoffsättigung (%)' in row and row['Blutsauerstoffsättigung (%)']:
spo2 = int(float(row['Blutsauerstoffsättigung (%)']))
if 'Atemfrequenz (count/min)' in row and row['Atemfrequenz (count/min)']:
respiratory_rate = float(row['Atemfrequenz (count/min)'])
# Skip if no vitals data
if not any([resting_hr, hrv, vo2_max, spo2, respiratory_rate]):
skipped += 1
continue
# Upsert
cur.execute(
"""
INSERT INTO vitals_log (
profile_id, date, resting_hr, hrv, vo2_max, spo2,
respiratory_rate, source
)
VALUES (%s, %s, %s, %s, %s, %s, %s, 'apple_health')
ON CONFLICT (profile_id, date)
DO UPDATE SET
resting_hr = COALESCE(EXCLUDED.resting_hr, vitals_log.resting_hr),
hrv = COALESCE(EXCLUDED.hrv, vitals_log.hrv),
vo2_max = COALESCE(EXCLUDED.vo2_max, vitals_log.vo2_max),
spo2 = COALESCE(EXCLUDED.spo2, vitals_log.spo2),
respiratory_rate = COALESCE(EXCLUDED.respiratory_rate, vitals_log.respiratory_rate),
source = CASE WHEN vitals_log.source = 'manual' THEN vitals_log.source ELSE 'apple_health' END,
updated_at = CURRENT_TIMESTAMP
RETURNING (xmax = 0) AS inserted
""",
(pid, date_str, resting_hr, hrv, vo2_max, spo2, respiratory_rate)
)
result = cur.fetchone()
if result['inserted']:
inserted += 1
else:
updated += 1
except Exception as e:
errors.append(f"Zeile {row_num}: {str(e)}")
logger.error(f"[APPLE-HEALTH-IMPORT] Error at row {row_num}: {e}")
continue
conn.commit()
logger.info(f"[APPLE-HEALTH-IMPORT] {pid}: {inserted} inserted, {updated} updated, {skipped} skipped, {len(errors)} errors")
return {
"message": "Apple Health CSV Import abgeschlossen",
"inserted": inserted,
"updated": updated,
"skipped": skipped,
"errors": errors[:10] # Limit to first 10 errors
}

View File

@ -1,5 +1,5 @@
import { useState, useEffect } from 'react' import { useState, useEffect, useRef } from 'react'
import { Pencil, Trash2, X, TrendingUp, TrendingDown, Minus } from 'lucide-react' import { Pencil, Trash2, X, TrendingUp, TrendingDown, Minus, Upload } from 'lucide-react'
import { api } from '../utils/api' import { api } from '../utils/api'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import 'dayjs/locale/de' import 'dayjs/locale/de'
@ -235,6 +235,15 @@ export default function VitalsPage() {
const [saved, setSaved] = useState(false) const [saved, setSaved] = useState(false)
const [error, setError] = useState(null) 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 () => { const load = async () => {
try { try {
const [e, s] = await Promise.all([api.listVitals(90), api.getVitalsStats(30)]) const [e, s] = await Promise.all([api.listVitals(90), api.getVitalsStats(30)])
@ -340,6 +349,71 @@ export default function VitalsPage() {
return <Minus size={14} style={{ color: 'var(--text3)' }} /> 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>
@ -351,6 +425,9 @@ export default function VitalsPage() {
<button className={'tab' + (tab === 'add' ? ' active' : '')} onClick={() => setTab('add')}> <button className={'tab' + (tab === 'add' ? ' active' : '')} onClick={() => setTab('add')}>
+ Erfassen + Erfassen
</button> </button>
<button className={'tab' + (tab === 'import' ? ' active' : '')} onClick={() => setTab('import')}>
Import
</button>
<button className={'tab' + (tab === 'stats' ? ' active' : '')} onClick={() => setTab('stats')}> <button className={'tab' + (tab === 'stats' ? ' active' : '')} onClick={() => setTab('stats')}>
Statistik Statistik
</button> </button>
@ -470,6 +547,156 @@ export default function VitalsPage() {
</div> </div>
)} )}
{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 && ( {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">Trend-Analyse (14 Tage)</div>

View File

@ -264,4 +264,16 @@ export const api = {
updateVitals: (id,d) => req(`/vitals/${id}`, jput(d)), updateVitals: (id,d) => req(`/vitals/${id}`, jput(d)),
deleteVitals: (id) => req(`/vitals/${id}`, {method:'DELETE'}), deleteVitals: (id) => req(`/vitals/${id}`, {method:'DELETE'}),
getVitalsStats: (days=30) => req(`/vitals/stats?days=${days}`), getVitalsStats: (days=30) => req(`/vitals/stats?days=${days}`),
// Vitals Import (v9d Phase 2d)
importVitalsOmron: (file) => {
const fd = new FormData()
fd.append('file', file)
return req('/vitals/import/omron', {method:'POST', body:fd})
},
importVitalsAppleHealth: (file) => {
const fd = new FormData()
fd.append('file', file)
return req('/vitals/import/apple-health', {method:'POST', body:fd})
},
} }