Files
HealthLog/src/lib/fitbit/client.ts
T
Marc-André Bombeck 6ad0a8a5cf feat(fitbit): sync activity, sleep stages, and workouts from Google Health
Extend the Fitbit / Google Health provider with the remaining sync
resources, driven by the existing syncUserFitbit orchestrator.

Activity (daily cumulative): steps -> ACTIVITY_STEPS, distance ->
WALKING_RUNNING_DISTANCE, the active-energy portion -> ACTIVE_ENERGY_BURNED,
floors -> FLIGHTS_CLIMBED, and VO2 max -> VO2_MAX. Each daily total mints a
stats:<tag>:<YYYY-MM-DD> externalId so a re-fetched day overwrites in place
rather than duplicating, matching the Apple-Health daily-total contract. The
running totals preserve a legitimate zero (a rest day is real data); VO2 max
stays strictly positive (daily latest-wins).

Sleep: map a session's per-stage segments to per-stage SLEEP_DURATION rows
(minutes; measuredAt = the stage end), harmonised onto the shared SleepStage
enum (light -> CORE, deep -> DEEP, rem -> REM, awake/restless -> AWAKE,
in-bed -> IN_BED, classic asleep -> ASLEEP) so the night-total and hypnogram
readers consume the same shape they already read for WHOOP and Apple.

Workouts: map exercise sessions to Workout rows keyed (userId, FITBIT,
externalId), with sport-type, start/end, duration, energy, distance, and HR.
Cross-source twins stay distinct at ingest; the read-time canonical picker
collapses them via the source ladder (Fitbit already ranked below WHOOP).

All three join the orchestrator's resource list and ride the per-resource
403 soft-skip so an independently-granted Restricted bundle that the user
withheld skips only its own resource. Rollups recompute and status-insight
caches invalidate through the shared upsert. No new MeasurementType, no
migration, no new dependency.
2026-06-04 22:57:44 +02:00

1326 lines
45 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Fitbit / Google Health API client — OAuth half (v1.12.0).
* Docs: https://developers.google.com/health (re-verify at build — the Google
* Health API is young; v4, post-Fitbit-Web-API).
*
* Mirrors the WHOOP client structure (`src/lib/whoop/client.ts`): hand-rolled
* fetch over `safeFetch` (no SDK), an OAuth handshake (`getAuthorizationUrl` /
* `exchangeCode` / `refreshAccessToken`), and a single profile fetch for the
* connection's external user id. The data-fetch half (paginated `dataPoints`
* walker + per-type mappers + `FITBIT_FIELD_MAP`) lands in a later wave.
*
* KEY DELTA vs WHOOP — Google does NOT rotate refresh tokens. WHOOP invalidates
* the prior refresh token on every refresh; Google's refresh tokens are stable
* (long-lived in production, 7-day expiry only in Testing mode). So
* `refreshAccessToken` may return a token response WITHOUT a `refresh_token`,
* and the sync layer keeps the stored one in that case (see `getValidToken` in
* `sync.ts`).
*
* Confidential web-server client: HealthLog holds the client secret
* server-side, so PKCE is omitted (matches WHOOP). Client credentials are sent
* to the token endpoint via HTTP Basic auth (RFC 6749 §2.3.1), the way Google's
* token endpoint accepts a confidential client.
*/
import { getEvent } from "@/lib/logging/context";
import { safeFetch } from "@/lib/safe-fetch";
import { FitbitApiError, classifyFitbitResponse } from "./response-classifier";
export const FITBIT_API_BASE = "https://health.googleapis.com/v4";
const GOOGLE_OAUTH_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
const GOOGLE_OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token";
export interface FitbitCredentials {
clientId: string;
clientSecret: string;
}
function getRedirectUri(): string {
return (
process.env.FITBIT_REDIRECT_URI ??
`${process.env.NEXT_PUBLIC_APP_URL}/api/fitbit/callback`
);
}
/**
* OAuth scopes HealthLog requests (space-separated on the wire). The four
* launch Restricted read bundles cover every v1.12.0 metric; ECG / nutrition /
* location / IRN are deliberately omitted to keep the Restricted-scope CASA
* review surface minimal. Every scope is Restricted → the operator's OAuth
* client needs Google brand verification + an annual CASA assessment before it
* leaves Testing mode.
*/
export const FITBIT_OAUTH_SCOPE = [
"https://www.googleapis.com/auth/googlehealth.activity_and_fitness.readonly",
"https://www.googleapis.com/auth/googlehealth.health_metrics_and_measurements.readonly",
"https://www.googleapis.com/auth/googlehealth.sleep.readonly",
"https://www.googleapis.com/auth/googlehealth.profile.readonly",
].join(" ");
/**
* Generate the Google OAuth authorization URL (a browser redirect, not a
* fetch). `state` is the opaque CSRF nonce minted by `oauth-state.ts`.
*
* `access_type=offline` + `prompt=consent` are Google's requirement to receive
* a refresh token (the equivalent of WHOOP's `offline` scope); `prompt=consent`
* forces the consent screen so a re-connect always returns a fresh refresh
* token even if the user previously granted.
*/
export function getAuthorizationUrl(
state: string,
creds: FitbitCredentials,
): string {
const params = new URLSearchParams({
response_type: "code",
client_id: creds.clientId,
redirect_uri: getRedirectUri(),
scope: FITBIT_OAUTH_SCOPE,
access_type: "offline",
prompt: "consent",
state,
});
return `${GOOGLE_OAUTH_AUTH_URL}?${params}`;
}
export interface FitbitTokenResponse {
access_token: string;
/**
* Present on the initial code exchange and whenever Google issues a new
* refresh token; ABSENT on a routine refresh because Google does not rotate.
* The sync layer keeps the stored token when this is missing.
*/
refresh_token?: string;
expires_in: number;
scope?: string;
token_type?: string;
}
/** Basic-auth header carrying the confidential client credentials. */
function basicAuthHeader(creds: FitbitCredentials): string {
const raw = `${creds.clientId}:${creds.clientSecret}`;
return `Basic ${Buffer.from(raw, "utf8").toString("base64")}`;
}
async function postToken(
params: URLSearchParams,
creds: FitbitCredentials,
verb: string,
): Promise<FitbitTokenResponse> {
const start = performance.now();
const res = await safeFetch(GOOGLE_OAUTH_TOKEN_URL, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: basicAuthHeader(creds),
},
body: params.toString(),
});
const json = await res.json().catch(() => null);
const verdict = classifyFitbitResponse(res.status);
getEvent()?.addExternalCall({
service: "fitbit",
method: verb,
duration_ms: Math.round(performance.now() - start),
status: res.status,
error: verdict.classification === "success" ? undefined : verdict.reason,
});
if (verdict.classification !== "success") {
throw new FitbitApiError({
verb,
classification: verdict.classification,
httpStatus: verdict.httpStatus,
reason: verdict.reason,
upstreamError: typeof json?.error === "string" ? json.error : undefined,
});
}
return json as FitbitTokenResponse;
}
/** Exchange an authorization code for the initial token pair. */
export async function exchangeCode(
code: string,
creds: FitbitCredentials,
): Promise<FitbitTokenResponse> {
return postToken(
new URLSearchParams({
grant_type: "authorization_code",
code,
client_id: creds.clientId,
redirect_uri: getRedirectUri(),
}),
creds,
"exchangeCode",
);
}
/**
* Refresh an expired access token. Google does NOT rotate refresh tokens — the
* response carries a fresh `access_token` + `expires_in` but usually omits
* `refresh_token`. The caller persists the new access token + expiry and keeps
* the stored refresh token unless a new one is returned. The original scope is
* preserved by Google, so no `scope` param is re-sent.
*/
export async function refreshAccessToken(
refreshToken: string,
creds: FitbitCredentials,
): Promise<FitbitTokenResponse> {
return postToken(
new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: creds.clientId,
}),
creds,
"refreshAccessToken",
);
}
/**
* The Google Health profile carries the external user id used as the
* connection's `fitbitUserId`. Mirrors WHOOP's `fetchProfile` (a single GET,
* not a paginated collection).
*/
export interface FitbitProfile {
/**
* External user identifier. Google returns `me`-relative profile data; the
* stable id is surfaced under `name` (a `users/{id}` resource name) or `id`
* depending on the API surface — both are captured here and resolved at the
* call site. Re-verify the exact field against a live account at the data-sync
* wave.
*/
name?: string;
id?: string;
}
export async function fetchProfile(
accessToken: string,
): Promise<FitbitProfile> {
const start = performance.now();
const res = await safeFetch(`${FITBIT_API_BASE}/users/me/profile`, {
method: "GET",
headers: { Authorization: `Bearer ${accessToken}` },
});
const json = (await res.json().catch(() => null)) as FitbitProfile | null;
const verdict = classifyFitbitResponse(res.status);
getEvent()?.addExternalCall({
service: "fitbit",
method: "fetchProfile",
duration_ms: Math.round(performance.now() - start),
status: res.status,
error: verdict.classification === "success" ? undefined : verdict.reason,
});
if (verdict.classification !== "success") {
throw new FitbitApiError({
verb: "fetchProfile",
classification: verdict.classification,
httpStatus: verdict.httpStatus,
reason: verdict.reason,
});
}
return (json ?? {}) as FitbitProfile;
}
/**
* Resolve the stable external user id from a Google Health profile. The
* `users/{id}` resource name is the canonical anchor; fall back to a bare `id`
* or "me" so the connection always persists a non-empty `fitbitUserId`.
*/
export function resolveFitbitUserId(profile: FitbitProfile): string {
if (profile.name) {
const tail = profile.name.split("/").pop();
if (tail) return tail;
}
if (profile.id) return profile.id;
return "me";
}
// ─── Data-point reads (Google Health `dataPoints.list`) ────────
//
// The Google Health API exposes one uniform `DataPoint` resource shape across
// every data type, read through `GET /v4/users/me/dataTypes/{dataType}/dataPoints`
// with `nextPageToken` pagination. This mirrors the WHOOP `fetchCollection`
// page-walk (`whoop/client.ts:270`), but Google's value-field JSON is NOT fully
// published yet — so the mappers below are intentionally defensive: every value
// is pulled out of a small set of candidate field shapes and guarded by a
// finite-positive check before it becomes a Measurement. Capture the real
// per-type value-field JSON into `src/lib/fitbit/mapping.md` against a live test
// account at build and tighten the extractors then.
//
// Casing gotcha (design §A.1): the data-type id is **kebab-case in the path**
// (`body-fat`) and **snake_case in the `filter`** (`body_fat`). `FITBIT_DATA_TYPES`
// pins both forms so a fetcher can never encode the wrong one.
/**
* Page-size ceiling for `dataPoints.list`. The daily/intraday reads default to
* 1440 (one-per-minute) and cap at 10 000; sleep/exercise cap at 25 (same as
* WHOOP's `WHOOP_PAGE_LIMIT`). The launch metrics use the daily/spot reads, so
* the default page size is the larger value.
*/
export const FITBIT_PAGE_SIZE = 1000;
/** Sleep/exercise read cap — matches the Google Health 25-cap for those types. */
export const FITBIT_ACTIVITY_PAGE_SIZE = 25;
/**
* One data type's two 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. The `timeField` names the value object's
* time anchor (a sample time for spot readings, a date for daily summaries) so
* the incremental filter targets the right field.
*/
export interface FitbitDataType {
/** kebab-case segment for the request path. */
path: string;
/** snake_case prefix for the `filter` predicate. */
filter: string;
/**
* Which time anchor the filter / measuredAt resolution targets:
* - `sample` → spot reading (`{type}.sample_time.physical_time`).
* - `date` → daily summary (`{type}.date`).
*/
timeField: "sample" | "date";
}
/**
* The launch (W3 metrics) data types. Each entry pins the kebab-path + snake-filter
* pair so the two encodings can never drift. Activity/sleep/workout types land in
* W5; only the health-metrics bundle is read here.
*/
export const FITBIT_DATA_TYPES = {
weight: { path: "weight", filter: "weight", timeField: "sample" },
bodyFat: { path: "body-fat", filter: "body_fat", timeField: "sample" },
oxygenSaturation: {
path: "daily-oxygen-saturation",
filter: "daily_oxygen_saturation",
timeField: "date",
},
heartRateVariability: {
path: "daily-heart-rate-variability",
filter: "daily_heart_rate_variability",
timeField: "date",
},
restingHeartRate: {
path: "daily-resting-heart-rate",
filter: "daily_resting_heart_rate",
timeField: "date",
},
respiratoryRate: {
path: "daily-respiratory-rate",
filter: "daily_respiratory_rate",
timeField: "date",
},
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",
},
// ── Activity bundle (W5) — daily cumulative totals ─────────────
// Scope: `googlehealth.activity_and_fitness.readonly`. Each is a per-day
// summary (`timeField: "date"`); 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", timeField: "date" },
distance: { path: "distance", filter: "distance", timeField: "date" },
activeCalories: {
path: "active-calories",
filter: "active_calories",
timeField: "date",
},
floors: { path: "floors", filter: "floors", timeField: "date" },
vo2Max: { path: "vo2-max", filter: "vo2_max", timeField: "date" },
// ── Sleep bundle (W5) ──────────────────────────────────────────
// Scope: `googlehealth.sleep.readonly`. A sleep session carries a start +
// end + a per-stage breakdown; mapped to per-stage SLEEP_DURATION rows.
sleep: { path: "sleep", filter: "sleep", timeField: "sample" },
// ── Exercise bundle (W5) ───────────────────────────────────────
// Scope: `googlehealth.activity_and_fitness.readonly`. An exercise session
// → a `Workout` row (NOT a Measurement).
exercise: { path: "exercise", filter: "exercise", timeField: "sample" },
} as const satisfies Record<string, FitbitDataType>;
export type FitbitDataTypeKey = keyof typeof FITBIT_DATA_TYPES;
/** Google Health `DataPoint` — value object is type-keyed + carries a time anchor. */
export interface FitbitDataPoint {
[key: string]: unknown;
}
/** `dataPoints.list` envelope: `{ dataPoints, nextPageToken }`. */
interface FitbitDataPointPage {
dataPoints?: FitbitDataPoint[];
nextPageToken?: string | null;
}
interface DataPointQuery {
/** Lower-bound incremental cursor; omitted on a full backfill. */
start?: Date;
/** Page size (defaults to `FITBIT_PAGE_SIZE`). */
pageSize?: number;
/** Hard ceiling on pages walked (defence against a runaway cursor). */
maxPages?: number;
}
/**
* Walk every `DataPoint` for one data type since the incremental cursor.
* Mirrors the WHOOP `fetchCollection` page-loop: `nextPageToken` pagination, a
* 1000-page `maxPages` ceiling, and per-page `addExternalCall` telemetry. 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: FitbitDataType,
accessToken: string,
verb: string,
query: DataPointQuery = {},
): Promise<FitbitDataPoint[]> {
const points: FitbitDataPoint[] = [];
let pageToken: string | null | undefined;
let pageCount = 0;
const maxPages = query.maxPages ?? 1000;
const pageSize = query.pageSize ?? FITBIT_PAGE_SIZE;
do {
const params = new URLSearchParams({ pageSize: String(pageSize) });
if (query.start) {
// snake_case filter prefix; sample readings filter on the sample time,
// daily summaries on the civil date.
const field =
dataType.timeField === "sample"
? `${dataType.filter}.sample_time.physical_time`
: `${dataType.filter}.date`;
const bound =
dataType.timeField === "sample"
? query.start.toISOString()
: query.start.toISOString().slice(0, 10);
params.set("filter", `${field} >= "${bound}"`);
}
if (pageToken) params.set("pageToken", pageToken);
const pageStart = performance.now();
const res = await safeFetch(
`${FITBIT_API_BASE}/users/me/dataTypes/${dataType.path}/dataPoints?${params}`,
{
method: "GET",
headers: { Authorization: `Bearer ${accessToken}` },
},
);
const json = (await res.json().catch(() => null)) as FitbitDataPointPage | null;
const verdict = classifyFitbitResponse(res.status);
getEvent()?.addExternalCall({
service: "fitbit",
method: `${verb}(page=${pageCount})`,
duration_ms: Math.round(performance.now() - pageStart),
status: res.status,
error: verdict.classification === "success" ? undefined : verdict.reason,
});
if (verdict.classification !== "success") {
throw new FitbitApiError({
verb,
classification: verdict.classification,
httpStatus: verdict.httpStatus,
reason: verdict.reason,
});
}
for (const p of json?.dataPoints ?? []) points.push(p);
pageToken = json?.nextPageToken ?? null;
pageCount += 1;
} while (pageToken && pageCount < maxPages);
return points;
}
// ─── Field → Measurement mapping ───────────────────────────────
// The single source of truth is `src/lib/fitbit/mapping.md` — keep both in
// sync when adding entries.
/** Metres → centimetres (Fitbit `height` → `User.heightCm`). */
const M_TO_CM = 100;
function round2(n: number): number {
return parseFloat(n.toFixed(2));
}
/** Finite + strictly-positive guard — the WHOOP `positive()` discipline. */
function positive(n: unknown): n is number {
return typeof n === "number" && Number.isFinite(n) && n > 0;
}
/**
* A single mapped Fitbit reading destined for one `Measurement` row. The
* `source` (`FITBIT`) 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. Mirrors WHOOP's `MappedMeasurement`.
*/
export interface FitbitMappedMeasurement {
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?: FitbitSleepStage;
/**
* `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 Fitbit sleep stage maps onto. */
export type FitbitSleepStage =
| "IN_BED"
| "AWAKE"
| "ASLEEP"
| "REM"
| "CORE"
| "DEEP";
/**
* Pull the first finite number out of a list of candidate value paths on a
* `DataPoint`. The Google value-field JSON is undocumented, so each mapper hands
* the small set of shapes the field is likely to take (a bare `value`, a typed
* sub-object, a `{ value: { fpVal } }` wrapper, …); the first finite hit wins.
*/
function firstNumber(
point: FitbitDataPoint,
paths: string[],
): number | null {
for (const path of paths) {
const v = readPath(point, path);
if (positive(v)) return v;
// A value can legitimately be zero for some metrics, but the launch metrics
// (weight, body-fat, SpO2, HRV, RHR, respiratory rate, HR, height) are all
// strictly positive — a zero is a garbage/empty reading, so `positive` is
// the right guard.
}
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;
}
/**
* Resolve a `DataPoint`'s measurement timestamp. Sample readings carry a
* `sample_time.physical_time`; daily summaries carry a `date` (a civil date or a
* `{year,month,day}` object). Falls back to the fetch time only when nothing
* parses, so a row is never dropped for a missing anchor.
*/
function resolveMeasuredAt(
point: FitbitDataPoint,
dataType: FitbitDataType,
fallback: Date,
): 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;
}
} else {
const dateVal = readPath(point, `${dataType.filter}.date`);
if (typeof dateVal === "string") {
const d = new Date(dateVal);
if (!Number.isNaN(d.getTime())) return d;
}
if (dateVal && typeof dateVal === "object") {
const o = dateVal 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 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 (a re-sync of
* the same window overwrites in place rather than minting a duplicate).
*/
function externalAnchor(point: FitbitDataPoint, dataType: FitbitDataType): string {
const at = resolveMeasuredAt(point, dataType, new Date(0));
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 (an empty/garbage point).
*/
function mapSimple(
point: FitbitDataPoint,
dataType: FitbitDataType,
spec: { type: string; unit: string; fieldTag: string; valuePaths: string[]; factor?: number },
): FitbitMappedMeasurement[] {
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}`,
},
];
}
/** Candidate value-field shapes a Google `DataPoint` is likely to carry. */
function valuePaths(filterKey: string, leaf: string): string[] {
return [
`${filterKey}.${leaf}`,
`${filterKey}.value.${leaf}`,
`${filterKey}.value`,
`value.${leaf}`,
`value`,
];
}
export function mapWeight(point: FitbitDataPoint): FitbitMappedMeasurement[] {
const dt = FITBIT_DATA_TYPES.weight;
return mapSimple(point, dt, {
type: "WEIGHT",
unit: "kg",
fieldTag: "weight",
valuePaths: valuePaths(dt.filter, "kilograms"),
});
}
export function mapBodyFat(point: FitbitDataPoint): FitbitMappedMeasurement[] {
const dt = FITBIT_DATA_TYPES.bodyFat;
return mapSimple(point, dt, {
type: "BODY_FAT",
unit: "%",
fieldTag: "body_fat",
valuePaths: valuePaths(dt.filter, "percentage"),
});
}
export function mapOxygenSaturation(
point: FitbitDataPoint,
): FitbitMappedMeasurement[] {
const dt = FITBIT_DATA_TYPES.oxygenSaturation;
return mapSimple(point, dt, {
type: "OXYGEN_SATURATION",
unit: "%",
fieldTag: "spo2",
valuePaths: [
...valuePaths(dt.filter, "average_percentage"),
...valuePaths(dt.filter, "percentage"),
],
});
}
export function mapHeartRateVariability(
point: FitbitDataPoint,
): FitbitMappedMeasurement[] {
const dt = FITBIT_DATA_TYPES.heartRateVariability;
// Fitbit reports a nightly RMSSD-style HRV. Per the design decision it lands
// in the SDNN-lineage `HEART_RATE_VARIABILITY` slot (Apple-comparable), NOT
// WHOOP's `HRV_RMSSD` (reserved for the WHOOP-native estimator). Re-confirm
// the estimator against a live account and revisit if it warrants `HRV_RMSSD`.
return mapSimple(point, dt, {
type: "HEART_RATE_VARIABILITY",
unit: "ms",
fieldTag: "hrv",
valuePaths: [
...valuePaths(dt.filter, "rmssd_milliseconds"),
...valuePaths(dt.filter, "milliseconds"),
],
});
}
export function mapRestingHeartRate(
point: FitbitDataPoint,
): FitbitMappedMeasurement[] {
const dt = FITBIT_DATA_TYPES.restingHeartRate;
return mapSimple(point, dt, {
type: "RESTING_HEART_RATE",
unit: "bpm",
fieldTag: "rhr",
valuePaths: valuePaths(dt.filter, "beats_per_minute"),
});
}
export function mapRespiratoryRate(
point: FitbitDataPoint,
): FitbitMappedMeasurement[] {
const dt = FITBIT_DATA_TYPES.respiratoryRate;
return mapSimple(point, dt, {
type: "RESPIRATORY_RATE",
unit: "breaths/min",
fieldTag: "resp_rate",
valuePaths: [
...valuePaths(dt.filter, "breaths_per_minute"),
...valuePaths(dt.filter, "average_breaths_per_minute"),
],
});
}
export function mapHeartRate(point: FitbitDataPoint): FitbitMappedMeasurement[] {
const dt = FITBIT_DATA_TYPES.heartRate;
return mapSimple(point, dt, {
type: "PULSE",
unit: "bpm",
fieldTag: "hr",
valuePaths: valuePaths(dt.filter, "beats_per_minute"),
});
}
export function mapSleepTemperature(
point: FitbitDataPoint,
): FitbitMappedMeasurement[] {
const dt = FITBIT_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 Fitbit `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, mirroring WHOOP's
* `mapBody` height handling. Returns cm.
*/
export function mapHeightCm(point: FitbitDataPoint): number | null {
const dt = FITBIT_DATA_TYPES.height;
// Google may report height in metres or centimetres; try cm-direct first, then
// metres × 100. Both pass through the positive guard.
const cm = firstNumber(point, valuePaths(dt.filter, "centimeters"));
if (cm !== null) return round2(cm);
const m = firstNumber(point, valuePaths(dt.filter, "meters"));
if (m !== null) return round2(m * M_TO_CM);
return null;
}
/**
* Field→Measurement mapping table (mirror of `mapping.md`). Documents which
* Fitbit/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 FITBIT_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 Fitbit",
},
bodyFat: { type: "BODY_FAT", unit: "%", path: "body-fat", filter: "body_fat" },
oxygenSaturation: {
type: "OXYGEN_SATURATION",
unit: "%",
path: "daily-oxygen-saturation",
filter: "daily_oxygen_saturation",
},
heartRateVariability: {
type: "HEART_RATE_VARIABILITY",
unit: "ms",
path: "daily-heart-rate-variability",
filter: "daily_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: "daily-respiratory-rate",
filter: "daily_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 (W5) — 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",
},
activeCalories: {
type: "ACTIVE_ENERGY_BURNED",
unit: "kcal",
path: "active-calories",
filter: "active_calories",
note: "ACTIVE portion only (NOT total caloriesOut); 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",
},
};
// ─── W5 mappers: activity (cumulative), sleep stages, workouts ──
/** Finite + non-negative guard — cumulative metrics admit a legitimate 0. */
function nonNegative(n: unknown): n is number {
return typeof n === "number" && Number.isFinite(n) && n >= 0;
}
/**
* Pull the first finite NON-negative number out of a list of candidate value
* paths. Unlike `firstNumber` (strictly positive), this admits a legitimate
* zero — a day of rest still records 0 steps / 0 floors / 0 active kcal, and
* dropping the zero would leave a hole the chart misreads as missing data.
*/
function firstNonNegativeNumber(
point: FitbitDataPoint,
paths: string[],
): number | null {
for (const path of paths) {
const v = readPath(point, path);
if (nonNegative(v)) return v;
}
return null;
}
/**
* Map one daily cumulative-activity data point into a single Measurement
* reading. The externalId is the `stats:`-prefixed daily-total shape so a
* re-fetched day overwrites in place rather than minting a duplicate — the same
* 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). `latestWins`
* (VO2 max) carries the same daily anchor; it is daily latest-wins rather than a
* running sum, but the per-day overwrite key is identical.
*/
function mapDailyCumulative(
point: FitbitDataPoint,
dataType: FitbitDataType,
spec: {
type: string;
unit: string;
fieldTag: string;
valuePaths: string[];
factor?: number;
/** VO2 max is daily latest-wins, not a running total — still one row/day. */
latestWins?: boolean;
},
): FitbitMappedMeasurement[] {
// VO2 max is strictly positive; the running totals admit a legitimate zero.
let value = spec.latestWins
? firstNumber(point, spec.valuePaths)
: 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
return [
{
type: spec.type,
value: round2(value),
unit: spec.unit,
measuredAt: resolveMeasuredAt(point, dataType, new Date()),
// `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: FitbitDataPoint): FitbitMappedMeasurement[] {
const dt = FITBIT_DATA_TYPES.steps;
return mapDailyCumulative(point, dt, {
type: "ACTIVITY_STEPS",
unit: "steps",
fieldTag: "steps",
valuePaths: [...valuePaths(dt.filter, "count"), ...valuePaths(dt.filter, "steps")],
});
}
export function mapDistance(point: FitbitDataPoint): FitbitMappedMeasurement[] {
const dt = FITBIT_DATA_TYPES.distance;
// Google may report metres or kilometres; prefer the explicit-metres field,
// else convert km → m. Both pass the non-negative guard.
const meters = firstNonNegativeNumber(
point,
valuePaths(dt.filter, "meters"),
);
if (meters !== null) {
return [
{
type: "WALKING_RUNNING_DISTANCE",
value: round2(meters),
unit: "m",
measuredAt: resolveMeasuredAt(point, dt, new Date()),
fieldTag: `distance:${externalAnchor(point, dt)}`,
cumulativeDaily: true,
},
];
}
return mapDailyCumulative(point, dt, {
type: "WALKING_RUNNING_DISTANCE",
unit: "m",
fieldTag: "distance",
valuePaths: valuePaths(dt.filter, "kilometers"),
factor: 1000,
});
}
export function mapActiveCalories(
point: FitbitDataPoint,
): FitbitMappedMeasurement[] {
const dt = FITBIT_DATA_TYPES.activeCalories;
// ACTIVE energy only — NOT total caloriesOut (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_calories",
valuePaths: [
...valuePaths(dt.filter, "active_kilocalories"),
...valuePaths(dt.filter, "kilocalories"),
...valuePaths(dt.filter, "calories"),
],
});
}
export function mapFloors(point: FitbitDataPoint): FitbitMappedMeasurement[] {
const dt = FITBIT_DATA_TYPES.floors;
return mapDailyCumulative(point, dt, {
type: "FLIGHTS_CLIMBED",
unit: "flights",
fieldTag: "floors",
valuePaths: [...valuePaths(dt.filter, "count"), ...valuePaths(dt.filter, "floors")],
});
}
export function mapVo2Max(point: FitbitDataPoint): FitbitMappedMeasurement[] {
const dt = FITBIT_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
});
}
// ── 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 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 FITBIT_SLEEP_STAGE_MAP: Record<string, FitbitSleepStage> = {
// Canonical Google Health stage names (snake / lower variants accepted).
in_bed: "IN_BED",
inbed: "IN_BED",
awake: "AWAKE",
wake: "AWAKE",
light: "CORE", // Fitbit "light" ↔ Apple "core" (same shallow-NREM band)
core: "CORE",
rem: "REM",
deep: "DEEP",
// Fitbit's 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 mapFitbitSleepStage(raw: unknown): FitbitSleepStage | null {
if (typeof raw !== "string") return null;
const key = raw.trim().toLowerCase().replace(/[\s-]+/g, "_");
return FITBIT_SLEEP_STAGE_MAP[key] ?? FITBIT_SLEEP_STAGE_MAP[key.replace(/_/g, "")] ?? null;
}
/** One sleep-stage segment pulled defensively off a Google sleep session. */
interface FitbitSleepSegment {
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`, tolerating the
* undocumented JSON shape. Candidate locations: `sleep.segments`,
* `sleep.stages`, `sleep.levels.data`, or a bare top-level `segments`/`stages`.
*/
function readSleepSegments(point: FitbitDataPoint): FitbitSleepSegment[] {
const candidates = [
readPath(point, "sleep.segments"),
readPath(point, "sleep.stages"),
readPath(point, "sleep.levels.data"),
readPath(point, "segments"),
readPath(point, "stages"),
readPath(point, "levels.data"),
];
for (const c of candidates) {
if (!Array.isArray(c)) continue;
const out: FitbitSleepSegment[] = [];
for (const raw of c) {
if (!raw || typeof raw !== "object") continue;
const o = raw as Record<string, unknown>;
const stage =
(typeof o.stage === "string" && o.stage) ||
(typeof o.level === "string" && o.level) ||
(typeof o.type === "string" && o.type) ||
"";
const startTime =
(typeof o.startTime === "string" && o.startTime) ||
(typeof o.start_time === "string" && o.start_time) ||
(typeof o.dateTime === "string" && o.dateTime) ||
undefined;
const endTime =
(typeof o.endTime === "string" && o.endTime) ||
(typeof o.end_time === "string" && o.end_time) ||
undefined;
if (stage) out.push({ stage, startTime, endTime });
}
if (out.length > 0) return out;
}
return [];
}
/**
* The stable session anchor for a sleep `DataPoint`'s externalId. The session
* end (or start) ISO instant is unique per night, so per-stage rows key as
* `<session-anchor>:sleep_<stage>` — a re-scored night overwrites in place.
*/
function sleepSessionAnchor(point: FitbitDataPoint): string {
const end =
readPath(point, "sleep.endTime") ??
readPath(point, "sleep.end_time") ??
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 start =
readPath(point, "sleep.startTime") ??
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();
}
return new Date(0).toISOString();
}
/**
* Map one Google sleep session into per-stage `SLEEP_DURATION` rows. Each
* stage's segments are summed into one row carrying the stage's `SleepStage`,
* `measuredAt = the latest segment END for that stage`, and a stage-scoped
* fieldTag so the (up to six) stage rows for one night stay distinct under the
* dedup key. Unknown stage labels are skipped; a session with no parseable
* segment yields nothing.
*/
export function mapSleepSession(
point: FitbitDataPoint,
): FitbitMappedMeasurement[] {
const segments = readSleepSegments(point);
if (segments.length === 0) return [];
// Accumulate per-stage minutes + track the latest end instant for each stage.
const perStage = new Map<
FitbitSleepStage,
{ minutes: number; lastEnd: Date }
>();
for (const seg of segments) {
const stage = mapFitbitSleepStage(seg.stage);
if (!stage) continue;
const mins = minutesBetween(seg.startTime, seg.endTime);
if (mins === null) continue;
const end = new Date(seg.endTime as string);
const prev = perStage.get(stage);
if (prev) {
prev.minutes += mins;
if (end > prev.lastEnd) prev.lastEnd = end;
} else {
perStage.set(stage, { minutes: mins, lastEnd: end });
}
}
const anchor = sleepSessionAnchor(point);
const out: FitbitMappedMeasurement[] = [];
for (const [stage, agg] of perStage) {
if (!(agg.minutes > 0)) continue;
out.push({
type: "SLEEP_DURATION",
value: round2(agg.minutes),
unit: "minutes",
measuredAt: agg.lastEnd,
fieldTag: `${anchor}:sleep_${stage.toLowerCase()}`,
sleepStage: stage,
});
}
return out;
}
// ── Workouts (exercise sessions) ───────────────────────────────
/**
* Google Health exercise-activity-type → HealthLog `WorkoutSportType`. Unknown
* types fall through to a generic label; the column is free-text so an unmapped
* type still persists (just not under a canonical sport bucket).
*/
const FITBIT_EXERCISE_TYPE_MAP: Record<string, string> = {
walk: "walking",
walking: "walking",
run: "running",
running: "running",
treadmill: "running",
bike: "cycling",
biking: "cycling",
cycling: "cycling",
spinning: "cycling",
hike: "hiking",
hiking: "hiking",
swim: "swimming",
swimming: "swimming",
rowing: "rowing",
elliptical: "elliptical",
stairclimber: "stairClimber",
yoga: "yoga",
pilates: "mindAndBody",
weights: "strength",
strength: "strength",
workout: "strength",
hiit: "hiit",
interval_workout: "hiit",
dance: "dance",
golf: "golf",
tennis: "tennis",
basketball: "basketball",
soccer: "soccer",
bootcamp: "crossTraining",
circuit_training: "crossTraining",
sport: "mixedCardio",
} as const;
/** Resolve a Google exercise activity type to a canonical sport label. */
export function mapFitbitSportType(raw: unknown): string {
if (typeof raw !== "string" || raw.trim() === "") return "other";
const key = raw.trim().toLowerCase().replace(/[\s-]+/g, "_");
return (
FITBIT_EXERCISE_TYPE_MAP[key] ??
FITBIT_EXERCISE_TYPE_MAP[key.replace(/_/g, "")] ??
"other"
);
}
/** One mapped Fitbit exercise session destined for a `Workout` row. */
export interface FitbitMappedWorkout {
externalId: string;
sportType: string;
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), or null. */
function readNumber(point: FitbitDataPoint, paths: string[]): number | null {
for (const path of paths) {
const v = readPath(point, path);
if (typeof v === "number" && Number.isFinite(v)) return v;
}
return null;
}
/** Read an ISO/string instant off a list of candidate paths, or null. */
function readInstant(point: FitbitDataPoint, paths: 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;
}
}
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). 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.
*/
export function mapWorkout(point: FitbitDataPoint): FitbitMappedWorkout | null {
const f = FITBIT_DATA_TYPES.exercise.filter;
const startedAt = readInstant(point, [
`${f}.startTime`,
`${f}.start_time`,
`${f}.sample_time.physical_time`,
"startTime",
"start_time",
]);
const endedAt = readInstant(point, [
`${f}.endTime`,
`${f}.end_time`,
"endTime",
"end_time",
]);
if (!startedAt || !endedAt || endedAt <= startedAt) return null;
const durationSec = Math.round((endedAt.getTime() - startedAt.getTime()) / 1000);
const sessionId =
(typeof readPath(point, `${f}.session_id`) === "string" &&
(readPath(point, `${f}.session_id`) as string)) ||
(typeof readPath(point, `${f}.id`) === "string" &&
(readPath(point, `${f}.id`) as string)) ||
(typeof readPath(point, "name") === "string" &&
(readPath(point, "name") as string)) ||
null;
const externalId = sessionId ?? `exercise:${startedAt.toISOString()}`;
const sportRaw =
readPath(point, `${f}.activity_type`) ??
readPath(point, `${f}.exercise_type`) ??
readPath(point, `${f}.type`) ??
readPath(point, "activityType");
const energyKcal = readNumber(point, [
`${f}.active_kilocalories`,
`${f}.calories`,
`${f}.energy.kilocalories`,
]);
const distanceM = readNumber(point, [
`${f}.distance.meters`,
`${f}.distance_meters`,
`${f}.distance`,
]);
const avgHr = readNumber(point, [
`${f}.average_heart_rate.beats_per_minute`,
`${f}.average_heart_rate`,
`${f}.heart_rate.average`,
]);
const maxHr = readNumber(point, [
`${f}.maximum_heart_rate.beats_per_minute`,
`${f}.max_heart_rate`,
`${f}.heart_rate.maximum`,
]);
const minHr = readNumber(point, [
`${f}.minimum_heart_rate.beats_per_minute`,
`${f}.min_heart_rate`,
`${f}.heart_rate.minimum`,
]);
return {
externalId,
sportType: mapFitbitSportType(sportRaw),
startedAt,
endedAt,
durationSec,
totalEnergyKcal: energyKcal !== null ? Math.round(energyKcal) : null,
totalDistanceM: distanceM !== null && distanceM >= 0 ? round2(distanceM) : null,
avgHeartRate: avgHr !== null && avgHr > 0 ? Math.round(avgHr) : null,
maxHeartRate: maxHr !== null && maxHr > 0 ? Math.round(maxHr) : null,
minHeartRate: minHr !== null && minHr > 0 ? Math.round(minHr) : null,
};
}