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
+ Exportiere CSV aus der Omron Connect App:
+ • Blutdruck (Systolisch/Diastolisch)
+ • Puls
+ • Unregelmäßiger Herzschlag & AFib-Warnungen
+
+ Exportiere Health-Daten von der Health-App:
+ • Ruhepuls (Resting Heart Rate)
+ • HRV (Heart Rate Variability)
+ • VO2 Max
+ • SpO2 (Blutsauerstoffsättigung)
+ • Atemfrequenz
+