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..b957522b6 100644 --- a/src/lib/google-health/__tests__/mappers.test.ts +++ b/src/lib/google-health/__tests__/mappers.test.ts @@ -136,10 +136,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 +148,21 @@ 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("mapHeartRate — intraday spot sample", () => { @@ -172,10 +184,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 +197,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 +438,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 +470,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..402a353b7 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`, @@ -601,6 +605,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 +627,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 +663,7 @@ export function incrementalFilter( dataType: GoogleHealthDataType, start: Date, tz?: string, + dateStyle: GoogleHealthDateFilterStyle = "camel", ): { field: string; bound: string } { switch (dataType.timeField) { case "sample": @@ -653,7 +683,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 +769,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 +789,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 +1164,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 +1467,14 @@ 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`], }); } @@ -1425,19 +1501,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 +1822,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 +1889,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..ccb830a52 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,10 @@ 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` is a plain JSON number.) ## Read method @@ -57,12 +67,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,21 +80,21 @@ 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. | +| `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.