mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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.
225 lines
8.1 KiB
Markdown
225 lines
8.1 KiB
Markdown
# 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.
|