Skip to content

feat(tools): import an Apple Health export into the Pulse HEALTH tab - #1754

Open
elhoim wants to merge 1 commit into
danielmiessler:mainfrom
elhoim:feat/apple-health-import
Open

feat(tools): import an Apple Health export into the Pulse HEALTH tab#1754
elhoim wants to merge 1 commit into
danielmiessler:mainfrom
elhoim:feat/apple-health-import

Conversation

@elhoim

@elhoim elhoim commented Aug 3, 2026

Copy link
Copy Markdown

Why

There is no way to get an Apple Health export into LifeOS today. healthsync/apple.ts reads a JSON file written by an iPhone Shortcut into iCloud Drive at a hardcoded macOS path; the Health app's Export All Health Data produces export.zip wrapping export.xml, which nothing in the tree parses.

What

One new file, install/LIFEOS/TOOLS/AppleHealthImport.ts:

bun AppleHealthImport.ts <export.zip|export.xml> [--days N] [--out PATH] [--dry-run]

It streams the export, folds records into daily buckets across 18 HealthKit types, and writes a generated APPLE_HEALTH.md into the health directory. It writes only that file and regenerates it end to end, so hand-authored METRICS.md / FITNESS.md are never read or touched.

The headline numbers go in the ## headings on purpose. handleLifeHealth returns section bodies, but the health page's FileCard renders headings only, capped at 8. A heading of "Steps" would display the word "Steps" with the value invisible. Tables still sit under each heading for reading the file directly and for the DA.

Two things real exports made non-obvious

Multi-source double counting. Health stores every device's view of the same day side by side. The export I tested against carried StepCount from six sources and BasalEnergyBurned from four — phones, a ring, and app-level writers. Summing samples inflated steps by 62% and put basal energy at roughly twice any plausible BMR. Samples are now bucketed per source and collapsed per day: largest total wins for sum, most samples wins for avg, globally-latest wins for last. The winning device and the days it won are reported, so a wrong pick is visible rather than silent. Apple's own Health app solves this with a per-source priority list; this needs no configuration.

Locale-dependent units. The export is written in the phone's locale, so a US export emits mi and lb where a metric one emits km and kg. The record's own unit attribute is now read and converted. A unit that can be neither aliased nor converted causes the sample to be dropped and reported rather than printed under the wrong label. Note unit="count" is a bare tally whose meaning is per-metric (steps vs flights), so it resolves against the metric rather than through a global alias — getting that wrong silently dropped an entire metric in testing.

Verification

Tested against a real 12-year export: 1,140,469 records, 358MB of XML in a 20MB zip, 773,282 samples used across 10 metrics, in 17s.

  • Chunk safety — identical record counts and per-metric fingerprints at chunk sizes 7, 13, 64, 511, 4096 and 65536 bytes versus a whole-file parse. At 7 bytes nearly every record splits mid-attribute.
  • Streaming, not buffering — 285MB input → 332MB peak RSS; 855MB input → 341MB peak RSS. Flat memory across a 3× input, time scaling linearly.
  • Double-count fix — steps dropped from 12,710 to 7,823/day and basal energy from 4,073 to 2,120 kcal/day on the same export, the latter moving from implausible to consistent with the subject's body mass.
  • Renders/api/life/health returned the generated file with all 11 sections; a DOM read of the running dashboard showed the card with its bullets, confirmed against a captured screenshot.
  • Both .zip (via unzip -p) and raw .xml inputs parse; a missing unzip gives an actionable error rather than a stack trace.

Not run: a fresh-system install verification (step 3 of the contributing process).

Follow-up, not in this PR

The richer path is emitting the healthsync DayFile shape into USER/HEALTH/DATA so Apple data composes with the oura/eightsleep/function adapters. That needs a consumer first — nothing in Pulse currently reads that directory, and the health page would need timeseries rendering to show it.

Adds AppleHealthImport.ts, which turns the Health app's 'Export All Health
Data' backup into a generated APPLE_HEALTH.md that the existing health
surface reads. There was no path for this before: healthsync/apple.ts
expects a live-sync JSON file written by an iPhone Shortcut into iCloud
Drive, not the export.zip the Health app produces, and no parser for
export.xml existed anywhere in the tree.

Two things the export makes non-obvious, both handled:

Health stores every device's view of the same day side by side. A real
export carried StepCount from six sources and BasalEnergyBurned from
four, so summing samples double- and triple-counted: steps came out 62%
high and basal energy at roughly twice a plausible BMR. Samples are now
bucketed per source and collapsed per day, picking the device with the
largest total for sums and the most samples for averages. The winning
device and how many days it won are reported in the output.

The export is written in the phone's locale, so a US export emits mi and
lb where a metric one emits km and kg. The record's own unit attribute is
now read and converted, and a sample in a unit that can be neither
aliased nor converted is dropped and reported rather than printed under
the wrong label.

export.xml is routinely hundreds of megabytes, so it is streamed out of
the zip via unzip -p and matched per record, carrying the trailing
fragment across chunk boundaries. Peak memory stays flat regardless of
input size.

Writes only its own file; hand-authored METRICS.md and FITNESS.md are
never read or touched.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant