fix(google-health): build dailyRollUp ranges the way the docs demonstrate

The first live sync on v1.27.4 imported every list-based type but died on
the first cumulative chunk: fetchSteps(chunk=0,page=0) returned
HTTP 400 INVALID_ARGUMENT "Invalid argument in request.".

Re-verified the request contract against the v4 reference:

- Request body (users.dataTypes.dataPoints/dailyRollUp): range
  { start/end: CivilDateTime }, windowSizeDays, pageSize, pageToken,
  dataSourceFamily - every field the client sends is legal under that
  exact name.
- CivilDateTime (inlined on the DataPoint page): nested
  {date:{year,month,day}, time:{hours,minutes,seconds,nanos}} objects.
- range: "Required. Closed-open range of data points that will be rolled
  up. The start time must be aligned with the aggregation window. The
  maximum range for calories-in-heart-rate-zone, heart-rate,
  active-minutes and total-calories is 14 days. The maximum range for
  all other data types is 90 days." - steps / distance / active-energy /
  floors sit in the 90-day class, so the 90-day chunk constant stands
  (the in-file "14-day-max" note applies to heart-rate only).
- The worked dailyRollUp example (developers.google.com/health/endpoints)
  rolls up ONE civil day as start = {date: day, time: 00:00:00} and
  end = {date: SAME day, time: 23:59:59} - the end bound names the last
  covered day, not the next day's midnight.

The previous body sent end = {date: <next day>} with no time object, so
a maximal 90-day chunk touched 91 civil dates - the one divergence from
the documented example, and the prime suspect for the 400 (the docs do
not spell out how the cap counts days).

- buildDailyRollUpBody() pins the documented shape: explicit date+time
  on both bounds, end = last covered day at 23:59:59, windowSizeDays: 1,
  no pageSize (the documented default of 1440 covers the <=90 windows a
  chunk can produce), pageToken threaded on follow-up pages.
- fetchDailyRollUp() keeps 90-day chunking; if the very FIRST request of
  a walk is rejected with a 400 it re-walks the range once at the most
  conservative documented span (14 days, the tightest cap the docs state
  for any type) and annotates googleHealth.rollup.shape with the shape
  that succeeded, so the next live sync reports which constraint binds.
- extractGoogleApiErrorDetail() surfaces AIP-193 error.details[]
  BadRequest fieldViolations as "field: description" fragments (field
  paths and descriptions only, redacted and capped at three), so the
  next 400 names the offending request field in the logs instead of the
  bare "Invalid argument in request.".
- mapping.md documents the verified range semantics; unit tests pin the
  exact body JSON, the 90/91-day boundary, the fallback walk and the
  violation extraction.
This commit is contained in:
Marc-André Bombeck committed 2026-07-06 20:24:24 +02:00
1 parent 4ea50c5d64
commit 1c3c733fab
3 files changed
+494 -77

No files matched your search

@@ -0,0 +1,290 @@
/**
* Pins the dailyRollUp request contract against the documented v4 shape.
*
* The request body mirrors the official example (developers.google.com/health/
* endpoints): both range bounds are explicit CivilDateTime objects
* (`{date:{year,month,day}, time:{hours,minutes,seconds,nanos}}`) and the
* `end` bound is the LAST covered civil day at 23:59:59 — NOT the next day's
* midnight. The range validator counts the civil days a range touches against
* the documented cap ("The maximum range for all other data types is 90
* days"), so an exclusive next-day-midnight end makes a maximal 90-day chunk
* read as 91 days and 400s — the shape that broke the first live backfill
* chunk.
*/
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,
GOOGLE_HEALTH_ROLLUP_FALLBACK_RANGE_DAYS,
GOOGLE_HEALTH_ROLLUP_RANGE_DAYS,
buildDailyRollUpBody,
extractGoogleApiErrorDetail,
fetchDailyRollUp,
type GoogleHealthRollupShape,
} 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;
}
/** Inclusive civil-day count a captured request-body range touches. */
function touchedDays(body: {
range: {
start: { date: { year: number; month: number; day: number } };
end: { date: { year: number; month: number; day: number } };
};
}): number {
const s = body.range.start.date;
const e = body.range.end.date;
return (
(Date.UTC(e.year, e.month - 1, e.day) -
Date.UTC(s.year, s.month - 1, s.day)) /
(24 * 60 * 60 * 1000) +
1
);
}
function capturedBodies(): Array<{
range: {
start: { date: { year: number; month: number; day: number } };
end: { date: { year: number; month: number; day: number } };
};
}> {
return safeFetchMock.mock.calls.map((c) =>
JSON.parse((c[1] as { body: string }).body),
);
}
beforeEach(() => {
safeFetchMock.mockReset();
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-30T12:00:00Z"));
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
describe("buildDailyRollUpBody — documented request shape", () => {
it("matches the documented single-day example byte-for-byte", () => {
// Doc example: one civil day rolls up as [day 00:00:00, same day 23:59:59].
expect(
buildDailyRollUpBody({
start: { year: 2026, month: 2, day: 26 },
end: { year: 2026, month: 2, day: 27 },
}),
).toEqual({
range: {
start: {
date: { year: 2026, month: 2, day: 26 },
time: { hours: 0, minutes: 0, seconds: 0, nanos: 0 },
},
end: {
date: { year: 2026, month: 2, day: 26 },
time: { hours: 23, minutes: 59, seconds: 59, nanos: 0 },
},
},
windowSizeDays: 1,
});
});
it("keeps a maximal 90-day chunk inside the documented 90-day cap", () => {
const body = buildDailyRollUpBody({
start: { year: 2026, month: 1, day: 1 },
end: { year: 2026, month: 4, day: 1 }, // exclusive: 90 civil days
}) as Parameters<typeof touchedDays>[0];
// End bound lands on the last covered day (Mar 31), not Apr 1 midnight.
expect(body.range.end.date).toEqual({ year: 2026, month: 3, day: 31 });
expect(touchedDays(body)).toBe(GOOGLE_HEALTH_ROLLUP_RANGE_DAYS);
});
it("omits pageSize and carries pageToken only when paginating", () => {
const first = buildDailyRollUpBody({
start: { year: 2026, month: 6, day: 1 },
end: { year: 2026, month: 6, day: 8 },
});
expect(first).not.toHaveProperty("pageSize");
expect(first).not.toHaveProperty("pageToken");
const next = buildDailyRollUpBody(
{
start: { year: 2026, month: 6, day: 1 },
end: { year: 2026, month: 6, day: 8 },
},
"tok",
);
expect(next.pageToken).toBe("tok");
});
});
describe("fetchDailyRollUp — chunk walk + conservative fallback", () => {
it("walks 90-day chunks and reports the standard shape", async () => {
safeFetchMock.mockResolvedValue(
jsonResponse(200, {
rollupDataPoints: [{ steps: { countSum: "100" } }],
}),
);
let shape: GoogleHealthRollupShape | undefined;
const points = await fetchDailyRollUp(
GOOGLE_HEALTH_DATA_TYPES.steps,
"token",
"fetchSteps",
{
start: new Date("2026-01-01T00:00:00Z"),
onShape: (s) => {
shape = s;
},
},
);
expect(shape).toBe("days90");
// 2026-01-01 → 2026-07-01 (exclusive tomorrow) = 181 days → 3 chunks.
expect(safeFetchMock).toHaveBeenCalledTimes(3);
expect(points).toHaveLength(3);
for (const body of capturedBodies()) {
expect(touchedDays(body)).toBeLessThanOrEqual(
GOOGLE_HEALTH_ROLLUP_RANGE_DAYS,
);
}
});
it("re-walks at 14 days when the FIRST request is rejected with a 400", async () => {
safeFetchMock.mockResolvedValueOnce(
jsonResponse(400, {
error: { code: 400, status: "INVALID_ARGUMENT", message: "bad range" },
}),
);
safeFetchMock.mockResolvedValue(
jsonResponse(200, { rollupDataPoints: [] }),
);
let shape: GoogleHealthRollupShape | undefined;
await fetchDailyRollUp(
GOOGLE_HEALTH_DATA_TYPES.steps,
"token",
"fetchSteps",
{
start: new Date("2026-06-01T00:00:00Z"),
onShape: (s) => {
shape = s;
},
},
);
expect(shape).toBe("days14");
// First body was a single 30-day chunk; the retry re-chunks at ≤14 days.
const bodies = capturedBodies();
expect(bodies.length).toBeGreaterThan(1);
for (const body of bodies.slice(1)) {
expect(touchedDays(body)).toBeLessThanOrEqual(
GOOGLE_HEALTH_ROLLUP_FALLBACK_RANGE_DAYS,
);
}
});
it("propagates a 400 past the first request instead of re-walking", async () => {
safeFetchMock.mockResolvedValueOnce(
jsonResponse(200, { rollupDataPoints: [] }),
);
safeFetchMock.mockResolvedValueOnce(
jsonResponse(400, {
error: { code: 400, status: "INVALID_ARGUMENT", message: "nope" },
}),
);
await expect(
fetchDailyRollUp(GOOGLE_HEALTH_DATA_TYPES.steps, "token", "fetchSteps", {
start: new Date("2026-01-01T00:00:00Z"),
}),
).rejects.toBeInstanceOf(GoogleHealthApiError);
expect(safeFetchMock).toHaveBeenCalledTimes(2);
});
it("propagates a non-400 first-request failure without a fallback walk", async () => {
safeFetchMock.mockResolvedValueOnce(jsonResponse(500, {}));
await expect(
fetchDailyRollUp(GOOGLE_HEALTH_DATA_TYPES.steps, "token", "fetchSteps", {
start: new Date("2026-06-01T00:00:00Z"),
}),
).rejects.toBeInstanceOf(GoogleHealthApiError);
expect(safeFetchMock).toHaveBeenCalledTimes(1);
});
});
describe("extractGoogleApiErrorDetail — AIP-193 field violations", () => {
it("keeps the plain STATUS: message shape without details", () => {
expect(
extractGoogleApiErrorDetail({
error: { code: 400, status: "INVALID_ARGUMENT", message: "Invalid" },
}),
).toBe("INVALID_ARGUMENT: Invalid");
});
it("appends fieldViolations as field: description fragments", () => {
expect(
extractGoogleApiErrorDetail({
error: {
code: 400,
status: "INVALID_ARGUMENT",
message: "Invalid argument in request.",
details: [
{
"@type": "type.googleapis.com/google.rpc.BadRequest",
fieldViolations: [
{ field: "range", description: "Range too wide." },
],
},
],
},
}),
).toBe(
"INVALID_ARGUMENT: Invalid argument in request. [range: Range too wide.]",
);
});
it("caps at three violations and tolerates a details-only envelope", () => {
const detail = extractGoogleApiErrorDetail({
error: {
details: [
{
fieldViolations: [
{ field: "a", description: "1" },
{ field: "b", description: "2" },
{ field: "c", description: "3" },
{ field: "d", description: "4" },
],
},
],
},
});
expect(detail).toBe("[a: 1; b: 2; c: 3]");
});
it("redacts tokens and URL queries inside violation descriptions", () => {
const detail = extractGoogleApiErrorDetail({
error: {
status: "INVALID_ARGUMENT",
details: [
{
fieldViolations: [
{
field: "range.start",
description:
"Bearer abc123 rejected at https://health.googleapis.com/v4/x?filter=secret",
},
],
},
],
},
});
expect(detail).not.toContain("abc123");
expect(detail).not.toContain("filter=secret");
});
});
+196 -74
View File
@@ -27,7 +27,7 @@
* a refresh token (and force one on every re-consent).
*/
import { createHash, randomBytes } from "node:crypto";
import { getEvent } from "@/lib/logging/context";
import { annotate, getEvent } from "@/lib/logging/context";
import { safeFetch } from "@/lib/safe-fetch";
import { wallClockInTz, zonedWallClockToUtc } from "@/lib/tz/wall-clock";
import {
@@ -677,25 +677,63 @@ function redactApiErrorMessage(msg: string): string {
}
/**
* Extract the AIP-193 error envelope (`{"error":{code,message,status}}`) from a
* non-2xx Google Health body into a short redacted `STATUS: message` detail
* string, or undefined when the body carries no such envelope. This is what
* makes a field-grammar 400 (`INVALID_ARGUMENT: Invalid filter …`) diagnosable
* from operator logs. (The OAuth token endpoint uses the flat
* `{"error":"invalid_grant"}` shape instead — handled in `postToken`.)
* Collect the `google.rpc.BadRequest` field violations out of an AIP-193
* `error.details[]` array as `field: description` fragments. Only the field
* PATH and the description sentence survive — both run through the message
* redactor and a per-fragment cap, and at most three violations are kept, so
* no request value can ride an error string into the logs.
*/
function extractFieldViolations(details: unknown): string[] {
if (!Array.isArray(details)) return [];
const out: string[] = [];
for (const d of details) {
if (!d || typeof d !== "object") continue;
const fv = (d as { fieldViolations?: unknown }).fieldViolations;
if (!Array.isArray(fv)) continue;
for (const v of fv) {
if (out.length >= 3) return out;
if (!v || typeof v !== "object") continue;
const o = v as { field?: unknown; description?: unknown };
const field =
typeof o.field === "string"
? redactApiErrorMessage(o.field).slice(0, 80)
: undefined;
const description =
typeof o.description === "string"
? redactApiErrorMessage(o.description).slice(0, 120)
: undefined;
if (!field && !description) continue;
out.push([field, description].filter(Boolean).join(": "));
}
}
return out;
}
/**
* Extract the AIP-193 error envelope (`{"error":{code,message,status,details}}`)
* from a non-2xx Google Health body into a short redacted
* `STATUS: message [field: description]` detail string, or undefined when the
* body carries no such envelope. This is what makes a field-grammar 400
* (`INVALID_ARGUMENT: Invalid filter …`) diagnosable from operator logs: the
* `details[]` BadRequest field violations name the exact offending request
* field. (The OAuth token endpoint uses the flat `{"error":"invalid_grant"}`
* shape instead — handled in `postToken`.)
*/
export function extractGoogleApiErrorDetail(json: unknown): string | undefined {
if (!json || typeof json !== "object") return undefined;
const e = (json as { error?: unknown }).error;
if (!e || typeof e !== "object") return undefined;
const o = e as { status?: unknown; message?: unknown };
const o = e as { status?: unknown; message?: unknown; details?: unknown };
const status = typeof o.status === "string" ? o.status : undefined;
const message =
typeof o.message === "string"
? redactApiErrorMessage(o.message)
: undefined;
if (!status && !message) return undefined;
return [status, message].filter(Boolean).join(": ");
const violations = extractFieldViolations(o.details);
if (!status && !message && violations.length === 0) return undefined;
const head = [status, message].filter(Boolean).join(": ");
const tail = violations.length > 0 ? `[${violations.join("; ")}]` : "";
return [head, tail].filter(Boolean).join(" ").slice(0, 500);
}
/**
@@ -778,9 +816,25 @@ export async function fetchDataPoints(
// ─── Daily roll-up reads (`POST …/dataPoints:dailyRollUp`) ─────────────────
/** Max civil days one dailyRollUp request range may span (per the v4 docs). */
/**
* Max civil days one dailyRollUp request range may span. Per the v4 reference
* (`users.dataTypes.dataPoints/dailyRollUp`, `range`): "The maximum range for
* `calories-in-heart-rate-zone`, `heart-rate`, `active-minutes` and
* `total-calories` is 14 days. The maximum range for all other data types is
* 90 days." The four types read here (steps / distance / active-energy-burned /
* floors) all sit in the 90-day class; the 14-day cap would only bind if
* `heart-rate` or `total-calories` ever moved onto the rollup path.
*/
export const GOOGLE_HEALTH_ROLLUP_RANGE_DAYS = 90;
/**
* Conservative chunk span for the one-shot fallback walk: the tightest range
* cap the dailyRollUp docs state for ANY data type (the 14-day class above).
* Used only after the standard 90-day first request is rejected with a 400 —
* see `fetchDailyRollUp`.
*/
export const GOOGLE_HEALTH_ROLLUP_FALLBACK_RANGE_DAYS = 14;
/**
* Full-sync horizon for the rollup types, in civil days. The rollup read needs
* an explicit range (unlike the unbounded list walk), so the backfill is
@@ -872,15 +926,70 @@ interface RollupQuery {
start?: Date;
/** The user's IANA zone — the civil range is user-local. */
tz?: string;
/**
* Observes which request shape the walk settled on (`days90` standard vs
* `days14` fallback) — the structure probe surfaces it so a live account
* reports which shape Google actually accepted.
*/
onShape?: (shape: GoogleHealthRollupShape) => void;
}
/** The dailyRollUp request shape a walk settled on. */
export type GoogleHealthRollupShape = "days90" | "days14";
/**
* Build the documented dailyRollUp request body for one closed-open civil-day
* chunk (`end` exclusive). Doc-example parity (developers.google.com/health/
* endpoints, dailyRollUp sample): both range bounds carry explicit
* `{date, time}` CivilDateTime objects, and the `end` bound is the LAST civil
* day INSIDE the chunk at 23:59:59 — NOT the next day's midnight. The range
* validator counts the civil days the range touches against the documented
* cap, so an exclusive next-day-midnight end makes a maximal chunk read one
* day too wide. `windowSizeDays: 1` is the documented daily-total window;
* `pageSize` is omitted (the documented default of 1440 already covers the
* ≤90 daily windows a chunk can produce); `pageToken` rides along on
* follow-up pages ("All other request fields need to be the same as in the
* initial request when the page token is specified").
*/
export function buildDailyRollUpBody(
chunk: { start: GoogleHealthCivilDate; end: GoogleHealthCivilDate },
pageToken?: string,
): Record<string, unknown> {
const lastDay = utcMsToCivil(civilToUtcMs(chunk.end) - DAY_MS);
const body: Record<string, unknown> = {
range: {
start: {
date: chunk.start,
time: { hours: 0, minutes: 0, seconds: 0, nanos: 0 },
},
end: {
date: lastDay,
time: { hours: 23, minutes: 59, seconds: 59, nanos: 0 },
},
},
windowSizeDays: 1,
};
if (pageToken) body.pageToken = pageToken;
return body;
}
/**
* Read one cumulative data type's daily totals via `POST …/dataPoints:dailyRollUp`
* with `windowSizeDays: 1`. The civil range is closed-open and user-local,
* chunked at ≤90 days per request; without an incremental `start` the walk
* covers the pinned backfill horizon. A `nextPageToken` is honoured defensively
* (the response is documented without one, but the request accepts page
* tokens).
* with `windowSizeDays: 1`. The civil range is user-local, chunked at ≤90 days
* per request (the documented cap for these types); without an incremental
* `start` the walk covers the pinned backfill horizon. A `nextPageToken` is
* honoured defensively (the dailyRollUp response is documented without one,
* but the request accepts page tokens and the sibling `:rollUp` documents the
* response field).
*
* FALLBACK: if the very first request of a walk is rejected with a 400
* (INVALID_ARGUMENT — a range/shape constraint, since the body already
* mirrors the documented example), the whole range is re-walked once with the
* most conservative documented span (14 days, the tightest cap the docs state
* for any type). The shape that succeeded is annotated as
* `googleHealth.rollup.shape` so live behaviour reports which constraint
* actually binds. Any other failure, or a 400 past the first request,
* propagates.
*/
export async function fetchDailyRollUp(
dataType: GoogleHealthDataType,
@@ -896,71 +1005,84 @@ export async function fetchDailyRollUp(
const startCivil = civilDateInTz(from, query.tz);
const endCivil = civilDateInTz(new Date(now.getTime() + DAY_MS), query.tz);
const points: GoogleHealthRollupPoint[] = [];
let chunkIndex = 0;
for (const chunk of chunkCivilRange(startCivil, endCivil)) {
let pageToken: string | null | undefined;
let pageCount = 0;
do {
const body: Record<string, unknown> = {
range: {
start: { date: chunk.start },
end: { date: chunk.end },
},
windowSizeDays: 1,
pageSize: 100,
};
if (pageToken) body.pageToken = pageToken;
let requestCount = 0;
const reqStart = performance.now();
const res = await safeFetch(
`${GOOGLE_HEALTH_API_BASE}/users/me/dataTypes/${dataType.path}/dataPoints:dailyRollUp`,
{
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
const walk = async (maxDays: number): Promise<GoogleHealthRollupPoint[]> => {
const points: GoogleHealthRollupPoint[] = [];
let chunkIndex = 0;
for (const chunk of chunkCivilRange(startCivil, endCivil, maxDays)) {
let pageToken: string | null | undefined;
let pageCount = 0;
do {
const body = buildDailyRollUpBody(chunk, pageToken ?? undefined);
requestCount += 1;
const reqStart = performance.now();
const res = await safeFetch(
`${GOOGLE_HEALTH_API_BASE}/users/me/dataTypes/${dataType.path}/dataPoints:dailyRollUp`,
{
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
},
body: JSON.stringify(body),
},
);
const json = (await res
.json()
.catch(() => null)) as GoogleHealthRollupPage | null;
const verdict = classifyGoogleHealthResponse(res.status);
const apiErrorDetail =
verdict.classification === "success"
? undefined
: extractGoogleApiErrorDetail(json);
getEvent()?.addExternalCall({
service: "google-health",
method: `${verb}(chunk=${chunkIndex},page=${pageCount})`,
duration_ms: Math.round(performance.now() - reqStart),
status: res.status,
error:
);
const json = (await res
.json()
.catch(() => null)) as GoogleHealthRollupPage | 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}(chunk=${chunkIndex},page=${pageCount})`,
duration_ms: Math.round(performance.now() - reqStart),
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?.rollupDataPoints ?? []) points.push(p);
pageToken = json?.nextPageToken ?? null;
pageCount += 1;
} while (pageToken && pageCount < 100);
chunkIndex += 1;
for (const p of json?.rollupDataPoints ?? []) points.push(p);
pageToken = json?.nextPageToken ?? null;
pageCount += 1;
} while (pageToken && pageCount < 100);
chunkIndex += 1;
}
return points;
};
let shape: GoogleHealthRollupShape = "days90";
let points: GoogleHealthRollupPoint[];
try {
points = await walk(GOOGLE_HEALTH_ROLLUP_RANGE_DAYS);
} catch (err) {
const firstRequestRejected =
requestCount === 1 &&
err instanceof GoogleHealthApiError &&
err.httpStatus === 400;
if (!firstRequestRejected) throw err;
shape = "days14";
points = await walk(GOOGLE_HEALTH_ROLLUP_FALLBACK_RANGE_DAYS);
}
annotate({ meta: { "googleHealth.rollup.shape": shape } });
query.onShape?.(shape);
return points;
}
+8 -3
View File
@@ -44,9 +44,14 @@ Two read methods are in use — NOT every type supports `dataPoints.list`:
- **`dataPoints:dailyRollUp`** (POST, `windowSizeDays: 1`) — the cumulative
activity totals (steps, distance, active-energy-burned, floors). Their list
surface returns minute-grain observation buckets (≈1440/day), NOT daily
totals — and **floors has no list method at all**. The request range is
CivilDateTime, closed-open, user-local, max **90 days** per request; the
client chunks longer ranges and pins the full-sync horizon
totals — and **floors has no list method at all**. The request range is a
pair of CivilDateTime bounds (`{date:{year,month,day}, time:{hours,minutes,
seconds,nanos}}`), user-local; per the documented example, the `end` bound
is the LAST covered civil day at 23:59:59 — not the next day's midnight.
Max **90 days** per request for these types (14 days only for
heart-rate / total-calories / active-minutes / calories-in-heart-rate-zone);
the client chunks longer ranges (`buildDailyRollUpBody`), falls back once to
14-day chunks if the first request 400s, and pins the full-sync horizon
(`GOOGLE_HEALTH_ROLLUP_BACKFILL_DAYS`).
Legal incremental `filter` fields are per-shape — anything else is an HTTP 400