mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
docs(google-health): rewrite the mapping notes for the verified v4 contract
The previous notes carried two wrong premises: that every launch type supports dataPoints.list, and that the response payload is snake_case. Document the three per-type encodings (kebab path, snake_case filter, camelCase payload union), the int64-as-string fields, the per-shape filter grammar, the dailyRollUp read for the cumulative activity types, the corrected daily-* data-type ids, the sleep end-time filter, the exercise metricsSummary payload, the AIP-193 error surfacing and the structure probe.
This commit is contained in:
1 parent
f92e9fd840
commit
ff27faf3ef
1 file changed
+125
-77
@@ -4,74 +4,86 @@ Single source of truth for the Google Health API v4 data-type → HealthLog
|
||||
`Measurement` mapping. Keep this in sync with the per-type mappers in `client.ts`.
|
||||
Every row ingests server-side with `source = GOOGLE_HEALTH` and
|
||||
`externalId = <anchor>:<fieldTag>` (the anchor is the data point's sample time for
|
||||
spot readings, its civil date for daily summaries, or — for INTERVAL types
|
||||
(steps/distance/energy/floors, sleep, exercise) — its `interval.civil_start_time`
|
||||
anchored at UTC-midday, falling back to `interval.start_time` only when the civil
|
||||
date is absent; the field-tag disambiguates the metric).
|
||||
spot readings or its civil date for daily summaries; the field-tag disambiguates
|
||||
the metric). The cumulative daily totals use the `stats:<fieldTag>:<YYYY-MM-DD>`
|
||||
overwrite shape instead.
|
||||
|
||||
Google Health is a **separate, coexisting provider** from the classic Fitbit
|
||||
integration (`src/lib/fitbit/*`, `source = FITBIT`). They never share a token,
|
||||
connection row, source enum, or cookie.
|
||||
|
||||
## Time anchor by grain
|
||||
## The three encodings (casing)
|
||||
|
||||
Google Health exposes three time-anchor shapes. The incremental `filter` and the
|
||||
read-time `measuredAt`/externalId resolution must target the right one or the
|
||||
incremental sync stalls (a `sample_time` filter 400s/empties for an INTERVAL type):
|
||||
Each data type has THREE on-the-wire encodings, all pinned in
|
||||
`GOOGLE_HEALTH_DATA_TYPES` so a fetcher can never mix them up:
|
||||
|
||||
- **sample** — spot reading; anchor `{filter}.sample_time.physical_time`.
|
||||
- **date** — daily-summary metric (SpO2, HRV, RHR, respiratory rate, VO2 max);
|
||||
anchor `{filter}.date` (a civil date / `{year,month,day}`).
|
||||
- **interval** — INTERVAL type (steps/distance/energy/floors daily totals, plus
|
||||
sleep + exercise sessions); the incremental `filter` targets
|
||||
`{filter}.interval.start_time`, but the read-time `measuredAt`/day-key derive
|
||||
from `{filter}.interval.civil_start_time` anchored at UTC-midday, so a daily
|
||||
total keys on the civil day (aligning with the Apple/Fitbit `stats:` contract);
|
||||
the physical `start_time` is the fallback only when civil is absent.
|
||||
- **request path** — kebab-case (`body-fat`, `daily-resting-heart-rate`).
|
||||
- **`filter` parameter** — snake_case (`body_fat.sample_time.physical_time`).
|
||||
snake_case exists ONLY here.
|
||||
- **response payload** — camelCase. The `DataPoint` value is a union keyed by
|
||||
the camelCase type name (`bodyFat`, `dailyRestingHeartRate`,
|
||||
`activeEnergyBurned`, …) with camelCase nested objects
|
||||
(`sampleTime.physicalTime`, `interval.startTime`, `civilStartTime.date`).
|
||||
|
||||
Transport docs: `developers.google.com/health` (v4). The per-type value-field
|
||||
JSON is **NOT fully published** — the mappers read a small set of candidate value
|
||||
paths defensively (`firstNumber` walks each shape and takes the first
|
||||
finite-positive hit). **Re-verify against a live test account at build** and
|
||||
tighten the extractors.
|
||||
proto3 int64 fields arrive as JSON **strings** (`"12345"`): `steps.countSum`,
|
||||
`heartRate.beatsPerMinute`, `dailyRestingHeartRate.beatsPerMinute`,
|
||||
`dailyRespiratoryRate.dailyRespiratoryRateBpm`, `distance.millimetersSum`,
|
||||
`floors.countSum`, `exercise.metricsSummary.averageHeartRateBeatsPerMinute`, the
|
||||
sleep summary minutes. Every numeric extractor coerces numeric strings before
|
||||
the finite check.
|
||||
|
||||
## Read method
|
||||
|
||||
Every launch data type supports `dataPoints.list` (`readMethod: "list"`, the only
|
||||
method the transport uses). The rollup-only types (`total-calories`,
|
||||
`calories-in-heart-rate-zone`, which have no `:list`) are deliberately **not** in
|
||||
the launch set: active energy already covers the active-calories slot, and
|
||||
`total-calories` folds in BMR and needs a modelling decision before it lands as a
|
||||
metric. Adding either later means adding a `:dailyRollUp` reader together with it.
|
||||
Two read methods are in use — NOT every type supports `dataPoints.list`:
|
||||
|
||||
## Casing gotcha
|
||||
- **`dataPoints.list`** (GET, `nextPageToken` pagination) — spot samples
|
||||
(weight, body-fat, heart-rate, height), daily summaries
|
||||
(daily-oxygen-saturation, daily-heart-rate-variability,
|
||||
daily-resting-heart-rate, daily-respiratory-rate, daily-vo2-max), and the
|
||||
session types (sleep, exercise; pageSize default & max **25**).
|
||||
- **`dataPoints:dailyRollUp`** (POST, `windowSizeDays: 1`) — the cumulative
|
||||
activity totals (steps, distance, active-energy-burned, floors). Their list
|
||||
surface returns minute-grain observation buckets (≈1440/day), NOT daily
|
||||
totals — and **floors has no list method at all**. The request range is
|
||||
CivilDateTime, closed-open, user-local, max **90 days** per request; the
|
||||
client chunks longer ranges and pins the full-sync horizon
|
||||
(`GOOGLE_HEALTH_ROLLUP_BACKFILL_DAYS`).
|
||||
|
||||
The data-type id is **kebab-case in the request path** (`body-fat`) and
|
||||
**snake_case in the `filter` predicate** (`body_fat`). `GOOGLE_HEALTH_DATA_TYPES`
|
||||
pins both forms per type so a fetcher can never encode the wrong one.
|
||||
Legal incremental `filter` fields are per-shape — anything else is an HTTP 400
|
||||
`INVALID_ARGUMENT`:
|
||||
|
||||
| Shape | Filter field | Bound format |
|
||||
| ------------- | ------------------------------------------------------- | ------------------------------ |
|
||||
| Sample | `{type}.sample_time.physical_time` | RFC-3339 |
|
||||
| Daily summary | `{type}.date` | `YYYY-MM-DD` |
|
||||
| Sleep | `sleep.interval.end_time` (the ONLY legal time field) | RFC-3339 |
|
||||
| Session | `{type}.interval.civil_start_time` (the ONLY legal one) | offset-less civil ISO (no `Z`) |
|
||||
|
||||
The exercise civil bound is derived from the watermark in the USER'S zone.
|
||||
|
||||
## Health-metrics bundle
|
||||
|
||||
Scope: `googlehealth.health_metrics_and_measurements.readonly`. Identifiers
|
||||
verified against the 2026 contract (API-RESEARCH §3/§4).
|
||||
Scope: `googlehealth.health_metrics_and_measurements.readonly`.
|
||||
|
||||
| Data type (path / filter) | MeasurementType | Unit | fieldTag | Grain | Note |
|
||||
| ------------------------------------------------------- | ------------------------ | ----------- | ----------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `weight` / `weight` | `WEIGHT` | kg | `weight` | sample | Picker ranks a real Withings scale above Google Health. |
|
||||
| `body-fat` / `body_fat` | `BODY_FAT` | % | `body_fat` | sample | |
|
||||
| `oxygen-saturation` / `oxygen_saturation` | `OXYGEN_SATURATION` | % | `spo2` | daily | Already 0–100. |
|
||||
| `heart-rate-variability` / `heart_rate_variability` | `HEART_RATE_VARIABILITY` | ms | `hrv` | daily | **Decision:** SDNN slot (Apple-comparable), NOT `HRV_RMSSD`. Google reports an RMSSD-style nightly HRV — confirm the estimator at build and reconsider if warranted. |
|
||||
| `daily-resting-heart-rate` / `daily_resting_heart_rate` | `RESTING_HEART_RATE` | bpm | `rhr` | daily | |
|
||||
| `respiratory-rate` / `respiratory_rate` | `RESPIRATORY_RATE` | breaths/min | `resp_rate` | daily | Identifier appears in spec prose; confirm `:list` support at build (API-RESEARCH OPEN §4). |
|
||||
| `heart-rate` / `heart_rate` | `PULSE` | bpm | `hr` | sample | Intraday spot HR. |
|
||||
| `height` / `height` | `User.heightCm` | cm | — | sample | Profile seed — written to `User.heightCm` ONLY when null; never minted as a Measurement (WHOOP `mapBody` pattern). m→cm when reported in metres. |
|
||||
| Data type (path) | Payload value read | MeasurementType | Unit | fieldTag | Grain | Note |
|
||||
| ------------------------------ | ------------------------------------------------------------------- | ------------------------ | ----------- | ----------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `weight` | `weight.weightGrams` ÷ 1000 | `WEIGHT` | kg | `weight` | sample | Grams on the wire. Picker ranks a real Withings scale above Google Health. |
|
||||
| `body-fat` | `bodyFat.percentage` | `BODY_FAT` | % | `body_fat` | sample | Union key is camelCase `bodyFat`. |
|
||||
| `daily-oxygen-saturation` | `dailyOxygenSaturation.averagePercentage` | `OXYGEN_SATURATION` | % | `spo2` | daily | The bare `oxygen-saturation` type is per-SAMPLE and rejects a `.date` filter. |
|
||||
| `daily-heart-rate-variability` | `dailyHeartRateVariability.averageHeartRateVariabilityMilliseconds` | `HEART_RATE_VARIABILITY` | ms | `hrv` | daily | **Decision:** SDNN slot (Apple-comparable), NOT `HRV_RMSSD`. The daily field is an unlabelled "average HRV ms" — re-confirm estimator live. |
|
||||
| `daily-resting-heart-rate` | `dailyRestingHeartRate.beatsPerMinute` (int64 string) | `RESTING_HEART_RATE` | bpm | `rhr` | daily | |
|
||||
| `daily-respiratory-rate` | `dailyRespiratoryRate.dailyRespiratoryRateBpm` (int64 string) | `RESPIRATORY_RATE` | breaths/min | `resp_rate` | daily | `respiratory-rate` does NOT exist in the catalogue. |
|
||||
| `heart-rate` | `heartRate.beatsPerMinute` (int64 string) | `PULSE` | bpm | `hr` | sample | Intraday spot HR; per-minute volume — consider the 14-day-max rollup if PULSE should stay coarse. |
|
||||
| `height` | `height.heightMeters` × 100 | `User.heightCm` | cm | — | sample | Profile seed — written ONLY when null; never a Measurement. List order is DESCENDING → the seed picks max(sampleTime) explicitly. |
|
||||
|
||||
Daily-summary `date` fields are `{year,month,day}` objects, anchored at
|
||||
UTC-midday so a timezone shift can't roll the civil day. Every value passes a
|
||||
finite + strictly-positive guard (the launch metrics are all positive — a
|
||||
zero/NaN is a garbage/empty reading and is dropped).
|
||||
|
||||
Skin temperature (`daily-sleep-temperature-derivations`) is intentionally **not**
|
||||
in the launch set — see **Deferred** below.
|
||||
|
||||
Every value passes a finite + strictly-positive guard (the launch metrics are all
|
||||
positive — a zero/NaN is a garbage/empty reading and is dropped).
|
||||
|
||||
## Deferred (Google Health Q3-2026 roadmap — slots exist, NOT in launch set)
|
||||
|
||||
`blood-glucose` → `BLOOD_GLUCOSE`, `blood-pressure` → `BLOOD_PRESSURE_SYS`+`_DIA`,
|
||||
@@ -83,44 +95,62 @@ model before it can land — mapping it into `WRIST_TEMPERATURE` would store a d
|
||||
as an absolute temperature. ECG/IRN light up when Google ships the data types and
|
||||
a reader is added together with its Restricted scopes.
|
||||
|
||||
## Activity bundle — daily cumulative
|
||||
## Activity bundle — daily cumulative (dailyRollUp)
|
||||
|
||||
Scope: `googlehealth.activity_and_fitness.readonly`. Each is a per-day summary
|
||||
(one value per calendar day). The externalId carries the `stats:` daily-total
|
||||
prefix — `stats:<fieldTag>:<YYYY-MM-DD>` — so a re-fetched day **overwrites** in
|
||||
place rather than minting a duplicate, matching the Apple-Health
|
||||
`stats:<HK>:<YYYY-MM-DD>` daily-total overwrite contract. The running totals
|
||||
(steps/distance/floors/active-energy) **preserve a 0** (a rest day is real data,
|
||||
not a gap); VO2 max stays strictly positive (daily latest-wins).
|
||||
Scope: `googlehealth.activity_and_fitness.readonly`. The four running totals
|
||||
read through `POST :dailyRollUp` with `windowSizeDays: 1`; each aggregate window
|
||||
carries `civilStartTime`/`civilEndTime` (CivilDateTime objects —
|
||||
`{date:{year,month,day}, time:{…}}`) and a union keyed by the camelCase type
|
||||
name with the `*Sum` rollup fields. The day key comes from
|
||||
`civilStartTime.date`, anchored at UTC-midday. The externalId carries the
|
||||
`stats:` daily-total prefix — `stats:<fieldTag>:<YYYY-MM-DD>` — so a re-fetched
|
||||
day **overwrites** in place, matching the Apple-Health
|
||||
`stats:<HK>:<YYYY-MM-DD>` overwrite contract. The running totals **preserve a
|
||||
0** (a rest day is real data, not a gap).
|
||||
|
||||
| Data type (path / filter) | MeasurementType | Unit | fieldTag | Note |
|
||||
| ----------------------------------------------- | -------------------------- | ----------- | --------------- | ------------------------------------------------------------------- |
|
||||
| `steps` / `steps` | `ACTIVITY_STEPS` | steps | `steps` | daily total; 0 valid |
|
||||
| `distance` / `distance` | `WALKING_RUNNING_DISTANCE` | m | `distance` | metres (km → m when reported in km) |
|
||||
| `active-energy-burned` / `active_energy_burned` | `ACTIVE_ENERGY_BURNED` | kcal | `active_energy` | **ACTIVE portion only** — NOT `total-calories` (which folds in BMR) |
|
||||
| `floors` / `floors` | `FLIGHTS_CLIMBED` | flights | `floors` | daily total; 0 valid |
|
||||
| `vo2-max` / `vo2_max` | `VO2_MAX` | mL/(kg·min) | `vo2_max` | daily latest-wins; strictly positive |
|
||||
| Data type (path) | Rollup value read | MeasurementType | Unit | fieldTag | Note |
|
||||
| ---------------------- | ---------------------------------------------- | -------------------------- | ------- | --------------- | ------------------------------------------------------------------- |
|
||||
| `steps` | `steps.countSum` (int64 string) | `ACTIVITY_STEPS` | steps | `steps` | daily total; 0 valid |
|
||||
| `distance` | `distance.millimetersSum` (int64 string) ÷1000 | `WALKING_RUNNING_DISTANCE` | m | `distance` | millimetres on the wire |
|
||||
| `active-energy-burned` | `activeEnergyBurned.kcalSum` (number) | `ACTIVE_ENERGY_BURNED` | kcal | `active_energy` | **ACTIVE portion only** — NOT `total-calories` (which folds in BMR) |
|
||||
| `floors` | `floors.countSum` (int64 string) | `FLIGHTS_CLIMBED` | flights | `floors` | daily total; 0 valid; **no list method** — rollup is the only read |
|
||||
|
||||
VO2 max is NOT a rollup type — `daily-vo2-max` is a daily summary read via list
|
||||
(`daily_vo2_max.date` filter), value `dailyVo2Max.vo2Max`, strictly positive,
|
||||
daily latest-wins, same `stats:`-style per-day overwrite key (`vo2_max`).
|
||||
|
||||
## Sleep bundle
|
||||
|
||||
Scope: `googlehealth.sleep.readonly`. A sleep session carries per-stage segments
|
||||
(stage label + start + end). HealthLog stores one `SLEEP_DURATION` row per stage
|
||||
SEGMENT, `measuredAt = that segment's END instant`, harmonised onto the shared
|
||||
`SleepStage` enum the night-total + hypnogram readers consume. externalId =
|
||||
`<session-anchor>:sleep_<stage>:<i>` (session end ISO instant + indexed segment),
|
||||
so a re-scored night overwrites in place. Stage map: `light → CORE` ("light" ↔
|
||||
Apple "core" shallow-NREM band), `deep → DEEP`, `rem → REM`,
|
||||
`awake`/`wake`/`restless → AWAKE`, `in_bed → IN_BED`, classic `asleep → ASLEEP`.
|
||||
Unknown stage labels are skipped, not mis-bucketed.
|
||||
Scope: `googlehealth.sleep.readonly`. Sleep sessions filter **only** on
|
||||
`sleep.interval.end_time` (a night is fetched when it ENDS after the cursor).
|
||||
The payload: `sleep.interval` (SessionTimeInterval — `startTime`/`endTime`
|
||||
RFC-3339), `sleep.type` (`CLASSIC | STAGES`), `sleep.stages[]`
|
||||
(`{startTime, startUtcOffset, endTime, endUtcOffset, type}`), and
|
||||
`sleep.summary` (`minutesAsleep`/`minutesAwake`, int64 strings). HealthLog
|
||||
stores one `SLEEP_DURATION` row per stage SEGMENT, `measuredAt = that segment's
|
||||
END instant`, harmonised onto the shared `SleepStage` enum the night-total +
|
||||
hypnogram readers consume. externalId = `<session-anchor>:sleep_<stage>:<i>`
|
||||
(session `interval.endTime` ISO instant + indexed segment), so a re-scored night
|
||||
overwrites in place. Stage map (`SLEEP_STAGE_TYPE` enum, lowercased):
|
||||
`LIGHT → CORE` ("light" ↔ Apple "core" shallow-NREM band), `DEEP → DEEP`,
|
||||
`REM → REM`, `AWAKE`/`RESTLESS → AWAKE`, classic `ASLEEP → ASLEEP`.
|
||||
`SLEEP_STAGE_TYPE_UNSPECIFIED` / unknown labels are skipped, not mis-bucketed.
|
||||
|
||||
## Exercise bundle — Workouts
|
||||
|
||||
Scope: `googlehealth.activity_and_fitness.readonly`. Each exercise session → one
|
||||
`Workout` row (NOT a Measurement), keyed `(userId, source: "GOOGLE_HEALTH",
|
||||
externalId)` where externalId is the session id (or `exercise:<startISO>` when
|
||||
absent). Fields: sportType (Google activity type → canonical `WorkoutSportType`,
|
||||
`other` fallback), startedAt/endedAt, durationSec, totalEnergyKcal (active session
|
||||
energy), totalDistanceM, avg/max/min HR (optional). A Google Health run and the
|
||||
Scope: `googlehealth.activity_and_fitness.readonly`. Sessions filter **only** on
|
||||
`exercise.interval.civil_start_time` with an offset-less civil bound (derived
|
||||
from the watermark in the user's zone). Each exercise session → one `Workout`
|
||||
row (NOT a Measurement), keyed `(userId, source: "GOOGLE_HEALTH", externalId)`
|
||||
where externalId is the DataPoint's top-level `name` resource name (there is no
|
||||
session-id field; `exercise:<startISO>` when absent). Fields: sportType
|
||||
(`exercise.exerciseType` UPPERCASE enum → canonical `WorkoutSportType`, `other`
|
||||
fallback), startedAt/endedAt from `exercise.interval.startTime/endTime`
|
||||
(RFC-3339), durationSec, totalEnergyKcal
|
||||
(`exercise.metricsSummary.caloriesKcal`), totalDistanceM
|
||||
(`exercise.metricsSummary.distanceMillimeters` ÷ 1000), avgHeartRate
|
||||
(`exercise.metricsSummary.averageHeartRateBeatsPerMinute`, int64 string).
|
||||
metricsSummary carries **no max/min HR** → null. A Google Health run and the
|
||||
same run via Apple Health / WHOOP stay distinct rows; the read-time
|
||||
`pickCanonicalWorkoutRows` picker collapses the cross-source twin per the user's
|
||||
source ladder.
|
||||
@@ -133,3 +163,21 @@ rows: `externalId = <anchor>:<fieldTag>`. Daily cumulative activity rows:
|
||||
Workouts: `(userId, source: "GOOGLE_HEALTH", externalId)` on the `Workout` table.
|
||||
A re-fetch of the same window (daily summaries re-roll after the fact, so the
|
||||
incremental overlap is 24 h) overwrites in place.
|
||||
|
||||
## Error surfacing
|
||||
|
||||
Non-2xx Google Health bodies carry the AIP-193 envelope
|
||||
(`{"error":{code,message,status}}`). The client extracts a redacted
|
||||
`STATUS: message` detail (message capped at 200 chars, bearer tokens and URL
|
||||
query strings stripped) into `GoogleHealthApiError.upstreamError` and the
|
||||
external-call annotation, so a field-grammar 400 is diagnosable from operator
|
||||
logs. The OAuth token endpoint uses the flat `{"error":"invalid_grant"}` shape
|
||||
instead — handled separately in `postToken`.
|
||||
|
||||
## Structure probe
|
||||
|
||||
`POST /api/integrations/google-health/test` with body
|
||||
`{"probe":"structure"}` fetches ONE recent page/window per data type and returns
|
||||
only the JSON **structure** (field names + `typeof` leaves — never values), plus
|
||||
the per-type error verdict on failure. Self-hoster diagnostics for payload-shape
|
||||
drift.
|
||||
Reference in new issue
Block a user