diff --git a/src/lib/google-health/mapping.md b/src/lib/google-health/mapping.md index 6cbc585e4..694c9e022 100644 --- a/src/lib/google-health/mapping.md +++ b/src/lib/google-health/mapping.md @@ -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 = :` (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::` +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::` — so a re-fetched day **overwrites** in -place rather than minting a duplicate, matching the Apple-Health -`stats::` 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::` — so a re-fetched +day **overwrites** in place, matching the Apple-Health +`stats::` 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 = -`:sleep_:` (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 = `:sleep_:` +(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:` 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:` 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 = :`. 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.