mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
c7bf62e4d9
commit
4d4dcac505
3 files changed
+264
-8
No files matched your search
@@ -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:
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
Reference in new issue
Block a user