mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
feat(01-19): expose privacy-safe ECG import outcomes
- return and render bounded ECG result counters with partial-failure wording - document archive limits, encrypted storage, and exact WHOOP constraints
This commit is contained in:
1 parent
a686c7200b
commit
4b64556b38
3 files changed
+118
-11
No files matched your search
@@ -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>` | `Workout` rows | Activity type + duration + energy + distance. |
|
||||
| `<Correlation>` | `Measurement` rows | Compound samples like blood pressure (systolic + diastolic) explode into their per-metric children. |
|
||||
| `<ClinicalRecord>` | `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
|
||||
|
||||
@@ -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<string, unknown> | null,
|
||||
): Record<string, unknown> | null {
|
||||
if (!value) return null;
|
||||
const ecg =
|
||||
typeof value.ecg === "object" && value.ecg !== null
|
||||
? (value.ecg as Record<string, unknown>)
|
||||
: {};
|
||||
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<string, unknown>) ?? {},
|
||||
result: (row.result as Record<string, unknown> | null) ?? null,
|
||||
result: privacySafeResult(
|
||||
(row.result as Record<string, unknown> | null) ?? null,
|
||||
),
|
||||
failureReason: row.failureReason,
|
||||
};
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<ImportCardShell
|
||||
@@ -241,7 +250,7 @@ export function AppleHealthImportCard() {
|
||||
{isDone && (
|
||||
<WrittenOutcomeLine
|
||||
outcome={classifyWrittenOutcome({
|
||||
written: status?.result?.totals?.rowsUpserted ?? 0,
|
||||
written: (status?.result?.totals?.rowsUpserted ?? 0) + ecgWritten,
|
||||
// The clinical count is a documented, deliberate exclusion
|
||||
// (electrocardiograms, lab documents), not a refused row — it
|
||||
// must not tip the archive into a warning. Only "nothing
|
||||
@@ -262,6 +271,17 @@ export function AppleHealthImportCard() {
|
||||
testId="import-apple-health-result"
|
||||
/>
|
||||
)}
|
||||
{isDone && (ecg?.discovered ?? 0) > 0 && (
|
||||
<p
|
||||
data-testid="import-apple-health-ecg-result"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
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.
|
||||
</p>
|
||||
)}
|
||||
{isDone && (status?.result?.cumulativeEstimates?.days ?? 0) > 0 && (
|
||||
<AppleHealthEstimateWarning
|
||||
days={status?.result?.cumulativeEstimates?.days ?? 0}
|
||||
|
||||
Reference in new issue
Block a user