From 295a6a8050e9de7e3706eb659e4af881db339552 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 5 Jul 2026 13:58:50 +0200 Subject: [PATCH] fix(google-health): parse the camelCase v4 payloads and correct the read methods The v4 client read none of its documented response shapes: the DataPoint value is a camelCase union keyed by the camelCase type name with camelCase nested times, proto3 int64 fields arrive as JSON strings, and four fetchers targeted wrong data types or illegal filter fields. Every HTTP-200 type parsed zero measurements and four fetchers returned HTTP 400. - Read every value/time path in camelCase; keep snake_case only inside the filter parameter. Pin path/filter/payload-key triples per data type. - Coerce int64 JSON strings in every numeric extractor. - weight.weightGrams -> kg, height.heightMeters -> cm, distance millimetres -> m, bodyFat.percentage, heartRate.beatsPerMinute. - Move SpO2, HRV, respiratory rate and VO2 max to their daily-* data types (the bare sample types reject a .date filter). - Move steps, distance, active energy and floors to POST :dailyRollUp with windowSizeDays 1 (list returns minute-grain buckets, not daily totals; floors has no list method), chunked at 90 civil days, day-keyed on civilStartTime.date at the UTC-midday anchor. - Filter sleep on interval.end_time (the only legal sleep time field) and anchor sessions on interval.endTime; filter exercise on interval.civil_start_time with an offset-less civil bound in the user's zone; read exercise metrics from metricsSummary and take the workout id from the DataPoint name. - Seed height from max(sampleTime) explicitly - list order is descending, so last-row-wins picked the oldest sample. - Rewrite the mapper fixtures to the documented shapes. --- .../google-health/__tests__/mappers.test.ts | 454 ++++++-- src/lib/google-health/client.ts | 984 ++++++++++-------- src/lib/google-health/sync-activity.ts | 85 +- src/lib/google-health/sync-metrics.ts | 33 +- src/lib/google-health/sync-workout.ts | 7 +- 5 files changed, 998 insertions(+), 565 deletions(-) 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..8eb755520 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, @@ -382,18 +382,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 +413,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 +585,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,16 +631,25 @@ 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`, + ); } } @@ -551,7 +657,8 @@ export function incrementalFilter( * 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 +675,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); @@ -610,6 +721,185 @@ 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); + 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 : verdict.reason, + }); + if (verdict.classification !== "success") { + throw new GoogleHealthApiError({ + verb, + classification: verdict.classification, + httpStatus: verdict.httpStatus, + reason: verdict.reason, + }); + } + + 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 +911,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 +955,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 +1053,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 +1072,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 +1096,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 +1135,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 +1157,7 @@ export function mapBodyFat( type: "BODY_FAT", unit: "%", fieldTag: "body_fat", - valuePaths: valuePaths(dt.filter, "percentage"), + valuePaths: [`${dt.key}.percentage`], }); } @@ -902,10 +1169,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 +1177,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 +1194,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 +1207,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 +1220,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 +1317,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 +1454,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 +1549,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 +1562,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 +1575,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 +1627,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 +1665,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 +1715,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/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);