Files
HealthLog/src/lib/google-health/mapping.md
T
Marc-André Bombeck 9c63d3ce20 chore(release): v1.28.25 — every integration held to the same standard
Platform-wide hardening: the failure classes found live this week hunted down
across every integration.

Fitbit carried the same tombstone wedge fixed for Google — probe now matches
soft-deleted rows and the update resurrects them; the comment describing a
partial unique index that never existed is corrected. Sleep segment ids are
stable across re-scoring for Withings (session+segment-start), WHOOP/Polar
(one segment per stage, no positional index) and Oura (run-start keyed), each
with a session-scoped sweep that soft-deletes rows a re-score orphaned — so
legacy and affected nights heal as they are re-read.

One platform rule for deleted rows: source-owned readings resurrect on
re-import (Withings, WHOOP, Oura, Polar, Nightscout, mood webhook, CSV
re-imports, Apple stats day-totals); Apple sample-grain deletions stick per
the paired-client contract, and the mood bulk route's tombstone match is now
a true no-op reported as duplicate. Import counts reconcile against actual
createMany counts.

Google lifecycle: a dead token registers on the cycle's hard-fail ledger, so
an expired connection parks instead of stamping success hourly; backfill and
sleep-repair markers stamp only after a clean pass (pg-boss retries are now
real); failed measurement writes hold the watermark; no-op updates are
skipped; mapping.md tells the current truth.

Scale: doctor-report reads only consumed columns and enabled sections;
exports and backups keyset-paginate; achievements tally via SQL buckets;
series charts day-bucket dense kinds beyond 90 days; BP pairing is a linear
two-pointer; distinct-type scans run server-side; the graded-series cold
fallback is bounded; the fitbit defer tracker loops instead of spreading.
2026-07-11 01:54:12 +02:00

19 KiB

Google Health field → Measurement mapping

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 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.

The three encodings (casing)

Each data type has THREE on-the-wire encodings, all pinned in GOOGLE_HEALTH_DATA_TYPES so a fetcher can never mix them up:

  • request path — kebab-case (body-fat, daily-resting-heart-rate).
  • filter parameter — snake_case (body_fat.sample_time.physical_time) for sample / interval / session / sleep shapes. Daily-summary .date filters are the documented self-contradiction: the data-types index's "filter parameter" column says snake_case (daily_heart_rate_variability), but the dataPoints.list reference's only worked daily example is dailyHeartRateVariability.date < "2024-08-15" — the camelCase payload key. Live, the snake form returned HTTP 200 with zero rows on accounts whose companion app visibly holds daily HRV/RHR. The client sends the camelCase worked-example form first and falls back to snake_case once if the first page 400s; the accepted style is annotated (googleHealth.dateFilter.style) and surfaced by the structure probe as requestShape.
  • 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).

proto3 int64 fields arrive as JSON strings ("12345"): steps.countSum, heartRate.beatsPerMinute, dailyRestingHeartRate.beatsPerMinute, height.heightMillimeters, distance.millimetersSum, floors.countSum, exercise.metricsSummary.averageHeartRateBeatsPerMinute, the sleep summary minutes. Every numeric extractor coerces numeric strings before the finite check. (dailyRespiratoryRate.breathsPerMinute and the temperature / glucose fields are plain JSON numbers.)

Read method

Two read methods are in use — NOT every type supports dataPoints.list:

  • 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 a pair of CivilDateTime bounds ({date:{year,month,day}, time:{hours,minutes, seconds,nanos}}), user-local; per the documented example, the end bound is the LAST covered civil day at 23:59:59 — not the next day's midnight. Max 90 days per request for these types (14 days only for heart-rate / total-calories / active-minutes / calories-in-heart-rate-zone); the client chunks longer ranges (buildDailyRollUpBody), falls back once to 14-day chunks if the first request 400s, and pins the full-sync horizon (GOOGLE_HEALTH_ROLLUP_BACKFILL_DAYS).

Legal incremental filter fields are per-shape — anything else is an HTTP 400 INVALID_ARGUMENT:

Shape Filter field Bound format
Sample {snake_type}.sample_time.physical_time RFC-3339
Daily summary {camelType}.date (worked example), snake fallback on first-page 400 YYYY-MM-DD
Sleep sleep.interval.end_time (the ONLY legal time field) RFC-3339
Session {snake_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.

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.breathsPerMinute (number) RESPIRATORY_RATE breaths/min resp_rate daily respiratory-rate does NOT exist in the catalogue. Schema is {date, breathsPerMinute} — the earlier dailyRespiratoryRateBpm leaf never existed.
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.
blood-glucose bloodGlucose.bloodGlucoseMilligramsPerDeciliter (number) BLOOD_GLUCOSE mg/dL glucose sample Already in the canonical mg/dL storage unit. Meal/timing/specimen enums not stored.
core-body-temperature coreBodyTemperature.temperatureCelsius (number) BODY_TEMPERATURE celsius core_temp sample Core slot — distinct from SKIN_TEMPERATURE / WRIST_TEMPERATURE.
daily-sleep-temperature-derivations dailySleepTemperatureDerivations.nightlyTemperatureCelsius (number) WRIST_TEMPERATURE celsius wrist_temp daily ABSOLUTE nightly skin temperature (mean over the night's samples) — the schema refutes the earlier "deviation-only" premise. Baseline/stddev derivations not stored.
height height.heightMillimeters (int64 string) ÷ 10 User.heightCm cm — sample Profile seed — written ONLY when null; never a Measurement. List order is DESCENDING → the seed picks max(sampleTime) explicitly. The earlier heightMeters leaf never existed.

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 metric-bundle readings are all positive — a zero/NaN is a garbage/empty reading and is dropped).

Documented skips (full-catalogue reconciliation, 2026-07-07)

Every remaining catalogue type, with the reason it is NOT fetched:

  • oxygen-saturation, heart-rate-variability (per-sample) — the daily grain is the chosen product surface (daily-oxygen-saturation / daily-heart-rate-variability); a second per-sample reader would double the same nights. The per-sample HRV type carries explicit RMSSD + SDNN fields — the reference if the daily "average HRV ms" estimator ever needs re-checking.
  • respiratory-rate-sleep-summary — per-sleep-stage breathing statistics; daily-respiratory-rate already fills RESPIRATORY_RATE.
  • run-vo2-max — per-run sample; daily-vo2-max already fills VO2_MAX (a second writer would double the same day).
  • active-minutes, active-zone-minutes, time-in-heart-rate-zone, daily-heart-rate-zones, calories-in-heart-rate-zone — no HealthLog zone-minutes concept; not inventing a MeasurementType without a product surface.
  • total-calories — folds BMR into the total; HealthLog stores the ACTIVE portion (active-energy-burned). No total-energy bucket (ENERGY_EXPENDITURE_KJ is WHOOP-native and stays vendor-scoped).
  • activity-level, sedentary-period — interval classifications with no HealthLog concept.
  • altitude — elevation gain; floors already covers the climbed signal.
  • swim-lengths-data — per-interval stroke data; swims land as exercise sessions (Workout rows).
  • hydration-log — fluid INTAKE; TOTAL_BODY_WATER is body composition, not intake. No intake module.
  • nutrition-log, food, food-measurement-unit — no nutrition module.
  • electrocardiogram, irregular-rhythm-notification — deliberate scope policy: the ECG/IRN Restricted scopes are only added together with a real reader (see resolveGoogleHealthScopes).

Deferred (Google Health Q3-2026 roadmap — slots exist, NOT in catalogue yet)

blood-pressure → BLOOD_PRESSURE_SYS+_DIA and basal-body-temperature → BODY_TEMPERATURE are roadmap items without catalogue entries today. ECG/IRN light up when a reader is added together with its Restricted scopes.

Activity bundle — daily cumulative (dailyRollUp)

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) 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. 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 = <anchor>:sleep:<segment-start-ISO> (v1.28.18): the anchor prefers the DataPoint's invariant resource name, falling back to the session interval.endTime (then startTime) parsed to a UTC ISO instant, and the per-segment key is the segment's own START instant — NOT a positional index and NOT the stage label, so a re-scored night overwrites in place and a mere re-classification (LIGHT→DEEP on the same block) UPDATES the row rather than orphaning it. After mapping, the sync runs a replace-by-window cleanup (replaceStaleGoogleHealthSleep): any LIVE GOOGLE_HEALTH SLEEP_DURATION row inside the session's [windowStart, windowEnd] that this fetch did NOT re-produce is soft-deleted, so segments an earlier scoring left behind (old volatile keys, dropped blocks) never double-count the night total. 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. 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.

Idempotency

(userId, type, source: "GOOGLE_HEALTH", externalId) unique. Spot/daily metric rows: externalId = <anchor>:<fieldTag>. Daily cumulative activity rows: externalId = stats:<fieldTag>:<YYYY-MM-DD> (Apple-Health overwrite shape). Sleep rows: externalId = <anchor>:sleep:<segment-start-ISO> (see the sleep bundle above). 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.

The measurements unique index is FULL — it covers soft-deleted rows too, so exactly one row can ever exist per key, live or tombstoned. The upsert probe therefore matches tombstoned rows as well, and a re-import RESURRECTS them (the update branch clears deletedAt; v1.28.24) — Google is the source of truth for its own rows, and treating a tombstone as absent would plan an insert that skipDuplicates drops silently, wedging the key forever. An unchanged LIVE row is skipped entirely (no write, no syncVersion bump) so the 24 h overlap re-fetch doesn't churn the DB or the iOS delta pull.

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.