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
|
||||
- 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 <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 (
|
||||
<div>
|
||||
<h1 className="page-title">Vitalwerte</h1>
|
||||
|
|
@ -351,6 +425,9 @@ export default function VitalsPage() {
|
|||
<button className={'tab' + (tab === 'add' ? ' active' : '')} onClick={() => setTab('add')}>
|
||||
+ Erfassen
|
||||
</button>
|
||||
<button className={'tab' + (tab === 'import' ? ' active' : '')} onClick={() => setTab('import')}>
|
||||
Import
|
||||
</button>
|
||||
<button className={'tab' + (tab === 'stats' ? ' active' : '')} onClick={() => setTab('stats')}>
|
||||
Statistik
|
||||
</button>
|
||||
|
|
@ -470,6 +547,156 @@ export default function VitalsPage() {
|
|||
</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 && (
|
||||
<div className="card section-gap">
|
||||
<div className="card-title">Trend-Analyse (14 Tage)</div>
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
},
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user