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:
Marc-André Bombeck committed 2026-07-05 13:59:31 +02:00
1 parent f92e9fd840
commit ff27faf3ef
1 file changed
+125 -77
+125 -77
View File
@@ -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.