mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
Merge branch 'fix/google-health-coverage' into release/v1.27.15
This commit is contained in:
commit
5cc35aae6e
6 files changed
+669
-127
No files matched your search
@@ -81,9 +81,11 @@ type ProbeResult =
|
||||
count: number;
|
||||
structure: unknown;
|
||||
/**
|
||||
* Rollup types only: which documented dailyRollUp request shape Google
|
||||
* Rollup types: which documented dailyRollUp request shape Google
|
||||
* accepted (`days90` standard chunking vs `days14` conservative
|
||||
* fallback) — the live verdict on the range constraint.
|
||||
* fallback). Daily-summary types: which `.date` filter prefix style
|
||||
* Google accepted (`camel` worked-example vs `snake` fallback) — the
|
||||
* live verdict on the docs' self-contradiction.
|
||||
*/
|
||||
requestShape?: string;
|
||||
}
|
||||
@@ -141,6 +143,11 @@ async function runStructureProbe(
|
||||
pageSize,
|
||||
maxPages: 1,
|
||||
tz,
|
||||
// Daily-summary types report which `.date` filter prefix style
|
||||
// Google accepted, so a live account settles the docs' conflict.
|
||||
onDateFilterStyle: (style) => {
|
||||
requestShape = style;
|
||||
},
|
||||
});
|
||||
}
|
||||
results[name] = {
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Pins the daily-summary `.date` filter contract in `fetchDataPoints`.
|
||||
*
|
||||
* The official docs contradict themselves on the type-name prefix inside a
|
||||
* daily-summary filter: the data-types index's "filter parameter" column says
|
||||
* snake_case (`daily_resting_heart_rate`), while the `dataPoints.list`
|
||||
* reference's only worked daily example is camelCase
|
||||
* (`dailyHeartRateVariability.date < "2024-08-15"`). The client sends the
|
||||
* worked-example camelCase form first and retries the whole walk once with
|
||||
* snake_case if the VERY FIRST request 400s. Sample/session/sleep filters have
|
||||
* a single documented grammar and never fall back.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { safeFetchMock } = vi.hoisted(() => ({ safeFetchMock: vi.fn() }));
|
||||
vi.mock("@/lib/safe-fetch", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/safe-fetch")>();
|
||||
return { ...actual, safeFetch: safeFetchMock };
|
||||
});
|
||||
|
||||
import { GOOGLE_HEALTH_DATA_TYPES, fetchDataPoints } from "../client";
|
||||
import { GoogleHealthApiError } from "../response-classifier";
|
||||
|
||||
function jsonResponse(status: number, body: unknown): Response {
|
||||
return {
|
||||
status,
|
||||
ok: status >= 200 && status < 300,
|
||||
json: async () => body,
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
/** The decoded `filter` query parameter of the n-th captured request. */
|
||||
function capturedFilter(n: number): string | null {
|
||||
const url = safeFetchMock.mock.calls[n]?.[0] as string;
|
||||
return new URL(url).searchParams.get("filter");
|
||||
}
|
||||
|
||||
const start = new Date("2026-06-01T00:00:00.000Z");
|
||||
|
||||
beforeEach(() => {
|
||||
safeFetchMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("fetchDataPoints — daily-summary filter style", () => {
|
||||
it("sends the camelCase worked-example prefix first", async () => {
|
||||
safeFetchMock.mockResolvedValueOnce(
|
||||
jsonResponse(200, {
|
||||
dataPoints: [{ dailyRestingHeartRate: { beatsPerMinute: "54" } }],
|
||||
}),
|
||||
);
|
||||
|
||||
const styles: string[] = [];
|
||||
const points = await fetchDataPoints(
|
||||
GOOGLE_HEALTH_DATA_TYPES.restingHeartRate,
|
||||
"token",
|
||||
"fetchRhr",
|
||||
{ start, onDateFilterStyle: (s) => styles.push(s) },
|
||||
);
|
||||
|
||||
expect(points).toHaveLength(1);
|
||||
expect(safeFetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(capturedFilter(0)).toBe(
|
||||
'dailyRestingHeartRate.date >= "2026-06-01"',
|
||||
);
|
||||
expect(styles).toEqual(["camel"]);
|
||||
});
|
||||
|
||||
it("re-walks once with the snake_case prefix when the first page 400s", async () => {
|
||||
safeFetchMock
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse(400, {
|
||||
error: { status: "INVALID_ARGUMENT", message: "Invalid filter" },
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse(200, {
|
||||
dataPoints: [{ dailyRestingHeartRate: { beatsPerMinute: "54" } }],
|
||||
}),
|
||||
);
|
||||
|
||||
const styles: string[] = [];
|
||||
const points = await fetchDataPoints(
|
||||
GOOGLE_HEALTH_DATA_TYPES.restingHeartRate,
|
||||
"token",
|
||||
"fetchRhr",
|
||||
{ start, onDateFilterStyle: (s) => styles.push(s) },
|
||||
);
|
||||
|
||||
expect(points).toHaveLength(1);
|
||||
expect(safeFetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(capturedFilter(0)).toBe(
|
||||
'dailyRestingHeartRate.date >= "2026-06-01"',
|
||||
);
|
||||
expect(capturedFilter(1)).toBe(
|
||||
'daily_resting_heart_rate.date >= "2026-06-01"',
|
||||
);
|
||||
expect(styles).toEqual(["snake"]);
|
||||
});
|
||||
|
||||
it("propagates a 400 on the snake retry (both grammars rejected)", async () => {
|
||||
safeFetchMock.mockResolvedValue(
|
||||
jsonResponse(400, {
|
||||
error: { status: "INVALID_ARGUMENT", message: "Invalid filter" },
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
fetchDataPoints(
|
||||
GOOGLE_HEALTH_DATA_TYPES.heartRateVariability,
|
||||
"token",
|
||||
"fetchHrv",
|
||||
{ start },
|
||||
),
|
||||
).rejects.toBeInstanceOf(GoogleHealthApiError);
|
||||
expect(safeFetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("propagates a 400 past the first page without re-walking", async () => {
|
||||
safeFetchMock
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse(200, { dataPoints: [], nextPageToken: "p2" }),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse(400, {
|
||||
error: { status: "INVALID_ARGUMENT", message: "boom" },
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
fetchDataPoints(
|
||||
GOOGLE_HEALTH_DATA_TYPES.restingHeartRate,
|
||||
"token",
|
||||
"fetchRhr",
|
||||
{ start },
|
||||
),
|
||||
).rejects.toBeInstanceOf(GoogleHealthApiError);
|
||||
expect(safeFetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("never falls back for a sample-type filter (single documented grammar)", async () => {
|
||||
safeFetchMock.mockResolvedValue(
|
||||
jsonResponse(400, {
|
||||
error: { status: "INVALID_ARGUMENT", message: "Invalid filter" },
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
fetchDataPoints(GOOGLE_HEALTH_DATA_TYPES.weight, "token", "fetchWeight", {
|
||||
start,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(GoogleHealthApiError);
|
||||
expect(safeFetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(capturedFilter(0)).toBe(
|
||||
'weight.sample_time.physical_time >= "2026-06-01T00:00:00.000Z"',
|
||||
);
|
||||
});
|
||||
|
||||
it("never falls back on an unfiltered backfill walk (no filter to reshape)", async () => {
|
||||
safeFetchMock.mockResolvedValue(
|
||||
jsonResponse(400, {
|
||||
error: { status: "INVALID_ARGUMENT", message: "boom" },
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
fetchDataPoints(
|
||||
GOOGLE_HEALTH_DATA_TYPES.restingHeartRate,
|
||||
"token",
|
||||
"fetchRhr",
|
||||
),
|
||||
).rejects.toBeInstanceOf(GoogleHealthApiError);
|
||||
expect(safeFetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(capturedFilter(0)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
@@ -136,10 +139,11 @@ describe("mapRestingHeartRate — int64-string coercion", () => {
|
||||
});
|
||||
|
||||
describe("mapRespiratoryRate — daily-respiratory-rate summary", () => {
|
||||
it("coerces the dailyRespiratoryRateBpm int64 JSON string", () => {
|
||||
it("reads the documented breathsPerMinute number", () => {
|
||||
// Documented schema: `{ date, breathsPerMinute }` — a plain number.
|
||||
const rows = mapRespiratoryRate({
|
||||
dailyRespiratoryRate: {
|
||||
dailyRespiratoryRateBpm: "14",
|
||||
breathsPerMinute: 14.2,
|
||||
date: { year: 2026, month: 6, day: 1 },
|
||||
},
|
||||
});
|
||||
@@ -147,10 +151,81 @@ describe("mapRespiratoryRate — daily-respiratory-rate summary", () => {
|
||||
expect(rows[0]).toMatchObject({
|
||||
type: "RESPIRATORY_RATE",
|
||||
unit: "breaths/min",
|
||||
value: 14,
|
||||
value: 14.2,
|
||||
fieldTag: "2026-06-01:resp_rate",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns nothing for the leaf name that does not exist in the schema", () => {
|
||||
expect(
|
||||
mapRespiratoryRate({
|
||||
dailyRespiratoryRate: {
|
||||
dailyRespiratoryRateBpm: "14",
|
||||
date: { year: 2026, month: 6, day: 1 },
|
||||
},
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
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", () => {
|
||||
@@ -172,10 +247,11 @@ describe("mapHeartRate — intraday spot sample", () => {
|
||||
});
|
||||
|
||||
describe("mapHeight — profile seed", () => {
|
||||
it("converts heightMeters to cm and surfaces the sample instant", () => {
|
||||
it("converts the heightMillimeters int64 string to cm and surfaces the sample instant", () => {
|
||||
// Documented field: `heightMillimeters` (string, int64) — millimetres.
|
||||
const sample = mapHeight({
|
||||
height: {
|
||||
heightMeters: 1.82,
|
||||
heightMillimeters: "1820",
|
||||
sampleTime: { physicalTime: "2026-06-01T08:00:00.000Z" },
|
||||
},
|
||||
});
|
||||
@@ -184,8 +260,9 @@ describe("mapHeight — profile seed", () => {
|
||||
expect(sample!.sampledAt?.toISOString()).toBe("2026-06-01T08:00:00.000Z");
|
||||
});
|
||||
|
||||
it("returns null when no value parses", () => {
|
||||
it("returns null when no value parses (incl. the nonexistent heightMeters leaf)", () => {
|
||||
expect(mapHeight({ height: {} })).toBeNull();
|
||||
expect(mapHeight({ height: { heightMeters: 1.82 } })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -424,6 +501,26 @@ describe("mapWorkout — exercise session", () => {
|
||||
expect(mapGoogleHealthSportType("KITESURFING")).toBe("other");
|
||||
expect(mapGoogleHealthSportType("")).toBe("other");
|
||||
});
|
||||
|
||||
it("resolves the documented multi-word ExerciseType values (catalogue audit)", () => {
|
||||
// Values verbatim from the Exercise.ExerciseType enum in the v4 reference.
|
||||
expect(mapGoogleHealthSportType("MOUNTAIN_BIKE")).toBe("cycling");
|
||||
expect(mapGoogleHealthSportType("STATIONARY_BIKE")).toBe("cycling");
|
||||
expect(mapGoogleHealthSportType("TRAIL_RUN")).toBe("running");
|
||||
expect(mapGoogleHealthSportType("TREADMILL_WALK")).toBe("walking");
|
||||
expect(mapGoogleHealthSportType("NORDIC_WALKING")).toBe("walking");
|
||||
expect(mapGoogleHealthSportType("SWIMMING_POOL")).toBe("swimming");
|
||||
expect(mapGoogleHealthSportType("SWIMMING_OPEN_WATER")).toBe("swimming");
|
||||
expect(mapGoogleHealthSportType("ROWING_MACHINE")).toBe("rowing");
|
||||
expect(mapGoogleHealthSportType("FREE_WEIGHTS")).toBe("strength");
|
||||
expect(mapGoogleHealthSportType("FUNCTIONAL_STRENGTH_TRAINING")).toBe(
|
||||
"strength",
|
||||
);
|
||||
expect(mapGoogleHealthSportType("CROSSFIT")).toBe("crossTraining");
|
||||
expect(mapGoogleHealthSportType("CARDIO_WORKOUT")).toBe("mixedCardio");
|
||||
expect(mapGoogleHealthSportType("TABATA_WORKOUT")).toBe("hiit");
|
||||
expect(mapGoogleHealthSportType("TAI_CHI")).toBe("mindAndBody");
|
||||
});
|
||||
});
|
||||
|
||||
describe("incrementalFilter — per-shape filter grammar", () => {
|
||||
@@ -436,11 +533,34 @@ describe("incrementalFilter — per-shape filter grammar", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("filters daily summaries on .date (YYYY-MM-DD)", () => {
|
||||
it("filters daily summaries on .date (YYYY-MM-DD) with the camelCase worked-example prefix", () => {
|
||||
// The list reference's only worked daily example is
|
||||
// `dailyHeartRateVariability.date < "2024-08-15"` — camelCase, unlike the
|
||||
// snake_case sample/session prefixes. Camel is the default.
|
||||
expect(
|
||||
incrementalFilter(GOOGLE_HEALTH_DATA_TYPES.oxygenSaturation, start),
|
||||
).toEqual({
|
||||
field: "daily_oxygen_saturation.date",
|
||||
field: "dailyOxygenSaturation.date",
|
||||
bound: "2026-06-01",
|
||||
});
|
||||
expect(
|
||||
incrementalFilter(GOOGLE_HEALTH_DATA_TYPES.heartRateVariability, start),
|
||||
).toEqual({
|
||||
field: "dailyHeartRateVariability.date",
|
||||
bound: "2026-06-01",
|
||||
});
|
||||
});
|
||||
|
||||
it("builds the snake_case daily prefix for the fallback style", () => {
|
||||
expect(
|
||||
incrementalFilter(
|
||||
GOOGLE_HEALTH_DATA_TYPES.restingHeartRate,
|
||||
start,
|
||||
undefined,
|
||||
"snake",
|
||||
),
|
||||
).toEqual({
|
||||
field: "daily_resting_heart_rate.date",
|
||||
bound: "2026-06-01",
|
||||
});
|
||||
});
|
||||
|
||||
+248
-71
@@ -402,7 +402,11 @@ export function resolveGoogleHealthUserId(
|
||||
// Casing gotcha (three encodings per type, all pinned in
|
||||
// `GOOGLE_HEALTH_DATA_TYPES` so a fetcher can never mix them up):
|
||||
// - request path: kebab-case (`body-fat`)
|
||||
// - `filter` predicate: snake_case (`body_fat.sample_time.physical_time`)
|
||||
// - `filter` predicate: snake_case (`body_fat.sample_time.physical_time`) —
|
||||
// EXCEPT daily-summary `.date` filters, where the docs contradict
|
||||
// themselves and the worked example uses the camelCase payload key; the
|
||||
// client sends camel first and falls back to snake on a first-page 400
|
||||
// (see `GoogleHealthDateFilterStyle`).
|
||||
// - response payload: camelCase — the `DataPoint` value is a union keyed by
|
||||
// the camelCase type name (`bodyFat`, `dailyRestingHeartRate`, …) with
|
||||
// camelCase nested objects (`sampleTime.physicalTime`,
|
||||
@@ -515,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
|
||||
@@ -601,6 +624,12 @@ interface DataPointQuery {
|
||||
* zone). Omitted → the bound forms in UTC.
|
||||
*/
|
||||
tz?: string;
|
||||
/**
|
||||
* Observes which daily-summary `.date` filter prefix style the walk settled
|
||||
* on (`camel` worked-example vs `snake` fallback) — the structure probe
|
||||
* surfaces it so a live account reports which grammar Google accepted.
|
||||
*/
|
||||
onDateFilterStyle?: (style: GoogleHealthDateFilterStyle) => void;
|
||||
}
|
||||
|
||||
const pad2 = (n: number): string => String(n).padStart(2, "0");
|
||||
@@ -617,12 +646,31 @@ export function formatCivilBound(instant: Date, tz?: string): string {
|
||||
return `${p.year}-${pad2(p.month)}-${pad2(p.day)}T${pad2(p.hour)}:${pad2(p.minute)}:${pad2(p.second)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The daily-summary `.date` filter prefix style. The official docs contradict
|
||||
* themselves on this one point:
|
||||
* - The data-types index states "In a filter parameter … the data type name
|
||||
* must be in snake case, such as `body_fat`" and its per-type "filter
|
||||
* parameter" column lists `daily_heart_rate_variability` etc.
|
||||
* - The `dataPoints.list` reference's ONLY worked daily-summary example is
|
||||
* `dailyHeartRateVariability.date < "2024-08-15"` — the camelCase payload
|
||||
* union key, NOT snake_case.
|
||||
* Live behaviour: the snake_case daily filter is accepted (HTTP 200) but has
|
||||
* returned zero rows on accounts whose companion app visibly holds daily HRV /
|
||||
* RHR — consistent with a vacuously-unmatched predicate. The client therefore
|
||||
* sends the worked-example camelCase form first and falls back to snake_case
|
||||
* once if the very first page is rejected with a 400 (see `fetchDataPoints`).
|
||||
*/
|
||||
export type GoogleHealthDateFilterStyle = "camel" | "snake";
|
||||
|
||||
/**
|
||||
* Build the incremental `filter` field + bound for one list-read data type.
|
||||
* Centralised so the predicate and the read-time anchor resolution can never
|
||||
* drift. The legal filter fields are per-shape (anything else 400s):
|
||||
* - `sample` → `{filter}.sample_time.physical_time` (RFC-3339).
|
||||
* - `date` → `{filter}.date` (`YYYY-MM-DD`).
|
||||
* - `date` → `{key|filter}.date` (`YYYY-MM-DD`) — prefix style per
|
||||
* `GoogleHealthDateFilterStyle` (docs conflict; camel is the worked
|
||||
* example, snake the fallback).
|
||||
* - `sessionEnd` → sleep only: `{filter}.interval.end_time` (RFC-3339) — the
|
||||
* ONLY filterable time field on sleep; watermark semantics improve too (a
|
||||
* night is fetched when it ENDS after the cursor).
|
||||
@@ -634,6 +682,7 @@ export function incrementalFilter(
|
||||
dataType: GoogleHealthDataType,
|
||||
start: Date,
|
||||
tz?: string,
|
||||
dateStyle: GoogleHealthDateFilterStyle = "camel",
|
||||
): { field: string; bound: string } {
|
||||
switch (dataType.timeField) {
|
||||
case "sample":
|
||||
@@ -653,7 +702,7 @@ export function incrementalFilter(
|
||||
};
|
||||
case "date":
|
||||
return {
|
||||
field: `${dataType.filter}.date`,
|
||||
field: `${dateStyle === "camel" ? dataType.key : dataType.filter}.date`,
|
||||
bound: start.toISOString().slice(0, 10),
|
||||
};
|
||||
case "rollup":
|
||||
@@ -739,9 +788,19 @@ export function extractGoogleApiErrorDetail(json: unknown): string | undefined {
|
||||
/**
|
||||
* Walk every `DataPoint` for one data type since the incremental cursor via
|
||||
* `dataPoints.list` with `nextPageToken` pagination. The data-type id is
|
||||
* kebab-cased in the path; the `filter` predicate is built from the snake_case
|
||||
* form against the type's time anchor. `rollup` types refuse — they read via
|
||||
* kebab-cased in the path; the `filter` predicate is built per-shape against
|
||||
* the type's time anchor. `rollup` types refuse — they read via
|
||||
* `fetchDailyRollUp`.
|
||||
*
|
||||
* DAILY-SUMMARY FILTER FALLBACK: the docs contradict themselves on the `.date`
|
||||
* filter's type-name prefix (the index's "filter parameter" column says
|
||||
* snake_case; the list reference's only worked example says camelCase — see
|
||||
* `GoogleHealthDateFilterStyle`). The walk sends the camelCase worked-example
|
||||
* form first; if the VERY FIRST request of the walk is rejected with a 400,
|
||||
* the whole walk retries once with the snake_case form. The style that
|
||||
* succeeded is annotated as `googleHealth.dateFilter.style` so live behaviour
|
||||
* reports which grammar Google actually accepts. Any other failure, or a 400
|
||||
* past the first request, propagates.
|
||||
*/
|
||||
export async function fetchDataPoints(
|
||||
dataType: GoogleHealthDataType,
|
||||
@@ -749,68 +808,102 @@ export async function fetchDataPoints(
|
||||
verb: string,
|
||||
query: DataPointQuery = {},
|
||||
): Promise<GoogleHealthDataPoint[]> {
|
||||
const points: GoogleHealthDataPoint[] = [];
|
||||
let pageToken: string | null | undefined;
|
||||
let pageCount = 0;
|
||||
const maxPages = query.maxPages ?? 1000;
|
||||
const pageSize = query.pageSize ?? GOOGLE_HEALTH_PAGE_SIZE;
|
||||
|
||||
do {
|
||||
const params = new URLSearchParams({ pageSize: String(pageSize) });
|
||||
if (query.start) {
|
||||
const { field, bound } = incrementalFilter(
|
||||
dataType,
|
||||
query.start,
|
||||
query.tz,
|
||||
let requestCount = 0;
|
||||
|
||||
const walk = async (
|
||||
dateStyle: GoogleHealthDateFilterStyle,
|
||||
): Promise<GoogleHealthDataPoint[]> => {
|
||||
const points: GoogleHealthDataPoint[] = [];
|
||||
let pageToken: string | null | undefined;
|
||||
let pageCount = 0;
|
||||
|
||||
do {
|
||||
const params = new URLSearchParams({ pageSize: String(pageSize) });
|
||||
if (query.start) {
|
||||
const { field, bound } = incrementalFilter(
|
||||
dataType,
|
||||
query.start,
|
||||
query.tz,
|
||||
dateStyle,
|
||||
);
|
||||
params.set("filter", `${field} >= "${bound}"`);
|
||||
}
|
||||
if (pageToken) params.set("pageToken", pageToken);
|
||||
|
||||
requestCount += 1;
|
||||
const pageStart = performance.now();
|
||||
const res = await safeFetch(
|
||||
`${GOOGLE_HEALTH_API_BASE}/users/me/dataTypes/${dataType.path}/dataPoints?${params}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
},
|
||||
);
|
||||
params.set("filter", `${field} >= "${bound}"`);
|
||||
}
|
||||
if (pageToken) params.set("pageToken", pageToken);
|
||||
|
||||
const pageStart = performance.now();
|
||||
const res = await safeFetch(
|
||||
`${GOOGLE_HEALTH_API_BASE}/users/me/dataTypes/${dataType.path}/dataPoints?${params}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
},
|
||||
);
|
||||
|
||||
const json = (await res
|
||||
.json()
|
||||
.catch(() => null)) as GoogleHealthDataPointPage | null;
|
||||
const verdict = classifyGoogleHealthResponse(res.status);
|
||||
const apiErrorDetail =
|
||||
verdict.classification === "success"
|
||||
? undefined
|
||||
: extractGoogleApiErrorDetail(json);
|
||||
getEvent()?.addExternalCall({
|
||||
service: "google-health",
|
||||
method: `${verb}(page=${pageCount})`,
|
||||
duration_ms: Math.round(performance.now() - pageStart),
|
||||
status: res.status,
|
||||
error:
|
||||
const json = (await res
|
||||
.json()
|
||||
.catch(() => null)) as GoogleHealthDataPointPage | null;
|
||||
const verdict = classifyGoogleHealthResponse(res.status);
|
||||
const apiErrorDetail =
|
||||
verdict.classification === "success"
|
||||
? undefined
|
||||
: apiErrorDetail
|
||||
? `${verdict.reason} ${apiErrorDetail}`
|
||||
: verdict.reason,
|
||||
});
|
||||
if (verdict.classification !== "success") {
|
||||
throw new GoogleHealthApiError({
|
||||
verb,
|
||||
classification: verdict.classification,
|
||||
httpStatus: verdict.httpStatus,
|
||||
reason: verdict.reason,
|
||||
upstreamError: apiErrorDetail,
|
||||
: extractGoogleApiErrorDetail(json);
|
||||
getEvent()?.addExternalCall({
|
||||
service: "google-health",
|
||||
method: `${verb}(page=${pageCount})`,
|
||||
duration_ms: Math.round(performance.now() - pageStart),
|
||||
status: res.status,
|
||||
error:
|
||||
verdict.classification === "success"
|
||||
? undefined
|
||||
: apiErrorDetail
|
||||
? `${verdict.reason} ${apiErrorDetail}`
|
||||
: verdict.reason,
|
||||
});
|
||||
}
|
||||
if (verdict.classification !== "success") {
|
||||
throw new GoogleHealthApiError({
|
||||
verb,
|
||||
classification: verdict.classification,
|
||||
httpStatus: verdict.httpStatus,
|
||||
reason: verdict.reason,
|
||||
upstreamError: apiErrorDetail,
|
||||
});
|
||||
}
|
||||
|
||||
for (const p of json?.dataPoints ?? []) points.push(p);
|
||||
pageToken = json?.nextPageToken ?? null;
|
||||
pageCount += 1;
|
||||
} while (pageToken && pageCount < maxPages);
|
||||
for (const p of json?.dataPoints ?? []) points.push(p);
|
||||
pageToken = json?.nextPageToken ?? null;
|
||||
pageCount += 1;
|
||||
} while (pageToken && pageCount < maxPages);
|
||||
|
||||
return points;
|
||||
};
|
||||
|
||||
// The fallback only exists for filtered daily-summary reads — every other
|
||||
// shape has one documented, doc-consistent filter field.
|
||||
const canFallBack =
|
||||
dataType.timeField === "date" && query.start !== undefined;
|
||||
|
||||
let style: GoogleHealthDateFilterStyle = "camel";
|
||||
let points: GoogleHealthDataPoint[];
|
||||
try {
|
||||
points = await walk("camel");
|
||||
} catch (err) {
|
||||
const firstRequestRejected =
|
||||
canFallBack &&
|
||||
requestCount === 1 &&
|
||||
err instanceof GoogleHealthApiError &&
|
||||
err.httpStatus === 400;
|
||||
if (!firstRequestRejected) throw err;
|
||||
style = "snake";
|
||||
points = await walk("snake");
|
||||
}
|
||||
if (canFallBack) {
|
||||
annotate({ meta: { "googleHealth.dateFilter.style": style } });
|
||||
query.onDateFilterStyle?.(style);
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
@@ -1090,8 +1183,8 @@ export async function fetchDailyRollUp(
|
||||
// The single source of truth is `mapping.md` — keep both in sync when adding
|
||||
// entries.
|
||||
|
||||
/** Metres → centimetres (Google `height` → `User.heightCm`). */
|
||||
const M_TO_CM = 100;
|
||||
/** Millimetres → centimetres (Google `height.heightMillimeters` → `User.heightCm`). */
|
||||
const MM_TO_CM = 0.1;
|
||||
|
||||
function round2(n: number): number {
|
||||
return parseFloat(n.toFixed(2));
|
||||
@@ -1393,12 +1486,60 @@ export function mapRespiratoryRate(
|
||||
point: GoogleHealthDataPoint,
|
||||
): GoogleHealthMappedMeasurement[] {
|
||||
const dt = GOOGLE_HEALTH_DATA_TYPES.respiratoryRate;
|
||||
// `dailyRespiratoryRateBpm` is an int64 JSON string.
|
||||
// Documented schema: `{ date, breathsPerMinute }` — `breathsPerMinute` is a
|
||||
// plain number ("The average number of breaths taken per minute"). The
|
||||
// earlier `dailyRespiratoryRateBpm` leaf does not exist and never parsed.
|
||||
return mapSimple(point, dt, {
|
||||
type: "RESPIRATORY_RATE",
|
||||
unit: "breaths/min",
|
||||
fieldTag: "resp_rate",
|
||||
valuePaths: [`${dt.key}.dailyRespiratoryRateBpm`],
|
||||
valuePaths: [`${dt.key}.breathsPerMinute`],
|
||||
});
|
||||
}
|
||||
|
||||
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`],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1425,19 +1566,20 @@ export interface GoogleHealthHeightSample {
|
||||
* Extract the profile height from a Google `height` data point, or null when
|
||||
* nothing parses. Height is a one-time `User.heightCm` profile seed (written
|
||||
* only when the user has no height yet) — NOT a Measurement. The documented
|
||||
* field is `heightMeters` (metres) → cm. `sampledAt` lets the caller pick the
|
||||
* LATEST sample explicitly — list responses are ordered DESCENDING, so
|
||||
* "last row wins" would pick the OLDEST.
|
||||
* field is `heightMillimeters` (int64 JSON string, millimetres) → cm ÷ 10.
|
||||
* (The earlier `heightMeters` leaf does not exist and never parsed.)
|
||||
* `sampledAt` lets the caller pick the LATEST sample explicitly — list
|
||||
* responses are ordered DESCENDING, so "last row wins" would pick the OLDEST.
|
||||
*/
|
||||
export function mapHeight(
|
||||
point: GoogleHealthDataPoint,
|
||||
): GoogleHealthHeightSample | null {
|
||||
const dt = GOOGLE_HEALTH_DATA_TYPES.height;
|
||||
const m = firstNumber(point, [`${dt.key}.heightMeters`]);
|
||||
if (m === null) return null;
|
||||
const mm = firstNumber(point, [`${dt.key}.heightMillimeters`]);
|
||||
if (mm === null) return null;
|
||||
const t = readPath(point, `${dt.key}.sampleTime.physicalTime`);
|
||||
const sampledAt = typeof t === "string" ? parseLocalInstant(t) : null;
|
||||
return { cm: round2(m * M_TO_CM), sampledAt };
|
||||
return { cm: round2(mm * MM_TO_CM), sampledAt };
|
||||
}
|
||||
|
||||
// ─── Activity mappers: daily roll-up totals ────────────────────
|
||||
@@ -1745,34 +1887,64 @@ export function mapSleepSession(
|
||||
const GOOGLE_HEALTH_EXERCISE_TYPE_MAP: Record<string, string> = {
|
||||
walk: "walking",
|
||||
walking: "walking",
|
||||
treadmill_walk: "walking",
|
||||
incline_walk: "walking",
|
||||
power_walking: "walking",
|
||||
nordic_walking: "walking",
|
||||
stroller_walk: "walking",
|
||||
walk_with_weights: "walking",
|
||||
run: "running",
|
||||
running: "running",
|
||||
treadmill: "running",
|
||||
treadmill_running: "running",
|
||||
trail_run: "running",
|
||||
incline_run: "running",
|
||||
bike: "cycling",
|
||||
biking: "cycling",
|
||||
cycling: "cycling",
|
||||
spinning: "cycling",
|
||||
mountain_biking: "cycling",
|
||||
mountain_bike: "cycling",
|
||||
outdoor_bike: "cycling",
|
||||
stationary_bike: "cycling",
|
||||
electric_bike: "cycling",
|
||||
assault_bike: "cycling",
|
||||
hike: "hiking",
|
||||
hiking: "hiking",
|
||||
backpacking: "hiking",
|
||||
rucking: "hiking",
|
||||
swim: "swimming",
|
||||
swimming: "swimming",
|
||||
swimming_pool: "swimming",
|
||||
swimming_open_water: "swimming",
|
||||
rowing: "rowing",
|
||||
rowing_machine: "rowing",
|
||||
elliptical: "elliptical",
|
||||
stairclimber: "stairClimber",
|
||||
stair_climbing: "stairClimber",
|
||||
step_training: "stairClimber",
|
||||
yoga: "yoga",
|
||||
pilates: "mindAndBody",
|
||||
meditate: "mindAndBody",
|
||||
tai_chi: "mindAndBody",
|
||||
stretching: "mindAndBody",
|
||||
weights: "strength",
|
||||
strength: "strength",
|
||||
strength_training: "strength",
|
||||
functional_strength_training: "strength",
|
||||
weightlifting: "strength",
|
||||
powerlifting: "strength",
|
||||
free_weights: "strength",
|
||||
body_weight: "strength",
|
||||
resistance_bands: "strength",
|
||||
calisthenics: "strength",
|
||||
core_training: "strength",
|
||||
workout: "strength",
|
||||
hiit: "hiit",
|
||||
high_intensity_interval_training: "hiit",
|
||||
interval_workout: "hiit",
|
||||
interval_training: "hiit",
|
||||
tabata_workout: "hiit",
|
||||
dance: "dance",
|
||||
dancing: "dance",
|
||||
golf: "golf",
|
||||
@@ -1782,6 +1954,11 @@ const GOOGLE_HEALTH_EXERCISE_TYPE_MAP: Record<string, string> = {
|
||||
football: "soccer",
|
||||
bootcamp: "crossTraining",
|
||||
circuit_training: "crossTraining",
|
||||
crossfit: "crossTraining",
|
||||
cross_training: "crossTraining",
|
||||
aerobic_workout: "mixedCardio",
|
||||
cardio_workout: "mixedCardio",
|
||||
cardio_sculpt: "mixedCardio",
|
||||
sport: "mixedCardio",
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -18,8 +18,18 @@ 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`).
|
||||
snake_case exists ONLY here.
|
||||
- **`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
|
||||
@@ -27,10 +37,11 @@ Each data type has THREE on-the-wire encodings, all pinned in
|
||||
|
||||
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.
|
||||
`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
|
||||
|
||||
@@ -57,12 +68,12 @@ seconds,nanos}}`), user-local; per the documented example, the `end` bound
|
||||
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`) |
|
||||
| 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.
|
||||
|
||||
@@ -70,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.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. |
|
||||
| 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 launch metrics are all positive — a
|
||||
zero/NaN is a garbage/empty reading and is dropped).
|
||||
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