diff --git a/CHANGELOG.md b/CHANGELOG.md index e1facfd3e..2823094c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ ## [Unreleased] +## [1.27.4] — 2026-07-05 — Google Health sync fixed against the live API + +### Fixed + +- The Google Health sync now actually imports data. Against the live service the v4 responses differ from what the connection was built against: payload fields arrive in a different naming form, large numbers arrive as text, and several data types only answer on other read methods. Steps, distance, floors, and active energy now read as true daily totals; sleep, workouts, blood oxygen, HRV, respiratory rate, and cardio fitness use the read method and data type the service accepts, so their requests no longer fail. Weight and height convert from the service's units correctly. +- When Google rejects a request, the sync log now carries the service's own error message (shortened, without credentials), so a failing setup can be diagnosed from the integration status instead of a bare status code. + +### Added + +- The Google Health connection test can return a structure probe: one sample request per data type, reduced to field names and value kinds — never actual readings. Useful when reporting sync issues. + ## [1.27.3] — 2026-07-05 — Briefing resilience and Insights readability ### Fixed diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 57bbe3a11..d5a8533ea 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.27.3 + version: 1.27.4 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index 1493bd625..28be8581e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.27.3", + "version": "1.27.4", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index 1b9ede5aa..34566d841 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.27.0"; + /* @sw-version-fallback */ "v1.27.4"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/src/app/api/integrations/google-health/test/route.ts b/src/app/api/integrations/google-health/test/route.ts index da8ee2b9e..8cb8359cf 100644 --- a/src/app/api/integrations/google-health/test/route.ts +++ b/src/app/api/integrations/google-health/test/route.ts @@ -5,7 +5,15 @@ import { checkRateLimit } from "@/lib/rate-limit"; import { annotate } from "@/lib/logging/context"; import { prisma } from "@/lib/db"; import { getValidToken } from "@/lib/google-health/sync"; -import { GOOGLE_HEALTH_API_BASE } from "@/lib/google-health/client"; +import { + GOOGLE_HEALTH_ACTIVITY_PAGE_SIZE, + GOOGLE_HEALTH_API_BASE, + GOOGLE_HEALTH_DATA_TYPES, + fetchDailyRollUp, + fetchDataPoints, +} from "@/lib/google-health/client"; +import { GoogleHealthApiError } from "@/lib/google-health/response-classifier"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; import { safeFetch, SafeFetchError } from "@/lib/safe-fetch"; export const dynamic = "force-dynamic"; @@ -43,8 +51,106 @@ function categoriseHttpStatus(status: number): CategorisedError { }; } +/** + * Reduce an arbitrary JSON value to its STRUCTURE: object keys survive, every + * leaf collapses to its `typeof` label (`"string"`, `"number"`, …; null → + * `"null"`), arrays keep only their first element's structure. No health value, + * timestamp, or identifier survives — the output is safe for a self-hoster to + * paste into a public diagnostics thread. + */ +function describeStructure(v: unknown, depth = 0): unknown { + if (depth > 8) return "…"; + if (v === null) return "null"; + if (Array.isArray(v)) { + return v.length > 0 ? [describeStructure(v[0], depth + 1)] : []; + } + if (typeof v === "object") { + return Object.fromEntries( + Object.entries(v as Record).map(([k, val]) => [ + k, + describeStructure(val, depth + 1), + ]), + ); + } + return typeof v; +} + +type ProbeResult = + | { ok: true; count: number; structure: unknown } + | { + ok: false; + httpStatus: number | null; + classification: string | null; + detail: string | null; + }; + +/** + * Fetch ONE recent page/window per data type and reduce the first data point to + * its structure. Errors are captured per type (a 400's AIP-193 detail included) + * so a broken type doesn't hide the others — that per-type verdict is the whole + * point of the probe. + */ +async function runStructureProbe( + accessToken: string, + tz: string | undefined, +): Promise> { + const now = Date.now(); + const listStart = new Date(now - 14 * 24 * 60 * 60 * 1000); + const rollupStart = new Date(now - 7 * 24 * 60 * 60 * 1000); + + const results: Record = {}; + for (const [name, dataType] of Object.entries(GOOGLE_HEALTH_DATA_TYPES)) { + try { + let points: Record[]; + if (dataType.timeField === "rollup") { + points = await fetchDailyRollUp( + dataType, + accessToken, + `probe:${name}`, + { + start: rollupStart, + tz, + }, + ); + } else { + const pageSize = + dataType.timeField === "sessionEnd" || + dataType.timeField === "civilStart" + ? GOOGLE_HEALTH_ACTIVITY_PAGE_SIZE + : 5; + points = await fetchDataPoints(dataType, accessToken, `probe:${name}`, { + start: listStart, + pageSize, + maxPages: 1, + tz, + }); + } + results[name] = { + ok: true, + count: points.length, + structure: points.length > 0 ? describeStructure(points[0]) : null, + }; + } catch (e) { + results[name] = + e instanceof GoogleHealthApiError + ? { + ok: false, + httpStatus: e.httpStatus ?? null, + classification: e.classification, + detail: e.upstreamError ?? null, + } + : { + ok: false, + httpStatus: null, + classification: null, + detail: "fetch_failed", + }; + } + } + return results; +} + export const POST = apiHandler(async (request: NextRequest) => { - void request; const { user } = await requireAuth(); annotate({ action: { name: "integrations.google-health.test" } }); @@ -55,6 +161,21 @@ export const POST = apiHandler(async (request: NextRequest) => { }); } + // Flag-only payload — `{ "probe": "structure" }` switches to the per-type + // structure probe; anything else runs the plain connection check. Cap the + // parse cost (mirrors safeJson maxBytes). + let structureProbe = false; + try { + const raw = await request.text(); + if (raw.length > 64 * 1024) { + return apiError(`Request body exceeds ${64 * 1024} bytes`, 413); + } + const body = JSON.parse(raw); + structureProbe = body?.probe === "structure"; + } catch { + // no body provided -> plain connection check + } + const tokenInfo = await getValidToken(user.id); if (!tokenInfo) { return apiError("Google Health not connected", 422, { @@ -62,6 +183,22 @@ export const POST = apiHandler(async (request: NextRequest) => { }); } + if (structureProbe) { + // Per-data-type structure probe: one page/window per type, reduced to field + // names + leaf types (never values) so a self-hoster can paste the output + // as diagnostics without leaking a single reading. + annotate({ + action: { name: "integrations.google-health.structure_probe" }, + }); + const tz = await resolveUserTimezone(user.id); + const types = await runStructureProbe(tokenInfo.accessToken, tz); + return apiSuccess({ + probe: "structure", + probedAt: new Date().toISOString(), + types, + }); + } + const connection = await prisma.googleHealthConnection.findUnique({ where: { userId: user.id }, select: { lastSyncedAt: true }, diff --git a/src/lib/google-health/__tests__/mappers.test.ts b/src/lib/google-health/__tests__/mappers.test.ts index 8c5f97abf..088d26b09 100644 --- a/src/lib/google-health/__tests__/mappers.test.ts +++ b/src/lib/google-health/__tests__/mappers.test.ts @@ -1,33 +1,49 @@ /** - * Representative-payload tests for the Google Health data-point mappers. + * Documented-payload tests for the Google Health data-point mappers. * - * The Google value-field JSON is undocumented, so each mapper hands a small set - * of candidate shapes; these tests pin the launch-metric happy paths — the - * MeasurementType, unit, value (incl. km→m conversion), the resolved - * `measuredAt` timestamp/timezone, and the externalId `fieldTag` grain (spot - * anchor vs the `stats:`-style daily-total overwrite key vs per-sleep-stage). + * Fixtures follow the official v4 response shapes: the DataPoint value is a + * camelCase union keyed by the camelCase type name (`bodyFat`, + * `dailyRestingHeartRate`, …) with camelCase nested times + * (`sampleTime.physicalTime`, `interval.startTime`), proto3 int64 fields arrive + * as JSON strings (`"8500"`), daily-summary `date` fields are `{year,month,day}` + * objects, and the cumulative activity totals come from `:dailyRollUp` + * aggregate windows (`civilStartTime.date` + `*Sum` fields). The tests pin the + * MeasurementType, unit, value (incl. gram/millimetre conversions), the + * resolved `measuredAt`, and the externalId `fieldTag` grain (spot anchor vs + * the `stats:`-style daily-total overwrite key vs per-sleep-stage). */ import { describe, expect, it } from "vitest"; import { + chunkCivilRange, + formatCivilBound, + incrementalFilter, + GOOGLE_HEALTH_DATA_TYPES, mapActiveEnergy, + mapBodyFat, mapDistance, + mapFloors, mapGoogleHealthSleepStage, mapGoogleHealthSportType, + mapHeartRate, + mapHeartRateVariability, + mapHeight, mapOxygenSaturation, + mapRespiratoryRate, mapRestingHeartRate, mapSleepSession, mapSteps, + mapVo2Max, mapWeight, mapWorkout, } from "../client"; describe("mapWeight — spot sample", () => { - it("maps a kilograms sample to a WEIGHT reading anchored on its physical time", () => { + it("maps a weightGrams sample to a WEIGHT reading in kg anchored on its physical time", () => { const rows = mapWeight({ weight: { - kilograms: 72.53, - sample_time: { physical_time: "2026-06-01T08:00:00.000Z" }, + weightGrams: 72530, + sampleTime: { physicalTime: "2026-06-01T08:00:00.000Z" }, }, }); expect(rows).toHaveLength(1); @@ -42,30 +58,72 @@ describe("mapWeight — spot sample", () => { }); it("returns nothing when no value parses", () => { - expect(mapWeight({ weight: { sample_time: {} } })).toEqual([]); + expect(mapWeight({ weight: { sampleTime: {} } })).toEqual([]); }); }); -describe("mapOxygenSaturation — daily civil-date summary", () => { - it("maps a nightly average to a percentage anchored on the civil day", () => { +describe("mapBodyFat — camelCase union key", () => { + it("reads the percentage under the camelCase bodyFat key", () => { + const rows = mapBodyFat({ + bodyFat: { + percentage: 21.7, + sampleTime: { physicalTime: "2026-06-01T08:00:00.000Z" }, + }, + }); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + type: "BODY_FAT", + unit: "%", + value: 21.7, + fieldTag: "2026-06-01T08:00:00.000Z:body_fat", + }); + }); +}); + +describe("mapOxygenSaturation — daily-oxygen-saturation summary", () => { + it("maps averagePercentage anchored on the {year,month,day} civil date", () => { const rows = mapOxygenSaturation({ - oxygen_saturation: { average_percentage: 97.4, date: "2026-06-01" }, + dailyOxygenSaturation: { + averagePercentage: 97.4, + date: { year: 2026, month: 6, day: 1 }, + }, }); expect(rows).toHaveLength(1); const r = rows[0]!; expect(r.type).toBe("OXYGEN_SATURATION"); expect(r.unit).toBe("%"); expect(r.value).toBe(97.4); - // A civil-date summary anchors at UTC midnight and keys per-day. - expect(r.measuredAt.toISOString()).toBe("2026-06-01T00:00:00.000Z"); + // Civil-date summaries anchor at UTC midday (tz-shift-proof) and key per-day. + expect(r.measuredAt.toISOString()).toBe("2026-06-01T12:00:00.000Z"); expect(r.fieldTag).toBe("2026-06-01:spo2"); }); }); -describe("mapRestingHeartRate", () => { - it("maps beats_per_minute to a RESTING_HEART_RATE reading in bpm", () => { +describe("mapHeartRateVariability — daily-heart-rate-variability summary", () => { + it("maps averageHeartRateVariabilityMilliseconds to the HRV slot in ms", () => { + const rows = mapHeartRateVariability({ + dailyHeartRateVariability: { + averageHeartRateVariabilityMilliseconds: 42.5, + date: { year: 2026, month: 6, day: 1 }, + }, + }); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + type: "HEART_RATE_VARIABILITY", + unit: "ms", + value: 42.5, + fieldTag: "2026-06-01:hrv", + }); + }); +}); + +describe("mapRestingHeartRate — int64-string coercion", () => { + it("coerces the beatsPerMinute int64 JSON string", () => { const rows = mapRestingHeartRate({ - daily_resting_heart_rate: { beats_per_minute: 54, date: "2026-06-01" }, + dailyRestingHeartRate: { + beatsPerMinute: "54", + date: { year: 2026, month: 6, day: 1 }, + }, }); expect(rows).toHaveLength(1); expect(rows[0]).toMatchObject({ @@ -77,18 +135,68 @@ describe("mapRestingHeartRate", () => { }); }); -describe("mapSteps — daily cumulative total", () => { - it("maps an interval daily total to a cumulative ACTIVITY_STEPS row", () => { - const rows = mapSteps({ - steps: { - count: 8500, - interval: { - start_time: "2026-06-01T00:00:00.000Z", - end_time: "2026-06-01T23:59:59.000Z", - }, +describe("mapRespiratoryRate — daily-respiratory-rate summary", () => { + it("coerces the dailyRespiratoryRateBpm int64 JSON string", () => { + const rows = mapRespiratoryRate({ + dailyRespiratoryRate: { + dailyRespiratoryRateBpm: "14", + date: { year: 2026, month: 6, day: 1 }, }, }); expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + type: "RESPIRATORY_RATE", + unit: "breaths/min", + value: 14, + fieldTag: "2026-06-01:resp_rate", + }); + }); +}); + +describe("mapHeartRate — intraday spot sample", () => { + it("coerces the beatsPerMinute int64 JSON string and anchors on the instant", () => { + const rows = mapHeartRate({ + heartRate: { + beatsPerMinute: "62", + sampleTime: { physicalTime: "2026-06-01T09:15:00.000Z" }, + }, + }); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + type: "PULSE", + unit: "bpm", + value: 62, + fieldTag: "2026-06-01T09:15:00.000Z:hr", + }); + }); +}); + +describe("mapHeight — profile seed", () => { + it("converts heightMeters to cm and surfaces the sample instant", () => { + const sample = mapHeight({ + height: { + heightMeters: 1.82, + sampleTime: { physicalTime: "2026-06-01T08:00:00.000Z" }, + }, + }); + expect(sample).not.toBeNull(); + expect(sample!.cm).toBe(182); + expect(sample!.sampledAt?.toISOString()).toBe("2026-06-01T08:00:00.000Z"); + }); + + it("returns null when no value parses", () => { + expect(mapHeight({ height: {} })).toBeNull(); + }); +}); + +describe("mapSteps — dailyRollUp aggregate window", () => { + it("maps countSum (int64 string) keyed on civilStartTime.date", () => { + const rows = mapSteps({ + civilStartTime: { date: { year: 2026, month: 6, day: 1 } }, + civilEndTime: { date: { year: 2026, month: 6, day: 2 } }, + steps: { countSum: "8500" }, + }); + expect(rows).toHaveLength(1); const r = rows[0]!; expect(r.type).toBe("ACTIVITY_STEPS"); expect(r.unit).toBe("steps"); @@ -97,72 +205,36 @@ describe("mapSteps — daily cumulative total", () => { // per-day fieldTag) so a re-fetched day replaces rather than duplicates. expect(r.cumulativeDaily).toBe(true); expect(r.fieldTag).toBe("steps:2026-06-01"); - expect(r.measuredAt.toISOString()).toBe("2026-06-01T00:00:00.000Z"); - }); -}); - -describe("mapSteps — civil-day keying for a non-UTC-midnight interval", () => { - it("keys the day + measuredAt on the CIVIL day, not the physical start instant", () => { - const rows = mapSteps({ - steps: { - count: 12000, - interval: { - // Physical instant is 15:00Z on 2026-06-01, but the civil day the - // total belongs to is 2026-06-02 (a positive-UTC-offset user's local - // midnight). The day-key must follow civil_start_time so Google/Apple/ - // Fitbit `stats::` keys agree — start_time alone would - // off-by-one the day. - start_time: "2026-06-01T15:00:00.000Z", - civil_start_time: "2026-06-02", - }, - }, - }); - expect(rows).toHaveLength(1); - const r = rows[0]!; - expect(r.type).toBe("ACTIVITY_STEPS"); - expect(r.value).toBe(12000); - expect(r.cumulativeDaily).toBe(true); - // Day-key follows the civil day, not the 2026-06-01 physical instant. - expect(r.fieldTag).toBe("steps:2026-06-02"); // Anchored at UTC-midday so a tz shift can't roll the civil day. - expect(r.measuredAt.toISOString()).toBe("2026-06-02T12:00:00.000Z"); + expect(r.measuredAt.toISOString()).toBe("2026-06-01T12:00:00.000Z"); + }); + + it("drops a window with no parseable civil day (it cannot be keyed)", () => { + expect(mapSteps({ steps: { countSum: "1200" } })).toEqual([]); }); }); -describe("mapDistance — unit conversion", () => { - it("converts a kilometers daily total to metres", () => { +describe("mapDistance — millimetre conversion", () => { + it("converts millimetersSum (int64 string) to metres", () => { const rows = mapDistance({ - distance: { - kilometers: 5.2, - interval: { start_time: "2026-06-01T00:00:00.000Z" }, - }, + civilStartTime: { date: { year: 2026, month: 6, day: 1 } }, + distance: { millimetersSum: "5200000" }, }); expect(rows).toHaveLength(1); const r = rows[0]!; expect(r.type).toBe("WALKING_RUNNING_DISTANCE"); expect(r.unit).toBe("m"); - expect(r.value).toBe(5200); // 5.2 km → 5200 m + expect(r.value).toBe(5200); // 5 200 000 mm → 5200 m expect(r.cumulativeDaily).toBe(true); - }); - - it("prefers an explicit metres field when present", () => { - const rows = mapDistance({ - distance: { - meters: 8000, - interval: { start_time: "2026-06-01T00:00:00.000Z" }, - }, - }); - expect(rows[0]!.value).toBe(8000); + expect(r.fieldTag).toBe("distance:2026-06-01"); }); }); describe("mapActiveEnergy — preserves a legitimate zero", () => { - it("keeps a rest-day 0 kcal (a real reading, not a gap)", () => { + it("keeps a rest-day 0 kcalSum (a real reading, not a gap)", () => { const rows = mapActiveEnergy({ - active_energy_burned: { - active_kilocalories: 0, - interval: { start_time: "2026-06-01T00:00:00.000Z" }, - }, + civilStartTime: { date: { year: 2026, month: 6, day: 1 } }, + activeEnergyBurned: { kcalSum: 0 }, }); expect(rows).toHaveLength(1); expect(rows[0]).toMatchObject({ @@ -170,30 +242,80 @@ describe("mapActiveEnergy — preserves a legitimate zero", () => { unit: "kcal", value: 0, cumulativeDaily: true, + fieldTag: "active_energy:2026-06-01", }); }); }); +describe("mapFloors — dailyRollUp aggregate window", () => { + it("maps countSum (int64 string) to FLIGHTS_CLIMBED", () => { + const rows = mapFloors({ + civilStartTime: { date: { year: 2026, month: 6, day: 1 } }, + floors: { countSum: "3" }, + }); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + type: "FLIGHTS_CLIMBED", + unit: "flights", + value: 3, + cumulativeDaily: true, + fieldTag: "floors:2026-06-01", + }); + }); +}); + +describe("mapVo2Max — daily-vo2-max summary", () => { + it("maps vo2Max keyed per civil day (latest-wins overwrite grain)", () => { + const rows = mapVo2Max({ + dailyVo2Max: { + vo2Max: 41.2, + estimated: true, + date: { year: 2026, month: 6, day: 1 }, + }, + }); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + type: "VO2_MAX", + unit: "mL/(kg·min)", + value: 41.2, + cumulativeDaily: true, + fieldTag: "vo2_max:2026-06-01", + }); + }); + + it("drops a zero (VO2 max is strictly positive)", () => { + expect( + mapVo2Max({ + dailyVo2Max: { vo2Max: 0, date: { year: 2026, month: 6, day: 1 } }, + }), + ).toEqual([]); + }); +}); + describe("mapSleepSession — per-stage segments", () => { it("emits one SLEEP_DURATION row per known stage, skipping unknown labels", () => { const rows = mapSleepSession({ sleep: { - interval: { end_time: "2026-06-02T06:30:00.000Z" }, + interval: { + startTime: "2026-06-01T22:30:00.000Z", + endTime: "2026-06-02T06:30:00.000Z", + }, + type: "STAGES", stages: [ { - stage: "deep", - start_time: "2026-06-02T02:00:00.000Z", - end_time: "2026-06-02T02:45:00.000Z", + type: "DEEP", + startTime: "2026-06-02T02:00:00.000Z", + endTime: "2026-06-02T02:45:00.000Z", }, { - stage: "rem", - start_time: "2026-06-02T03:00:00.000Z", - end_time: "2026-06-02T03:30:00.000Z", + type: "REM", + startTime: "2026-06-02T03:00:00.000Z", + endTime: "2026-06-02T03:30:00.000Z", }, { - stage: "totally_unknown_label", - start_time: "2026-06-02T04:00:00.000Z", - end_time: "2026-06-02T04:10:00.000Z", + type: "SLEEP_STAGE_TYPE_UNSPECIFIED", + startTime: "2026-06-02T04:00:00.000Z", + endTime: "2026-06-02T04:10:00.000Z", }, ], }, @@ -206,7 +328,7 @@ describe("mapSleepSession — per-stage segments", () => { expect(deep.value).toBe(45); expect(deep.sleepStage).toBe("DEEP"); expect(deep.measuredAt.toISOString()).toBe("2026-06-02T02:45:00.000Z"); - // Per-stage rows key off the session anchor + an indexed stage tag. + // Per-stage rows key off the session END anchor + an indexed stage tag. expect(deep.fieldTag).toBe("2026-06-02T06:30:00.000Z:sleep_deep:0"); const rem = rows[1]!; @@ -215,52 +337,63 @@ describe("mapSleepSession — per-stage segments", () => { expect(rem.fieldTag).toBe("2026-06-02T06:30:00.000Z:sleep_rem:1"); }); - it("maps a 'light' stage onto the shared CORE band", () => { - expect(mapGoogleHealthSleepStage("light")).toBe("CORE"); + it("maps the documented SLEEP_STAGE_TYPE enum onto the shared bands", () => { + expect(mapGoogleHealthSleepStage("LIGHT")).toBe("CORE"); // shallow-NREM band expect(mapGoogleHealthSleepStage("REM")).toBe("REM"); - expect(mapGoogleHealthSleepStage("in-bed")).toBe("IN_BED"); + expect(mapGoogleHealthSleepStage("RESTLESS")).toBe("AWAKE"); + expect(mapGoogleHealthSleepStage("ASLEEP")).toBe("ASLEEP"); + expect( + mapGoogleHealthSleepStage("SLEEP_STAGE_TYPE_UNSPECIFIED"), + ).toBeNull(); expect(mapGoogleHealthSleepStage("nonsense")).toBeNull(); }); }); describe("mapWorkout — exercise session", () => { - it("maps a running session to a Workout with duration, energy, distance and HR", () => { + it("maps a RUNNING session with metricsSummary fields and the top-level name id", () => { const w = mapWorkout({ + name: "users/me/dataTypes/exercise/dataPoints/abc123", exercise: { - activity_type: "running", - session_id: "abc123", + exerciseType: "RUNNING", interval: { - start_time: "2026-06-03T07:00:00.000Z", - end_time: "2026-06-03T07:45:00.000Z", + startTime: "2026-06-03T07:00:00.000Z", + endTime: "2026-06-03T07:45:00.000Z", + civilStartTime: { + date: { year: 2026, month: 6, day: 3 }, + time: { hours: 9 }, + }, + }, + metricsSummary: { + caloriesKcal: 420, + distanceMillimeters: 8000000, + averageHeartRateBeatsPerMinute: "150", + steps: "7600", }, - active_kilocalories: 420, - distance: { meters: 8000 }, - average_heart_rate: { beats_per_minute: 150 }, - maximum_heart_rate: { beats_per_minute: 172 }, }, }); expect(w).not.toBeNull(); expect(w).toMatchObject({ - externalId: "abc123", + externalId: "users/me/dataTypes/exercise/dataPoints/abc123", sportType: "running", durationSec: 2700, // 45 min totalEnergyKcal: 420, - totalDistanceM: 8000, + totalDistanceM: 8000, // 8 000 000 mm → 8000 m avgHeartRate: 150, - maxHeartRate: 172, + // metricsSummary carries no max/min heart rate. + maxHeartRate: null, minHeartRate: null, }); expect(w!.startedAt.toISOString()).toBe("2026-06-03T07:00:00.000Z"); expect(w!.endedAt.toISOString()).toBe("2026-06-03T07:45:00.000Z"); }); - it("falls back to a start-anchored externalId when no session id is present", () => { + it("falls back to a start-anchored externalId when the resource name is absent", () => { const w = mapWorkout({ exercise: { - type: "walk", + exerciseType: "WALKING", interval: { - start_time: "2026-06-03T09:00:00.000Z", - end_time: "2026-06-03T09:30:00.000Z", + startTime: "2026-06-03T09:00:00.000Z", + endTime: "2026-06-03T09:30:00.000Z", }, }, }); @@ -274,17 +407,114 @@ describe("mapWorkout — exercise session", () => { mapWorkout({ exercise: { interval: { - start_time: "2026-06-03T09:00:00.000Z", - end_time: "2026-06-03T09:00:00.000Z", // zero-length + startTime: "2026-06-03T09:00:00.000Z", + endTime: "2026-06-03T09:00:00.000Z", // zero-length }, }, }), ).toBeNull(); }); - it("resolves unknown sport types to a generic label", () => { - expect(mapGoogleHealthSportType("kitesurfing")).toBe("other"); + it("resolves the UPPERCASE ExerciseType enum, with a generic fallback", () => { + expect(mapGoogleHealthSportType("STRENGTH_TRAINING")).toBe("strength"); + expect(mapGoogleHealthSportType("HIGH_INTENSITY_INTERVAL_TRAINING")).toBe( + "hiit", + ); + expect(mapGoogleHealthSportType("CYCLING")).toBe("cycling"); + expect(mapGoogleHealthSportType("KITESURFING")).toBe("other"); expect(mapGoogleHealthSportType("")).toBe("other"); - expect(mapGoogleHealthSportType("Cycling")).toBe("cycling"); + }); +}); + +describe("incrementalFilter — per-shape filter grammar", () => { + const start = new Date("2026-06-01T15:30:00.000Z"); + + it("filters samples on sample_time.physical_time (RFC-3339)", () => { + expect(incrementalFilter(GOOGLE_HEALTH_DATA_TYPES.weight, start)).toEqual({ + field: "weight.sample_time.physical_time", + bound: "2026-06-01T15:30:00.000Z", + }); + }); + + it("filters daily summaries on .date (YYYY-MM-DD)", () => { + expect( + incrementalFilter(GOOGLE_HEALTH_DATA_TYPES.oxygenSaturation, start), + ).toEqual({ + field: "daily_oxygen_saturation.date", + bound: "2026-06-01", + }); + }); + + it("filters sleep on interval.end_time — the only legal sleep time field", () => { + expect(incrementalFilter(GOOGLE_HEALTH_DATA_TYPES.sleep, start)).toEqual({ + field: "sleep.interval.end_time", + bound: "2026-06-01T15:30:00.000Z", + }); + }); + + it("filters exercise on interval.civil_start_time with an offset-less civil bound in the user's zone", () => { + expect( + incrementalFilter( + GOOGLE_HEALTH_DATA_TYPES.exercise, + start, + "Europe/Berlin", + ), + ).toEqual({ + field: "exercise.interval.civil_start_time", + // 15:30Z = 17:30 Berlin wall clock (CEST) — no Z, no offset. + bound: "2026-06-01T17:30:00", + }); + }); + + it("refuses to build a list filter for a rollup type", () => { + expect(() => + incrementalFilter(GOOGLE_HEALTH_DATA_TYPES.steps, start), + ).toThrow(/dailyRollUp/); + }); +}); + +describe("formatCivilBound", () => { + it("forms the bound in UTC when no zone is known", () => { + expect(formatCivilBound(new Date("2026-06-01T15:30:45.000Z"))).toBe( + "2026-06-01T15:30:45", + ); + }); +}); + +describe("chunkCivilRange — dailyRollUp 90-day slicing", () => { + it("returns a single chunk for a range under the cap", () => { + expect( + chunkCivilRange( + { year: 2026, month: 6, day: 1 }, + { year: 2026, month: 6, day: 8 }, + ), + ).toEqual([ + { + start: { year: 2026, month: 6, day: 1 }, + end: { year: 2026, month: 6, day: 8 }, + }, + ]); + }); + + it("slices a long range into closed-open ≤90-day chunks with no gap or overlap", () => { + const chunks = chunkCivilRange( + { year: 2026, month: 1, day: 1 }, + { year: 2026, month: 8, day: 1 }, + ); + expect(chunks.length).toBe(3); // 212 days → 90 + 90 + 32 + expect(chunks[0]!.start).toEqual({ year: 2026, month: 1, day: 1 }); + // Each chunk starts exactly where the previous one ends (closed-open). + expect(chunks[1]!.start).toEqual(chunks[0]!.end); + expect(chunks[2]!.start).toEqual(chunks[1]!.end); + expect(chunks[2]!.end).toEqual({ year: 2026, month: 8, day: 1 }); + }); + + it("returns nothing for an empty or inverted range", () => { + expect( + chunkCivilRange( + { year: 2026, month: 6, day: 8 }, + { year: 2026, month: 6, day: 1 }, + ), + ).toEqual([]); }); }); diff --git a/src/lib/google-health/client.ts b/src/lib/google-health/client.ts index 3ebc413a9..b409a4c43 100644 --- a/src/lib/google-health/client.ts +++ b/src/lib/google-health/client.ts @@ -29,7 +29,7 @@ import { createHash, randomBytes } from "node:crypto"; import { getEvent } from "@/lib/logging/context"; import { safeFetch } from "@/lib/safe-fetch"; -import { zonedWallClockToUtc } from "@/lib/tz/wall-clock"; +import { wallClockInTz, zonedWallClockToUtc } from "@/lib/tz/wall-clock"; import { GoogleHealthApiError, classifyGoogleHealthResponse, @@ -345,12 +345,21 @@ export async function fetchProfile( .json() .catch(() => null)) as GoogleHealthProfile | null; const verdict = classifyGoogleHealthResponse(res.status); + const apiErrorDetail = + verdict.classification === "success" + ? undefined + : extractGoogleApiErrorDetail(json); getEvent()?.addExternalCall({ service: "google-health", method: "fetchProfile", duration_ms: Math.round(performance.now() - start), status: res.status, - error: verdict.classification === "success" ? undefined : verdict.reason, + error: + verdict.classification === "success" + ? undefined + : apiErrorDetail + ? `${verdict.reason} ${apiErrorDetail}` + : verdict.reason, }); if (verdict.classification !== "success") { throw new GoogleHealthApiError({ @@ -358,6 +367,7 @@ export async function fetchProfile( classification: verdict.classification, httpStatus: verdict.httpStatus, reason: verdict.reason, + upstreamError: apiErrorDetail, }); } return (json ?? {}) as GoogleHealthProfile; @@ -382,18 +392,24 @@ export function resolveGoogleHealthUserId( // ─── Data-point reads (Google Health `dataPoints.list` / `:dailyRollUp`) ────── // // The Google Health API exposes one uniform `DataPoint` resource shape across -// every data type, read through `GET /v4/users/me/dataTypes/{dataType}/dataPoints` -// with `nextPageToken` pagination. Google's value-field JSON is NOT fully -// published, so the mappers below are intentionally defensive: every value is -// pulled out of a small set of candidate field shapes and guarded by a -// finite-positive check before it becomes a Measurement. Capture the real -// per-type value-field JSON into `mapping.md` against a live test account at -// build and tighten the extractors then. +// every data type. Spot / daily-summary / session types are read through +// `GET /v4/users/me/dataTypes/{dataType}/dataPoints` with `nextPageToken` +// pagination; the cumulative activity types (steps / distance / active energy / +// floors) are read through `POST …/dataPoints:dailyRollUp` — their `list` +// surface returns minute-grain observation buckets (or, for floors, does not +// exist at all), so the daily totals MUST come from the rollup. // -// Casing gotcha: the data-type id is **kebab-case in the path** (`body-fat`) -// and **snake_case in the `filter`** (`body_fat`). `GOOGLE_HEALTH_DATA_TYPES` -// pins both forms so a fetcher can never encode the wrong one. Every launch -// data type is read through `dataPoints.list`. +// 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`) +// - response payload: camelCase — the `DataPoint` value is a union keyed by +// the camelCase type name (`bodyFat`, `dailyRestingHeartRate`, …) with +// camelCase nested objects (`sampleTime.physicalTime`, +// `interval.startTime`, `civilStartTime.date`). +// +// proto3 int64 fields arrive as JSON **strings** (`"12345"`) — every numeric +// extractor coerces numeric strings before the finite check. /** * Page-size ceiling for `dataPoints.list`. The daily/intraday reads default to @@ -407,94 +423,158 @@ export const GOOGLE_HEALTH_ACTIVITY_PAGE_SIZE = 25; /** * One data type's on-the-wire encodings. `path` is the kebab-case segment * spliced into the request URL; `filter` is the snake_case prefix used to build - * the incremental `filter=` predicate. `timeField` names the value object's - * time anchor. Every launch data type reads through `dataPoints.list`. + * the incremental `filter=` predicate; `key` is the camelCase union key the + * response payload nests the value object under. `timeField` names the read + * method + time anchor. */ export interface GoogleHealthDataType { /** kebab-case segment for the request path. */ path: string; /** snake_case prefix for the `filter` predicate. */ filter: string; + /** camelCase union key in the response `DataPoint` payload. */ + key: string; /** - * Which time anchor the filter / measuredAt resolution targets: - * - `sample` → spot reading (`{type}.sample_time.physical_time`). - * - `date` → daily summary keyed on a civil date (`{type}.date`). - * - `interval` → an INTERVAL data type (steps / distance / active energy / - * floors, sleep, exercise). The cumulative daily totals key on the CIVIL - * day: `{type}.interval.civil_start_time` (anchored at UTC-midday) with - * `{type}.interval.start_time` (the physical instant) as the fallback — - * NOT a `sample_time` (which 400s/empties for interval types and stalls - * the incremental filter) and NOT a bare `date`. + * Which read method + time anchor the type uses: + * - `sample` → spot reading via list; filter + * `{filter}.sample_time.physical_time`, read `{key}.sampleTime.physicalTime`. + * - `date` → daily summary via list; filter `{filter}.date` + * (`YYYY-MM-DD`), read `{key}.date` (a `{year,month,day}` object). + * - `sessionEnd` → sleep sessions via list; sleep filters ONLY on + * `{filter}.interval.end_time` (any other field 400s); anchor + * `{key}.interval.endTime`. + * - `civilStart` → exercise sessions via list; session types filter ONLY on + * `{filter}.interval.civil_start_time` with an offset-less civil bound; + * times read from `{key}.interval.startTime/endTime` (RFC-3339). + * - `rollup` → cumulative daily totals via `POST :dailyRollUp` + * (`windowSizeDays: 1`); never listed. Day key `civilStartTime.date`. */ - timeField: "sample" | "date" | "interval"; + timeField: "sample" | "date" | "sessionEnd" | "civilStart" | "rollup"; } /** - * The launch data types. Each entry pins the kebab-path + snake-filter pair so - * the two encodings can never drift. Identifiers verified against the 2026 - * Google Health contract (API-RESEARCH §3/§4). + * The launch data types. Each entry pins the kebab-path + snake-filter + + * camelCase-payload triple so the three encodings can never drift. Identifiers + * reconciled against the official v4 reference (data-types index + per-type + * schema pages). */ export const GOOGLE_HEALTH_DATA_TYPES = { - weight: { path: "weight", filter: "weight", timeField: "sample" }, - bodyFat: { path: "body-fat", filter: "body_fat", timeField: "sample" }, + weight: { + path: "weight", + filter: "weight", + key: "weight", + timeField: "sample", + }, + bodyFat: { + path: "body-fat", + filter: "body_fat", + key: "bodyFat", + timeField: "sample", + }, + // Daily-grain SpO2 lives on `daily-oxygen-saturation` (`averagePercentage`); + // the bare `oxygen-saturation` type is per-SAMPLE and does not accept a + // `.date` filter (400). oxygenSaturation: { - path: "oxygen-saturation", - filter: "oxygen_saturation", + path: "daily-oxygen-saturation", + filter: "daily_oxygen_saturation", + key: "dailyOxygenSaturation", timeField: "date", }, + // Nightly HRV summary lives on `daily-heart-rate-variability`; the bare + // `heart-rate-variability` type is per-sample (RMSSD + SDNN fields) and does + // not accept a `.date` filter. heartRateVariability: { - path: "heart-rate-variability", - filter: "heart_rate_variability", + path: "daily-heart-rate-variability", + filter: "daily_heart_rate_variability", + key: "dailyHeartRateVariability", timeField: "date", }, restingHeartRate: { path: "daily-resting-heart-rate", filter: "daily_resting_heart_rate", + key: "dailyRestingHeartRate", timeField: "date", }, + // `respiratory-rate` does not exist in the catalogue; the daily summary is + // `daily-respiratory-rate` (`dailyRespiratoryRateBpm`). respiratoryRate: { - path: "respiratory-rate", - filter: "respiratory_rate", + path: "daily-respiratory-rate", + filter: "daily_respiratory_rate", + key: "dailyRespiratoryRate", timeField: "date", }, - heartRate: { path: "heart-rate", filter: "heart_rate", timeField: "sample" }, - height: { path: "height", filter: "height", timeField: "sample" }, + heartRate: { + path: "heart-rate", + filter: "heart_rate", + key: "heartRate", + timeField: "sample", + }, + height: { + path: "height", + filter: "height", + 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. // ── Activity bundle — daily cumulative totals ────────────────── - // Scope: `googlehealth.activity_and_fitness.readonly`. These are INTERVAL - // data types: Google buckets a daily total into an `interval` (a `start_time` - // + `end_time`, with `civil_start_time` for the calendar-day grain). The - // incremental filter targets `interval.start_time` and the externalId carries - // the `stats:` prefix so a re-fetched day overwrites in place (mirrors the - // Apple-Health `stats::` daily-total overwrite contract). - steps: { path: "steps", filter: "steps", timeField: "interval" }, - distance: { path: "distance", filter: "distance", timeField: "interval" }, - // Active energy — canonical id `active-energy-burned` (release-notes - // 2026-05-26); this is the ACTIVE portion only, NOT `total-calories` (which - // folds in BMR and is roll-up-only, see below). + // Scope: `googlehealth.activity_and_fitness.readonly`. Read through + // `POST :dailyRollUp` with `windowSizeDays: 1`: the `list` surface returns + // minute-grain observation buckets, NOT daily totals (and floors has no list + // method at all). The externalId carries the `stats:` prefix so a re-fetched + // day overwrites in place (mirrors the Apple-Health + // `stats::` daily-total overwrite contract). + steps: { path: "steps", filter: "steps", key: "steps", timeField: "rollup" }, + distance: { + path: "distance", + filter: "distance", + key: "distance", + timeField: "rollup", + }, + // Active energy — canonical id `active-energy-burned`; this is the ACTIVE + // portion only, NOT `total-calories` (which folds in BMR). activeEnergy: { path: "active-energy-burned", filter: "active_energy_burned", - timeField: "interval", + key: "activeEnergyBurned", + timeField: "rollup", + }, + floors: { + path: "floors", + filter: "floors", + key: "floors", + timeField: "rollup", + }, + // The daily VO2-max reading lives on `daily-vo2-max` (`vo2Max`); the bare + // `vo2-max` type is per-sample and does not accept a `.date` filter. + vo2Max: { + path: "daily-vo2-max", + filter: "daily_vo2_max", + key: "dailyVo2Max", + timeField: "date", }, - floors: { path: "floors", filter: "floors", timeField: "interval" }, - // VO2 max is a daily-summary metric (one civil-date reading), not an interval - // bucket — keep the `date` anchor. - vo2Max: { path: "vo2-max", filter: "vo2_max", timeField: "date" }, // ── Sleep bundle ─────────────────────────────────────────────── - // Scope: `googlehealth.sleep.readonly`. A sleep session is an INTERVAL data - // type (a start + end span carrying a per-stage breakdown); the incremental - // filter must target `interval.start_time`, not a `sample_time` (which 400s - // for interval types). Mapped to per-stage SLEEP_DURATION rows. - sleep: { path: "sleep", filter: "sleep", timeField: "interval" }, + // Scope: `googlehealth.sleep.readonly`. Sleep sessions filter ONLY on + // `sleep.interval.end_time` / `.civil_end_time` — a start-time filter 400s. + // Mapped to per-stage SLEEP_DURATION rows. + sleep: { + path: "sleep", + filter: "sleep", + key: "sleep", + timeField: "sessionEnd", + }, // ── Exercise bundle ──────────────────────────────────────────── - // Scope: `googlehealth.activity_and_fitness.readonly`. An exercise session is - // an INTERVAL data type (a start + end span) → a `Workout` row (NOT a - // Measurement). The incremental filter targets `interval.start_time`. - exercise: { path: "exercise", filter: "exercise", timeField: "interval" }, + // Scope: `googlehealth.activity_and_fitness.readonly`. Session types + // (excluding sleep/ECG) filter ONLY on `interval.civil_start_time` with an + // offset-less civil bound → a `Workout` row (NOT a Measurement). + exercise: { + path: "exercise", + filter: "exercise", + key: "exercise", + timeField: "civilStart", + }, } as const satisfies Record; /** Google Health `DataPoint` — value object is type-keyed + carries a time anchor. */ @@ -515,18 +595,45 @@ interface DataPointQuery { pageSize?: number; /** Hard ceiling on pages walked (defence against a runaway cursor). */ maxPages?: number; + /** + * The user's IANA zone — needed only by the `civilStart` filter (the + * offset-less civil bound must be the watermark's wall clock in the USER'S + * zone). Omitted → the bound forms in UTC. + */ + tz?: string; +} + +const pad2 = (n: number): string => String(n).padStart(2, "0"); + +/** + * Format a UTC instant as the offset-less civil wall clock an observer in `tz` + * reads at that moment (`YYYY-MM-DDTHH:MM:SS`, no `Z`, no offset) — the bound + * format the session `civil_start_time` filter expects. Without `tz` the bound + * forms in UTC. + */ +export function formatCivilBound(instant: Date, tz?: string): string { + if (!tz) return instant.toISOString().slice(0, 19); + const p = wallClockInTz(instant, tz); + return `${p.year}-${pad2(p.month)}-${pad2(p.day)}T${pad2(p.hour)}:${pad2(p.minute)}:${pad2(p.second)}`; } /** - * Build the incremental `filter` field + bound for one data type. Centralised - * so the predicate and the read-time anchor resolution can never drift. - * - `sample` → spot reading, filter on `{filter}.sample_time.physical_time`. - * - `interval` → INTERVAL type, filter on `{filter}.interval.start_time`. - * - `date` → daily civil-date summary, filter on `{filter}.date`. + * 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`). + * - `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). + * - `civilStart` → exercise: `{filter}.interval.civil_start_time` with an + * offset-less civil bound in the user's zone. + * `rollup` types never build a list filter — they read via `:dailyRollUp`. */ export function incrementalFilter( dataType: GoogleHealthDataType, start: Date, + tz?: string, ): { field: string; bound: string } { switch (dataType.timeField) { case "sample": @@ -534,24 +641,69 @@ export function incrementalFilter( field: `${dataType.filter}.sample_time.physical_time`, bound: start.toISOString(), }; - case "interval": + case "sessionEnd": return { - field: `${dataType.filter}.interval.start_time`, + field: `${dataType.filter}.interval.end_time`, bound: start.toISOString(), }; + case "civilStart": + return { + field: `${dataType.filter}.interval.civil_start_time`, + bound: formatCivilBound(start, tz), + }; case "date": return { field: `${dataType.filter}.date`, bound: start.toISOString().slice(0, 10), }; + case "rollup": + throw new Error( + `Google Health data type ${dataType.path} reads via :dailyRollUp, not dataPoints.list`, + ); } } +/** + * Redact + bound an upstream Google API error message before it reaches an + * error object / wide event: strip bearer tokens, drop query strings from any + * embedded URL (they can carry filters echoing user data or tokens), cap at + * 200 chars. + */ +function redactApiErrorMessage(msg: string): string { + return msg + .replace(/Bearer\s+\S+/gi, "Bearer [REDACTED]") + .replace(/(https?:\/\/[^\s"'?]+)\?[^\s"']*/gi, "$1?[REDACTED]") + .slice(0, 200); +} + +/** + * 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`.) + */ +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 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(": "); +} + /** * 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. + * form against the type's time anchor. `rollup` types refuse — they read via + * `fetchDailyRollUp`. */ export async function fetchDataPoints( dataType: GoogleHealthDataType, @@ -568,7 +720,11 @@ export async function fetchDataPoints( do { const params = new URLSearchParams({ pageSize: String(pageSize) }); if (query.start) { - const { field, bound } = incrementalFilter(dataType, query.start); + const { field, bound } = incrementalFilter( + dataType, + query.start, + query.tz, + ); params.set("filter", `${field} >= "${bound}"`); } if (pageToken) params.set("pageToken", pageToken); @@ -586,12 +742,21 @@ export async function fetchDataPoints( .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: verdict.classification === "success" ? undefined : verdict.reason, + error: + verdict.classification === "success" + ? undefined + : apiErrorDetail + ? `${verdict.reason} ${apiErrorDetail}` + : verdict.reason, }); if (verdict.classification !== "success") { throw new GoogleHealthApiError({ @@ -599,6 +764,7 @@ export async function fetchDataPoints( classification: verdict.classification, httpStatus: verdict.httpStatus, reason: verdict.reason, + upstreamError: apiErrorDetail, }); } @@ -610,6 +776,194 @@ export async function fetchDataPoints( return points; } +// ─── Daily roll-up reads (`POST …/dataPoints:dailyRollUp`) ───────────────── + +/** Max civil days one dailyRollUp request range may span (per the v4 docs). */ +export const GOOGLE_HEALTH_ROLLUP_RANGE_DAYS = 90; + +/** + * 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 + * pinned: 5 years ≈ 21 chunks per type — bounded, and deeper than any + * wearable-history horizon the dashboard reads. + */ +export const GOOGLE_HEALTH_ROLLUP_BACKFILL_DAYS = 5 * 365; + +/** A civil calendar date (1-based month), the dailyRollUp range unit. */ +export interface GoogleHealthCivilDate { + year: number; + month: number; + day: number; +} + +/** The civil date an observer in `tz` reads at `instant` (UTC without `tz`). */ +export function civilDateInTz( + instant: Date, + tz?: string, +): GoogleHealthCivilDate { + if (!tz) { + return { + year: instant.getUTCFullYear(), + month: instant.getUTCMonth() + 1, + day: instant.getUTCDate(), + }; + } + const p = wallClockInTz(instant, tz); + return { year: p.year, month: p.month, day: p.day }; +} + +const DAY_MS = 24 * 60 * 60 * 1000; + +function civilToUtcMs(d: GoogleHealthCivilDate): number { + return Date.UTC(d.year, d.month - 1, d.day); +} + +function utcMsToCivil(ms: number): GoogleHealthCivilDate { + const d = new Date(ms); + return { + year: d.getUTCFullYear(), + month: d.getUTCMonth() + 1, + day: d.getUTCDate(), + }; +} + +/** + * Split a closed-open civil range into ≤`maxDays` closed-open chunks — the + * dailyRollUp request range caps at 90 days, so a multi-year backfill walks the + * range in slices. Returns an empty list when `start >= end`. + */ +export function chunkCivilRange( + start: GoogleHealthCivilDate, + endExclusive: GoogleHealthCivilDate, + maxDays: number = GOOGLE_HEALTH_ROLLUP_RANGE_DAYS, +): Array<{ start: GoogleHealthCivilDate; end: GoogleHealthCivilDate }> { + const s = civilToUtcMs(start); + const e = civilToUtcMs(endExclusive); + const out: Array<{ + start: GoogleHealthCivilDate; + end: GoogleHealthCivilDate; + }> = []; + for (let cur = s; cur < e; cur += maxDays * DAY_MS) { + const chunkEnd = Math.min(cur + maxDays * DAY_MS, e); + out.push({ start: utcMsToCivil(cur), end: utcMsToCivil(chunkEnd) }); + } + return out; +} + +/** + * One dailyRollUp aggregate window: `civilStartTime`/`civilEndTime` are + * CivilDateTime objects (`{date:{year,month,day}, time:{…}}`), the value is a + * union keyed by the camelCase type name carrying the `*Sum` rollup fields + * (`steps.countSum`, `distance.millimetersSum`, `activeEnergyBurned.kcalSum`, + * `floors.countSum` — int64 sums arrive as JSON strings). + */ +export interface GoogleHealthRollupPoint { + [key: string]: unknown; +} + +/** `dataPoints:dailyRollUp` envelope. */ +interface GoogleHealthRollupPage { + rollupDataPoints?: GoogleHealthRollupPoint[]; + nextPageToken?: string | null; +} + +interface RollupQuery { + /** Lower-bound incremental cursor; a full backfill walks the pinned horizon. */ + start?: Date; + /** The user's IANA zone — the civil range is user-local. */ + tz?: string; +} + +/** + * 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). + */ +export async function fetchDailyRollUp( + dataType: GoogleHealthDataType, + accessToken: string, + verb: string, + query: RollupQuery = {}, +): Promise { + const now = new Date(); + const from = + query.start ?? + new Date(now.getTime() - GOOGLE_HEALTH_ROLLUP_BACKFILL_DAYS * DAY_MS); + // End exclusive at tomorrow (user-local) so today's running total is covered. + 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 = { + range: { + start: { date: chunk.start }, + end: { date: chunk.end }, + }, + windowSizeDays: 1, + pageSize: 100, + }; + if (pageToken) body.pageToken = pageToken; + + 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), + }, + ); + 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: + 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; + } + + return points; +} + // ─── Field → Measurement mapping ─────────────────────────────── // The single source of truth is `mapping.md` — keep both in sync when adding // entries. @@ -621,9 +975,19 @@ function round2(n: number): number { return parseFloat(n.toFixed(2)); } -/** Finite + strictly-positive guard. */ -function positive(n: unknown): n is number { - return typeof n === "number" && Number.isFinite(n) && n > 0; +/** + * Coerce a raw payload value to a finite number, or null. proto3 int64 fields + * arrive as JSON strings (`"12345"`); `Number()` handles both int and decimal + * strings, guarded by a finite check (empty / whitespace strings coerce to 0 + * via `Number("")` — reject them explicitly). + */ +function coerceNumber(v: unknown): number | null { + if (typeof v === "number") return Number.isFinite(v) ? v : null; + if (typeof v === "string" && v.trim() !== "") { + const n = Number(v); + return Number.isFinite(n) ? n : null; + } + return null; } /** @@ -655,22 +1019,18 @@ export type GoogleHealthSleepStage = "IN_BED" | "AWAKE" | "ASLEEP" | "REM" | "CORE" | "DEEP"; /** - * Pull the first finite number out of a list of candidate value paths on a - * `DataPoint`. The Google value-field JSON is undocumented, so each mapper hands - * the small set of shapes the field is likely to take (a bare `value`, a typed - * sub-object, a `{ value: { fpVal } }` wrapper, …); the first finite hit wins. + * Pull the first finite STRICTLY-POSITIVE number out of a list of candidate + * value paths on a `DataPoint`, coercing int64 JSON strings. The launch metrics + * (weight, body-fat, SpO2, HRV, RHR, respiratory rate, HR, height, VO2 max) are + * all strictly positive — a zero is a garbage/empty reading and is dropped. */ function firstNumber( point: GoogleHealthDataPoint, paths: string[], ): number | null { for (const path of paths) { - const v = readPath(point, path); - if (positive(v)) return v; - // A value can legitimately be zero for some metrics, but the launch metrics - // (weight, body-fat, SpO2, HRV, RHR, respiratory rate, HR, height) are all - // strictly positive — a zero is a garbage/empty reading, so `positive` is - // the right guard. + const n = coerceNumber(readPath(point, path)); + if (n !== null && n > 0) return n; } return null; } @@ -757,18 +1117,17 @@ function parseLocalInstant(iso: string, tz?: string): Date | null { } /** - * Resolve a `DataPoint`'s measurement timestamp. - * - `sample` → `sample_time.physical_time` (a spot instant; offset-less + * Resolve a `DataPoint`'s measurement timestamp. The read paths are camelCase — + * the response payload nests the value union under the camelCase type name with + * camelCase time objects (the snake_case forms exist only inside the request + * `filter` parameter). + * - `sample` → `{key}.sampleTime.physicalTime` (a spot instant; offset-less * strings resolve against `tz`). - * - `interval` → the cumulative daily total keys on the CIVIL day: - * `interval.civil_start_time` anchored at UTC-midday (so a tz shift can't - * roll the day and the day-key aligns with the Apple/Fitbit `stats:` civil - * convention), falling back to `interval.start_time` (the physical instant) - * only when civil is absent. `start_time` alone off-by-ones the day for - * positive-UTC-offset users. - * - `date` → `date` (a civil string or `{year,month,day}` object). + * - `date` → `{key}.date` (a `{year,month,day}` object, or a civil string) — + * anchored at UTC-midday so a tz shift can't roll the civil day. * Falls back to `fallback` only when nothing parses, so a row is never dropped - * for a missing anchor. + * for a missing anchor. (Sleep / exercise sessions and the rollup types anchor + * through their own helpers, never here.) */ function resolveMeasuredAt( point: GoogleHealthDataPoint, @@ -777,33 +1136,15 @@ function resolveMeasuredAt( tz?: string, ): Date { if (dataType.timeField === "sample") { - const t = readPath(point, `${dataType.filter}.sample_time.physical_time`); + const t = readPath(point, `${dataType.key}.sampleTime.physicalTime`); if (typeof t === "string") { const d = parseLocalInstant(t, tz); if (d) return d; } - } else if (dataType.timeField === "interval") { - // Civil day first — the day the total belongs to, tz-invariant at UTC-midday. - const civil = readPath( - point, - `${dataType.filter}.interval.civil_start_time`, - ); - const civilDate = parseCivilStart(civil); - if (civilDate) return civilDate; - // Fall back to the physical instant only when civil is absent. - const t = readPath(point, `${dataType.filter}.interval.start_time`); - if (typeof t === "string") { - const d = parseLocalInstant(t, tz); - if (d) return d; - } - } else { - const dateVal = readPath(point, `${dataType.filter}.date`); - if (typeof dateVal === "string") { - const d = new Date(dateVal); - if (!Number.isNaN(d.getTime())) return d; - } - const civilObj = parseCivilDateObject(dateVal); - if (civilObj) return civilObj; + } else if (dataType.timeField === "date") { + const dateVal = readPath(point, `${dataType.key}.date`); + const civil = parseCivilStart(dateVal); + if (civil) return civil; } return fallback; } @@ -819,11 +1160,10 @@ function externalAnchor( tz?: string, ): string { const at = resolveMeasuredAt(point, dataType, new Date(0), tz); - // The mapper-driven interval types (steps/distance/calories/floors) are daily - // totals, so they share the civil-day externalId grain with `date` summaries — - // a re-fetched day overwrites in place. (Sleep/exercise are interval too but - // mint their own per-session anchors and never reach this helper.) - if (dataType.timeField === "date" || dataType.timeField === "interval") { + // Daily summaries share the civil-day externalId grain so a re-fetched day + // overwrites in place. (Sleep/exercise sessions and the rollup daily totals + // mint their own anchors and never reach this helper.) + if (dataType.timeField === "date") { return at.toISOString().slice(0, 10); } return at.toISOString(); @@ -859,26 +1199,17 @@ function mapSimple( ]; } -/** Candidate value-field shapes a Google `DataPoint` is likely to carry. */ -function valuePaths(filterKey: string, leaf: string): string[] { - return [ - `${filterKey}.${leaf}`, - `${filterKey}.value.${leaf}`, - `${filterKey}.value`, - `value.${leaf}`, - `value`, - ]; -} - export function mapWeight( point: GoogleHealthDataPoint, ): GoogleHealthMappedMeasurement[] { const dt = GOOGLE_HEALTH_DATA_TYPES.weight; + // Documented field is `weightGrams` (grams) → kg. return mapSimple(point, dt, { type: "WEIGHT", unit: "kg", fieldTag: "weight", - valuePaths: valuePaths(dt.filter, "kilograms"), + valuePaths: [`${dt.key}.weightGrams`], + factor: 0.001, }); } @@ -890,7 +1221,7 @@ export function mapBodyFat( type: "BODY_FAT", unit: "%", fieldTag: "body_fat", - valuePaths: valuePaths(dt.filter, "percentage"), + valuePaths: [`${dt.key}.percentage`], }); } @@ -902,10 +1233,7 @@ export function mapOxygenSaturation( type: "OXYGEN_SATURATION", unit: "%", fieldTag: "spo2", - valuePaths: [ - ...valuePaths(dt.filter, "average_percentage"), - ...valuePaths(dt.filter, "percentage"), - ], + valuePaths: [`${dt.key}.averagePercentage`], }); } @@ -913,18 +1241,16 @@ export function mapHeartRateVariability( point: GoogleHealthDataPoint, ): GoogleHealthMappedMeasurement[] { const dt = GOOGLE_HEALTH_DATA_TYPES.heartRateVariability; - // Google reports a nightly RMSSD-style HRV. Per the design decision it lands - // in the SDNN-lineage `HEART_RATE_VARIABILITY` slot (Apple-comparable), NOT - // WHOOP's `HRV_RMSSD` (reserved for the WHOOP-native estimator). Re-confirm - // the estimator against a live account and revisit if it warrants `HRV_RMSSD`. + // The daily field is an unlabelled "average HRV ms". Per the design decision + // it lands in the SDNN-lineage `HEART_RATE_VARIABILITY` slot + // (Apple-comparable), NOT WHOOP's `HRV_RMSSD` (reserved for the WHOOP-native + // estimator). The per-sample type carries explicit RMSSD + SDNN fields — + // re-confirm the estimator against live data and revisit if warranted. return mapSimple(point, dt, { type: "HEART_RATE_VARIABILITY", unit: "ms", fieldTag: "hrv", - valuePaths: [ - ...valuePaths(dt.filter, "rmssd_milliseconds"), - ...valuePaths(dt.filter, "milliseconds"), - ], + valuePaths: [`${dt.key}.averageHeartRateVariabilityMilliseconds`], }); } @@ -932,11 +1258,12 @@ export function mapRestingHeartRate( point: GoogleHealthDataPoint, ): GoogleHealthMappedMeasurement[] { const dt = GOOGLE_HEALTH_DATA_TYPES.restingHeartRate; + // `beatsPerMinute` is an int64 JSON string — coerced by the extractor. return mapSimple(point, dt, { type: "RESTING_HEART_RATE", unit: "bpm", fieldTag: "rhr", - valuePaths: valuePaths(dt.filter, "beats_per_minute"), + valuePaths: [`${dt.key}.beatsPerMinute`], }); } @@ -944,14 +1271,12 @@ export function mapRespiratoryRate( point: GoogleHealthDataPoint, ): GoogleHealthMappedMeasurement[] { const dt = GOOGLE_HEALTH_DATA_TYPES.respiratoryRate; + // `dailyRespiratoryRateBpm` is an int64 JSON string. return mapSimple(point, dt, { type: "RESPIRATORY_RATE", unit: "breaths/min", fieldTag: "resp_rate", - valuePaths: [ - ...valuePaths(dt.filter, "breaths_per_minute"), - ...valuePaths(dt.filter, "average_breaths_per_minute"), - ], + valuePaths: [`${dt.key}.dailyRespiratoryRateBpm`], }); } @@ -959,90 +1284,94 @@ export function mapHeartRate( point: GoogleHealthDataPoint, ): GoogleHealthMappedMeasurement[] { const dt = GOOGLE_HEALTH_DATA_TYPES.heartRate; + // `beatsPerMinute` is an int64 JSON string. return mapSimple(point, dt, { type: "PULSE", unit: "bpm", fieldTag: "hr", - valuePaths: valuePaths(dt.filter, "beats_per_minute"), + valuePaths: [`${dt.key}.beatsPerMinute`], }); } +/** One extracted `height` sample: centimetres + the sample instant (if any). */ +export interface GoogleHealthHeightSample { + cm: number; + sampledAt: Date | null; +} + /** - * Extract the profile height (in cm) 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. Returns cm. + * 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. */ -export function mapHeightCm(point: GoogleHealthDataPoint): number | null { +export function mapHeight( + point: GoogleHealthDataPoint, +): GoogleHealthHeightSample | null { const dt = GOOGLE_HEALTH_DATA_TYPES.height; - // Google may report height in metres or centimetres; try cm-direct first, then - // metres × 100. Both pass through the positive guard. - const cm = firstNumber(point, valuePaths(dt.filter, "centimeters")); - if (cm !== null) return round2(cm); - const m = firstNumber(point, valuePaths(dt.filter, "meters")); - if (m !== null) return round2(m * M_TO_CM); - return null; + const m = firstNumber(point, [`${dt.key}.heightMeters`]); + if (m === 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 }; } -// ─── Activity mappers: daily cumulative ──────────────────────── - -/** Finite + non-negative guard — cumulative metrics admit a legitimate 0. */ -function nonNegative(n: unknown): n is number { - return typeof n === "number" && Number.isFinite(n) && n >= 0; -} +// ─── Activity mappers: daily roll-up totals ──────────────────── /** * Pull the first finite NON-negative number out of a list of candidate value - * paths. Unlike `firstNumber` (strictly positive), this admits a legitimate - * zero — a day of rest still records 0 steps / 0 floors / 0 active kcal, and - * dropping the zero would leave a hole the chart misreads as missing data. + * paths, coercing int64 JSON strings. Unlike `firstNumber` (strictly positive), + * this admits a legitimate zero — a day of rest still records 0 steps / + * 0 floors / 0 active kcal, and dropping the zero would leave a hole the chart + * misreads as missing data. */ function firstNonNegativeNumber( point: GoogleHealthDataPoint, paths: string[], ): number | null { for (const path of paths) { - const v = readPath(point, path); - if (nonNegative(v)) return v; + const n = coerceNumber(readPath(point, path)); + if (n !== null && n >= 0) return n; } return null; } /** - * Map one daily cumulative-activity data point into a single Measurement - * reading. The externalId is the `stats:`-prefixed daily-total shape so a - * re-fetched day overwrites in place rather than minting a duplicate — the same + * Map one `dailyRollUp` aggregate window (`windowSizeDays: 1`) into a single + * daily-total Measurement reading. The day key comes from the window's + * `civilStartTime.date` (a `{year,month,day}` object), anchored at UTC-midday + * per the shared civil-day convention; a window with no parseable civil day is + * dropped (it cannot be keyed). The externalId is the `stats:`-prefixed + * daily-total shape so a re-fetched day overwrites in place — the same * overwrite contract the Apple-Health `stats::` daily totals - * use. A zero is preserved (a rest day is real data, not a gap). `latestWins` - * (VO2 max) carries the same daily anchor; it is daily latest-wins rather than a - * running sum, but the per-day overwrite key is identical. + * use. A zero is preserved (a rest day is real data, not a gap). */ -function mapDailyCumulative( - point: GoogleHealthDataPoint, - dataType: GoogleHealthDataType, +function mapDailyRollup( + point: GoogleHealthRollupPoint, spec: { type: string; unit: string; fieldTag: string; valuePaths: string[]; factor?: number; - /** VO2 max is daily latest-wins, not a running total — still one row/day. */ - latestWins?: boolean; }, - tz?: string, ): GoogleHealthMappedMeasurement[] { - // VO2 max is strictly positive; the running totals admit a legitimate zero. - let value = spec.latestWins - ? firstNumber(point, spec.valuePaths) - : firstNonNegativeNumber(point, spec.valuePaths); + const day = + parseCivilDateObject(readPath(point, "civilStartTime.date")) ?? + parseCivilStart(readPath(point, "civilStartTime")); + if (!day) return []; + let value = firstNonNegativeNumber(point, spec.valuePaths); if (value === null) return []; if (spec.factor) value = value * spec.factor; - const dayKey = externalAnchor(point, dataType, tz); // civil YYYY-MM-DD + const dayKey = day.toISOString().slice(0, 10); return [ { type: spec.type, value: round2(value), unit: spec.unit, - measuredAt: resolveMeasuredAt(point, dataType, new Date(), tz), + measuredAt: day, // `stats::` — the sync layer reads `cumulativeDaily` // to assemble the externalId, matching the Apple-Health daily-total shape. fieldTag: `${spec.fieldTag}:${dayKey}`, @@ -1052,125 +1381,81 @@ function mapDailyCumulative( } export function mapSteps( - point: GoogleHealthDataPoint, - tz?: string, + point: GoogleHealthRollupPoint, ): GoogleHealthMappedMeasurement[] { const dt = GOOGLE_HEALTH_DATA_TYPES.steps; - return mapDailyCumulative( - point, - dt, - { - type: "ACTIVITY_STEPS", - unit: "steps", - fieldTag: "steps", - valuePaths: [ - ...valuePaths(dt.filter, "count"), - ...valuePaths(dt.filter, "steps"), - ], - }, - tz, - ); + // `countSum` is an int64 JSON string. + return mapDailyRollup(point, { + type: "ACTIVITY_STEPS", + unit: "steps", + fieldTag: "steps", + valuePaths: [`${dt.key}.countSum`], + }); } export function mapDistance( - point: GoogleHealthDataPoint, - tz?: string, + point: GoogleHealthRollupPoint, ): GoogleHealthMappedMeasurement[] { const dt = GOOGLE_HEALTH_DATA_TYPES.distance; - // Google may report metres or kilometres; prefer the explicit-metres field, - // else convert km → m. Both pass the non-negative guard. - const meters = firstNonNegativeNumber(point, valuePaths(dt.filter, "meters")); - if (meters !== null) { - return [ - { - type: "WALKING_RUNNING_DISTANCE", - value: round2(meters), - unit: "m", - measuredAt: resolveMeasuredAt(point, dt, new Date(), tz), - fieldTag: `distance:${externalAnchor(point, dt, tz)}`, - cumulativeDaily: true, - }, - ]; - } - return mapDailyCumulative( - point, - dt, - { - type: "WALKING_RUNNING_DISTANCE", - unit: "m", - fieldTag: "distance", - valuePaths: valuePaths(dt.filter, "kilometers"), - factor: 1000, - }, - tz, - ); + // `millimetersSum` is an int64 JSON string → metres. + return mapDailyRollup(point, { + type: "WALKING_RUNNING_DISTANCE", + unit: "m", + fieldTag: "distance", + valuePaths: [`${dt.key}.millimetersSum`], + factor: 0.001, + }); } export function mapActiveEnergy( - point: GoogleHealthDataPoint, - tz?: string, + point: GoogleHealthRollupPoint, ): GoogleHealthMappedMeasurement[] { const dt = GOOGLE_HEALTH_DATA_TYPES.activeEnergy; - // ACTIVE energy only — NOT total-calories (which folds in BMR). The candidate - // paths target the active-portion field explicitly. - return mapDailyCumulative( - point, - dt, - { - type: "ACTIVE_ENERGY_BURNED", - unit: "kcal", - fieldTag: "active_energy", - valuePaths: [ - ...valuePaths(dt.filter, "active_kilocalories"), - ...valuePaths(dt.filter, "kilocalories"), - ...valuePaths(dt.filter, "calories"), - ], - }, - tz, - ); + // ACTIVE energy only — NOT total-calories (which folds in BMR). + return mapDailyRollup(point, { + type: "ACTIVE_ENERGY_BURNED", + unit: "kcal", + fieldTag: "active_energy", + valuePaths: [`${dt.key}.kcalSum`], + }); } export function mapFloors( - point: GoogleHealthDataPoint, - tz?: string, + point: GoogleHealthRollupPoint, ): GoogleHealthMappedMeasurement[] { const dt = GOOGLE_HEALTH_DATA_TYPES.floors; - return mapDailyCumulative( - point, - dt, - { - type: "FLIGHTS_CLIMBED", - unit: "flights", - fieldTag: "floors", - valuePaths: [ - ...valuePaths(dt.filter, "count"), - ...valuePaths(dt.filter, "floors"), - ], - }, - tz, - ); + // `countSum` is an int64 JSON string. + return mapDailyRollup(point, { + type: "FLIGHTS_CLIMBED", + unit: "flights", + fieldTag: "floors", + valuePaths: [`${dt.key}.countSum`], + }); } +/** + * Map one `daily-vo2-max` summary into a VO2_MAX reading. A daily latest-wins + * metric (one civil-date reading, strictly positive — not a running sum), read + * via list with a `.date` filter; it keeps the `stats:`-style per-day overwrite + * key so a re-rolled day replaces in place. + */ export function mapVo2Max( point: GoogleHealthDataPoint, - tz?: string, ): GoogleHealthMappedMeasurement[] { const dt = GOOGLE_HEALTH_DATA_TYPES.vo2Max; - return mapDailyCumulative( - point, - dt, + const value = firstNumber(point, [`${dt.key}.vo2Max`]); + if (value === null) return []; + const measuredAt = resolveMeasuredAt(point, dt, new Date()); + return [ { type: "VO2_MAX", + value: round2(value), unit: "mL/(kg·min)", - fieldTag: "vo2_max", - valuePaths: [ - ...valuePaths(dt.filter, "milliliters_per_kilogram_per_minute"), - ...valuePaths(dt.filter, "value"), - ], - latestWins: true, // strictly positive, one daily reading; not a running sum + measuredAt, + fieldTag: `vo2_max:${externalAnchor(point, dt)}`, + cumulativeDaily: true, }, - tz, - ); + ]; } // ── Sleep ────────────────────────────────────────────────────── @@ -1233,71 +1518,43 @@ function minutesBetween(startIso?: string, endIso?: string): number | null { } /** - * Read the per-stage segments off a Google sleep `DataPoint`, tolerating the - * undocumented JSON shape. Candidate locations: `sleep.segments`, - * `sleep.stages`, `sleep.levels.data`, or a bare top-level `segments`/`stages`. + * Read the per-stage segments off a Google sleep `DataPoint`. Documented shape: + * `sleep.stages` is an array of + * `{ startTime, startUtcOffset, endTime, endUtcOffset, type }` where `type` is + * the `SLEEP_STAGE_TYPE` enum (`AWAKE | LIGHT | DEEP | REM | ASLEEP | RESTLESS + * | SLEEP_STAGE_TYPE_UNSPECIFIED`) and the times are RFC-3339 Timestamps. */ function readSleepSegments( point: GoogleHealthDataPoint, ): GoogleHealthSleepSegment[] { - const candidates = [ - readPath(point, "sleep.segments"), - readPath(point, "sleep.stages"), - readPath(point, "sleep.levels.data"), - readPath(point, "segments"), - readPath(point, "stages"), - readPath(point, "levels.data"), - ]; - for (const c of candidates) { - if (!Array.isArray(c)) continue; - const out: GoogleHealthSleepSegment[] = []; - for (const raw of c) { - if (!raw || typeof raw !== "object") continue; - const o = raw as Record; - const stage = - (typeof o.stage === "string" && o.stage) || - (typeof o.level === "string" && o.level) || - (typeof o.type === "string" && o.type) || - ""; - const startTime = - (typeof o.startTime === "string" && o.startTime) || - (typeof o.start_time === "string" && o.start_time) || - (typeof o.dateTime === "string" && o.dateTime) || - undefined; - const endTime = - (typeof o.endTime === "string" && o.endTime) || - (typeof o.end_time === "string" && o.end_time) || - undefined; - if (stage) out.push({ stage, startTime, endTime }); - } - if (out.length > 0) return out; + const stages = readPath(point, "sleep.stages"); + if (!Array.isArray(stages)) return []; + const out: GoogleHealthSleepSegment[] = []; + for (const raw of stages) { + if (!raw || typeof raw !== "object") continue; + const o = raw as Record; + const stage = typeof o.type === "string" ? o.type : ""; + const startTime = typeof o.startTime === "string" ? o.startTime : undefined; + const endTime = typeof o.endTime === "string" ? o.endTime : undefined; + if (stage) out.push({ stage, startTime, endTime }); } - return []; + return out; } /** * The stable session anchor for a sleep `DataPoint`'s externalId. The session * end (or start) ISO instant is unique per night, so per-stage rows key as * `:sleep_` — a re-scored night overwrites in place. + * Paths are camelCase (`sleep.interval.endTime`) — the response payload never + * uses snake_case. */ function sleepSessionAnchor(point: GoogleHealthDataPoint, tz?: string): string { - const end = - readPath(point, "sleep.interval.end_time") ?? - readPath(point, "interval.end_time") ?? - readPath(point, "sleep.endTime") ?? - readPath(point, "sleep.end_time") ?? - readPath(point, "endTime") ?? - readPath(point, "end_time"); + const end = readPath(point, "sleep.interval.endTime"); if (typeof end === "string") { const d = parseLocalInstant(end, tz); if (d) return d.toISOString(); } - const start = - readPath(point, "sleep.interval.start_time") ?? - readPath(point, "interval.start_time") ?? - readPath(point, "sleep.startTime") ?? - readPath(point, "sleep.start_time") ?? - readPath(point, "startTime"); + const start = readPath(point, "sleep.interval.startTime"); if (typeof start === "string") { const d = parseLocalInstant(start, tz); if (d) return d.toISOString(); @@ -1356,9 +1613,12 @@ export function mapSleepSession( // ── Workouts (exercise sessions) ─────────────────────────────── /** - * Google Health exercise-activity-type → HealthLog `WorkoutSportType`. Unknown - * types fall through to a generic label; the column is free-text so an unmapped - * type still persists (just not under a canonical sport bucket). + * Google Health `Exercise.exerciseType` → HealthLog `WorkoutSportType`. The + * enum arrives UPPERCASE (`RUNNING`, `STRENGTH_TRAINING`, …); the resolver + * lowercases + underscores before the lookup, so the keys here are the + * normalised forms. Unknown types fall through to a generic label; the column + * is free-text so an unmapped type still persists (just not under a canonical + * sport bucket). */ const GOOGLE_HEALTH_EXERCISE_TYPE_MAP: Record = { walk: "walking", @@ -1366,10 +1626,12 @@ const GOOGLE_HEALTH_EXERCISE_TYPE_MAP: Record = { run: "running", running: "running", treadmill: "running", + treadmill_running: "running", bike: "cycling", biking: "cycling", cycling: "cycling", spinning: "cycling", + mountain_biking: "cycling", hike: "hiking", hiking: "hiking", swim: "swimming", @@ -1377,18 +1639,25 @@ const GOOGLE_HEALTH_EXERCISE_TYPE_MAP: Record = { rowing: "rowing", elliptical: "elliptical", stairclimber: "stairClimber", + stair_climbing: "stairClimber", yoga: "yoga", pilates: "mindAndBody", weights: "strength", strength: "strength", + strength_training: "strength", + weightlifting: "strength", workout: "strength", hiit: "hiit", + high_intensity_interval_training: "hiit", interval_workout: "hiit", + interval_training: "hiit", dance: "dance", + dancing: "dance", golf: "golf", tennis: "tennis", basketball: "basketball", soccer: "soccer", + football: "soccer", bootcamp: "crossTraining", circuit_training: "crossTraining", sport: "mixedCardio", @@ -1422,14 +1691,17 @@ export interface GoogleHealthMappedWorkout { minHeartRate: number | null; } -/** Read a finite number off a list of candidate paths (any sign), or null. */ +/** + * Read a finite number off a list of candidate paths (any sign), coercing int64 + * JSON strings, or null. + */ function readNumber( point: GoogleHealthDataPoint, paths: string[], ): number | null { for (const path of paths) { - const v = readPath(point, path); - if (typeof v === "number" && Number.isFinite(v)) return v; + const n = coerceNumber(readPath(point, path)); + if (n !== null) return n; } return null; } @@ -1457,89 +1729,46 @@ function readInstant( /** * Map one Google exercise session `DataPoint` into a `Workout` shape. Returns * null when there is no usable start/end (a session with no time span is not a - * workout). The externalId anchors on the session id when present, else on the - * start instant, so a re-fetch overwrites the same `Workout` row in place. - * Energy is the active session energy in kcal; HR fields are optional. + * workout). * - * Session start/end can arrive OFFSET-LESS (local wall clock); `tz` anchors them - * to the correct UTC instant rather than the process zone. + * Documented payload: `exercise.interval.startTime/endTime` (RFC-3339), + * `exercise.exerciseType` (UPPERCASE enum), and + * `exercise.metricsSummary.{caloriesKcal, distanceMillimeters, + * averageHeartRateBeatsPerMinute (int64 string), …}`. There is no session-id + * field — the DataPoint's top-level `name` (a resource name) is the stable id; + * the start instant is the fallback. metricsSummary carries no max/min HR → + * null. + * + * Session start/end can arrive OFFSET-LESS (local wall clock); `tz` anchors + * them to the correct UTC instant rather than the process zone. */ export function mapWorkout( point: GoogleHealthDataPoint, tz?: string, ): GoogleHealthMappedWorkout | null { - const f = GOOGLE_HEALTH_DATA_TYPES.exercise.filter; - const startedAt = readInstant( - point, - [ - `${f}.interval.start_time`, - `${f}.startTime`, - `${f}.start_time`, - `${f}.sample_time.physical_time`, - "interval.start_time", - "startTime", - "start_time", - ], - tz, - ); - const endedAt = readInstant( - point, - [ - `${f}.interval.end_time`, - `${f}.endTime`, - `${f}.end_time`, - "interval.end_time", - "endTime", - "end_time", - ], - tz, - ); + const k = GOOGLE_HEALTH_DATA_TYPES.exercise.key; + const startedAt = readInstant(point, [`${k}.interval.startTime`], tz); + const endedAt = readInstant(point, [`${k}.interval.endTime`], tz); if (!startedAt || !endedAt || endedAt <= startedAt) return null; const durationSec = Math.round( (endedAt.getTime() - startedAt.getTime()) / 1000, ); - const sessionId = - (typeof readPath(point, `${f}.session_id`) === "string" && - (readPath(point, `${f}.session_id`) as string)) || - (typeof readPath(point, `${f}.id`) === "string" && - (readPath(point, `${f}.id`) as string)) || - (typeof readPath(point, "name") === "string" && - (readPath(point, "name") as string)) || - null; - const externalId = sessionId ?? `exercise:${startedAt.toISOString()}`; + const name = readPath(point, "name"); + const externalId = + typeof name === "string" && name !== "" + ? name + : `exercise:${startedAt.toISOString()}`; - const sportRaw = - readPath(point, `${f}.activity_type`) ?? - readPath(point, `${f}.exercise_type`) ?? - readPath(point, `${f}.type`) ?? - readPath(point, "activityType"); + const sportRaw = readPath(point, `${k}.exerciseType`); - const energyKcal = readNumber(point, [ - `${f}.active_kilocalories`, - `${f}.calories`, - `${f}.energy.kilocalories`, - ]); - const distanceM = readNumber(point, [ - `${f}.distance.meters`, - `${f}.distance_meters`, - `${f}.distance`, + const energyKcal = readNumber(point, [`${k}.metricsSummary.caloriesKcal`]); + const distanceMm = readNumber(point, [ + `${k}.metricsSummary.distanceMillimeters`, ]); const avgHr = readNumber(point, [ - `${f}.average_heart_rate.beats_per_minute`, - `${f}.average_heart_rate`, - `${f}.heart_rate.average`, - ]); - const maxHr = readNumber(point, [ - `${f}.maximum_heart_rate.beats_per_minute`, - `${f}.max_heart_rate`, - `${f}.heart_rate.maximum`, - ]); - const minHr = readNumber(point, [ - `${f}.minimum_heart_rate.beats_per_minute`, - `${f}.min_heart_rate`, - `${f}.heart_rate.minimum`, + `${k}.metricsSummary.averageHeartRateBeatsPerMinute`, ]); return { @@ -1550,9 +1779,10 @@ export function mapWorkout( durationSec, totalEnergyKcal: energyKcal !== null ? Math.round(energyKcal) : null, totalDistanceM: - distanceM !== null && distanceM >= 0 ? round2(distanceM) : null, + distanceMm !== null && distanceMm >= 0 ? round2(distanceMm / 1000) : null, avgHeartRate: avgHr !== null && avgHr > 0 ? Math.round(avgHr) : null, - maxHeartRate: maxHr !== null && maxHr > 0 ? Math.round(maxHr) : null, - minHeartRate: minHr !== null && minHr > 0 ? Math.round(minHr) : null, + // metricsSummary carries no maximum/minimum heart rate. + maxHeartRate: null, + minHeartRate: null, }; } diff --git a/src/lib/google-health/mapping.md b/src/lib/google-health/mapping.md index 6cbc585e4..694c9e022 100644 --- a/src/lib/google-health/mapping.md +++ b/src/lib/google-health/mapping.md @@ -4,74 +4,86 @@ Single source of truth for the Google Health API v4 data-type → HealthLog `Measurement` mapping. Keep this in sync with the per-type mappers in `client.ts`. Every row ingests server-side with `source = GOOGLE_HEALTH` and `externalId = :` (the anchor is the data point's sample time for -spot readings, its civil date for daily summaries, or — for INTERVAL types -(steps/distance/energy/floors, sleep, exercise) — its `interval.civil_start_time` -anchored at UTC-midday, falling back to `interval.start_time` only when the civil -date is absent; the field-tag disambiguates the metric). +spot readings or its civil date for daily summaries; the field-tag disambiguates +the metric). The cumulative daily totals use the `stats::` +overwrite shape instead. Google Health is a **separate, coexisting provider** from the classic Fitbit integration (`src/lib/fitbit/*`, `source = FITBIT`). They never share a token, connection row, source enum, or cookie. -## Time anchor by grain +## The three encodings (casing) -Google Health exposes three time-anchor shapes. The incremental `filter` and the -read-time `measuredAt`/externalId resolution must target the right one or the -incremental sync stalls (a `sample_time` filter 400s/empties for an INTERVAL type): +Each data type has THREE on-the-wire encodings, all pinned in +`GOOGLE_HEALTH_DATA_TYPES` so a fetcher can never mix them up: -- **sample** — spot reading; anchor `{filter}.sample_time.physical_time`. -- **date** — daily-summary metric (SpO2, HRV, RHR, respiratory rate, VO2 max); - anchor `{filter}.date` (a civil date / `{year,month,day}`). -- **interval** — INTERVAL type (steps/distance/energy/floors daily totals, plus - sleep + exercise sessions); the incremental `filter` targets - `{filter}.interval.start_time`, but the read-time `measuredAt`/day-key derive - from `{filter}.interval.civil_start_time` anchored at UTC-midday, so a daily - total keys on the civil day (aligning with the Apple/Fitbit `stats:` contract); - the physical `start_time` is the fallback only when civil is absent. +- **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. +- **response payload** — camelCase. The `DataPoint` value is a union keyed by + the camelCase type name (`bodyFat`, `dailyRestingHeartRate`, + `activeEnergyBurned`, …) with camelCase nested objects + (`sampleTime.physicalTime`, `interval.startTime`, `civilStartTime.date`). -Transport docs: `developers.google.com/health` (v4). The per-type value-field -JSON is **NOT fully published** — the mappers read a small set of candidate value -paths defensively (`firstNumber` walks each shape and takes the first -finite-positive hit). **Re-verify against a live test account at build** and -tighten the extractors. +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. ## Read method -Every launch data type supports `dataPoints.list` (`readMethod: "list"`, the only -method the transport uses). The rollup-only types (`total-calories`, -`calories-in-heart-rate-zone`, which have no `:list`) are deliberately **not** in -the launch set: active energy already covers the active-calories slot, and -`total-calories` folds in BMR and needs a modelling decision before it lands as a -metric. Adding either later means adding a `:dailyRollUp` reader together with it. +Two read methods are in use — NOT every type supports `dataPoints.list`: -## Casing gotcha +- **`dataPoints.list`** (GET, `nextPageToken` pagination) — spot samples + (weight, body-fat, heart-rate, height), daily summaries + (daily-oxygen-saturation, daily-heart-rate-variability, + daily-resting-heart-rate, daily-respiratory-rate, daily-vo2-max), and the + session types (sleep, exercise; pageSize default & max **25**). +- **`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 + (`GOOGLE_HEALTH_ROLLUP_BACKFILL_DAYS`). -The data-type id is **kebab-case in the request path** (`body-fat`) and -**snake_case in the `filter` predicate** (`body_fat`). `GOOGLE_HEALTH_DATA_TYPES` -pins both forms per type so a fetcher can never encode the wrong one. +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`) | + +The exercise civil bound is derived from the watermark in the USER'S zone. ## Health-metrics bundle -Scope: `googlehealth.health_metrics_and_measurements.readonly`. Identifiers -verified against the 2026 contract (API-RESEARCH §3/§4). +Scope: `googlehealth.health_metrics_and_measurements.readonly`. -| Data type (path / filter) | MeasurementType | Unit | fieldTag | Grain | Note | -| ------------------------------------------------------- | ------------------------ | ----------- | ----------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `weight` / `weight` | `WEIGHT` | kg | `weight` | sample | Picker ranks a real Withings scale above Google Health. | -| `body-fat` / `body_fat` | `BODY_FAT` | % | `body_fat` | sample | | -| `oxygen-saturation` / `oxygen_saturation` | `OXYGEN_SATURATION` | % | `spo2` | daily | Already 0–100. | -| `heart-rate-variability` / `heart_rate_variability` | `HEART_RATE_VARIABILITY` | ms | `hrv` | daily | **Decision:** SDNN slot (Apple-comparable), NOT `HRV_RMSSD`. Google reports an RMSSD-style nightly HRV — confirm the estimator at build and reconsider if warranted. | -| `daily-resting-heart-rate` / `daily_resting_heart_rate` | `RESTING_HEART_RATE` | bpm | `rhr` | daily | | -| `respiratory-rate` / `respiratory_rate` | `RESPIRATORY_RATE` | breaths/min | `resp_rate` | daily | Identifier appears in spec prose; confirm `:list` support at build (API-RESEARCH OPEN §4). | -| `heart-rate` / `heart_rate` | `PULSE` | bpm | `hr` | sample | Intraday spot HR. | -| `height` / `height` | `User.heightCm` | cm | — | sample | Profile seed — written to `User.heightCm` ONLY when null; never minted as a Measurement (WHOOP `mapBody` pattern). m→cm when reported in metres. | +| 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. | + +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). Skin temperature (`daily-sleep-temperature-derivations`) is intentionally **not** in the launch set — see **Deferred** below. -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). - ## Deferred (Google Health Q3-2026 roadmap — slots exist, NOT in launch set) `blood-glucose` → `BLOOD_GLUCOSE`, `blood-pressure` → `BLOOD_PRESSURE_SYS`+`_DIA`, @@ -83,44 +95,62 @@ model before it can land — mapping it into `WRIST_TEMPERATURE` would store a d as an absolute temperature. ECG/IRN light up when Google ships the data types and a reader is added together with its Restricted scopes. -## Activity bundle — daily cumulative +## Activity bundle — daily cumulative (dailyRollUp) -Scope: `googlehealth.activity_and_fitness.readonly`. Each is a per-day summary -(one value per calendar day). The externalId carries the `stats:` daily-total -prefix — `stats::` — so a re-fetched day **overwrites** in -place rather than minting a duplicate, matching the Apple-Health -`stats::` daily-total overwrite contract. The running totals -(steps/distance/floors/active-energy) **preserve a 0** (a rest day is real data, -not a gap); VO2 max stays strictly positive (daily latest-wins). +Scope: `googlehealth.activity_and_fitness.readonly`. The four running totals +read through `POST :dailyRollUp` with `windowSizeDays: 1`; each aggregate window +carries `civilStartTime`/`civilEndTime` (CivilDateTime objects — +`{date:{year,month,day}, time:{…}}`) and a union keyed by the camelCase type +name with the `*Sum` rollup fields. The day key comes from +`civilStartTime.date`, anchored at UTC-midday. The externalId carries the +`stats:` daily-total prefix — `stats::` — so a re-fetched +day **overwrites** in place, matching the Apple-Health +`stats::` overwrite contract. The running totals **preserve a +0** (a rest day is real data, not a gap). -| Data type (path / filter) | MeasurementType | Unit | fieldTag | Note | -| ----------------------------------------------- | -------------------------- | ----------- | --------------- | ------------------------------------------------------------------- | -| `steps` / `steps` | `ACTIVITY_STEPS` | steps | `steps` | daily total; 0 valid | -| `distance` / `distance` | `WALKING_RUNNING_DISTANCE` | m | `distance` | metres (km → m when reported in km) | -| `active-energy-burned` / `active_energy_burned` | `ACTIVE_ENERGY_BURNED` | kcal | `active_energy` | **ACTIVE portion only** — NOT `total-calories` (which folds in BMR) | -| `floors` / `floors` | `FLIGHTS_CLIMBED` | flights | `floors` | daily total; 0 valid | -| `vo2-max` / `vo2_max` | `VO2_MAX` | mL/(kg·min) | `vo2_max` | daily latest-wins; strictly positive | +| Data type (path) | Rollup value read | MeasurementType | Unit | fieldTag | Note | +| ---------------------- | ---------------------------------------------- | -------------------------- | ------- | --------------- | ------------------------------------------------------------------- | +| `steps` | `steps.countSum` (int64 string) | `ACTIVITY_STEPS` | steps | `steps` | daily total; 0 valid | +| `distance` | `distance.millimetersSum` (int64 string) ÷1000 | `WALKING_RUNNING_DISTANCE` | m | `distance` | millimetres on the wire | +| `active-energy-burned` | `activeEnergyBurned.kcalSum` (number) | `ACTIVE_ENERGY_BURNED` | kcal | `active_energy` | **ACTIVE portion only** — NOT `total-calories` (which folds in BMR) | +| `floors` | `floors.countSum` (int64 string) | `FLIGHTS_CLIMBED` | flights | `floors` | daily total; 0 valid; **no list method** — rollup is the only read | + +VO2 max is NOT a rollup type — `daily-vo2-max` is a daily summary read via list +(`daily_vo2_max.date` filter), value `dailyVo2Max.vo2Max`, strictly positive, +daily latest-wins, same `stats:`-style per-day overwrite key (`vo2_max`). ## Sleep bundle -Scope: `googlehealth.sleep.readonly`. A sleep session carries per-stage segments -(stage label + start + end). HealthLog stores one `SLEEP_DURATION` row per stage -SEGMENT, `measuredAt = that segment's END instant`, harmonised onto the shared -`SleepStage` enum the night-total + hypnogram readers consume. externalId = -`:sleep_:` (session end ISO instant + indexed segment), -so a re-scored night overwrites in place. Stage map: `light → CORE` ("light" ↔ -Apple "core" shallow-NREM band), `deep → DEEP`, `rem → REM`, -`awake`/`wake`/`restless → AWAKE`, `in_bed → IN_BED`, classic `asleep → ASLEEP`. -Unknown stage labels are skipped, not mis-bucketed. +Scope: `googlehealth.sleep.readonly`. Sleep sessions filter **only** on +`sleep.interval.end_time` (a night is fetched when it ENDS after the cursor). +The payload: `sleep.interval` (SessionTimeInterval — `startTime`/`endTime` +RFC-3339), `sleep.type` (`CLASSIC | STAGES`), `sleep.stages[]` +(`{startTime, startUtcOffset, endTime, endUtcOffset, type}`), and +`sleep.summary` (`minutesAsleep`/`minutesAwake`, int64 strings). HealthLog +stores one `SLEEP_DURATION` row per stage SEGMENT, `measuredAt = that segment's +END instant`, harmonised onto the shared `SleepStage` enum the night-total + +hypnogram readers consume. externalId = `:sleep_:` +(session `interval.endTime` ISO instant + indexed segment), so a re-scored night +overwrites in place. Stage map (`SLEEP_STAGE_TYPE` enum, lowercased): +`LIGHT → CORE` ("light" ↔ Apple "core" shallow-NREM band), `DEEP → DEEP`, +`REM → REM`, `AWAKE`/`RESTLESS → AWAKE`, classic `ASLEEP → ASLEEP`. +`SLEEP_STAGE_TYPE_UNSPECIFIED` / unknown labels are skipped, not mis-bucketed. ## Exercise bundle — Workouts -Scope: `googlehealth.activity_and_fitness.readonly`. Each exercise session → one -`Workout` row (NOT a Measurement), keyed `(userId, source: "GOOGLE_HEALTH", -externalId)` where externalId is the session id (or `exercise:` when -absent). Fields: sportType (Google activity type → canonical `WorkoutSportType`, -`other` fallback), startedAt/endedAt, durationSec, totalEnergyKcal (active session -energy), totalDistanceM, avg/max/min HR (optional). A Google Health run and the +Scope: `googlehealth.activity_and_fitness.readonly`. Sessions filter **only** on +`exercise.interval.civil_start_time` with an offset-less civil bound (derived +from the watermark in the user's zone). Each exercise session → one `Workout` +row (NOT a Measurement), keyed `(userId, source: "GOOGLE_HEALTH", externalId)` +where externalId is the DataPoint's top-level `name` resource name (there is no +session-id field; `exercise:` when absent). Fields: sportType +(`exercise.exerciseType` UPPERCASE enum → canonical `WorkoutSportType`, `other` +fallback), startedAt/endedAt from `exercise.interval.startTime/endTime` +(RFC-3339), durationSec, totalEnergyKcal +(`exercise.metricsSummary.caloriesKcal`), totalDistanceM +(`exercise.metricsSummary.distanceMillimeters` ÷ 1000), avgHeartRate +(`exercise.metricsSummary.averageHeartRateBeatsPerMinute`, int64 string). +metricsSummary carries **no max/min HR** → null. A Google Health run and the same run via Apple Health / WHOOP stay distinct rows; the read-time `pickCanonicalWorkoutRows` picker collapses the cross-source twin per the user's source ladder. @@ -133,3 +163,21 @@ rows: `externalId = :`. Daily cumulative activity rows: Workouts: `(userId, source: "GOOGLE_HEALTH", externalId)` on the `Workout` table. A re-fetch of the same window (daily summaries re-roll after the fact, so the incremental overlap is 24 h) overwrites in place. + +## Error surfacing + +Non-2xx Google Health bodies carry the AIP-193 envelope +(`{"error":{code,message,status}}`). The client extracts a redacted +`STATUS: message` detail (message capped at 200 chars, bearer tokens and URL +query strings stripped) into `GoogleHealthApiError.upstreamError` and the +external-call annotation, so a field-grammar 400 is diagnosable from operator +logs. The OAuth token endpoint uses the flat `{"error":"invalid_grant"}` shape +instead — handled separately in `postToken`. + +## Structure probe + +`POST /api/integrations/google-health/test` with body +`{"probe":"structure"}` fetches ONE recent page/window per data type and returns +only the JSON **structure** (field names + `typeof` leaves — never values), plus +the per-type error verdict on failure. Self-hoster diagnostics for payload-shape +drift. diff --git a/src/lib/google-health/sync-activity.ts b/src/lib/google-health/sync-activity.ts index 974a4f56b..a2f9f45eb 100644 --- a/src/lib/google-health/sync-activity.ts +++ b/src/lib/google-health/sync-activity.ts @@ -5,19 +5,21 @@ * `activity_and_fitness.readonly` Restricted bundle and upserts each mapped * daily total as `source = GOOGLE_HEALTH`: * - * - steps → ACTIVITY_STEPS (count) - * - distance → WALKING_RUNNING_DISTANCE (metres) - * - active-energy-burned→ ACTIVE_ENERGY_BURNED (kcal — ACTIVE portion only) - * - floors → FLIGHTS_CLIMBED (count) - * - vo2-max → VO2_MAX (mL/(kg·min); daily latest-wins) + * - steps → ACTIVITY_STEPS (countSum) + * - distance → WALKING_RUNNING_DISTANCE (millimetersSum → m) + * - active-energy-burned → ACTIVE_ENERGY_BURNED (kcalSum — ACTIVE portion only) + * - floors → FLIGHTS_CLIMBED (countSum) + * - daily-vo2-max → VO2_MAX (mL/(kg·min); daily latest-wins) * - * These are per-day summaries (one value per calendar day). The externalId is - * minted with the `stats:` daily-total prefix — `stats::` - * — so a re-fetched day OVERWRITES the existing row rather than minting a - * duplicate, matching the Apple-Health `stats::` overwrite - * contract. A day of rest legitimately records 0 steps / 0 floors / 0 active - * kcal, so the cumulative mappers preserve a zero; VO2 max stays strictly - * positive. + * The four cumulative types read through `POST :dailyRollUp` with + * `windowSizeDays: 1` (their `list` surface returns minute-grain buckets, not + * daily totals — and floors has no list method at all); VO2 max is a daily + * summary read through list with a `.date` filter. The externalId is minted + * with the `stats:` daily-total prefix — `stats::` — so a + * re-fetched day OVERWRITES the existing row rather than minting a duplicate, + * matching the Apple-Health `stats::` overwrite contract. A day + * of rest legitimately records 0 steps / 0 floors / 0 active kcal, so the + * rollup mapper preserves a zero; VO2 max stays strictly positive. * * A per-data-class 403 soft-skips THAT class (returns 0, leaves the connection * connected) — the Restricted bundles are granted independently. @@ -26,6 +28,7 @@ import { GOOGLE_HEALTH_DATA_TYPES, type GoogleHealthDataType, type GoogleHealthMappedMeasurement, + fetchDailyRollUp, fetchDataPoints, mapActiveEnergy, mapDistance, @@ -46,14 +49,12 @@ import { resolveUserTimezone } from "@/lib/tz/resolver"; /** One mappable activity metric: its data-type encoding + the mapper + a verb. */ interface ActivityResource { dataType: GoogleHealthDataType; - map: ( - point: Record, - tz?: string, - ) => GoogleHealthMappedMeasurement[]; + map: (point: Record) => GoogleHealthMappedMeasurement[]; verb: string; } -const ACTIVITY_RESOURCES: ActivityResource[] = [ +/** The four cumulative daily totals — read via `POST :dailyRollUp`. */ +const ROLLUP_RESOURCES: ActivityResource[] = [ { dataType: GOOGLE_HEALTH_DATA_TYPES.steps, map: mapSteps, @@ -74,11 +75,6 @@ const ACTIVITY_RESOURCES: ActivityResource[] = [ map: mapFloors, verb: "fetchFloors", }, - { - dataType: GOOGLE_HEALTH_DATA_TYPES.vo2Max, - map: mapVo2Max, - verb: "fetchVo2Max", - }, ]; /** @@ -98,10 +94,10 @@ export async function syncUserActivity( const tokenInfo = await getValidToken(userId); if (!tokenInfo) return 0; - // A cumulative daily total whose `interval.start_time` sits at the user's - // local midnight can carry an offset-less civil anchor; resolve the user's - // stored zone so the day-key lands on the correct civil day rather than the - // process zone's. + // The dailyRollUp request range is civil and user-local; resolve the user's + // stored zone so the range bounds land on the correct civil days rather than + // the process zone's. (The response day-key comes from each window's own + // `civilStartTime.date`, tz-independent.) const tz = await resolveUserTimezone(userId); // Cycle-wide watermark snapshotted once by `syncUserGoogleHealth`; undefined @@ -109,14 +105,14 @@ export async function syncUserActivity( const start = opts.start; let imported = 0; - for (const resource of ACTIVITY_RESOURCES) { + for (const resource of ROLLUP_RESOURCES) { let points: Record[]; try { - points = await fetchDataPoints( + points = await fetchDailyRollUp( resource.dataType, tokenInfo.accessToken, resource.verb, - { start }, + { start, tz }, ); } catch (err) { imported += await handleCollectionFetchError(resource.verb, userId, err); @@ -125,7 +121,7 @@ export async function syncUserActivity( const readings: GoogleHealthMeasurementUpsert[] = []; for (const point of points) { - for (const m of resource.map(point, tz)) { + for (const m of resource.map(point)) { readings.push({ type: m.type, value: m.value, @@ -142,6 +138,35 @@ export async function syncUserActivity( ).imported; } + // VO2 max — a daily summary (list + `.date` filter), not a rollup type. + try { + const points = await fetchDataPoints( + GOOGLE_HEALTH_DATA_TYPES.vo2Max, + tokenInfo.accessToken, + "fetchVo2Max", + { start }, + ); + const readings: GoogleHealthMeasurementUpsert[] = []; + for (const point of points) { + for (const m of mapVo2Max(point)) { + readings.push({ + type: m.type, + value: m.value, + unit: m.unit, + measuredAt: m.measuredAt, + externalId: externalIdFor(m), + }); + } + } + imported += ( + await upsertGoogleHealthMeasurements(userId, readings, { + deferRollup: opts.deferRollup, + }) + ).imported; + } catch (err) { + imported += await handleCollectionFetchError("fetchVo2Max", userId, err); + } + // `markSynced` is owned by the orchestrator (`syncUserGoogleHealth`). annotate({ action: { name: "googleHealth.activity.sync", details: { imported } }, diff --git a/src/lib/google-health/sync-metrics.ts b/src/lib/google-health/sync-metrics.ts index 36e105966..666d65a67 100644 --- a/src/lib/google-health/sync-metrics.ts +++ b/src/lib/google-health/sync-metrics.ts @@ -5,14 +5,14 @@ * `health_metrics_and_measurements.readonly` Restricted bundle and upserts each * mapped reading as `source = GOOGLE_HEALTH`: * - * - weight → WEIGHT - * - body-fat → BODY_FAT - * - oxygen-saturation → OXYGEN_SATURATION - * - heart-rate-variability → HEART_RATE_VARIABILITY (SDNN slot; see mapper) - * - daily-resting-HR → RESTING_HEART_RATE - * - 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 + * - 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 @@ -30,7 +30,7 @@ import { mapBodyFat, mapHeartRate, mapHeartRateVariability, - mapHeightCm, + mapHeight, mapOxygenSaturation, mapRespiratoryRate, mapRestingHeartRate, @@ -160,11 +160,20 @@ export async function syncUserMetrics( await handleCollectionFetchError("fetchHeight", userId, err); } - // Latest non-null parsed height wins (the points are time-ordered ascending). + // Latest sample wins — picked by max(sampledAt) explicitly, because the + // list response is ordered DESCENDING by time (a "last row wins" loop would + // seed the OLDEST height). A sample with no parseable instant only wins + // over nothing. let heightCm: number | null = null; + let heightAt = -Infinity; for (const point of heightPoints) { - const cm = mapHeightCm(point); - if (cm !== null) heightCm = cm; + const sample = mapHeight(point); + if (sample === null) continue; + const at = sample.sampledAt?.getTime() ?? -Infinity; + if (heightCm === null || at > heightAt) { + heightCm = sample.cm; + heightAt = at; + } } if (heightCm !== null) { const user = await prisma.user.findUnique({ diff --git a/src/lib/google-health/sync-workout.ts b/src/lib/google-health/sync-workout.ts index cd4ead5f9..b49aee117 100644 --- a/src/lib/google-health/sync-workout.ts +++ b/src/lib/google-health/sync-workout.ts @@ -37,7 +37,10 @@ export async function syncUserWorkout( if (!tokenInfo) return 0; // Session start/end can arrive offset-less; anchor them against the user's - // stored zone rather than the process zone. + // stored zone rather than the process zone. The zone also shapes the + // incremental filter: exercise sessions filter on + // `exercise.interval.civil_start_time` with an offset-less civil bound, which + // must be the watermark's wall clock in the USER'S zone. const tz = await resolveUserTimezone(userId); // Cycle-wide watermark snapshotted once by `syncUserGoogleHealth`; undefined @@ -50,7 +53,7 @@ export async function syncUserWorkout( GOOGLE_HEALTH_DATA_TYPES.exercise, tokenInfo.accessToken, "fetchExercise", - { start, pageSize: GOOGLE_HEALTH_ACTIVITY_PAGE_SIZE }, + { start, pageSize: GOOGLE_HEALTH_ACTIVITY_PAGE_SIZE, tz }, ); } catch (err) { return handleCollectionFetchError("fetchExercise", userId, err);