From 422a6a7df078b416670228a2d0d9d639abda55be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 3 Jul 2026 14:41:44 +0200 Subject: [PATCH] fix(google-health): correct day-keying, timezone, and skin-temperature handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tighten the transport so device data lands on the right day and in the right slot. - Cumulative daily totals (steps, distance, energy, floors) now key their measuredAt and stats:: external id off the interval's civil start date anchored at UTC-midday, not the physical instant. Keying off the physical instant put a positive-UTC-offset user's day one calendar day early and split the same local day across the Google, Apple, and Fitbit sources so neither won the per-day source pick. - Thread the user's timezone through the sleep, workout, and interval parses so an offset-less wall-clock timestamp resolves in the user's zone rather than the process zone — the same fix the classic Fitbit sleep path already carries. - Drop skin temperature from the launch set. Google reports a signed nightly deviation from baseline, not an absolute reading, so mapping it into the absolute wrist-temperature slot stored a delta as a temperature and dropped every cold-night negative. It returns once a signed-delta model exists. - Remove the unused daily-rollup read path and field-map, and the experimental ECG and irregular-rhythm scopes that had no reader — the connect flow now requests exactly the four core scopes it uses. --- .../google-health/__tests__/mappers.test.ts | 28 + .../google-health/__tests__/scopes.test.ts | 50 +- src/lib/google-health/client.ts | 631 +++++++----------- src/lib/google-health/mapping.md | 81 ++- src/lib/google-health/sync-activity.ts | 14 +- src/lib/google-health/sync-metrics.ts | 10 +- src/lib/google-health/sync-sleep.ts | 8 +- src/lib/google-health/sync-workout.ts | 7 +- src/lib/validations/source-priority.ts | 11 +- 9 files changed, 335 insertions(+), 505 deletions(-) diff --git a/src/lib/google-health/__tests__/mappers.test.ts b/src/lib/google-health/__tests__/mappers.test.ts index ce044e25f..8c5f97abf 100644 --- a/src/lib/google-health/__tests__/mappers.test.ts +++ b/src/lib/google-health/__tests__/mappers.test.ts @@ -101,6 +101,34 @@ describe("mapSteps — daily cumulative total", () => { }); }); +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"); + }); +}); + describe("mapDistance — unit conversion", () => { it("converts a kilometers daily total to metres", () => { const rows = mapDistance({ diff --git a/src/lib/google-health/__tests__/scopes.test.ts b/src/lib/google-health/__tests__/scopes.test.ts index 8c69cf462..824511b9d 100644 --- a/src/lib/google-health/__tests__/scopes.test.ts +++ b/src/lib/google-health/__tests__/scopes.test.ts @@ -1,28 +1,13 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { describe, expect, it } from "vitest"; import { GOOGLE_HEALTH_CORE_SCOPES, - GOOGLE_HEALTH_EXPERIMENTAL_SCOPES, - experimentalScopesEnabled, getGoogleHealthScopeString, resolveGoogleHealthScopes, } from "../client"; describe("Google Health scope resolver", () => { - const KEY = "GOOGLE_HEALTH_EXPERIMENTAL_SCOPES"; - let prev: string | undefined; - - beforeEach(() => { - prev = process.env[KEY]; - delete process.env[KEY]; - }); - afterEach(() => { - if (prev === undefined) delete process.env[KEY]; - else process.env[KEY] = prev; - }); - - it("ships exactly the four core Restricted read scopes by default", () => { - delete process.env[KEY]; + it("ships exactly the four core Restricted read scopes", () => { const scopes = resolveGoogleHealthScopes(); expect(scopes).toEqual([...GOOGLE_HEALTH_CORE_SCOPES]); expect(scopes).toHaveLength(4); @@ -34,37 +19,14 @@ describe("Google Health scope resolver", () => { } }); - it("keeps the ECG/IRN experimental scopes off unless opted in", () => { - delete process.env[KEY]; - expect(experimentalScopesEnabled()).toBe(false); - const scopes = resolveGoogleHealthScopes(); - for (const ex of GOOGLE_HEALTH_EXPERIMENTAL_SCOPES) { - expect(scopes).not.toContain(ex); - } - }); - - it('adds ECG + IRN when the flag is "true" or "1"', () => { - for (const flag of ["true", "1"]) { - process.env[KEY] = flag; - expect(experimentalScopesEnabled()).toBe(true); - const scopes = resolveGoogleHealthScopes(); - expect(scopes).toEqual([ - ...GOOGLE_HEALTH_CORE_SCOPES, - ...GOOGLE_HEALTH_EXPERIMENTAL_SCOPES, - ]); - expect(scopes).toHaveLength(6); - } - }); - - it("treats any other flag value as off", () => { - for (const flag of ["", "false", "yes", "0"]) { - process.env[KEY] = flag; - expect(experimentalScopesEnabled()).toBe(false); + it("requests no ECG/IRN Restricted scope (no reader consumes them yet)", () => { + for (const s of resolveGoogleHealthScopes()) { + expect(s).not.toContain("googlehealth.ecg"); + expect(s).not.toContain("googlehealth.irn"); } }); it("joins the resolved scopes with a single space for the authorize request", () => { - delete process.env[KEY]; expect(getGoogleHealthScopeString()).toBe( GOOGLE_HEALTH_CORE_SCOPES.join(" "), ); diff --git a/src/lib/google-health/client.ts b/src/lib/google-health/client.ts index 3a6f5c69e..3ebc413a9 100644 --- a/src/lib/google-health/client.ts +++ b/src/lib/google-health/client.ts @@ -29,6 +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 { GoogleHealthApiError, classifyGoogleHealthResponse, @@ -137,35 +138,13 @@ export const GOOGLE_HEALTH_CORE_SCOPES = [ ] as const; /** - * Optional Pixel-Watch clinical scopes (ECG + irregular-rhythm notifications). - * Kept defined but OFF by default: they widen the Restricted-scope review - * surface and only apply to Pixel Watch sources, so they are opt-in behind the - * `GOOGLE_HEALTH_EXPERIMENTAL_SCOPES` env flag until they land cleanly. Both are - * read-only categories (no `.writeonly` variant exists upstream). - */ -export const GOOGLE_HEALTH_EXPERIMENTAL_SCOPES = [ - "https://www.googleapis.com/auth/googlehealth.ecg.readonly", - "https://www.googleapis.com/auth/googlehealth.irn.readonly", -] as const; - -/** - * True when the operator has opted into the experimental ECG/IRN scopes via - * `GOOGLE_HEALTH_EXPERIMENTAL_SCOPES=true`. Read lazily so tests can toggle it - * per case and so a deploy can flip it without a rebuild. - */ -export function experimentalScopesEnabled(): boolean { - const raw = process.env.GOOGLE_HEALTH_EXPERIMENTAL_SCOPES; - return raw === "true" || raw === "1"; -} - -/** - * Resolve the scope list HealthLog requests: the four core Restricted bundles, - * plus the ECG/IRN bundles only when the experimental flag is set. + * Resolve the scope list HealthLog requests: the four core Restricted bundles. + * ECG/IRN are a future enhancement — add those two Restricted read scopes only + * together with an ECG/IRN reader, never before (Google penalizes requesting a + * Restricted scope the app never consumes). */ export function resolveGoogleHealthScopes(): string[] { - return experimentalScopesEnabled() - ? [...GOOGLE_HEALTH_CORE_SCOPES, ...GOOGLE_HEALTH_EXPERIMENTAL_SCOPES] - : [...GOOGLE_HEALTH_CORE_SCOPES]; + return [...GOOGLE_HEALTH_CORE_SCOPES]; } /** The space-separated scope string sent on the authorize request. */ @@ -413,12 +392,8 @@ export function resolveGoogleHealthUserId( // // 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. -// -// READ METHOD: most data types support `:list`. Two types — -// `total-calories` and `calories-in-heart-rate-zone` — support ONLY the roll-up -// surface (no `:list`); they carry `readMethod: "dailyRollUp"` and route through -// the single-day roll-up walker instead. Every other type stays on `:list`. +// pins both forms so a fetcher can never encode the wrong one. Every launch +// data type is read through `dataPoints.list`. /** * Page-size ceiling for `dataPoints.list`. The daily/intraday reads default to @@ -433,8 +408,7 @@ 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. `readMethod` selects the read surface (`list` by default; - * `dailyRollUp` for the two roll-up-only types). + * time anchor. Every launch data type reads through `dataPoints.list`. */ export interface GoogleHealthDataType { /** kebab-case segment for the request path. */ @@ -445,19 +419,14 @@ export interface GoogleHealthDataType { * 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 / calories / - * floors, sleep, exercise), anchored on `{type}.interval.start_time` - * (the physical instant) with `{type}.interval.civil_start_time` as the - * civil fallback — NOT a `sample_time` (which 400s/empties for interval - * types and stalls the incremental filter) and NOT a bare `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`. */ timeField: "sample" | "date" | "interval"; - /** - * Which read surface to use. Defaults to `list`. `total-calories` and - * `calories-in-heart-rate-zone` are roll-up-only (no `:list`) and MUST use - * `dailyRollUp` — the list walker refuses them. - */ - readMethod?: "list" | "dailyRollUp"; } /** @@ -490,11 +459,10 @@ export const GOOGLE_HEALTH_DATA_TYPES = { }, heartRate: { path: "heart-rate", filter: "heart_rate", timeField: "sample" }, height: { path: "height", filter: "height", timeField: "sample" }, - sleepTemperature: { - path: "daily-sleep-temperature-derivations", - filter: "daily_sleep_temperature_derivations", - timeField: "date", - }, + // 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` @@ -516,24 +484,6 @@ export const GOOGLE_HEALTH_DATA_TYPES = { // 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" }, - // ── Roll-up-only types (no `:list`) ─────────────────────────── - // `total-calories` and `calories-in-heart-rate-zone` support ONLY the roll-up - // surface. Defined here so the read layer routes them through `:dailyRollUp` - // and never accidentally `:list`s them; not yet wired to a Measurement mapper - // (active energy already covers the active-calories slot; total-calories folds - // in BMR and needs a modelling decision before it lands as a metric). - totalCalories: { - path: "total-calories", - filter: "total_calories", - timeField: "interval", - readMethod: "dailyRollUp", - }, - caloriesInHeartRateZone: { - path: "calories-in-heart-rate-zone", - filter: "calories_in_heart_rate_zone", - timeField: "interval", - readMethod: "dailyRollUp", - }, // ── 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 @@ -558,17 +508,6 @@ interface GoogleHealthDataPointPage { nextPageToken?: string | null; } -/** - * `dataPoints:dailyRollUp` envelope. The response shape is not fully published; - * both `dataPoints` and `rollupDataPoints` are tolerated so a live-verified - * shape doesn't require a code change here. (OPEN — confirm at build.) - */ -interface GoogleHealthDailyRollUpPage { - dataPoints?: GoogleHealthDataPoint[]; - rollupDataPoints?: GoogleHealthDataPoint[]; - nextPageToken?: string | null; -} - interface DataPointQuery { /** Lower-bound incremental cursor; omitted on a full backfill. */ start?: Date; @@ -576,14 +515,8 @@ interface DataPointQuery { pageSize?: number; /** Hard ceiling on pages walked (defence against a runaway cursor). */ maxPages?: number; - /** Hard ceiling on days walked for a `:dailyRollUp` read. */ - maxDays?: number; } -/** Default backfill horizon (days) when a roll-up read has no incremental start. */ -const DAILY_ROLLUP_BACKFILL_DAYS = 90; -const MS_PER_DAY = 24 * 60 * 60 * 1000; - /** * Build the incremental `filter` field + bound for one data type. Centralised * so the predicate and the read-time anchor resolution can never drift. @@ -615,11 +548,10 @@ export function incrementalFilter( } /** - * Walk every `DataPoint` for one data type since the incremental cursor. - * Routes by `readMethod`: `list` types walk `dataPoints.list` with - * `nextPageToken` pagination; the two roll-up-only types walk `:dailyRollUp` - * one day at a time. 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. + * 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. */ export async function fetchDataPoints( dataType: GoogleHealthDataType, @@ -627,10 +559,6 @@ export async function fetchDataPoints( verb: string, query: DataPointQuery = {}, ): Promise { - if (dataType.readMethod === "dailyRollUp") { - return fetchDailyRollUp(dataType, accessToken, verb, query); - } - const points: GoogleHealthDataPoint[] = []; let pageToken: string | null | undefined; let pageCount = 0; @@ -682,79 +610,6 @@ export async function fetchDataPoints( return points; } -/** - * Walk `dataPoints:dailyRollUp` one civil day at a time for a roll-up-only data - * type (`total-calories`, `calories-in-heart-rate-zone`). Those types have no - * `:list`; the daily-summary endpoint returns one aggregate per day. Bounded by - * `maxDays` (default 90) and by the incremental `start` when present. - * - * The exact request/response shape of `:dailyRollUp` is not fully published - * (OPEN — confirm at build); this reads defensively and tolerates either a - * `dataPoints` or a `rollupDataPoints` array so a live-verified shape needs no - * code change. The per-day `date` filter is the documented single-day selector. - */ -export async function fetchDailyRollUp( - dataType: GoogleHealthDataType, - accessToken: string, - verb: string, - query: DataPointQuery = {}, -): Promise { - const points: GoogleHealthDataPoint[] = []; - const maxDays = query.maxDays ?? DAILY_ROLLUP_BACKFILL_DAYS; - const now = Date.now(); - const startMs = query.start - ? query.start.getTime() - : now - maxDays * MS_PER_DAY; - - let dayCount = 0; - for ( - let cursor = startMs; - cursor <= now && dayCount < maxDays; - cursor += MS_PER_DAY, dayCount += 1 - ) { - const day = new Date(cursor).toISOString().slice(0, 10); - const params = new URLSearchParams({ - // The daily-summary selector: the single civil day this roll-up covers. - filter: `${dataType.filter}.date = "${day}"`, - }); - - const dayStart = performance.now(); - const res = await safeFetch( - `${GOOGLE_HEALTH_API_BASE}/users/me/dataTypes/${dataType.path}/dataPoints:dailyRollUp?${params}`, - { - method: "GET", - headers: { Authorization: `Bearer ${accessToken}` }, - }, - ); - - const json = (await res - .json() - .catch(() => null)) as GoogleHealthDailyRollUpPage | null; - const verdict = classifyGoogleHealthResponse(res.status); - getEvent()?.addExternalCall({ - service: "google-health", - method: `${verb}(day=${dayCount})`, - duration_ms: Math.round(performance.now() - dayStart), - 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?.dataPoints ?? json?.rollupDataPoints ?? []) { - points.push(p); - } - } - - return points; -} - // ─── Field → Measurement mapping ─────────────────────────────── // The single source of truth is `mapping.md` — keep both in sync when adding // entries. @@ -846,12 +701,71 @@ function parseCivilDateObject(val: unknown): Date | null { return null; } +/** + * Parse a Google civil START anchor — the calendar day a cumulative daily total + * belongs to — into a UTC-midday Date, or null. Accepts a `{year,month,day}` + * object OR a civil string (`YYYY-MM-DD`, optionally carrying a time suffix like + * `2026-06-02T00:00:00`); only the Y-M-D is kept, anchored at UTC midday so a + * timezone shift can't roll the civil day. Mirrors the Fitbit `parseCivilDate` + * UTC-midday convention. + */ +function parseCivilStart(val: unknown): Date | null { + if (typeof val === "string") { + const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(val.trim()); + if (!m) return null; + return new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]), 12)); + } + return parseCivilDateObject(val); +} + +/** + * Matches an offset-less local ISO wall-clock string (`2026-06-02T03:02:30` / + * `...T03:02:30.000`). No trailing `Z`, no `±hh:mm` — those denote an absolute + * instant and are honoured verbatim. + */ +const OFFSET_LESS_LOCAL_ISO = + /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?(?:\.\d+)?$/; + +/** + * Parse a Google timestamp into a UTC instant. Sleep segments + exercise + * sessions can emit LOCAL wall-clock strings WITHOUT an offset — the night / + * session belongs to the user's local clock, so an offset-less string is + * resolved against the USER'S timezone, not the process zone (a bare + * `new Date(iso)` parses an offset-less string in the host zone, which shifts a + * non-UTC user by their offset and can flip a near-midnight wake-day). When `tz` + * is omitted the host-local fallback preserves the prior behaviour. Strings that + * DO carry an offset/`Z` are absolute and parsed as-is. Mirrors the Fitbit + * `parseLocalInstant`. Returns null on a miss. + */ +function parseLocalInstant(iso: string, tz?: string): Date | null { + const m = OFFSET_LESS_LOCAL_ISO.exec(iso.trim()); + if (m) { + return zonedWallClockToUtc( + { + year: Number(m[1]), + month: Number(m[2]), + day: Number(m[3]), + hour: Number(m[4]), + minute: Number(m[5]), + second: m[6] ? Number(m[6]) : 0, + }, + tz, + ); + } + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? null : d; +} + /** * Resolve a `DataPoint`'s measurement timestamp. - * - `sample` → `sample_time.physical_time` (a spot ISO instant). - * - `interval` → `interval.start_time` (the bucket's start ISO instant), with - * `interval.civil_start_time` (a civil string / `{year,month,day}`) as the - * fallback. + * - `sample` → `sample_time.physical_time` (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). * Falls back to `fallback` only when nothing parses, so a row is never dropped * for a missing anchor. @@ -860,29 +774,28 @@ function resolveMeasuredAt( point: GoogleHealthDataPoint, dataType: GoogleHealthDataType, fallback: Date, + tz?: string, ): Date { if (dataType.timeField === "sample") { const t = readPath(point, `${dataType.filter}.sample_time.physical_time`); if (typeof t === "string") { - const d = new Date(t); - if (!Number.isNaN(d.getTime())) return d; + const d = parseLocalInstant(t, tz); + if (d) return d; } } else if (dataType.timeField === "interval") { - const t = readPath(point, `${dataType.filter}.interval.start_time`); - if (typeof t === "string") { - const d = new Date(t); - if (!Number.isNaN(d.getTime())) return d; - } + // Civil day first — the day the total belongs to, tz-invariant at UTC-midday. const civil = readPath( point, `${dataType.filter}.interval.civil_start_time`, ); - if (typeof civil === "string") { - const d = new Date(civil); - if (!Number.isNaN(d.getTime())) return d; + 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; } - const civilObj = parseCivilDateObject(civil); - if (civilObj) return civilObj; } else { const dateVal = readPath(point, `${dataType.filter}.date`); if (typeof dateVal === "string") { @@ -903,8 +816,9 @@ function resolveMeasuredAt( function externalAnchor( point: GoogleHealthDataPoint, dataType: GoogleHealthDataType, + tz?: string, ): string { - const at = resolveMeasuredAt(point, dataType, new Date(0)); + 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 @@ -1053,25 +967,6 @@ export function mapHeartRate( }); } -export function mapSleepTemperature( - point: GoogleHealthDataPoint, -): GoogleHealthMappedMeasurement[] { - const dt = GOOGLE_HEALTH_DATA_TYPES.sleepTemperature; - // Google surfaces a sleeping skin/wrist temperature derivation. - // `WRIST_TEMPERATURE` is the closest semantic slot (Apple sleeping-wrist-temp). - // Confirm absolute-vs-baseline at build; the guard rejects a non-positive - // (baseline-delta) reading. - return mapSimple(point, dt, { - type: "WRIST_TEMPERATURE", - unit: "celsius", - fieldTag: "wrist_temp", - valuePaths: [ - ...valuePaths(dt.filter, "nightly_temperature_celsius"), - ...valuePaths(dt.filter, "celsius"), - ], - }); -} - /** * 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 @@ -1088,127 +983,6 @@ export function mapHeightCm(point: GoogleHealthDataPoint): number | null { return null; } -/** - * Field→Measurement mapping table (mirror of `mapping.md`). Documents which - * Google Health source field becomes which MeasurementType + unit, and pins the - * kebab-path / snake-filter pair for each. Used as the single-glance reference - * and by the mapper tests; the mappers above are the executable form. - */ -export const GOOGLE_HEALTH_FIELD_MAP: Record< - string, - { type: string; unit: string; path: string; filter: string; note?: string } -> = { - weight: { - type: "WEIGHT", - unit: "kg", - path: "weight", - filter: "weight", - note: "picker ranks a real Withings scale above Google Health", - }, - bodyFat: { - type: "BODY_FAT", - unit: "%", - path: "body-fat", - filter: "body_fat", - }, - oxygenSaturation: { - type: "OXYGEN_SATURATION", - unit: "%", - path: "oxygen-saturation", - filter: "oxygen_saturation", - }, - heartRateVariability: { - type: "HEART_RATE_VARIABILITY", - unit: "ms", - path: "heart-rate-variability", - filter: "heart_rate_variability", - note: "SDNN slot (Apple-comparable), NOT HRV_RMSSD; confirm estimator at build", - }, - restingHeartRate: { - type: "RESTING_HEART_RATE", - unit: "bpm", - path: "daily-resting-heart-rate", - filter: "daily_resting_heart_rate", - }, - respiratoryRate: { - type: "RESPIRATORY_RATE", - unit: "breaths/min", - path: "respiratory-rate", - filter: "respiratory_rate", - }, - heartRate: { - type: "PULSE", - unit: "bpm", - path: "heart-rate", - filter: "heart_rate", - note: "intraday spot HR", - }, - height: { - type: "User.heightCm", - unit: "cm", - path: "height", - filter: "height", - note: "profile seed — written to User.heightCm only when null, never as a Measurement", - }, - sleepTemperature: { - type: "WRIST_TEMPERATURE", - unit: "celsius", - path: "daily-sleep-temperature-derivations", - filter: "daily_sleep_temperature_derivations", - note: "overnight wrist-temp; confirm absolute-vs-baseline at build", - }, - // ── Activity bundle — daily cumulative ────────────────────────── - steps: { - type: "ACTIVITY_STEPS", - unit: "steps", - path: "steps", - filter: "steps", - note: "daily total; stats: externalId overwrites on re-fetch; 0 is a valid rest day", - }, - distance: { - type: "WALKING_RUNNING_DISTANCE", - unit: "m", - path: "distance", - filter: "distance", - note: "daily total metres; stats: externalId overwrites on re-fetch", - }, - activeEnergy: { - type: "ACTIVE_ENERGY_BURNED", - unit: "kcal", - path: "active-energy-burned", - filter: "active_energy_burned", - note: "ACTIVE portion only (NOT total-calories, which folds in BMR); stats: externalId overwrites on re-fetch", - }, - floors: { - type: "FLIGHTS_CLIMBED", - unit: "flights", - path: "floors", - filter: "floors", - note: "daily total floors; stats: externalId overwrites on re-fetch", - }, - vo2Max: { - type: "VO2_MAX", - unit: "mL/(kg·min)", - path: "vo2-max", - filter: "vo2_max", - note: "daily latest-wins; daily-anchor externalId overwrites on re-fetch", - }, - sleep: { - type: "SLEEP_DURATION", - unit: "minutes", - path: "sleep", - filter: "sleep", - note: "per-stage rows (IN_BED/AWAKE/REM/CORE/DEEP); measuredAt = stage END", - }, - exercise: { - type: "Workout", - unit: "—", - path: "exercise", - filter: "exercise", - note: "exercise session → Workout row (NOT a Measurement); cross-source dedup at read time", - }, -}; - // ─── Activity mappers: daily cumulative ──────────────────────── /** Finite + non-negative guard — cumulative metrics admit a legitimate 0. */ @@ -1254,6 +1028,7 @@ function mapDailyCumulative( /** 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 @@ -1261,13 +1036,13 @@ function mapDailyCumulative( : firstNonNegativeNumber(point, spec.valuePaths); if (value === null) return []; if (spec.factor) value = value * spec.factor; - const dayKey = externalAnchor(point, dataType); // YYYY-MM-DD for a daily type + const dayKey = externalAnchor(point, dataType, tz); // civil YYYY-MM-DD return [ { type: spec.type, value: round2(value), unit: spec.unit, - measuredAt: resolveMeasuredAt(point, dataType, new Date()), + measuredAt: resolveMeasuredAt(point, dataType, new Date(), tz), // `stats::` — the sync layer reads `cumulativeDaily` // to assemble the externalId, matching the Apple-Health daily-total shape. fieldTag: `${spec.fieldTag}:${dayKey}`, @@ -1278,21 +1053,28 @@ function mapDailyCumulative( export function mapSteps( point: GoogleHealthDataPoint, + tz?: string, ): 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"), - ], - }); + return mapDailyCumulative( + point, + dt, + { + type: "ACTIVITY_STEPS", + unit: "steps", + fieldTag: "steps", + valuePaths: [ + ...valuePaths(dt.filter, "count"), + ...valuePaths(dt.filter, "steps"), + ], + }, + tz, + ); } export function mapDistance( point: GoogleHealthDataPoint, + tz?: string, ): GoogleHealthMappedMeasurement[] { const dt = GOOGLE_HEALTH_DATA_TYPES.distance; // Google may report metres or kilometres; prefer the explicit-metres field, @@ -1304,68 +1086,91 @@ export function mapDistance( type: "WALKING_RUNNING_DISTANCE", value: round2(meters), unit: "m", - measuredAt: resolveMeasuredAt(point, dt, new Date()), - fieldTag: `distance:${externalAnchor(point, dt)}`, + 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, - }); + return mapDailyCumulative( + point, + dt, + { + type: "WALKING_RUNNING_DISTANCE", + unit: "m", + fieldTag: "distance", + valuePaths: valuePaths(dt.filter, "kilometers"), + factor: 1000, + }, + tz, + ); } export function mapActiveEnergy( point: GoogleHealthDataPoint, + tz?: string, ): 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"), - ], - }); + 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, + ); } export function mapFloors( point: GoogleHealthDataPoint, + tz?: string, ): 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"), - ], - }); + return mapDailyCumulative( + point, + dt, + { + type: "FLIGHTS_CLIMBED", + unit: "flights", + fieldTag: "floors", + valuePaths: [ + ...valuePaths(dt.filter, "count"), + ...valuePaths(dt.filter, "floors"), + ], + }, + tz, + ); } export function mapVo2Max( point: GoogleHealthDataPoint, + tz?: string, ): GoogleHealthMappedMeasurement[] { const dt = GOOGLE_HEALTH_DATA_TYPES.vo2Max; - return mapDailyCumulative(point, dt, { - type: "VO2_MAX", - 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 - }); + return mapDailyCumulative( + point, + dt, + { + type: "VO2_MAX", + 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 + }, + tz, + ); } // ── Sleep ────────────────────────────────────────────────────── @@ -1475,7 +1280,7 @@ function readSleepSegments( * end (or start) ISO instant is unique per night, so per-stage rows key as * `:sleep_` — a re-scored night overwrites in place. */ -function sleepSessionAnchor(point: GoogleHealthDataPoint): string { +function sleepSessionAnchor(point: GoogleHealthDataPoint, tz?: string): string { const end = readPath(point, "sleep.interval.end_time") ?? readPath(point, "interval.end_time") ?? @@ -1484,8 +1289,8 @@ function sleepSessionAnchor(point: GoogleHealthDataPoint): string { readPath(point, "endTime") ?? readPath(point, "end_time"); if (typeof end === "string") { - const d = new Date(end); - if (!Number.isNaN(d.getTime())) return d.toISOString(); + const d = parseLocalInstant(end, tz); + if (d) return d.toISOString(); } const start = readPath(point, "sleep.interval.start_time") ?? @@ -1494,8 +1299,8 @@ function sleepSessionAnchor(point: GoogleHealthDataPoint): string { readPath(point, "sleep.start_time") ?? readPath(point, "startTime"); if (typeof start === "string") { - const d = new Date(start); - if (!Number.isNaN(d.getTime())) return d.toISOString(); + const d = parseLocalInstant(start, tz); + if (d) return d.toISOString(); } return new Date(0).toISOString(); } @@ -1512,14 +1317,20 @@ function sleepSessionAnchor(point: GoogleHealthDataPoint): string { * of one stage stay distinct under the `(userId, type, source, externalId)` * dedup key. Unknown stage labels are skipped; a session with no parseable * segment yields nothing. + * + * Segment timestamps can arrive OFFSET-LESS (local wall clock); `tz` (the user's + * stored zone) anchors them to the correct UTC instant rather than the process + * zone — without it a non-UTC user's near-midnight segment END would shift by + * their offset. Timestamps carrying an explicit offset/`Z` are honoured as-is. */ export function mapSleepSession( point: GoogleHealthDataPoint, + tz?: string, ): GoogleHealthMappedMeasurement[] { const segments = readSleepSegments(point); if (segments.length === 0) return []; - const anchor = sleepSessionAnchor(point); + const anchor = sleepSessionAnchor(point, tz); const out: GoogleHealthMappedMeasurement[] = []; let segIndex = 0; for (const seg of segments) { @@ -1527,7 +1338,8 @@ export function mapSleepSession( if (!stage) continue; const mins = minutesBetween(seg.startTime, seg.endTime); if (mins === null || !(mins > 0)) continue; - const end = new Date(seg.endTime as string); + const end = parseLocalInstant(seg.endTime as string, tz); + if (!end) continue; out.push({ type: "SLEEP_DURATION", value: round2(mins), @@ -1622,16 +1434,21 @@ function readNumber( return null; } -/** Read an ISO/string instant off a list of candidate paths, or null. */ +/** + * Read an instant off a list of candidate paths, or null. Offset-less strings + * resolve against `tz` (the user's stored zone) rather than the process zone; + * strings carrying an explicit offset/`Z` are honoured as-is. + */ function readInstant( point: GoogleHealthDataPoint, paths: string[], + tz?: string, ): Date | null { for (const path of paths) { const v = readPath(point, path); if (typeof v === "string") { - const d = new Date(v); - if (!Number.isNaN(d.getTime())) return d; + const d = parseLocalInstant(v, tz); + if (d) return d; } } return null; @@ -1643,28 +1460,40 @@ function readInstant( * 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. + * + * 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", - ]); - const endedAt = readInstant(point, [ - `${f}.interval.end_time`, - `${f}.endTime`, - `${f}.end_time`, - "interval.end_time", - "endTime", - "end_time", - ]); + 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, + ); if (!startedAt || !endedAt || endedAt <= startedAt) return null; const durationSec = Math.round( diff --git a/src/lib/google-health/mapping.md b/src/lib/google-health/mapping.md index 6619aaef4..6cbc585e4 100644 --- a/src/lib/google-health/mapping.md +++ b/src/lib/google-health/mapping.md @@ -1,12 +1,13 @@ # Google Health field → Measurement mapping Single source of truth for the Google Health API v4 data-type → HealthLog -`Measurement` mapping. Keep this in sync with `GOOGLE_HEALTH_FIELD_MAP` and 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 its `interval.start_time` for INTERVAL types — steps/distance/calories/floors, -sleep, exercise; the field-tag disambiguates the metric). +`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). Google Health is a **separate, coexisting provider** from the classic Fitbit integration (`src/lib/fitbit/*`, `source = FITBIT`). They never share a token, @@ -19,11 +20,14 @@ 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): - **sample** — spot reading; anchor `{filter}.sample_time.physical_time`. -- **date** — daily-summary metric (SpO2, HRV, RHR, respiratory rate, wrist temp, - VO2 max); anchor `{filter}.date` (a civil date / `{year,month,day}`). -- **interval** — INTERVAL type (steps/distance/calories/floors daily totals, plus - sleep + exercise sessions); anchor `{filter}.interval.start_time` (a physical - instant) with `{filter}.interval.civil_start_time` as the civil fallback. +- **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. 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 @@ -33,13 +37,12 @@ tighten the extractors. ## Read method -Most data types support `dataPoints.list` (`readMethod: "list"`, the default). -Two types — `total-calories` and `calories-in-heart-rate-zone` — have **no -`:list`** and are read via `dataPoints:dailyRollUp` (`readMethod: "dailyRollUp"`, -one civil day per request). They are defined in `GOOGLE_HEALTH_DATA_TYPES` so the -read layer routes them correctly, but are **not yet wired to a Measurement -mapper** (active energy already covers the active-calories slot; `total-calories` -folds in BMR and needs a modelling decision before it lands as a metric). +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. ## Casing gotcha @@ -52,17 +55,19 @@ pins both forms per type so a fetcher can never encode the wrong one. Scope: `googlehealth.health_metrics_and_measurements.readonly`. Identifiers verified against the 2026 contract (API-RESEARCH §3/§4). -| 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. | -| `daily-sleep-temperature-derivations` / `daily_sleep_temperature_derivations` | `WRIST_TEMPERATURE` | celsius | `wrist_temp` | daily | Closest semantic slot to Apple sleeping-wrist-temp. Confirm absolute-vs-baseline at build; the positive guard rejects a baseline-delta reading. | -| `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 / 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. | + +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). @@ -71,8 +76,12 @@ positive — a zero/NaN is a garbage/empty reading and is dropped). `blood-glucose` → `BLOOD_GLUCOSE`, `blood-pressure` → `BLOOD_PRESSURE_SYS`+`_DIA`, `basal-body-temperature` → `BODY_TEMPERATURE`, `electrocardiogram` (ECG) and -`irregular-rhythm-notification` (gated behind the experimental ECG/IRN scopes). -These light up when Google ships the data types + the operator opts in. +`irregular-rhythm-notification`. Skin-temperature deviation +(`daily-sleep-temperature-derivations`) also sits here: Google reports a signed +nightly delta from baseline, not an absolute reading, so it needs a signed-delta +model before it can land — mapping it into `WRIST_TEMPERATURE` would store a delta +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 @@ -92,16 +101,6 @@ not a gap); VO2 max stays strictly positive (daily latest-wins). | `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 | -## Roll-up-only types (no `:list`) - -Scope: `googlehealth.activity_and_fitness.readonly`. Read via `:dailyRollUp`; not -yet mapped to a MeasurementType (see **Read method** above). - -| Data type (path / filter) | Status | -| ------------------------------------------------------------- | ------------------------------------------------------- | -| `total-calories` / `total_calories` | roll-up-only; folds in BMR; modelling decision pending | -| `calories-in-heart-rate-zone` / `calories_in_heart_rate_zone` | roll-up-only; per-zone breakdown; no HealthLog slot yet | - ## Sleep bundle Scope: `googlehealth.sleep.readonly`. A sleep session carries per-stage segments diff --git a/src/lib/google-health/sync-activity.ts b/src/lib/google-health/sync-activity.ts index 4aad7a757..974a4f56b 100644 --- a/src/lib/google-health/sync-activity.ts +++ b/src/lib/google-health/sync-activity.ts @@ -41,11 +41,15 @@ import { type GoogleHealthResourceSyncOptions, } from "./sync"; import { annotate } from "@/lib/logging/context"; +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) => GoogleHealthMappedMeasurement[]; + map: ( + point: Record, + tz?: string, + ) => GoogleHealthMappedMeasurement[]; verb: string; } @@ -94,6 +98,12 @@ 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. + const tz = await resolveUserTimezone(userId); + // Cycle-wide watermark snapshotted once by `syncUserGoogleHealth`; undefined // on a full/backfill run. const start = opts.start; @@ -115,7 +125,7 @@ export async function syncUserActivity( const readings: GoogleHealthMeasurementUpsert[] = []; for (const point of points) { - for (const m of resource.map(point)) { + for (const m of resource.map(point, tz)) { readings.push({ type: m.type, value: m.value, diff --git a/src/lib/google-health/sync-metrics.ts b/src/lib/google-health/sync-metrics.ts index 7065eaac3..36e105966 100644 --- a/src/lib/google-health/sync-metrics.ts +++ b/src/lib/google-health/sync-metrics.ts @@ -12,7 +12,6 @@ * - daily-resting-HR → RESTING_HEART_RATE * - respiratory-rate → RESPIRATORY_RATE * - heart-rate (intraday) → PULSE - * - daily sleep-temp deriv.→ WRIST_TEMPERATURE * - height → User.heightCm (profile seed, NOT a Measurement) * * Each data point yields at most one Measurement row, disambiguated by the @@ -35,7 +34,6 @@ import { mapOxygenSaturation, mapRespiratoryRate, mapRestingHeartRate, - mapSleepTemperature, mapWeight, } from "./client"; import { @@ -92,11 +90,9 @@ const METRIC_RESOURCES: MetricResource[] = [ map: mapHeartRate, verb: "fetchHeartRate", }, - { - dataType: GOOGLE_HEALTH_DATA_TYPES.sleepTemperature, - map: mapSleepTemperature, - verb: "fetchSleepTemperature", - }, + // Skin temperature is intentionally omitted for v1 — Google surfaces a nightly + // signed DEVIATION from baseline, a future enhancement needing a signed-delta + // model (see `GOOGLE_HEALTH_DATA_TYPES` in `client.ts`). ]; export async function syncUserMetrics( diff --git a/src/lib/google-health/sync-sleep.ts b/src/lib/google-health/sync-sleep.ts index b75dd8279..5ea710694 100644 --- a/src/lib/google-health/sync-sleep.ts +++ b/src/lib/google-health/sync-sleep.ts @@ -32,6 +32,7 @@ import { type GoogleHealthResourceSyncOptions, } from "./sync"; import { annotate } from "@/lib/logging/context"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; export async function syncUserSleep( userId: string, @@ -40,6 +41,11 @@ export async function syncUserSleep( const tokenInfo = await getValidToken(userId); if (!tokenInfo) return 0; + // Sleep-segment timestamps can arrive offset-less; anchor them against the + // user's stored zone so a near-midnight segment END lands on the correct + // wake-day rather than being shifted by the process zone. + const tz = await resolveUserTimezone(userId); + // Cycle-wide watermark snapshotted once by `syncUserGoogleHealth`; undefined // on a full/backfill run. const start = opts.start; @@ -58,7 +64,7 @@ export async function syncUserSleep( const readings: GoogleHealthMeasurementUpsert[] = []; for (const point of points) { - for (const m of mapSleepSession(point)) { + for (const m of mapSleepSession(point, tz)) { readings.push({ type: m.type, value: m.value, diff --git a/src/lib/google-health/sync-workout.ts b/src/lib/google-health/sync-workout.ts index 6a7cbd9ca..cd4ead5f9 100644 --- a/src/lib/google-health/sync-workout.ts +++ b/src/lib/google-health/sync-workout.ts @@ -27,6 +27,7 @@ import { } from "./sync"; import { prisma } from "@/lib/db"; import { annotate, getEvent } from "@/lib/logging/context"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; export async function syncUserWorkout( userId: string, @@ -35,6 +36,10 @@ export async function syncUserWorkout( const tokenInfo = await getValidToken(userId); if (!tokenInfo) return 0; + // Session start/end can arrive offset-less; anchor them against the user's + // stored zone rather than the process zone. + const tz = await resolveUserTimezone(userId); + // Cycle-wide watermark snapshotted once by `syncUserGoogleHealth`; undefined // on a full/backfill run. const start = opts.start; @@ -53,7 +58,7 @@ export async function syncUserWorkout( let imported = 0; for (const point of points) { - const w: GoogleHealthMappedWorkout | null = mapWorkout(point); + const w: GoogleHealthMappedWorkout | null = mapWorkout(point, tz); if (!w) continue; // no usable time span — not a workout try { diff --git a/src/lib/validations/source-priority.ts b/src/lib/validations/source-priority.ts index 43bd2555c..1c71cf700 100644 --- a/src/lib/validations/source-priority.ts +++ b/src/lib/validations/source-priority.ts @@ -362,14 +362,9 @@ export const DEFAULT_SOURCE_PRIORITY: Required = { // v1.11.0 — new WHOOP-overlapping keys. ScanWatch dermal reading is the // primary skin-temperature sensor; WHOOP's strap is second, Fitbit third, // Oura's ring fourth. - skinTemperature: [ - "WITHINGS", - "WHOOP", - "FITBIT", - "GOOGLE_HEALTH", - "OURA", - "APPLE_HEALTH", - ], + // GOOGLE_HEALTH intentionally absent — it produces no skin-temperature row for + // v1 (Google surfaces a nightly signed deviation, not an absolute reading). + skinTemperature: ["WITHINGS", "WHOOP", "FITBIT", "OURA", "APPLE_HEALTH"], respiratoryRate: [ "WHOOP", "FITBIT",