From 4d4dcac505476f3cd2dc426bd984f770b7cbce4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 7 Jun 2026 00:07:58 +0200 Subject: [PATCH] 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. --- docs/integrations/apple-health.md | 9 +- docs/integrations/data-import.md | 224 ++++++++++++++++++++++++++++++ e2e/settings-export.spec.ts | 39 +++++- 3 files changed, 264 insertions(+), 8 deletions(-) create mode 100644 docs/integrations/data-import.md diff --git a/docs/integrations/apple-health.md b/docs/integrations/apple-health.md index f973f4fc7..285982d81 100644 --- a/docs/integrations/apple-health.md +++ b/docs/integrations/apple-health.md @@ -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: diff --git a/docs/integrations/data-import.md b/docs/integrations/data-import.md new file mode 100644 index 000000000..514a6907b --- /dev/null +++ b/docs/integrations/data-import.md @@ -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. diff --git a/e2e/settings-export.spec.ts b/e2e/settings-export.spec.ts index c468dd08c..b0ae575a7 100644 --- a/e2e/settings-export.spec.ts +++ b/e2e/settings-export.spec.ts @@ -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, }) => {