diff --git a/docs/integrations/apple-health.md b/docs/integrations/apple-health.md index 0b3c4209d..bc21e02ae 100644 --- a/docs/integrations/apple-health.md +++ b/docs/integrations/apple-health.md @@ -30,8 +30,10 @@ re-uploading the same archive merges instead of duplicating. The archive contains `apple_health_export/export.xml` (the canonical record stream) plus a handful of auxiliary files (workout routes, -clinical records, electrocardiograms). The importer reads -`export.xml` and ignores the rest. +clinical records, electrocardiograms). The importer reads `export.xml` +and supported ECG CSVs under +`apple_health_export/electrocardiograms/`; unsupported auxiliary files +remain ignored. ## 2. Upload to HealthLog @@ -76,7 +78,15 @@ canonical envelope: "uploadBytes": 412345678, "exportedAt": "2026-04-30T18:12:00.000Z", "progress": { "records_seen": 124000, "workouts_seen": 230 }, - "result": null, + "result": { + "ecg": { + "discovered": 2, + "imported": 1, + "updated": 0, + "skipped": 0, + "failed": 1 + } + }, "failureReason": null }, "error": null @@ -94,8 +104,8 @@ they triggered. ## What gets imported -The parser folds four XML element types into the existing HealthLog -schema (CHANGELOG v1.4.34): +The parser folds four XML element types and supported auxiliary ECG +CSVs into the existing HealthLog schema (CHANGELOG v1.4.34): | XML element | Maps to | Notes | | ------------------ | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -103,11 +113,45 @@ schema (CHANGELOG v1.4.34): | `` | `Workout` rows | Activity type + duration + energy + distance. | | `` | `Measurement` rows | Compound samples like blood pressure (systolic + diastolic) explode into their per-metric children. | | `` | `Measurement` rows where the type maps | Health-record FHIR snapshots; only the metric-typed fields are ingested. | +| ECG CSV | `EcgRecording` rows | The source-provided waveform is encrypted before persistence; descriptors and the device's classification are stored without diagnostic inference. | The HK quantity types that map to HealthLog metric types live in `src/lib/measurements/apple-health-mapping.ts`. Anything outside the -mapped set (workout routes, ECG voltage traces) is counted in -`progress.records_skipped` but not stored. +mapped set (for example workout routes) is counted as unsupported and +not stored. + +### ECG archive safety, privacy, and idempotency + +ECG support is source-agnostic: if HealthKit includes a valid +`HKElectrocardiogram` CSV, HealthLog can import it regardless of which +compatible device originally wrote it. Archive member names are never +used as filesystem paths and ECGs are never extracted to plaintext +temporary files. + +The auxiliary reader enforces limits before and during decompression: + +- at most 20,000 total archive members and 2,000 ECG CSV members; +- at most 16 MiB per decompressed ECG CSV and 512 MiB across all ECG CSVs; +- at most a 200:1 declared compression ratio; and +- at most 2,000,000 waveform samples per recording. + +Encrypted ZIP members, unsupported compression, unsafe or duplicate +paths, forged size metadata, malformed timestamps or samples, and +limit overruns are rejected. An ECG failure is fail-soft: valid +`export.xml` measurements and workouts still commit, while the +terminal result reports only +`discovered/imported/updated/skipped/failed` ECG counts. It never +returns filenames, source labels, sample values, or raw parser errors. + +Waveforms are converted to integer microvolts and encrypted with the +existing AES-256-GCM ECG codec before the transaction writes an +`EcgRecording` with `source=APPLE_HEALTH`. Stable identity is derived +from normalized recording content rather than the CSV filename, so a +renamed re-export updates the same user-scoped row while distinct +recordings and different users remain independent. Known Apple +device classifications are preserved as source-reported facts; +unknown or localized classifications are stored as `null`. HealthLog +does not inspect the waveform to infer AFib or make a diagnosis. ### Cumulative HK type → daily aggregate @@ -148,8 +192,8 @@ The two-axis idempotency story: 1. **File-level** — re-uploading the same `export.zip` (same SHA-256 of bytes) short-circuits to the previous job only when that job - used the current parser revision. Existing jobs are revision 1; - new jobs use revision 2, so a pre-fix successful archive can be + used the current parser revision. Existing jobs may use revisions 1 + or 2; new jobs use revision 3, so an earlier successful archive can be deliberately processed again under the corrected aggregation. 2. **Record-level** — when an older `export.zip` is re-exported from iOS (same history, slightly newer device timestamps), every @@ -162,6 +206,21 @@ sees fresh data merged in without duplicates. The job result line in the status response reports `recordsInserted` and `recordsUpdated` separately. +### WHOOP records and current export limits + +Supported measurements written into HealthKit by another app are +handled by HealthKit type, not by a raw writer label. For example, a +supported heart-rate record written by WHOOP is imported as an +Apple Health measurement and participates in the normal source +priority rules. + +WHOOP currently exports neither ECG recordings nor Blood Pressure +Insights to Apple Health. Those two WHOOP data classes therefore +cannot arrive through an Apple Health `export.zip`; a WHOOP writer +label on some other HealthKit record is not evidence of a WHOOP ECG +export. HealthLog's direct WHOOP OAuth/API integration remains +supported for the WHOOP data the API exposes. + ## Admin variant — import on behalf of another user `POST /api/admin/import-apple-health-export` is the cookie-only admin diff --git a/src/app/api/import/apple-health-export/[jobId]/status/route.ts b/src/app/api/import/apple-health-export/[jobId]/status/route.ts index 41e3e11f4..4cb8c2276 100644 --- a/src/app/api/import/apple-health-export/[jobId]/status/route.ts +++ b/src/app/api/import/apple-health-export/[jobId]/status/route.ts @@ -36,6 +36,32 @@ interface ImportJobStatusResponse { failureReason: string | null; } +function boundedCount(value: unknown): number { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 + ? value + : 0; +} + +function privacySafeResult( + value: Record | null, +): Record | null { + if (!value) return null; + const ecg = + typeof value.ecg === "object" && value.ecg !== null + ? (value.ecg as Record) + : {}; + return { + ...value, + ecg: { + discovered: boundedCount(ecg.discovered), + imported: boundedCount(ecg.imported), + updated: boundedCount(ecg.updated), + skipped: boundedCount(ecg.skipped), + failed: boundedCount(ecg.failed), + }, + }; +} + export const GET = apiHandler( async (_request: NextRequest, ctx: RouteContext) => { const { user } = await requireAuth(); @@ -68,7 +94,9 @@ export const GET = apiHandler( uploadBytes: row.uploadBytes, exportedAt: row.exportedAt?.toISOString() ?? null, progress: (row.progress as Record) ?? {}, - result: (row.result as Record | null) ?? null, + result: privacySafeResult( + (row.result as Record | null) ?? null, + ), failureReason: row.failureReason, }; diff --git a/src/components/settings/import-panel/apple-health-import-card.tsx b/src/components/settings/import-panel/apple-health-import-card.tsx index 1d8d12af8..cbd26db28 100644 --- a/src/components/settings/import-panel/apple-health-import-card.tsx +++ b/src/components/settings/import-panel/apple-health-import-card.tsx @@ -30,6 +30,13 @@ interface JobStatus { totals?: { recordsRead?: number; rowsUpserted?: number }; clinical?: { skipped?: number }; cumulativeEstimates?: { days?: number; rows?: number }; + ecg?: { + discovered?: number; + imported?: number; + updated?: number; + skipped?: number; + failed?: number; + }; } | null; failureReason: string | null; } @@ -152,6 +159,8 @@ export function AppleHealthImportCard() { const isFailed = status?.status === "failed"; const busy = uploading || isRunning; const percent = status?.progress?.percent ?? null; + const ecg = status?.result?.ecg; + const ecgWritten = (ecg?.imported ?? 0) + (ecg?.updated ?? 0); return ( )} + {isDone && (ecg?.discovered ?? 0) > 0 && ( +

+ ECGs: {ecg?.discovered ?? 0} discovered, {ecg?.imported ?? 0}{" "} + imported, {ecg?.updated ?? 0} updated, {ecg?.skipped ?? 0} skipped,{" "} + {ecg?.failed ?? 0} failed. ECG failures do not discard supported + records imported from export.xml. +

+ )} {isDone && (status?.result?.cumulativeEstimates?.days ?? 0) > 0 && (