feat: add CSV import for Vitals (Omron + Apple Health)
- 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:
parent
a55f11bc96
commit
548a5a481d
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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})
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user