Johannes Kaindl1k downloadsImport Apple Health exports and explore your health data in charts and tables.
An Obsidian plugin that reads Apple Health exports and makes the data searchable and visualizable inside your vault.
🇬🇧 English · 🇩🇪 Deutsch
No HealthKit access — Obsidian runs in Electron, and HealthKit is a native iOS/macOS API. This plugin works with the export file you send yourself from the Health app.

Export.zip or an unpacked Export.xml as a stream — several gigabytes
without filling up memory..md/.csv into a vault
folder of your choice.Apple's Export.xml easily reaches several gigabytes (2.6 GB with 5.7 million
records in the test case). Ordinary XML parsers load all of it into memory and crash.
This plugin parses as a stream (SAX-style, chunk by chunk) and stores nothing but
compact daily aggregates — the cache built from 5.7 million records is ~2.7 MB.
isDesktopOnly: true; importing multi-gigabyte XML files
only makes sense there.Export.zip or an unpacked Export.xml), created in the
Health app on an iPhone. So an iPhone is only needed to produce the export, not to
use the plugin.From the community store (recommended): in Obsidian → Settings → Community plugins → Browse → search for "Health Vitals" → Install → Enable.
Manual install: download main.js, manifest.json and styles.css of the latest
release from the
releases page, put them
into <vault>/.obsidian/plugins/health-vitals/, then enable the plugin under
Settings → Community plugins.
From source: clone the repository, run npm install && npm run build, then copy
main.js, manifest.json and styles.css into the same folder.
Export.zip to your computer.Export.zip (or an unpacked
Export.xml) in the file dialog.
A large export takes a few minutes. Progress, current phase and a cancel button stay visible in the dashboard while it runs; the overview opens by itself afterwards.
The result is health-cache.json in the plugin directory: daily aggregates per metric
plus a list of workouts.
The interface is bilingual (German/English) and follows Obsidian's UI language automatically — a German Obsidian shows German, anything else shows English. There is no separate setting for it.
Command palette → "Health Vitals: Open dashboard" (or the ribbon icon). The
dashboard loads health-cache.json lazily when opened, so vault startup stays
untouched. Three tabs:
The value table below the chart expands on demand, and can be copied or written into the vault as Markdown or CSV:
Charts are hand-drawn SVG without a charting library and use nothing but Obsidian's theme variables — so they adapt to any theme (light/dark/community).
This plugin reads one file outside your vault: the health export you pick in the
file dialog. That is necessary because an Apple Health export is several gigabytes and
does not belong in a vault. That export file is only ever read — nothing about it is
written, moved or sent anywhere. The data derived from it lands as
health-cache.json in the plugin directory on your machine.
Separately, the Detail tab can write value tables inside the vault as files on request — that is not access outside the vault, but an ordinary write into a folder you chose. Details under "Privacy".
The plugin has no settings tab. What it remembers, you set where it takes effect — in the dashboard:
| Setting | Where | Meaning |
|---|---|---|
| Favourites | Star on a tile (Overview) | Pinned metrics sit at the top |
| Collapsed state | Category groups (Overview) | Which groups stay open |
| Export folder | Detail tab, folder field with autocomplete | Target folder of the value table |
| Export format | Detail tab | .md or .csv |
That is stored in data.json in the plugin folder — not in the cache, which the next
import overwrites. The interface language is deliberately not a setting: it follows
Obsidian.
The chart takes its colours from your theme's own variables, so it already follows any theme you install. If you want different ones anyway — colour vision differences are individual, and no default fits everyone — a CSS snippet does it without a settings tab (Settings → Appearance → CSS snippets):
/* Every stage takes one rule: `fill` colours the bar segment, `background-color` the
swatch in the legend. Setting only one of them makes the legend lie. */
.ah-stage-deep { fill: var(--color-purple); background-color: var(--color-purple); }
.ah-stage-core { fill: var(--color-blue); background-color: var(--color-blue); }
.ah-stage-rem { fill: var(--color-pink); background-color: var(--color-pink); }
.ah-stage-unspecified { fill: var(--text-muted); background-color: var(--text-muted); }
One thing worth knowing when you pick: the accent colours of a theme (--color-*) owe
you no contrast against the background — they are made for lines and highlights. On a
light theme several of them land near 2:1, where WCAG asks for 3:1 on meaningful areas.
Mixing an accent with the text colour keeps the hue and buys the contrast, which is what
the default for "core" does:
color-mix(in srgb, var(--color-cyan) 75%, var(--text-normal)).
The presentation follows the kind of metric:
| Kind | Examples | Aggregation | Chart |
|---|---|---|---|
sum |
Steps, calories | Daily sum | Bars |
measure |
Weight, heart rate | Mean with min/max | Line + band |
duration |
Mindfulness | Sum of intervals | Bars |
Weekly/monthly bucketing sums or averages accordingly (not sums) — a mean heart rate over a month stays a mean.
Durations are shown as hours and minutes (7h 12m), and as hours only beyond a day.
The CSV export instead contains the raw values in minutes.
Sleep is not aggregated like the other metrics and appears as two equally ranked values: "asleep" (time actually slept) and "in bed".
The reason lies in the data: Apple exports the same time several times over for the same night — time in bed encloses the sleep stages within it, and several devices (iPhone, watch, third-party apps) describe the same night in parallel. Added up, that yields impossible values. Overlapping intervals are therefore merged rather than summed: time recorded twice counts once.
A night belongs to the day you wake up on. Sleep starting from 20:00 counts towards the following day — otherwise the night that ends in the morning and the one that begins in the evening would fall on the same calendar day.
Picking "Sleep" in the Detail tab adds a stacked bar per night below the time series — deep, core and REM, with the bar height still being the time slept:
Nights recorded before watchOS 9 carry no breakdown. They stay neutrally coloured, and once they make up more than half of the period, a note says so rather than leaving the grey to be guessed at:
Health data is particularly sensitive. Therefore:
health-cache.json is gitignored — it never ends up in a repository by accident.
There is no import/ folder anymore; the export is read straight from the file
dialog, without anything being copied into the plugin directory.isDesktopOnly: true — importing large XML files only makes sense on the desktop..md or .csv file into a vault
folder you chose (folder field with autocomplete over your existing folders). The file
name is composed of the metric name plus the first and last time key of the table. If
the file already exists, it is never overwritten — the plugin appends a running
number instead ( 2, 3, …) until it finds a free name. These export files then sit
in your vault like any other note: if your vault is synced or versioned, so are they.If you sync your vault, health-cache.json sits in the plugin folder under
.obsidian/ and comes along depending on your sync configuration — deliberately your
decision.
npm run dev # esbuild watch
npm run build # typecheck + production bundle → main.js
npm test # vitest
npm run typecheck # tsc --noEmit
npm run lint # eslint (obsidianmd, type-checked)
npm run deploy # build + copy to $OBSIDIAN_PLUGIN_DIR
The code is split into a pure core layer (src/core/ — parser, aggregation, chart
geometry, view models; no obsidian import, testable in Node) and an Obsidian layer
(src/obsidian/ — view, SVG rendering, file access). Conventions and architecture
notes: AGENTS.md.
Note for contributors: renderer-specific behaviour (SVG DOM, ItemView, web
workers) is invisible to Node unit tests — changes to the Obsidian layer need a manual
test in real Obsidian on top.
Copyright © 2026 Johannes Kaindl
Licensed under the GNU AGPL v3.0 or later. The documentation is licensed CC BY-SA 4.0, see LICENSE-DOCS.