diff --git a/backend/routers/vitals.py b/backend/routers/vitals.py index 07007c3..9180c5f 100644 --- a/backend/routers/vitals.py +++ b/backend/routers/vitals.py @@ -9,12 +9,17 @@ Endpoints: - PUT /api/vitals/{id} Update vitals - DELETE /api/vitals/{id} Delete vitals - 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 typing import Optional from datetime import datetime, timedelta import logging +import csv +import io +from dateutil import parser as date_parser from db import get_db, get_cursor, r2d from auth import require_auth @@ -22,6 +27,22 @@ from auth import require_auth router = APIRouter(prefix="/api/vitals", tags=["vitals"]) 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): date: str @@ -420,3 +441,241 @@ def get_vitals_stats( "trend_hrv": trend_hrv, "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 + } diff --git a/frontend/src/pages/VitalsPage.jsx b/frontend/src/pages/VitalsPage.jsx index c7aeb83..ef21f4f 100644 --- a/frontend/src/pages/VitalsPage.jsx +++ b/frontend/src/pages/VitalsPage.jsx @@ -1,5 +1,5 @@ -import { useState, useEffect } from 'react' -import { Pencil, Trash2, X, TrendingUp, TrendingDown, Minus } from 'lucide-react' +import { useState, useEffect, useRef } from 'react' +import { Pencil, Trash2, X, TrendingUp, TrendingDown, Minus, Upload } from 'lucide-react' import { api } from '../utils/api' import dayjs from 'dayjs' import 'dayjs/locale/de' @@ -235,6 +235,15 @@ export default function VitalsPage() { 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)]) @@ -340,6 +349,71 @@ export default function VitalsPage() { 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 = '' + } + } + } + return (

Vitalwerte

@@ -351,6 +425,9 @@ export default function VitalsPage() { + @@ -470,6 +547,156 @@ export default function VitalsPage() {
)} + {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)
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 37ee80a..7323cb2 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -264,4 +264,16 @@ export const api = { updateVitals: (id,d) => req(`/vitals/${id}`, jput(d)), deleteVitals: (id) => req(`/vitals/${id}`, {method:'DELETE'}), 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}) + }, }