fix(google-health): correct day-keying, timezone, and skin-temperature handling

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:<tag>:<day> 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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-03 14:41:44 +02:00
1 parent 9f7947a729
commit 422a6a7df0
9 files changed
+335 -505

No files matched your search

@@ -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:<tag>:<YYYY-MM-DD>` 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({
+6 -44
View File
@@ -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(" "),
);
+230 -401
View File
@@ -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<GoogleHealthDataPoint[]> {
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<GoogleHealthDataPoint[]> {
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:<type-tag>:<YYYY-MM-DD>` — 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
* `<session-anchor>:sleep_<stage>` — 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(
+40 -41
View File
@@ -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 = <anchor>:<fieldTag>` (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 = <anchor>:<fieldTag>` (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
+12 -2
View File
@@ -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<string, unknown>) => GoogleHealthMappedMeasurement[];
map: (
point: Record<string, unknown>,
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,
+3 -7
View File
@@ -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(
+7 -1
View File
@@ -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,
+6 -1
View File
@@ -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 {
+3 -8
View File
@@ -362,14 +362,9 @@ export const DEFAULT_SOURCE_PRIORITY: Required<MetricPriority> = {
// 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",