docs(import): document the data-import paths, point Apple Health import at the new settings area

Add docs/integrations/data-import.md covering the Apple Health
export.zip path, the generic JSON import schema with the full
MeasurementType enum and canonical units, a worked example, and a
CSV-to-JSON conversion how-to. Fix the Apple Health doc to point at
Settings -> Export & Import instead of a non-existent import page. Extend
the export e2e spec with the import surfaces and the example-download
happy path.
This commit is contained in:
Marc-André Bombeck committed 2026-06-07 00:07:58 +02:00
1 parent c7bf62e4d9
commit 4d4dcac505
3 files changed
+264 -8

No files matched your search

+5 -4
View File
@@ -35,10 +35,11 @@ clinical records, electrocardiograms). The importer reads
## 2. Upload to HealthLog
Open the import page from the in-app settings tree. Drop the
`export.zip` onto the upload control. The browser streams the body
directly to the server; nothing is buffered into memory on either
side.
Open **Settings → Export & Import** in the app and drop the
`export.zip` onto the Apple Health upload control (or click to pick the
file). The browser streams the body directly to the server; nothing is
buffered into memory on either side. A progress indicator polls the
job until it reports the imported / skipped counts.
The endpoint is `POST /api/import/apple-health-export`. The handler:
+224
View File
@@ -0,0 +1,224 @@
# Data import
HealthLog has two ways to bring data in, both reachable from
**Settings → Export & Import**:
1. An **Apple Health `export.zip`** upload — the full archive from the
iPhone Health app, parsed server-side. See
[`apple-health.md`](./apple-health.md) for the end-to-end walkthrough,
the HealthKit type mapping, and the streaming / idempotency model.
2. A **generic JSON import** — a small, explicit payload of measurements
and mood entries, useful for restoring a partial export or migrating
from another tracker. This page documents that format.
Both paths skip rows that already exist, so re-running an import is
safe — it merges rather than duplicating.
## 1. Apple Health `export.zip`
On the iPhone: **Health app → profile picture → Export All Health
Data**, then move the resulting `export.zip` somewhere you can reach
from a browser. In HealthLog open **Settings → Export & Import** and drop
the archive onto the Apple Health control (or click to pick it). The
upload streams to the server — a multi-gigabyte archive never buffers in
the browser — and a background job parses it while a progress indicator
polls for the imported / skipped counts.
Re-uploading the same archive resolves to the same job by content hash,
so it merges instead of creating duplicates. Clinical records
(electrocardiograms, lab documents) are intentionally skipped. The full
type mapping lives in [`apple-health.md`](./apple-health.md).
## 2. Generic JSON import
`POST /api/import` accepts a single JSON object with two optional
arrays:
```json
{
"measurements": [ /* … */ ],
"moodEntries": [ /* … */ ]
}
```
Each array holds at most **10 000** entries. The endpoint is rate-limited
to **5 imports per hour per account**. Existing rows (matched on the
type + timestamp uniqueness, or on `externalId` for mood entries) are
skipped and counted under `skipped` in the response.
### Measurement entry
| Field | Required | Type | Notes |
|---|---|---|---|
| `type` | yes | enum | One of the `MeasurementType` values in the table below. |
| `value` | yes | number | Plausibility-checked per type; out-of-range values are rejected. |
| `unit` | yes | string | The canonical unit for the type (see the table). |
| `measuredAt` | yes | string | ISO-8601 datetime, e.g. `2026-05-01T08:00:00.000Z`. |
| `source` | no | string | Free-text origin label. Imported rows are tagged `IMPORT` server-side regardless. |
| `notes` | no | string | Optional free text. |
| `glucoseContext` | no | enum | Only for `BLOOD_GLUCOSE` (e.g. fasting / post-meal). |
### Mood entry
| Field | Required | Type | Notes |
|---|---|---|---|
| `date` | yes | string | `YYYY-MM-DD`. |
| `mood` | yes | enum | One of `SUPER_GUT`, `GUT`, `OKAY`, `SCHLECHT`, `LAUSIG`. |
| `score` | yes | integer | `1`–`5`. |
| `tags` | no | string | Comma-separated tags. |
| `loggedAt` | no | string | ISO-8601 datetime; defaults to now. |
| `externalId` | no | string | A source-stable id (1–120 chars). When present, a re-import upserts on `(user, source, externalId)` so an edit upstream is reflected rather than skipped. |
### `MeasurementType` values and canonical units
Storage is always in the canonical unit listed here; the UI converts for
display per the user's preference. Event-class types record a single
fired event (`value` is `1`) and are normally produced by a device, not
hand-authored.
| Type | Canonical unit |
|---|---|
| `WEIGHT` | kg |
| `BLOOD_PRESSURE_SYS` | mmHg |
| `BLOOD_PRESSURE_DIA` | mmHg |
| `PULSE` | bpm |
| `RESTING_HEART_RATE` | bpm |
| `WALKING_HEART_RATE_AVERAGE` | bpm |
| `AVERAGE_HEART_RATE` | bpm |
| `MAX_HEART_RATE` | bpm |
| `CARDIO_RECOVERY` | bpm |
| `HEART_RATE_VARIABILITY` | ms (SDNN) |
| `HRV_RMSSD` | ms (RMSSD) |
| `RESPIRATORY_RATE` | count/min |
| `OXYGEN_SATURATION` | percent (0–100) |
| `BLOOD_GLUCOSE` | mg/dL |
| `BODY_FAT` | percent (0–100) |
| `FAT_MASS` | kg |
| `FAT_FREE_MASS` | kg |
| `LEAN_BODY_MASS` | kg |
| `MUSCLE_MASS` | kg |
| `BONE_MASS` | kg |
| `TOTAL_BODY_WATER` | kg |
| `VISCERAL_FAT` | rating |
| `BODY_MASS_INDEX` | kg/m² |
| `BODY_TEMPERATURE` | °C (core) |
| `SKIN_TEMPERATURE` | °C (dermal spot) |
| `WRIST_TEMPERATURE` | °C (overnight wrist) |
| `SLEEP_DURATION` | minutes |
| `SLEEP_NEED` | minutes |
| `SLEEP_PERFORMANCE` | percent (0–100) |
| `SLEEP_EFFICIENCY` | percent (0–100) |
| `SLEEP_CONSISTENCY` | percent (0–100) |
| `SLEEP_DISTURBANCE_COUNT` | count |
| `BREATHING_DISTURBANCES` | count |
| `ACTIVITY_STEPS` | count |
| `FLIGHTS_CLIMBED` | count |
| `WALKING_RUNNING_DISTANCE` | metres |
| `SIX_MINUTE_WALK_DISTANCE` | metres |
| `WALKING_STEP_LENGTH` | metres |
| `WALKING_SPEED` | m/s |
| `STAIR_ASCENT_SPEED` | m/s |
| `STAIR_DESCENT_SPEED` | m/s |
| `WALKING_STEADINESS` | percent (0–100) |
| `WALKING_ASYMMETRY` | percent (0–100) |
| `WALKING_DOUBLE_SUPPORT` | percent (0–100) |
| `ACTIVE_ENERGY_BURNED` | kcal |
| `ENERGY_EXPENDITURE_KJ` | kJ |
| `VO2_MAX` | mL/(kg·min) |
| `PULSE_WAVE_VELOCITY` | m/s |
| `VASCULAR_AGE` | years |
| `FALL_COUNT` | count |
| `AUDIO_EXPOSURE_ENV` | dBA |
| `AUDIO_EXPOSURE_HEADPHONE` | dBA |
| `TIME_IN_DAYLIGHT` | minutes |
| `RECOVERY_SCORE` | score (0–100) |
| `STRESS_SCORE` | score (0–100) |
| `STRAIN_SCORE` | score (0–100) |
| `DAY_STRAIN` | score (0–21) |
| `WORKOUT_STRAIN` | score (0–21) |
| `AUDIO_EXPOSURE_EVENT` | count (event) |
| `IRREGULAR_RHYTHM_NOTIFICATION` | event |
| `HIGH_HEART_RATE_EVENT` | event |
| `LOW_HEART_RATE_EVENT` | event |
| `WALKING_STEADINESS_EVENT` | event |
| `BREATHING_DISTURBANCE_EVENT` | event |
The live, machine-readable list of writable types and their units is
also served by `GET /api/meta/capabilities` under `ingest.quantityTypes`
— the authoritative source if this table ever lags a release.
### Worked example
The **Download example** button in **Settings → Export & Import** mints
exactly this payload, which is a valid import body:
```json
{
"measurements": [
{
"type": "WEIGHT",
"value": 80.5,
"unit": "kg",
"measuredAt": "2026-05-01T08:00:00.000Z",
"source": "manual",
"notes": "morning"
},
{
"type": "BLOOD_PRESSURE_SYS",
"value": 120,
"unit": "mmHg",
"measuredAt": "2026-05-01T08:05:00.000Z"
},
{
"type": "BLOOD_PRESSURE_DIA",
"value": 80,
"unit": "mmHg",
"measuredAt": "2026-05-01T08:05:00.000Z"
}
],
"moodEntries": [
{
"date": "2026-05-01",
"mood": "GUT",
"score": 4,
"tags": "work,exercise"
}
]
}
```
The response is a count envelope:
```json
{
"data": { "measurements": 3, "moodEntries": 1, "skipped": 0 }
}
```
## Converting a CSV into the import JSON
A spreadsheet export is the most common starting point. The shape is
small enough to convert by hand for a few rows, or with a one-off script
for many. The steps are the same either way:
1. **One CSV per concept.** Keep measurements and mood in separate
sheets — they map to the two arrays. A blood-pressure reading is two
measurement rows (`BLOOD_PRESSURE_SYS` and `BLOOD_PRESSURE_DIA`) that
share a `measuredAt`.
2. **Pick the `type`.** Map each CSV column to a `MeasurementType` from
the table above. A "Weight (kg)" column becomes `type: "WEIGHT"`,
`unit: "kg"`.
3. **Normalise the unit.** Convert each value into the canonical unit
before writing it — e.g. pounds → kg, hours of sleep → minutes,
`mmol/L` glucose → `mg/dL`. The importer rejects values outside the
plausible range for the type, which catches most unit mistakes.
4. **Format the timestamp.** Turn the CSV date/time column into an
ISO-8601 `measuredAt` (mood uses a `YYYY-MM-DD` `date`). Include the
timezone offset (`Z` for UTC) so the reading lands on the right day.
5. **Emit the two arrays.** Collect the rows into `measurements` and
`moodEntries` as shown above and paste the result into the JSON
import control, or upload it as a `.json` file. Keep each array under
10 000 entries; split a larger history across runs (5 per hour).
Because the import is idempotent on the unique constraints, you can
re-run a corrected file over a partial one without creating duplicates.
+35 -4
View File
@@ -3,15 +3,17 @@ import { expect, test } from "@playwright/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* Settings → Export consolidated UI smoke.
* Settings → Export & Import consolidated UI smoke.
*
* Validates that:
* 1. The export surfaces are all rendered with their stable testids:
* the health-record export hero, the four CSV/JSON tiles, and the
* demoted doctor-report card (v1.12).
* 2. The health-record "included data" checklist is a disclosure,
* 2. The import surfaces (v1.15.7, issue #281) render: the Apple Health
* and generic-JSON cards.
* 3. The health-record "included data" checklist is a disclosure,
* collapsed by default, and expands on demand.
* 3. Clicking the Measurements CSV download button fires a real
* 4. Clicking the Measurements CSV download button fires a real
* browser download — proving the `/api/export/measurements`
* endpoint is reachable from the browser end-to-end.
*
@@ -19,7 +21,7 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
* download is enough to lock in the wiring without doubling the e2e
* runtime.
*/
test.describe("Settings → Export", () => {
test.describe("Settings → Export & Import", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test("renders all export surfaces with stable testids", async ({ page }) => {
@@ -39,6 +41,35 @@ test.describe("Settings → Export", () => {
}
});
test("renders the import surfaces with stable testids", async ({ page }) => {
await page.goto("/settings/export", { waitUntil: "domcontentloaded" });
// v1.15.7 (issue #281) — the Apple Health and generic-JSON import
// cards live in the same section, below the export options.
for (const id of ["import-card-apple-health", "import-card-json"]) {
await expect(page.getByTestId(id)).toBeVisible({ timeout: 10_000 });
}
});
test("JSON import 'Download example' fires a real download", async ({
page,
}) => {
await page.goto("/settings/export", { waitUntil: "domcontentloaded" });
const exampleBtn = page.getByTestId("import-json-download-example");
await expect(exampleBtn).toBeVisible({ timeout: 10_000 });
const downloadPromise = page.waitForEvent("download", { timeout: 30_000 });
await exampleBtn.click();
const download = await downloadPromise;
const path = await download.path();
expect(path).toBeTruthy();
const fs = await import("node:fs/promises");
const buf = await fs.readFile(path!);
const parsed = JSON.parse(buf.toString("utf8"));
expect(Array.isArray(parsed.measurements)).toBe(true);
expect(Array.isArray(parsed.moodEntries)).toBe(true);
});
test("included-data checklist is collapsed by default and expands on demand", async ({
page,
}) => {