mitai-jinkendo/backend/routers/vitals.py
Lars 378bf434fc
All checks were successful
Deploy Development / deploy (push) Successful in 47s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 21s
fix: 3 critical bugs in Goals and Vitals
**Bug 1: Focus contributions not saved**
- GoalsPage: Added focus_contributions to data object (line 232)
- Was missing from API payload, causing loss of focus area assignments

**Bug 2: Filter focus areas in goal form**
- Only show focus areas user has weighted (weight > 0)
- Cleaner UX, avoids confusion with non-prioritized areas
- Filters focusAreasGrouped by userFocusWeights

**Bug 3: Vitals RHR entry - Internal Server Error**
- Fixed: Endpoint tried to INSERT into vitals_log (renamed in Migration 015)
- Now uses vitals_baseline table (correct post-migration table)
- Removed BP fields from baseline endpoint (use /blood-pressure instead)
- Backward compatible return format

All fixes tested and ready for production.
2026-03-27 21:04:28 +01:00

685 lines
25 KiB
Python

"""
Vitals Router - Resting HR + HRV Tracking
v9d Phase 2: Vitals Module
Endpoints:
- GET /api/vitals List vitals (with limit)
- GET /api/vitals/by-date/{date} Get vitals for specific date
- POST /api/vitals Create/update vitals (upsert)
- 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, 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
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
resting_hr: Optional[int] = None
hrv: Optional[int] = None
blood_pressure_systolic: Optional[int] = None
blood_pressure_diastolic: Optional[int] = None
pulse: Optional[int] = None
vo2_max: Optional[float] = None
spo2: Optional[int] = None
respiratory_rate: Optional[float] = None
irregular_heartbeat: Optional[bool] = None
possible_afib: Optional[bool] = None
note: Optional[str] = None
class VitalsUpdate(BaseModel):
date: Optional[str] = None
resting_hr: Optional[int] = None
hrv: Optional[int] = None
blood_pressure_systolic: Optional[int] = None
blood_pressure_diastolic: Optional[int] = None
pulse: Optional[int] = None
vo2_max: Optional[float] = None
spo2: Optional[int] = None
respiratory_rate: Optional[float] = None
irregular_heartbeat: Optional[bool] = None
possible_afib: Optional[bool] = None
note: Optional[str] = None
def get_pid(x_profile_id: Optional[str], session: dict) -> str:
"""Extract profile_id from session (never from header for security)."""
return session['profile_id']
@router.get("")
def list_vitals(
limit: int = 90,
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth)
):
"""Get vitals entries for current profile."""
pid = get_pid(x_profile_id, session)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT id, profile_id, date, resting_hr, hrv,
blood_pressure_systolic, blood_pressure_diastolic, pulse,
vo2_max, spo2, respiratory_rate,
irregular_heartbeat, possible_afib,
note, source, created_at, updated_at
FROM vitals_log
WHERE profile_id = %s
ORDER BY date DESC
LIMIT %s
""",
(pid, limit)
)
return [r2d(r) for r in cur.fetchall()]
@router.get("/by-date/{date}")
def get_vitals_by_date(
date: str,
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth)
):
"""Get vitals entry for a specific date."""
pid = get_pid(x_profile_id, session)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT id, profile_id, date, resting_hr, hrv,
blood_pressure_systolic, blood_pressure_diastolic, pulse,
vo2_max, spo2, respiratory_rate,
irregular_heartbeat, possible_afib,
note, source, created_at, updated_at
FROM vitals_log
WHERE profile_id = %s AND date = %s
""",
(pid, date)
)
row = cur.fetchone()
if not row:
raise HTTPException(404, "Keine Vitalwerte für dieses Datum gefunden")
return r2d(row)
@router.post("")
def create_vitals(
entry: VitalsEntry,
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth)
):
"""
Create or update vitals entry (upsert).
Post-Migration-015: Routes to vitals_baseline (for RHR, HRV, etc.)
Note: BP measurements should use /api/blood-pressure endpoint instead.
"""
pid = get_pid(x_profile_id, session)
# Validation: at least one baseline vital must be provided
has_baseline = any([
entry.resting_hr, entry.hrv, entry.vo2_max,
entry.spo2, entry.respiratory_rate
])
if not has_baseline:
raise HTTPException(400, "Mindestens ein Vitalwert muss angegeben werden (RHR, HRV, VO2Max, SpO2, oder Atemfrequenz)")
with get_db() as conn:
cur = get_cursor(conn)
# Upsert into vitals_baseline (Migration 015)
cur.execute(
"""
INSERT INTO vitals_baseline (
profile_id, date, resting_hr, hrv,
vo2_max, spo2, respiratory_rate,
note, source
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, 'manual')
ON CONFLICT (profile_id, date)
DO UPDATE SET
resting_hr = COALESCE(EXCLUDED.resting_hr, vitals_baseline.resting_hr),
hrv = COALESCE(EXCLUDED.hrv, vitals_baseline.hrv),
vo2_max = COALESCE(EXCLUDED.vo2_max, vitals_baseline.vo2_max),
spo2 = COALESCE(EXCLUDED.spo2, vitals_baseline.spo2),
respiratory_rate = COALESCE(EXCLUDED.respiratory_rate, vitals_baseline.respiratory_rate),
note = COALESCE(EXCLUDED.note, vitals_baseline.note),
updated_at = CURRENT_TIMESTAMP
RETURNING id, profile_id, date, resting_hr, hrv,
vo2_max, spo2, respiratory_rate,
note, source, created_at, updated_at
""",
(pid, entry.date, entry.resting_hr, entry.hrv,
entry.vo2_max, entry.spo2, entry.respiratory_rate,
entry.note)
)
row = cur.fetchone()
conn.commit()
logger.info(f"[VITALS] Upserted baseline vitals for {pid} on {entry.date}")
# Return in legacy format for backward compatibility
result = r2d(row)
result['blood_pressure_systolic'] = None
result['blood_pressure_diastolic'] = None
result['pulse'] = None
result['irregular_heartbeat'] = None
result['possible_afib'] = None
return result
@router.put("/{vitals_id}")
def update_vitals(
vitals_id: int,
updates: VitalsUpdate,
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth)
):
"""Update existing vitals entry."""
pid = get_pid(x_profile_id, session)
with get_db() as conn:
cur = get_cursor(conn)
# Check ownership
cur.execute(
"SELECT id FROM vitals_log WHERE id = %s AND profile_id = %s",
(vitals_id, pid)
)
if not cur.fetchone():
raise HTTPException(404, "Eintrag nicht gefunden")
# Build update query dynamically
fields = []
values = []
if updates.date is not None:
fields.append("date = %s")
values.append(updates.date)
if updates.resting_hr is not None:
fields.append("resting_hr = %s")
values.append(updates.resting_hr)
if updates.hrv is not None:
fields.append("hrv = %s")
values.append(updates.hrv)
if updates.blood_pressure_systolic is not None:
fields.append("blood_pressure_systolic = %s")
values.append(updates.blood_pressure_systolic)
if updates.blood_pressure_diastolic is not None:
fields.append("blood_pressure_diastolic = %s")
values.append(updates.blood_pressure_diastolic)
if updates.pulse is not None:
fields.append("pulse = %s")
values.append(updates.pulse)
if updates.vo2_max is not None:
fields.append("vo2_max = %s")
values.append(updates.vo2_max)
if updates.spo2 is not None:
fields.append("spo2 = %s")
values.append(updates.spo2)
if updates.respiratory_rate is not None:
fields.append("respiratory_rate = %s")
values.append(updates.respiratory_rate)
if updates.irregular_heartbeat is not None:
fields.append("irregular_heartbeat = %s")
values.append(updates.irregular_heartbeat)
if updates.possible_afib is not None:
fields.append("possible_afib = %s")
values.append(updates.possible_afib)
if updates.note is not None:
fields.append("note = %s")
values.append(updates.note)
if not fields:
raise HTTPException(400, "Keine Änderungen angegeben")
fields.append("updated_at = CURRENT_TIMESTAMP")
values.append(vitals_id)
query = f"""
UPDATE vitals_log
SET {', '.join(fields)}
WHERE id = %s
RETURNING id, profile_id, date, resting_hr, hrv,
blood_pressure_systolic, blood_pressure_diastolic, pulse,
vo2_max, spo2, respiratory_rate,
irregular_heartbeat, possible_afib,
note, source, created_at, updated_at
"""
cur.execute(query, values)
row = cur.fetchone()
conn.commit()
return r2d(row)
@router.delete("/{vitals_id}")
def delete_vitals(
vitals_id: int,
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth)
):
"""Delete vitals entry."""
pid = get_pid(x_profile_id, session)
with get_db() as conn:
cur = get_cursor(conn)
# Check ownership and delete
cur.execute(
"DELETE FROM vitals_log WHERE id = %s AND profile_id = %s RETURNING id",
(vitals_id, pid)
)
if not cur.fetchone():
raise HTTPException(404, "Eintrag nicht gefunden")
conn.commit()
logger.info(f"[VITALS] Deleted vitals {vitals_id} for {pid}")
return {"message": "Eintrag gelöscht"}
@router.get("/stats")
def get_vitals_stats(
days: int = 30,
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth)
):
"""
Get vitals statistics over the last N days.
Returns:
- avg_resting_hr (7d and 30d)
- avg_hrv (7d and 30d)
- trend (increasing/decreasing/stable)
- latest values
"""
pid = get_pid(x_profile_id, session)
with get_db() as conn:
cur = get_cursor(conn)
# Get latest entry
cur.execute(
"""
SELECT date, resting_hr, hrv
FROM vitals_log
WHERE profile_id = %s AND date >= CURRENT_DATE - INTERVAL '%s days'
ORDER BY date DESC
LIMIT 1
""",
(pid, days)
)
latest = cur.fetchone()
# Get averages (7d and 30d)
cur.execute(
"""
SELECT
AVG(CASE WHEN date >= CURRENT_DATE - INTERVAL '7 days' THEN resting_hr END) as avg_hr_7d,
AVG(CASE WHEN date >= CURRENT_DATE - INTERVAL '30 days' THEN resting_hr END) as avg_hr_30d,
AVG(CASE WHEN date >= CURRENT_DATE - INTERVAL '7 days' THEN hrv END) as avg_hrv_7d,
AVG(CASE WHEN date >= CURRENT_DATE - INTERVAL '30 days' THEN hrv END) as avg_hrv_30d,
AVG(CASE WHEN date >= CURRENT_DATE - INTERVAL '7 days' THEN blood_pressure_systolic END) as avg_bp_sys_7d,
AVG(CASE WHEN date >= CURRENT_DATE - INTERVAL '30 days' THEN blood_pressure_systolic END) as avg_bp_sys_30d,
AVG(CASE WHEN date >= CURRENT_DATE - INTERVAL '7 days' THEN blood_pressure_diastolic END) as avg_bp_dia_7d,
AVG(CASE WHEN date >= CURRENT_DATE - INTERVAL '30 days' THEN blood_pressure_diastolic END) as avg_bp_dia_30d,
AVG(CASE WHEN date >= CURRENT_DATE - INTERVAL '7 days' THEN spo2 END) as avg_spo2_7d,
AVG(CASE WHEN date >= CURRENT_DATE - INTERVAL '30 days' THEN spo2 END) as avg_spo2_30d,
COUNT(*) as total_entries
FROM vitals_log
WHERE profile_id = %s AND date >= CURRENT_DATE - INTERVAL '%s days'
""",
(pid, max(days, 30))
)
stats_row = cur.fetchone()
# Get latest VO2 Max
cur.execute(
"""
SELECT vo2_max
FROM vitals_log
WHERE profile_id = %s AND vo2_max IS NOT NULL
ORDER BY date DESC
LIMIT 1
""",
(pid,)
)
vo2_row = cur.fetchone()
latest_vo2 = vo2_row['vo2_max'] if vo2_row else None
# Get entries for trend calculation (last 14 days)
cur.execute(
"""
SELECT date, resting_hr, hrv
FROM vitals_log
WHERE profile_id = %s AND date >= CURRENT_DATE - INTERVAL '14 days'
ORDER BY date ASC
""",
(pid,)
)
entries = [r2d(r) for r in cur.fetchall()]
# Simple trend calculation (compare first half vs second half)
trend_hr = "stable"
trend_hrv = "stable"
if len(entries) >= 4:
mid = len(entries) // 2
first_half_hr = [e['resting_hr'] for e in entries[:mid] if e['resting_hr']]
second_half_hr = [e['resting_hr'] for e in entries[mid:] if e['resting_hr']]
if first_half_hr and second_half_hr:
avg_first = sum(first_half_hr) / len(first_half_hr)
avg_second = sum(second_half_hr) / len(second_half_hr)
diff = avg_second - avg_first
if diff > 2:
trend_hr = "increasing"
elif diff < -2:
trend_hr = "decreasing"
first_half_hrv = [e['hrv'] for e in entries[:mid] if e['hrv']]
second_half_hrv = [e['hrv'] for e in entries[mid:] if e['hrv']]
if first_half_hrv and second_half_hrv:
avg_first_hrv = sum(first_half_hrv) / len(first_half_hrv)
avg_second_hrv = sum(second_half_hrv) / len(second_half_hrv)
diff_hrv = avg_second_hrv - avg_first_hrv
if diff_hrv > 5:
trend_hrv = "increasing"
elif diff_hrv < -5:
trend_hrv = "decreasing"
return {
"latest": r2d(latest) if latest else None,
"avg_resting_hr_7d": round(stats_row['avg_hr_7d'], 1) if stats_row['avg_hr_7d'] else None,
"avg_resting_hr_30d": round(stats_row['avg_hr_30d'], 1) if stats_row['avg_hr_30d'] else None,
"avg_hrv_7d": round(stats_row['avg_hrv_7d'], 1) if stats_row['avg_hrv_7d'] else None,
"avg_hrv_30d": round(stats_row['avg_hrv_30d'], 1) if stats_row['avg_hrv_30d'] else None,
"avg_bp_systolic_7d": round(stats_row['avg_bp_sys_7d'], 1) if stats_row['avg_bp_sys_7d'] else None,
"avg_bp_systolic_30d": round(stats_row['avg_bp_sys_30d'], 1) if stats_row['avg_bp_sys_30d'] else None,
"avg_bp_diastolic_7d": round(stats_row['avg_bp_dia_7d'], 1) if stats_row['avg_bp_dia_7d'] else None,
"avg_bp_diastolic_30d": round(stats_row['avg_bp_dia_30d'], 1) if stats_row['avg_bp_dia_30d'] else None,
"avg_spo2_7d": round(stats_row['avg_spo2_7d'], 1) if stats_row['avg_spo2_7d'] else None,
"avg_spo2_30d": round(stats_row['avg_spo2_30d'], 1) if stats_row['avg_spo2_30d'] else None,
"latest_vo2_max": float(latest_vo2) if latest_vo2 else None,
"total_entries": stats_row['total_entries'],
"trend_resting_hr": trend_hr,
"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
}