mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
feat(google-health): import blood glucose, core body temperature, and nightly wrist temperature
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.
This commit is contained in:
1 parent
849607e7df
commit
ae2e39198a
4 files changed
+214
-37
No files matched your search
@@ -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({
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 (`<anchor>:<fieldTag>`) 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(
|
||||
|
||||
Reference in new issue
Block a user