Files
HealthLog/src/lib/google-health/mappers.ts
T

1182 lines
42 KiB
TypeScript

/**
* Google Health pure mapping layer — payload → Measurement / Workout shapes.
*
* Split out of `client.ts` as pure code motion: the per-type `map*` functions,
* the civil-date / local-instant parsing helpers, the sleep-stage + sport-type
* resolvers, and the mapped-row types. `client.ts` re-exports this module, so
* the sync layer and tests keep importing from `./client` unchanged.
*
* The shared wire-shape symbols (`GoogleHealthDataType`,
* `GOOGLE_HEALTH_DATA_TYPES`, `GoogleHealthDataPoint`,
* `GoogleHealthRollupPoint`) live HERE and `client.ts` imports them type-only:
* that direction keeps the module graph acyclic (`client` → `mappers`, never
* back), since `client.ts` also runtime-re-exports this module. No function in
* this file performs I/O — everything is synchronous and deterministic given a
* payload (+ optional user timezone).
*/
import { zonedWallClockToUtc } from "@/lib/tz/wall-clock";
/**
* 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; `key` is the camelCase union key the
* response payload nests the value object under. `timeField` names the read
* method + time anchor.
*/
export interface GoogleHealthDataType {
/** kebab-case segment for the request path. */
path: string;
/** snake_case prefix for the `filter` predicate. */
filter: string;
/** camelCase union key in the response `DataPoint` payload. */
key: string;
/**
* Which read method + time anchor the type uses:
* - `sample` → spot reading via list; filter
* `{filter}.sample_time.physical_time`, read `{key}.sampleTime.physicalTime`.
* - `date` → daily summary via list; filter `{filter}.date`
* (`YYYY-MM-DD`), read `{key}.date` (a `{year,month,day}` object).
* - `sessionEnd` → sleep sessions via list; sleep filters ONLY on
* `{filter}.interval.end_time` (any other field 400s); anchor
* `{key}.interval.endTime`.
* - `civilStart` → exercise sessions via list; session types filter ONLY on
* `{filter}.interval.civil_start_time` with an offset-less civil bound;
* times read from `{key}.interval.startTime/endTime` (RFC-3339).
* - `rollup` → cumulative daily totals via `POST :dailyRollUp`
* (`windowSizeDays: 1`); never listed. Day key `civilStartTime.date`.
*/
timeField: "sample" | "date" | "sessionEnd" | "civilStart" | "rollup";
}
/**
* The launch data types. Each entry pins the kebab-path + snake-filter +
* camelCase-payload triple so the three encodings can never drift. Identifiers
* reconciled against the official v4 reference (data-types index + per-type
* schema pages).
*/
export const GOOGLE_HEALTH_DATA_TYPES = {
weight: {
path: "weight",
filter: "weight",
key: "weight",
timeField: "sample",
},
bodyFat: {
path: "body-fat",
filter: "body_fat",
key: "bodyFat",
timeField: "sample",
},
// Daily-grain SpO2 lives on `daily-oxygen-saturation` (`averagePercentage`);
// the bare `oxygen-saturation` type is per-SAMPLE and does not accept a
// `.date` filter (400).
oxygenSaturation: {
path: "daily-oxygen-saturation",
filter: "daily_oxygen_saturation",
key: "dailyOxygenSaturation",
timeField: "date",
},
// Nightly HRV summary lives on `daily-heart-rate-variability`; the bare
// `heart-rate-variability` type is per-sample (RMSSD + SDNN fields) and does
// not accept a `.date` filter.
heartRateVariability: {
path: "daily-heart-rate-variability",
filter: "daily_heart_rate_variability",
key: "dailyHeartRateVariability",
timeField: "date",
},
restingHeartRate: {
path: "daily-resting-heart-rate",
filter: "daily_resting_heart_rate",
key: "dailyRestingHeartRate",
timeField: "date",
},
// `respiratory-rate` does not exist in the catalogue; the daily summary is
// `daily-respiratory-rate`, value leaf `dailyRespiratoryRate.breathsPerMinute`
// (the once-assumed `dailyRespiratoryRateBpm` leaf never existed).
respiratoryRate: {
path: "daily-respiratory-rate",
filter: "daily_respiratory_rate",
key: "dailyRespiratoryRate",
timeField: "date",
},
heartRate: {
path: "heart-rate",
filter: "heart_rate",
key: "heartRate",
timeField: "sample",
},
height: {
path: "height",
filter: "height",
key: "height",
timeField: "sample",
},
bloodGlucose: {
path: "blood-glucose",
filter: "blood_glucose",
key: "bloodGlucose",
timeField: "sample",
},
coreBodyTemperature: {
path: "core-body-temperature",
filter: "core_body_temperature",
key: "coreBodyTemperature",
timeField: "sample",
},
// Nightly sleep skin temperature. The documented `nightlyTemperatureCelsius`
// is an ABSOLUTE reading ("the mean of skin temperature samples taken from
// the user's sleep"), alongside a separate `baselineTemperatureCelsius` — so
// it maps cleanly onto the absolute WRIST_TEMPERATURE slot. (An earlier note
// claimed Google only ships a signed deviation; the schema refutes that.)
sleepTemperature: {
path: "daily-sleep-temperature-derivations",
filter: "daily_sleep_temperature_derivations",
key: "dailySleepTemperatureDerivations",
timeField: "date",
},
// ── Activity bundle — daily cumulative totals ──────────────────
// Scope: `googlehealth.activity_and_fitness.readonly`. Read through
// `POST :dailyRollUp` with `windowSizeDays: 1`: the `list` surface returns
// minute-grain observation buckets, NOT daily totals (and floors has no list
// method at all). The externalId carries the `stats:` prefix so a re-fetched
// day overwrites in place (mirrors the Apple-Health
// `stats:<HK>:<YYYY-MM-DD>` daily-total overwrite contract).
steps: { path: "steps", filter: "steps", key: "steps", timeField: "rollup" },
distance: {
path: "distance",
filter: "distance",
key: "distance",
timeField: "rollup",
},
// Active energy — canonical id `active-energy-burned`; this is the ACTIVE
// portion only, NOT `total-calories` (which folds in BMR).
activeEnergy: {
path: "active-energy-burned",
filter: "active_energy_burned",
key: "activeEnergyBurned",
timeField: "rollup",
},
floors: {
path: "floors",
filter: "floors",
key: "floors",
timeField: "rollup",
},
// The daily VO2-max reading lives on `daily-vo2-max` (`vo2Max`); the bare
// `vo2-max` type is per-sample and does not accept a `.date` filter.
vo2Max: {
path: "daily-vo2-max",
filter: "daily_vo2_max",
key: "dailyVo2Max",
timeField: "date",
},
// ── Sleep bundle ───────────────────────────────────────────────
// Scope: `googlehealth.sleep.readonly`. Sleep sessions filter ONLY on
// `sleep.interval.end_time` / `.civil_end_time` — a start-time filter 400s.
// Mapped to per-stage SLEEP_DURATION rows.
sleep: {
path: "sleep",
filter: "sleep",
key: "sleep",
timeField: "sessionEnd",
},
// ── Exercise bundle ────────────────────────────────────────────
// Scope: `googlehealth.activity_and_fitness.readonly`. Session types
// (excluding sleep/ECG) filter ONLY on `interval.civil_start_time` with an
// offset-less civil bound → a `Workout` row (NOT a Measurement).
exercise: {
path: "exercise",
filter: "exercise",
key: "exercise",
timeField: "civilStart",
},
} as const satisfies Record<string, GoogleHealthDataType>;
/** Google Health `DataPoint` — value object is type-keyed + carries a time anchor. */
export interface GoogleHealthDataPoint {
[key: string]: unknown;
}
/**
* One dailyRollUp aggregate window: `civilStartTime`/`civilEndTime` are
* CivilDateTime objects (`{date:{year,month,day}, time:{…}}`), the value is a
* union keyed by the camelCase type name carrying the `*Sum` rollup fields
* (`steps.countSum`, `distance.millimetersSum`, `activeEnergyBurned.kcalSum`,
* `floors.countSum` — int64 sums arrive as JSON strings).
*/
export interface GoogleHealthRollupPoint {
[key: string]: unknown;
}
// ─── Field → Measurement mapping ───────────────────────────────
// The single source of truth is `mapping.md` — keep both in sync when adding
// entries.
/** Millimetres → centimetres (Google `height.heightMillimeters` → `User.heightCm`). */
const MM_TO_CM = 0.1;
function round2(n: number): number {
return parseFloat(n.toFixed(2));
}
/**
* Coerce a raw payload value to a finite number, or null. proto3 int64 fields
* arrive as JSON strings (`"12345"`); `Number()` handles both int and decimal
* strings, guarded by a finite check (empty / whitespace strings coerce to 0
* via `Number("")` — reject them explicitly).
*/
function coerceNumber(v: unknown): number | null {
if (typeof v === "number") return Number.isFinite(v) ? v : null;
if (typeof v === "string" && v.trim() !== "") {
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
return null;
}
/**
* A single mapped reading destined for one `Measurement` row. The `source`
* (`GOOGLE_HEALTH`) and `externalId` (`<anchor>:<fieldTag>`) are stamped by the
* sync layer; the mapper emits only type/value/unit/measuredAt + the field-tag
* that disambiguates the externalId.
*/
export interface GoogleHealthMappedMeasurement {
type: string;
value: number;
unit: string;
measuredAt: Date;
/** Disambiguator appended to the per-point anchor to form the externalId. */
fieldTag: string;
/** Per-stage sleep rows carry the SleepStage; everything else omits it. */
sleepStage?: GoogleHealthSleepStage;
/**
* `true` when the externalId carries the `stats:` daily-total prefix (the
* cumulative activity metrics). The sync layer stamps the externalId; this
* flag lets it pick the right shape (`stats:<type-tag>:<YYYY-MM-DD>` vs the
* `<anchor>:<fieldTag>` spot shape) without re-deriving the grain.
*/
cumulativeDaily?: boolean;
}
/** HealthLog `SleepStage` values a Google sleep stage maps onto. */
export type GoogleHealthSleepStage =
"IN_BED" | "AWAKE" | "ASLEEP" | "REM" | "CORE" | "DEEP";
/**
* Pull the first finite STRICTLY-POSITIVE number out of a list of candidate
* value paths on a `DataPoint`, coercing int64 JSON strings. The launch metrics
* (weight, body-fat, SpO2, HRV, RHR, respiratory rate, HR, height, VO2 max) are
* all strictly positive — a zero is a garbage/empty reading and is dropped.
*/
function firstNumber(
point: GoogleHealthDataPoint,
paths: string[],
): number | null {
for (const path of paths) {
const n = coerceNumber(readPath(point, path));
if (n !== null && n > 0) return n;
}
return null;
}
/** Resolve a dotted path against a nested object; undefined on any miss. */
function readPath(obj: unknown, path: string): unknown {
let cur: unknown = obj;
for (const seg of path.split(".")) {
if (cur === null || typeof cur !== "object") return undefined;
cur = (cur as Record<string, unknown>)[seg];
}
return cur;
}
/** Parse a `{year,month,day}` civil-date object into a UTC-midday Date, or null. */
function parseCivilDateObject(val: unknown): Date | null {
if (!val || typeof val !== "object") return null;
const o = val as Record<string, unknown>;
if (
typeof o.year === "number" &&
typeof o.month === "number" &&
typeof o.day === "number"
) {
// Google civil dates are 1-based months; anchor at UTC midday so a timezone
// shift can't roll the civil day across a boundary.
return new Date(Date.UTC(o.year, o.month - 1, o.day, 12));
}
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. The read paths are camelCase —
* the response payload nests the value union under the camelCase type name with
* camelCase time objects (the snake_case forms exist only inside the request
* `filter` parameter).
* - `sample` → `{key}.sampleTime.physicalTime` (a spot instant; offset-less
* strings resolve against `tz`).
* - `date` → `{key}.date` (a `{year,month,day}` object, or a civil string) —
* anchored at UTC-midday so a tz shift can't roll the civil day.
* Falls back to `fallback` only when nothing parses, so a row is never dropped
* for a missing anchor. (Sleep / exercise sessions and the rollup types anchor
* through their own helpers, never here.)
*/
function resolveMeasuredAt(
point: GoogleHealthDataPoint,
dataType: GoogleHealthDataType,
fallback: Date,
tz?: string,
): Date {
if (dataType.timeField === "sample") {
const t = readPath(point, `${dataType.key}.sampleTime.physicalTime`);
if (typeof t === "string") {
const d = parseLocalInstant(t, tz);
if (d) return d;
}
} else if (dataType.timeField === "date") {
const dateVal = readPath(point, `${dataType.key}.date`);
const civil = parseCivilStart(dateVal);
if (civil) return civil;
}
return fallback;
}
/**
* Stable anchor for a `DataPoint`'s externalId. A spot reading anchors on its
* sample time; a daily summary on its civil date. Combined with a type-specific
* field-tag this makes the upsert key idempotent across re-fetches.
*/
function externalAnchor(
point: GoogleHealthDataPoint,
dataType: GoogleHealthDataType,
tz?: string,
): string {
const at = resolveMeasuredAt(point, dataType, new Date(0), tz);
// Daily summaries share the civil-day externalId grain so a re-fetched day
// overwrites in place. (Sleep/exercise sessions and the rollup daily totals
// mint their own anchors and never reach this helper.)
if (dataType.timeField === "date") {
return at.toISOString().slice(0, 10);
}
return at.toISOString();
}
/**
* Map one data point of a simple single-value metric into a Measurement
* reading. `valuePaths` lists the candidate value shapes; the first
* finite-positive hit wins. Returns an empty array when no value parses.
*/
function mapSimple(
point: GoogleHealthDataPoint,
dataType: GoogleHealthDataType,
spec: {
type: string;
unit: string;
fieldTag: string;
valuePaths: string[];
factor?: number;
},
): GoogleHealthMappedMeasurement[] {
let value = firstNumber(point, spec.valuePaths);
if (value === null) return [];
if (spec.factor) value = value * spec.factor;
return [
{
type: spec.type,
value: round2(value),
unit: spec.unit,
measuredAt: resolveMeasuredAt(point, dataType, new Date()),
fieldTag: `${externalAnchor(point, dataType)}:${spec.fieldTag}`,
},
];
}
export function mapWeight(
point: GoogleHealthDataPoint,
): GoogleHealthMappedMeasurement[] {
const dt = GOOGLE_HEALTH_DATA_TYPES.weight;
// Documented field is `weightGrams` (grams) → kg.
return mapSimple(point, dt, {
type: "WEIGHT",
unit: "kg",
fieldTag: "weight",
valuePaths: [`${dt.key}.weightGrams`],
factor: 0.001,
});
}
export function mapBodyFat(
point: GoogleHealthDataPoint,
): GoogleHealthMappedMeasurement[] {
const dt = GOOGLE_HEALTH_DATA_TYPES.bodyFat;
return mapSimple(point, dt, {
type: "BODY_FAT",
unit: "%",
fieldTag: "body_fat",
valuePaths: [`${dt.key}.percentage`],
});
}
export function mapOxygenSaturation(
point: GoogleHealthDataPoint,
): GoogleHealthMappedMeasurement[] {
const dt = GOOGLE_HEALTH_DATA_TYPES.oxygenSaturation;
return mapSimple(point, dt, {
type: "OXYGEN_SATURATION",
unit: "%",
fieldTag: "spo2",
valuePaths: [`${dt.key}.averagePercentage`],
});
}
export function mapHeartRateVariability(
point: GoogleHealthDataPoint,
): GoogleHealthMappedMeasurement[] {
const dt = GOOGLE_HEALTH_DATA_TYPES.heartRateVariability;
// The daily field is an unlabelled "average HRV ms". Per the design decision
// it lands in the SDNN-lineage `HEART_RATE_VARIABILITY` slot
// (Apple-comparable), NOT WHOOP's `HRV_RMSSD` (reserved for the WHOOP-native
// estimator). The per-sample type carries explicit RMSSD + SDNN fields —
// re-confirm the estimator against live data and revisit if warranted.
return mapSimple(point, dt, {
type: "HEART_RATE_VARIABILITY",
unit: "ms",
fieldTag: "hrv",
valuePaths: [`${dt.key}.averageHeartRateVariabilityMilliseconds`],
});
}
export function mapRestingHeartRate(
point: GoogleHealthDataPoint,
): GoogleHealthMappedMeasurement[] {
const dt = GOOGLE_HEALTH_DATA_TYPES.restingHeartRate;
// `beatsPerMinute` is an int64 JSON string — coerced by the extractor.
return mapSimple(point, dt, {
type: "RESTING_HEART_RATE",
unit: "bpm",
fieldTag: "rhr",
valuePaths: [`${dt.key}.beatsPerMinute`],
});
}
export function mapRespiratoryRate(
point: GoogleHealthDataPoint,
): GoogleHealthMappedMeasurement[] {
const dt = GOOGLE_HEALTH_DATA_TYPES.respiratoryRate;
// Documented schema: `{ date, breathsPerMinute }` — `breathsPerMinute` is a
// plain number ("The average number of breaths taken per minute"). The
// earlier `dailyRespiratoryRateBpm` leaf does not exist and never parsed.
return mapSimple(point, dt, {
type: "RESPIRATORY_RATE",
unit: "breaths/min",
fieldTag: "resp_rate",
valuePaths: [`${dt.key}.breathsPerMinute`],
});
}
export function mapBloodGlucose(
point: GoogleHealthDataPoint,
): GoogleHealthMappedMeasurement[] {
const dt = GOOGLE_HEALTH_DATA_TYPES.bloodGlucose;
// Documented field `bloodGlucoseMilligramsPerDeciliter` (number) — already in
// HealthLog's canonical mg/dL storage unit, no conversion.
return mapSimple(point, dt, {
type: "BLOOD_GLUCOSE",
unit: "mg/dL",
fieldTag: "glucose",
valuePaths: [`${dt.key}.bloodGlucoseMilligramsPerDeciliter`],
});
}
export function mapCoreBodyTemperature(
point: GoogleHealthDataPoint,
): GoogleHealthMappedMeasurement[] {
const dt = GOOGLE_HEALTH_DATA_TYPES.coreBodyTemperature;
// Documented field `temperatureCelsius` (number) → the core BODY_TEMPERATURE
// slot (distinct from SKIN_TEMPERATURE / WRIST_TEMPERATURE surface readings).
return mapSimple(point, dt, {
type: "BODY_TEMPERATURE",
unit: "celsius",
fieldTag: "core_temp",
valuePaths: [`${dt.key}.temperatureCelsius`],
});
}
export function mapWristTemperature(
point: GoogleHealthDataPoint,
): GoogleHealthMappedMeasurement[] {
const dt = GOOGLE_HEALTH_DATA_TYPES.sleepTemperature;
// `nightlyTemperatureCelsius` is the ABSOLUTE nightly skin temperature ("the
// mean of skin temperature samples taken from the user's sleep") → the
// WRIST_TEMPERATURE slot, mirroring the Apple sleeping-wrist-temperature
// absolute-reading convention. The sibling `baselineTemperatureCelsius` /
// `relativeNightlyStddev30dCelsius` derivations are not stored — the user's
// own series carries the baseline.
return mapSimple(point, dt, {
type: "WRIST_TEMPERATURE",
unit: "celsius",
fieldTag: "wrist_temp",
valuePaths: [`${dt.key}.nightlyTemperatureCelsius`],
});
}
export function mapHeartRate(
point: GoogleHealthDataPoint,
): GoogleHealthMappedMeasurement[] {
const dt = GOOGLE_HEALTH_DATA_TYPES.heartRate;
// `beatsPerMinute` is an int64 JSON string.
return mapSimple(point, dt, {
type: "PULSE",
unit: "bpm",
fieldTag: "hr",
valuePaths: [`${dt.key}.beatsPerMinute`],
});
}
/** One extracted `height` sample: centimetres + the sample instant (if any). */
export interface GoogleHealthHeightSample {
cm: number;
sampledAt: Date | null;
}
/**
* Extract the profile height from a Google `height` data point, or null when
* nothing parses. Height is a one-time `User.heightCm` profile seed (written
* only when the user has no height yet) — NOT a Measurement. The documented
* field is `heightMillimeters` (int64 JSON string, millimetres) → cm ÷ 10.
* (The earlier `heightMeters` leaf does not exist and never parsed.)
* `sampledAt` lets the caller pick the LATEST sample explicitly — list
* responses are ordered DESCENDING, so "last row wins" would pick the OLDEST.
*/
export function mapHeight(
point: GoogleHealthDataPoint,
): GoogleHealthHeightSample | null {
const dt = GOOGLE_HEALTH_DATA_TYPES.height;
const mm = firstNumber(point, [`${dt.key}.heightMillimeters`]);
if (mm === null) return null;
const t = readPath(point, `${dt.key}.sampleTime.physicalTime`);
const sampledAt = typeof t === "string" ? parseLocalInstant(t) : null;
return { cm: round2(mm * MM_TO_CM), sampledAt };
}
// ─── Activity mappers: daily roll-up totals ────────────────────
/**
* Pull the first finite NON-negative number out of a list of candidate value
* paths, coercing int64 JSON strings. Unlike `firstNumber` (strictly positive),
* this admits a legitimate zero — a day of rest still records 0 steps /
* 0 floors / 0 active kcal, and dropping the zero would leave a hole the chart
* misreads as missing data.
*/
function firstNonNegativeNumber(
point: GoogleHealthDataPoint,
paths: string[],
): number | null {
for (const path of paths) {
const n = coerceNumber(readPath(point, path));
if (n !== null && n >= 0) return n;
}
return null;
}
/**
* Map one `dailyRollUp` aggregate window (`windowSizeDays: 1`) into a single
* daily-total Measurement reading. The day key comes from the window's
* `civilStartTime.date` (a `{year,month,day}` object), anchored at UTC-midday
* per the shared civil-day convention; a window with no parseable civil day is
* dropped (it cannot be keyed). The externalId is the `stats:`-prefixed
* daily-total shape so a re-fetched day overwrites in place — the same
* overwrite contract the Apple-Health `stats:<HK>:<YYYY-MM-DD>` daily totals
* use. A zero is preserved (a rest day is real data, not a gap).
*/
function mapDailyRollup(
point: GoogleHealthRollupPoint,
spec: {
type: string;
unit: string;
fieldTag: string;
valuePaths: string[];
factor?: number;
},
): GoogleHealthMappedMeasurement[] {
const day =
parseCivilDateObject(readPath(point, "civilStartTime.date")) ??
parseCivilStart(readPath(point, "civilStartTime"));
if (!day) return [];
let value = firstNonNegativeNumber(point, spec.valuePaths);
if (value === null) return [];
if (spec.factor) value = value * spec.factor;
const dayKey = day.toISOString().slice(0, 10);
return [
{
type: spec.type,
value: round2(value),
unit: spec.unit,
measuredAt: day,
// `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}`,
cumulativeDaily: true,
},
];
}
export function mapSteps(
point: GoogleHealthRollupPoint,
): GoogleHealthMappedMeasurement[] {
const dt = GOOGLE_HEALTH_DATA_TYPES.steps;
// `countSum` is an int64 JSON string.
return mapDailyRollup(point, {
type: "ACTIVITY_STEPS",
unit: "steps",
fieldTag: "steps",
valuePaths: [`${dt.key}.countSum`],
});
}
export function mapDistance(
point: GoogleHealthRollupPoint,
): GoogleHealthMappedMeasurement[] {
const dt = GOOGLE_HEALTH_DATA_TYPES.distance;
// `millimetersSum` is an int64 JSON string → metres.
return mapDailyRollup(point, {
type: "WALKING_RUNNING_DISTANCE",
unit: "m",
fieldTag: "distance",
valuePaths: [`${dt.key}.millimetersSum`],
factor: 0.001,
});
}
export function mapActiveEnergy(
point: GoogleHealthRollupPoint,
): GoogleHealthMappedMeasurement[] {
const dt = GOOGLE_HEALTH_DATA_TYPES.activeEnergy;
// ACTIVE energy only — NOT total-calories (which folds in BMR).
return mapDailyRollup(point, {
type: "ACTIVE_ENERGY_BURNED",
unit: "kcal",
fieldTag: "active_energy",
valuePaths: [`${dt.key}.kcalSum`],
});
}
export function mapFloors(
point: GoogleHealthRollupPoint,
): GoogleHealthMappedMeasurement[] {
const dt = GOOGLE_HEALTH_DATA_TYPES.floors;
// `countSum` is an int64 JSON string.
return mapDailyRollup(point, {
type: "FLIGHTS_CLIMBED",
unit: "flights",
fieldTag: "floors",
valuePaths: [`${dt.key}.countSum`],
});
}
/**
* Map one `daily-vo2-max` summary into a VO2_MAX reading. A daily latest-wins
* metric (one civil-date reading, strictly positive — not a running sum), read
* via list with a `.date` filter; it keeps the `stats:`-style per-day overwrite
* key so a re-rolled day replaces in place.
*/
export function mapVo2Max(
point: GoogleHealthDataPoint,
): GoogleHealthMappedMeasurement[] {
const dt = GOOGLE_HEALTH_DATA_TYPES.vo2Max;
const value = firstNumber(point, [`${dt.key}.vo2Max`]);
if (value === null) return [];
const measuredAt = resolveMeasuredAt(point, dt, new Date());
return [
{
type: "VO2_MAX",
value: round2(value),
unit: "mL/(kg·min)",
measuredAt,
fieldTag: `vo2_max:${externalAnchor(point, dt)}`,
cumulativeDaily: true,
},
];
}
// ── Sleep ──────────────────────────────────────────────────────
//
// A Google Health sleep session carries a list of per-stage segments, each with
// a stage label + a start + end. HealthLog stores one SLEEP_DURATION row per
// stage segment with `measuredAt = stage END` (so the night-total + hypnogram
// readers consume the same enum WHOOP / Apple write). The stage labels are
// harmonised onto the shared `SleepStage` enum.
/** Google Health sleep stage label → HealthLog `SleepStage`. */
const GOOGLE_HEALTH_SLEEP_STAGE_MAP: Record<string, GoogleHealthSleepStage> = {
// Canonical Google Health stage names (snake / lower variants accepted).
in_bed: "IN_BED",
inbed: "IN_BED",
awake: "AWAKE",
wake: "AWAKE",
light: "CORE", // "light" ↔ Apple "core" (same shallow-NREM band)
core: "CORE",
rem: "REM",
deep: "DEEP",
// The classic (non-stages) sleep log uses asleep/restless/wake.
asleep: "ASLEEP",
restless: "AWAKE",
} as const;
/**
* Normalise a raw Google sleep-stage label to a `SleepStage`, or null for an
* unknown label (skipped rather than mis-bucketed).
*/
export function mapGoogleHealthSleepStage(
raw: unknown,
): GoogleHealthSleepStage | null {
if (typeof raw !== "string") return null;
const key = raw
.trim()
.toLowerCase()
.replace(/[\s-]+/g, "_");
return (
GOOGLE_HEALTH_SLEEP_STAGE_MAP[key] ??
GOOGLE_HEALTH_SLEEP_STAGE_MAP[key.replace(/_/g, "")] ??
null
);
}
/** One sleep-stage segment pulled defensively off a Google sleep session. */
interface GoogleHealthSleepSegment {
stage: string;
startTime?: string;
endTime?: string;
}
/** Minutes between two ISO instants, or null if either is unparseable. */
function minutesBetween(startIso?: string, endIso?: string): number | null {
if (!startIso || !endIso) return null;
const s = new Date(startIso).getTime();
const e = new Date(endIso).getTime();
if (Number.isNaN(s) || Number.isNaN(e) || e <= s) return null;
return (e - s) / 60_000;
}
/**
* Read the per-stage segments off a Google sleep `DataPoint`. Documented shape:
* `sleep.stages` is an array of
* `{ startTime, startUtcOffset, endTime, endUtcOffset, type }` where `type` is
* the `SLEEP_STAGE_TYPE` enum (`AWAKE | LIGHT | DEEP | REM | ASLEEP | RESTLESS
* | SLEEP_STAGE_TYPE_UNSPECIFIED`) and the times are RFC-3339 Timestamps.
*/
function readSleepSegments(
point: GoogleHealthDataPoint,
): GoogleHealthSleepSegment[] {
const stages = readPath(point, "sleep.stages");
if (!Array.isArray(stages)) return [];
const out: GoogleHealthSleepSegment[] = [];
for (const raw of stages) {
if (!raw || typeof raw !== "object") continue;
const o = raw as Record<string, unknown>;
const stage = typeof o.type === "string" ? o.type : "";
const startTime = typeof o.startTime === "string" ? o.startTime : undefined;
const endTime = typeof o.endTime === "string" ? o.endTime : undefined;
if (stage) out.push({ stage, startTime, endTime });
}
return out;
}
/**
* The stable session anchor for a sleep `DataPoint`'s externalId.
*
* Prefers the Google resource id (`name`) — it is INVARIANT across Google's
* after-the-fact re-scoring of a night, so the per-segment rows keep the SAME
* externalId on a re-fetch and overwrite in place instead of minting parallel
* duplicates. This mirrors `mapWorkout`, which anchors on `name` for exactly
* this reason.
*
* The session interval end/start is only a FALLBACK (for a payload without a
* resource name): it SHIFTS whenever Google refines the wake/onset instant on a
* re-score, which is precisely what used to duplicate a night's rows. Paths are
* camelCase (`sleep.interval.endTime`) — the response payload never uses
* snake_case.
*/
function sleepSessionAnchor(point: GoogleHealthDataPoint, tz?: string): string {
const name = readPath(point, "name");
if (typeof name === "string" && name !== "") return name;
const end = readPath(point, "sleep.interval.endTime");
if (typeof end === "string") {
const d = parseLocalInstant(end, tz);
if (d) return d.toISOString();
}
const start = readPath(point, "sleep.interval.startTime");
if (typeof start === "string") {
const d = parseLocalInstant(start, tz);
if (d) return d.toISOString();
}
return new Date(0).toISOString();
}
/**
* One mapped Google sleep session: the stable anchor, the session's covered
* time window (earliest segment start → latest segment end, both UTC), and the
* per-segment rows. The window drives the sync's replace-by-window cleanup of
* stale rows a re-score orphaned; `rows` is what gets upserted.
*/
export interface GoogleHealthSleepSession {
/** Stable session anchor — also the `<anchor>:sleep:` externalId prefix. */
anchor: string;
/** Earliest segment start across the session (UTC), or null if none map. */
windowStart: Date | null;
/** Latest segment end across the session (UTC), or null if none map. */
windowEnd: Date | null;
rows: GoogleHealthMappedMeasurement[];
}
/**
* Map one Google sleep session into per-SEGMENT `SLEEP_DURATION` rows plus its
* covered window. The Google sleep payload carries a real per-stage segment
* series (each with its own start/end), so one row is emitted PER SEGMENT —
* `measuredAt = that segment's END`. The timeline is MEASURED (real onsets), so
* these rows are NOT flagged reconstructed — unlike WHOOP, which has no onsets.
*
* Each segment's fieldTag keys off the STABLE session anchor plus the segment's
* own START instant — `<anchor>:sleep:<segment-start>` — NOT a positional index
* and NOT the stage label. This is the fix for the re-score duplication: a
* positional index renumbered (and a stage-scoped tag changed) whenever Google
* re-scored a night, minting fresh externalIds so the upsert created parallel
* duplicate rows the night-total then double-counted. A segment's start instant
* is stable per block, and dropping the stage from the key means a mere
* re-classification (LIGHT→DEEP on the same block) UPDATES the row in place
* rather than orphaning it. Unknown stage labels are skipped; a session with no
* parseable segment yields an empty `rows` (and a null window).
*
* 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 mapSleepSessionDetailed(
point: GoogleHealthDataPoint,
tz?: string,
): GoogleHealthSleepSession {
const anchor = sleepSessionAnchor(point, tz);
const segments = readSleepSegments(point);
const rows: GoogleHealthMappedMeasurement[] = [];
let windowStart: Date | null = null;
let windowEnd: Date | null = null;
for (const seg of segments) {
const stage = mapGoogleHealthSleepStage(seg.stage);
if (!stage) continue;
const mins = minutesBetween(seg.startTime, seg.endTime);
if (mins === null || !(mins > 0)) continue;
const start = parseLocalInstant(seg.startTime as string, tz);
const end = parseLocalInstant(seg.endTime as string, tz);
if (!start || !end) continue;
if (!windowStart || start < windowStart) windowStart = start;
if (!windowEnd || end > windowEnd) windowEnd = end;
rows.push({
type: "SLEEP_DURATION",
value: round2(mins),
unit: "minutes",
measuredAt: end,
fieldTag: `${anchor}:sleep:${start.toISOString()}`,
sleepStage: stage,
});
}
return { anchor, windowStart, windowEnd, rows };
}
/**
* Flat convenience wrapper — the per-segment rows of one session, without the
* window metadata. Retained for callers that only need the rows.
*/
export function mapSleepSession(
point: GoogleHealthDataPoint,
tz?: string,
): GoogleHealthMappedMeasurement[] {
return mapSleepSessionDetailed(point, tz).rows;
}
// ── Workouts (exercise sessions) ───────────────────────────────
/**
* Google Health `Exercise.exerciseType` → HealthLog `WorkoutSportType`. The
* enum arrives UPPERCASE (`RUNNING`, `STRENGTH_TRAINING`, …); the resolver
* lowercases + underscores before the lookup, so the keys here are the
* normalised forms. An unmapped type resolves to the literal `"other"`: the
* upstream label does NOT survive in `sportType`, and the column read back
* cannot tell a genuine `OTHER_WORKOUT` from a sport this map has never heard
* of. What preserves it is `GoogleHealthMappedWorkout.sportTypeRaw`, which the
* ingest writes verbatim to `Workout.metadata.googleExerciseType`
* (`sync-workout.ts`) — so a session that shows as "other" can still be traced
* back to what Google sent, without re-fetching it.
*/
const GOOGLE_HEALTH_EXERCISE_TYPE_MAP: Record<string, string> = {
walk: "walking",
walking: "walking",
treadmill_walk: "walking",
incline_walk: "walking",
power_walking: "walking",
nordic_walking: "walking",
stroller_walk: "walking",
walk_with_weights: "walking",
run: "running",
running: "running",
treadmill: "running",
treadmill_running: "running",
trail_run: "running",
incline_run: "running",
bike: "cycling",
biking: "cycling",
cycling: "cycling",
spinning: "cycling",
mountain_biking: "cycling",
mountain_bike: "cycling",
outdoor_bike: "cycling",
stationary_bike: "cycling",
electric_bike: "cycling",
assault_bike: "cycling",
hike: "hiking",
hiking: "hiking",
backpacking: "hiking",
rucking: "hiking",
swim: "swimming",
swimming: "swimming",
swimming_pool: "swimming",
swimming_open_water: "swimming",
rowing: "rowing",
rowing_machine: "rowing",
elliptical: "elliptical",
stairclimber: "stairClimber",
stair_climbing: "stairClimber",
step_training: "stairClimber",
yoga: "yoga",
pilates: "mindAndBody",
meditate: "mindAndBody",
tai_chi: "mindAndBody",
stretching: "mindAndBody",
weights: "strength",
strength: "strength",
strength_training: "strength",
functional_strength_training: "strength",
weightlifting: "strength",
powerlifting: "strength",
free_weights: "strength",
body_weight: "strength",
resistance_bands: "strength",
calisthenics: "strength",
core_training: "strength",
workout: "strength",
hiit: "hiit",
high_intensity_interval_training: "hiit",
interval_workout: "hiit",
interval_training: "hiit",
tabata_workout: "hiit",
dance: "dance",
dancing: "dance",
golf: "golf",
badminton: "badminton",
tennis: "tennis",
basketball: "basketball",
soccer: "soccer",
football: "soccer",
bootcamp: "crossTraining",
circuit_training: "crossTraining",
crossfit: "crossTraining",
cross_training: "crossTraining",
aerobic_workout: "mixedCardio",
cardio_workout: "mixedCardio",
cardio_sculpt: "mixedCardio",
sport: "mixedCardio",
} as const;
/** Resolve a Google exercise activity type to a canonical sport label. */
export function mapGoogleHealthSportType(raw: unknown): string {
if (typeof raw !== "string" || raw.trim() === "") return "other";
const key = raw
.trim()
.toLowerCase()
.replace(/[\s-]+/g, "_");
return (
GOOGLE_HEALTH_EXERCISE_TYPE_MAP[key] ??
GOOGLE_HEALTH_EXERCISE_TYPE_MAP[key.replace(/_/g, "")] ??
"other"
);
}
/** One mapped Google exercise session destined for a `Workout` row. */
export interface GoogleHealthMappedWorkout {
externalId: string;
sportType: string;
/**
* The upstream `exercise.exerciseType` verbatim (trimmed), or null when the
* session carried none. Provenance only — never the canonical `sportType`,
* which `mapGoogleHealthSportType()` owns.
*/
sportTypeRaw: string | null;
startedAt: Date;
endedAt: Date;
durationSec: number;
totalEnergyKcal: number | null;
totalDistanceM: number | null;
avgHeartRate: number | null;
maxHeartRate: number | null;
minHeartRate: number | null;
}
/**
* Read a finite number off a list of candidate paths (any sign), coercing int64
* JSON strings, or null.
*/
function readNumber(
point: GoogleHealthDataPoint,
paths: string[],
): number | null {
for (const path of paths) {
const n = coerceNumber(readPath(point, path));
if (n !== null) return n;
}
return 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 = parseLocalInstant(v, tz);
if (d) return d;
}
}
return null;
}
/**
* Map one Google exercise session `DataPoint` into a `Workout` shape. Returns
* null when there is no usable start/end (a session with no time span is not a
* workout).
*
* Documented payload: `exercise.interval.startTime/endTime` (RFC-3339),
* `exercise.exerciseType` (UPPERCASE enum), and
* `exercise.metricsSummary.{caloriesKcal, distanceMillimeters,
* averageHeartRateBeatsPerMinute (int64 string), …}`. There is no session-id
* field — the DataPoint's top-level `name` (a resource name) is the stable id;
* the start instant is the fallback. metricsSummary carries no max/min HR →
* null.
*
* `exerciseType` leaves here twice: once mapped onto a canonical sport bucket,
* once verbatim as `sportTypeRaw` so the ingest can keep the provenance.
*
* 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 k = GOOGLE_HEALTH_DATA_TYPES.exercise.key;
const startedAt = readInstant(point, [`${k}.interval.startTime`], tz);
const endedAt = readInstant(point, [`${k}.interval.endTime`], tz);
if (!startedAt || !endedAt || endedAt <= startedAt) return null;
const durationSec = Math.round(
(endedAt.getTime() - startedAt.getTime()) / 1000,
);
const name = readPath(point, "name");
const externalId =
typeof name === "string" && name !== ""
? name
: `exercise:${startedAt.toISOString()}`;
const sportRaw = readPath(point, `${k}.exerciseType`);
const sportTypeRaw =
typeof sportRaw === "string" && sportRaw.trim() !== ""
? sportRaw.trim()
: null;
const energyKcal = readNumber(point, [`${k}.metricsSummary.caloriesKcal`]);
const distanceMm = readNumber(point, [
`${k}.metricsSummary.distanceMillimeters`,
]);
const avgHr = readNumber(point, [
`${k}.metricsSummary.averageHeartRateBeatsPerMinute`,
]);
return {
externalId,
sportType: mapGoogleHealthSportType(sportRaw),
sportTypeRaw,
startedAt,
endedAt,
durationSec,
totalEnergyKcal: energyKcal !== null ? Math.round(energyKcal) : null,
totalDistanceM:
distanceMm !== null && distanceMm >= 0 ? round2(distanceMm / 1000) : null,
avgHeartRate: avgHr !== null && avgHr > 0 ? Math.round(avgHr) : null,
// metricsSummary carries no maximum/minimum heart rate.
maxHeartRate: null,
minHeartRate: null,
};
}