diff --git a/src/app/api/integrations/google-health/test/route.ts b/src/app/api/integrations/google-health/test/route.ts index 650349594..1ae7be1f9 100644 --- a/src/app/api/integrations/google-health/test/route.ts +++ b/src/app/api/integrations/google-health/test/route.ts @@ -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] = { diff --git a/src/lib/google-health/__tests__/date-filter-fallback.test.ts b/src/lib/google-health/__tests__/date-filter-fallback.test.ts new file mode 100644 index 000000000..c983cd3d6 --- /dev/null +++ b/src/lib/google-health/__tests__/date-filter-fallback.test.ts @@ -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(); + 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(); + }); +}); diff --git a/src/lib/google-health/__tests__/mappers.test.ts b/src/lib/google-health/__tests__/mappers.test.ts index 088d26b09..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", () => { @@ -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", }); }); diff --git a/src/lib/google-health/client.ts b/src/lib/google-health/client.ts index 73b63c45d..8dbb8c685 100644 --- a/src/lib/google-health/client.ts +++ b/src/lib/google-health/client.ts @@ -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 { - 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 => { + 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 = { 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 = { 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; diff --git a/src/lib/google-health/mapping.md b/src/lib/google-health/mapping.md index 276a9b14e..9b9470b4d 100644 --- a/src/lib/google-health/mapping.md +++ b/src/lib/google-health/mapping.md @@ -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) 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(