mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
1182 lines
42 KiB
TypeScript
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,
|
|
};
|
|
}
|