From ae2e39198a873a37fa8aaaeb72849ecb48e8ccad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 7 Jul 2026 11:17:06 +0200 Subject: [PATCH] feat(google-health): import blood glucose, core body temperature, and nightly wrist temperature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full-catalogue coverage pass against the v4 data-types index. Three types map onto existing MeasurementTypes and are now fetched in the metrics bundle: - blood-glucose -> BLOOD_GLUCOSE. `bloodGlucoseMilligramsPerDeciliter` is already the canonical mg/dL storage unit; meal/timing/specimen enums are not stored. - core-body-temperature -> BODY_TEMPERATURE. `temperatureCelsius`, the core slot — distinct from the surface-temperature buckets. - daily-sleep-temperature-derivations -> WRIST_TEMPERATURE. The documented `nightlyTemperatureCelsius` is an ABSOLUTE reading (the mean of the night's skin-temperature samples), which refutes the earlier deviation- only premise behind the launch omission. The baseline/stddev derivations are not stored — the user's own series carries the baseline. Every other catalogue type is now an explicit documented skip in mapping.md with its reason (per-sample twins of the chosen daily grains, zone-minutes types without a HealthLog concept, total-calories folding in BMR, hydration/nutrition without a module, ECG/IRN behind their scope policy). The structure probe iterates the type table, so the three new types are probed automatically. --- .../google-health/__tests__/mappers.test.ts | 63 ++++++++++++++++ src/lib/google-health/client.ts | 73 ++++++++++++++++++- src/lib/google-health/mapping.md | 72 ++++++++++++------ src/lib/google-health/sync-metrics.ts | 43 ++++++++--- 4 files changed, 214 insertions(+), 37 deletions(-) diff --git a/src/lib/google-health/__tests__/mappers.test.ts b/src/lib/google-health/__tests__/mappers.test.ts index b957522b6..86f6fb536 100644 --- a/src/lib/google-health/__tests__/mappers.test.ts +++ b/src/lib/google-health/__tests__/mappers.test.ts @@ -20,7 +20,9 @@ import { incrementalFilter, GOOGLE_HEALTH_DATA_TYPES, mapActiveEnergy, + mapBloodGlucose, mapBodyFat, + mapCoreBodyTemperature, mapDistance, mapFloors, mapGoogleHealthSleepStage, @@ -36,6 +38,7 @@ import { mapVo2Max, mapWeight, mapWorkout, + mapWristTemperature, } from "../client"; describe("mapWeight — spot sample", () => { @@ -165,6 +168,66 @@ describe("mapRespiratoryRate — daily-respiratory-rate summary", () => { }); }); +describe("mapBloodGlucose — spot sample", () => { + it("reads bloodGlucoseMilligramsPerDeciliter in the canonical mg/dL unit", () => { + const rows = mapBloodGlucose({ + bloodGlucose: { + bloodGlucoseMilligramsPerDeciliter: 104, + measurementSource: "GLUCOMETER", + sampleTime: { physicalTime: "2026-06-01T07:45:00.000Z" }, + }, + }); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + type: "BLOOD_GLUCOSE", + unit: "mg/dL", + value: 104, + fieldTag: "2026-06-01T07:45:00.000Z:glucose", + }); + }); +}); + +describe("mapCoreBodyTemperature — spot sample", () => { + it("reads temperatureCelsius into the core BODY_TEMPERATURE slot", () => { + const rows = mapCoreBodyTemperature({ + coreBodyTemperature: { + temperatureCelsius: 36.9, + measurementLocation: "BODY", + sampleTime: { physicalTime: "2026-06-01T19:00:00.000Z" }, + }, + }); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + type: "BODY_TEMPERATURE", + unit: "celsius", + value: 36.9, + fieldTag: "2026-06-01T19:00:00.000Z:core_temp", + }); + }); +}); + +describe("mapWristTemperature — daily-sleep-temperature-derivations", () => { + it("reads the ABSOLUTE nightlyTemperatureCelsius into WRIST_TEMPERATURE", () => { + // `nightlyTemperatureCelsius` is the mean absolute skin temperature over + // the night — NOT a signed deviation. Baseline/stddev siblings are ignored. + const rows = mapWristTemperature({ + dailySleepTemperatureDerivations: { + nightlyTemperatureCelsius: 33.4, + baselineTemperatureCelsius: 33.1, + relativeNightlyStddev30dCelsius: 0.4, + date: { year: 2026, month: 6, day: 1 }, + }, + }); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + type: "WRIST_TEMPERATURE", + unit: "celsius", + value: 33.4, + fieldTag: "2026-06-01:wrist_temp", + }); + }); +}); + describe("mapHeartRate — intraday spot sample", () => { it("coerces the beatsPerMinute int64 JSON string and anchors on the instant", () => { const rows = mapHeartRate({ diff --git a/src/lib/google-health/client.ts b/src/lib/google-health/client.ts index 402a353b7..8dbb8c685 100644 --- a/src/lib/google-health/client.ts +++ b/src/lib/google-health/client.ts @@ -519,10 +519,29 @@ export const GOOGLE_HEALTH_DATA_TYPES = { key: "height", timeField: "sample", }, - // Skin temperature (`daily-sleep-temperature-derivations`) is intentionally - // OMITTED: Google surfaces a nightly signed DEVIATION from baseline, not an - // absolute reading — a future enhancement needing a signed-delta model, not an - // absolute WRIST_TEMPERATURE row. + bloodGlucose: { + path: "blood-glucose", + filter: "blood_glucose", + key: "bloodGlucose", + timeField: "sample", + }, + coreBodyTemperature: { + path: "core-body-temperature", + filter: "core_body_temperature", + key: "coreBodyTemperature", + timeField: "sample", + }, + // Nightly sleep skin temperature. The documented `nightlyTemperatureCelsius` + // is an ABSOLUTE reading ("the mean of skin temperature samples taken from + // the user's sleep"), alongside a separate `baselineTemperatureCelsius` — so + // it maps cleanly onto the absolute WRIST_TEMPERATURE slot. (An earlier note + // claimed Google only ships a signed deviation; the schema refutes that.) + sleepTemperature: { + path: "daily-sleep-temperature-derivations", + filter: "daily_sleep_temperature_derivations", + key: "dailySleepTemperatureDerivations", + timeField: "date", + }, // ── Activity bundle — daily cumulative totals ────────────────── // Scope: `googlehealth.activity_and_fitness.readonly`. Read through // `POST :dailyRollUp` with `windowSizeDays: 1`: the `list` surface returns @@ -1478,6 +1497,52 @@ export function mapRespiratoryRate( }); } +export function mapBloodGlucose( + point: GoogleHealthDataPoint, +): GoogleHealthMappedMeasurement[] { + const dt = GOOGLE_HEALTH_DATA_TYPES.bloodGlucose; + // Documented field `bloodGlucoseMilligramsPerDeciliter` (number) — already in + // HealthLog's canonical mg/dL storage unit, no conversion. + return mapSimple(point, dt, { + type: "BLOOD_GLUCOSE", + unit: "mg/dL", + fieldTag: "glucose", + valuePaths: [`${dt.key}.bloodGlucoseMilligramsPerDeciliter`], + }); +} + +export function mapCoreBodyTemperature( + point: GoogleHealthDataPoint, +): GoogleHealthMappedMeasurement[] { + const dt = GOOGLE_HEALTH_DATA_TYPES.coreBodyTemperature; + // Documented field `temperatureCelsius` (number) → the core BODY_TEMPERATURE + // slot (distinct from SKIN_TEMPERATURE / WRIST_TEMPERATURE surface readings). + return mapSimple(point, dt, { + type: "BODY_TEMPERATURE", + unit: "celsius", + fieldTag: "core_temp", + valuePaths: [`${dt.key}.temperatureCelsius`], + }); +} + +export function mapWristTemperature( + point: GoogleHealthDataPoint, +): GoogleHealthMappedMeasurement[] { + const dt = GOOGLE_HEALTH_DATA_TYPES.sleepTemperature; + // `nightlyTemperatureCelsius` is the ABSOLUTE nightly skin temperature ("the + // mean of skin temperature samples taken from the user's sleep") → the + // WRIST_TEMPERATURE slot, mirroring the Apple sleeping-wrist-temperature + // absolute-reading convention. The sibling `baselineTemperatureCelsius` / + // `relativeNightlyStddev30dCelsius` derivations are not stored — the user's + // own series carries the baseline. + return mapSimple(point, dt, { + type: "WRIST_TEMPERATURE", + unit: "celsius", + fieldTag: "wrist_temp", + valuePaths: [`${dt.key}.nightlyTemperatureCelsius`], + }); +} + export function mapHeartRate( point: GoogleHealthDataPoint, ): GoogleHealthMappedMeasurement[] { diff --git a/src/lib/google-health/mapping.md b/src/lib/google-health/mapping.md index ccb830a52..9b9470b4d 100644 --- a/src/lib/google-health/mapping.md +++ b/src/lib/google-health/mapping.md @@ -40,7 +40,8 @@ proto3 int64 fields arrive as JSON **strings** (`"12345"`): `steps.countSum`, `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` is a plain JSON number.) +check. (`dailyRespiratoryRate.breathsPerMinute` and the temperature / glucose +fields are plain JSON numbers.) ## Read method @@ -80,35 +81,62 @@ The exercise civil bound is derived from the watermark in the USER'S zone. 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. | -| `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. | +| 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). -Skin temperature (`daily-sleep-temperature-derivations`) is intentionally **not** -in the launch set — see **Deferred** below. +## Documented skips (full-catalogue reconciliation, 2026-07-07) -## Deferred (Google Health Q3-2026 roadmap — slots exist, NOT in launch set) +Every remaining catalogue type, with the reason it is NOT fetched: -`blood-glucose` → `BLOOD_GLUCOSE`, `blood-pressure` → `BLOOD_PRESSURE_SYS`+`_DIA`, -`basal-body-temperature` → `BODY_TEMPERATURE`, `electrocardiogram` (ECG) and -`irregular-rhythm-notification`. Skin-temperature deviation -(`daily-sleep-temperature-derivations`) also sits here: Google reports a signed -nightly delta from baseline, not an absolute reading, so it needs a signed-delta -model before it can land — mapping it into `WRIST_TEMPERATURE` would store a delta -as an absolute temperature. ECG/IRN light up when Google ships the data types and -a reader is added together with its Restricted scopes. +- `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) diff --git a/src/lib/google-health/sync-metrics.ts b/src/lib/google-health/sync-metrics.ts index 666d65a67..408ab7adb 100644 --- a/src/lib/google-health/sync-metrics.ts +++ b/src/lib/google-health/sync-metrics.ts @@ -5,14 +5,17 @@ * `health_metrics_and_measurements.readonly` Restricted bundle and upserts each * mapped reading as `source = GOOGLE_HEALTH`: * - * - weight → WEIGHT - * - body-fat → BODY_FAT - * - daily-oxygen-saturation → OXYGEN_SATURATION - * - daily-heart-rate-variability → HEART_RATE_VARIABILITY (SDNN slot; see mapper) - * - daily-resting-heart-rate → RESTING_HEART_RATE - * - daily-respiratory-rate → RESPIRATORY_RATE - * - heart-rate (intraday) → PULSE - * - height → User.heightCm (profile seed, NOT a Measurement) + * - weight → WEIGHT + * - body-fat → BODY_FAT + * - daily-oxygen-saturation → OXYGEN_SATURATION + * - daily-heart-rate-variability → HEART_RATE_VARIABILITY (SDNN slot; see mapper) + * - daily-resting-heart-rate → RESTING_HEART_RATE + * - daily-respiratory-rate → RESPIRATORY_RATE + * - heart-rate (intraday) → PULSE + * - blood-glucose → BLOOD_GLUCOSE (mg/dL at source) + * - core-body-temperature → BODY_TEMPERATURE + * - daily-sleep-temperature-derivations → WRIST_TEMPERATURE (absolute nightly skin temp) + * - height → User.heightCm (profile seed, NOT a Measurement) * * Each data point yields at most one Measurement row, disambiguated by the * per-point anchor + field-tag in the externalId (`:`) so a @@ -27,7 +30,9 @@ import { type GoogleHealthDataType, type GoogleHealthMappedMeasurement, fetchDataPoints, + mapBloodGlucose, mapBodyFat, + mapCoreBodyTemperature, mapHeartRate, mapHeartRateVariability, mapHeight, @@ -35,6 +40,7 @@ import { mapRespiratoryRate, mapRestingHeartRate, mapWeight, + mapWristTemperature, } from "./client"; import { getValidToken, @@ -90,9 +96,24 @@ const METRIC_RESOURCES: MetricResource[] = [ map: mapHeartRate, verb: "fetchHeartRate", }, - // Skin temperature is intentionally omitted for v1 — Google surfaces a nightly - // signed DEVIATION from baseline, a future enhancement needing a signed-delta - // model (see `GOOGLE_HEALTH_DATA_TYPES` in `client.ts`). + { + dataType: GOOGLE_HEALTH_DATA_TYPES.bloodGlucose, + map: mapBloodGlucose, + verb: "fetchBloodGlucose", + }, + { + dataType: GOOGLE_HEALTH_DATA_TYPES.coreBodyTemperature, + map: mapCoreBodyTemperature, + verb: "fetchCoreBodyTemperature", + }, + // Nightly sleep skin temperature — the documented `nightlyTemperatureCelsius` + // is an absolute reading (not the once-assumed signed deviation), so it lands + // in the WRIST_TEMPERATURE absolute-reading slot. + { + dataType: GOOGLE_HEALTH_DATA_TYPES.sleepTemperature, + map: mapWristTemperature, + verb: "fetchSleepTemperature", + }, ]; export async function syncUserMetrics(