fix(google-health): reconcile the daily-summary filter and two leaf fields with the v4 reference

Daily HRV and resting heart rate returned HTTP 200 with zero imports on a
live Fitbit-band account. Full reconciliation against the v4 reference
(data-types index, DataPoint schema, list/reconcile/dailyRollUp pages)
surfaces three defects:

- The daily-summary `.date` filter prefix is the docs' own contradiction:
  the data-types index's filter-parameter column says snake_case
  (`daily_heart_rate_variability`), while the list reference's only worked
  daily example is `dailyHeartRateVariability.date < "2024-08-15"` — the
  camelCase payload key. Live, the snake form is accepted (200) but matches
  nothing. Send the worked-example camelCase form first and re-walk once
  with snake_case if the very first page 400s; annotate the accepted style
  (`googleHealth.dateFilter.style`) and surface it through the structure
  probe as `requestShape` so a live account settles the grammar.
- `daily-respiratory-rate` reads `breathsPerMinute` (a plain number); the
  previously assumed `dailyRespiratoryRateBpm` leaf does not exist, so
  RESPIRATORY_RATE never parsed a single row.
- `height` carries `heightMillimeters` (int64 string), not `heightMeters`;
  the heightCm profile seed never fired. Convert mm -> cm.

Also cover the documented Exercise.ExerciseType catalogue in the sport map
(MOUNTAIN_BIKE, TRAIL_RUN, TREADMILL_WALK, STATIONARY_BIKE, SWIMMING_POOL,
FREE_WEIGHTS, CROSSFIT, TABATA_WORKOUT, ...) — the enum's multi-word values
previously fell through to "other".

Tests pin the documented field names per fixture, the camel-first/snake-
fallback walk (including no-fallback for sample shapes and unfiltered
backfills), and the extended sport map. mapping.md records the filter
contradiction and the corrected leaves.
This commit is contained in:
Marc-André Bombeck committed 2026-07-07 11:16:27 +02:00
1 parent 0da11811e2
commit 849607e7df
5 files changed
+466 -101

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();
});
});
@@ -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",
});
});
+179 -67
View File
@@ -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<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 +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<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 +1889,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;
+34 -24
View File
@@ -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.