/** * Fitbit Web API client — classic `api.fitbit.com` transport (v1.20.0). * Docs: https://dev.fitbit.com/build/reference/web-api/ * * v1.20.0 retargets the Fitbit integration from the Google Health API * (`health.googleapis.com`, Restricted scopes behind brand-verification + an * annual CASA assessment — a hard adoption wall for self-hosters) onto the * CLASSIC Fitbit Web API. A self-hoster registers an app at dev.fitbit.com in * minutes (no CASA), so this is the path that actually ships to users. * * Only the TRANSPORT layer is forked: the auth/token URLs, PKCE handshake, * classic scopes, rotating-refresh-token handling, per-endpoint REST fetchers, * and the classic-shape value extractors. Everything downstream — the * `FitbitMappedMeasurement` / `FitbitMappedWorkout` output shapes, the * upsert/dedup/rollup tail in `sync.ts`, the source-priority ladders, the * settings card, the rotation registry, the poll worker, the backfill — reuses * unchanged. * * KEY DELTAS vs the prior Google transport: * - PKCE (S256). Fitbit recommends PKCE; we mint a `code_verifier` at connect, * stash it on the OAuth-state row, and present it on the token exchange. The * confidential client secret still rides in the Basic-auth header (Fitbit * accepts Basic + PKCE together), so a self-hoster's secret is not the only * protection. * - Refresh tokens ROTATE (one-time use). The refresh response carries a NEW * `refresh_token` that MUST be persisted, replacing the stored one — the * inverse of the Google path. `getValidToken` (sync.ts) persists it * unconditionally now. * - 150 requests/hour/user rate limit (far tighter than Google). The reads are * date-RANGE endpoints (one request covers a whole window, capped per the * per-endpoint range limits), and the page-walk / cohort concurrency caps are * re-tuned accordingly. * * NOTE (deprecation): the classic Fitbit Web API is announced for deprecation in * September 2026, with migration directed back at the Google Health API. Until a * self-serve Google path exists, the classic API is the only viable transport * for self-hosters; the card keeps its experimental badge. */ import { createHash, randomBytes } from "node:crypto"; import { getEvent } from "@/lib/logging/context"; import { safeFetch } from "@/lib/safe-fetch"; import { canonicalDailyTimestamp } from "@/lib/measurements/consolidation-tz"; import { zonedWallClockToUtc } from "@/lib/tz/wall-clock"; import { FitbitApiError, classifyFitbitResponse } from "./response-classifier"; import { envValue } from "@/lib/env"; /** Classic Fitbit Web API base. */ export const FITBIT_API_BASE = "https://api.fitbit.com"; const FITBIT_OAUTH_AUTH_URL = "https://www.fitbit.com/oauth2/authorize"; const FITBIT_OAUTH_TOKEN_URL = "https://api.fitbit.com/oauth2/token"; /** * Fitbit returns body weight / distance in metric (kg / km) only when an * `Accept-Language` that maps to a metric locale is sent — the US default is * imperial. Pin a metric locale on every data read so the mappers can trust the * unit. (`en_GB` is metric for weight + distance.) */ const FITBIT_METRIC_LOCALE = "en_GB"; export interface FitbitCredentials { clientId: string; clientSecret: string; } /** * Resolve the OAuth `redirect_uri`, then assert it against an allowlist * (defence-in-depth; carried over from the prior transport). * * The value is operator-controlled config (`FITBIT_REDIRECT_URI`, else derived * from `NEXT_PUBLIC_APP_URL`), not user input, and Fitbit's registered-redirect * check is the real backstop. But a misconfigured or `Host`-coerced * `NEXT_PUBLIC_APP_URL` (a mis-deployed reverse proxy reflecting a forwarded * Host) would otherwise send the authorization code's landing URL off-origin. * Pin the target so a malformed origin fails fast at the handshake: * - must be an absolute, parseable URL, * - must be https (the one exception is a localhost/loopback dev host), * - must land on the fixed `/api/fitbit/callback` path, * - when derived from `NEXT_PUBLIC_APP_URL`, must stay same-origin with it. */ export function getFitbitRedirectUri(): string { // An empty or blank value counts as unset: the compose whitelist // materialises an unset var as an empty string, which must fall through to // the derived URI rather than read as "not configured". const explicit = envValue("FITBIT_REDIRECT_URI"); const appUrl = envValue("NEXT_PUBLIC_APP_URL"); const raw = explicit ?? (appUrl ? `${appUrl}/api/fitbit/callback` : undefined); if (!raw) { throw new Error( "Fitbit redirect_uri is not configured — set FITBIT_REDIRECT_URI or NEXT_PUBLIC_APP_URL", ); } let parsed: URL; try { parsed = new URL(raw); } catch { throw new Error(`Fitbit redirect_uri is not an absolute URL: ${raw}`); } const isLoopback = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1" || parsed.hostname === "[::1]" || parsed.hostname === "::1"; if ( parsed.protocol !== "https:" && !(parsed.protocol === "http:" && isLoopback) ) { throw new Error( `Fitbit redirect_uri must be https (or http on localhost): ${parsed.origin}`, ); } if (parsed.pathname !== "/api/fitbit/callback") { throw new Error( `Fitbit redirect_uri must target /api/fitbit/callback, got ${parsed.pathname}`, ); } // When an explicit FITBIT_REDIRECT_URI is set alongside NEXT_PUBLIC_APP_URL, // require them to share an origin so the pinned value can't drift to an // unexpected host relative to the app's own base URL. if (explicit && appUrl) { let appOrigin: string; try { appOrigin = new URL(appUrl).origin; } catch { throw new Error(`NEXT_PUBLIC_APP_URL is not an absolute URL: ${appUrl}`); } if (parsed.origin !== appOrigin) { throw new Error( `Fitbit redirect_uri origin ${parsed.origin} does not match NEXT_PUBLIC_APP_URL origin ${appOrigin}`, ); } } return parsed.toString(); } /** * Classic Fitbit OAuth scopes HealthLog requests (space-separated on the wire). * Each is independently self-serve grantable at the consent screen and the * `temperature` set is omitted on purpose (see the skin-temperature note in * `mapping.md` — the classic skin-temp reading is a baseline DELTA, not an * absolute reading, so it has no honest canonical slot here). The launch bundle * covers every metric the v1.20.0 mappers extract. */ export const FITBIT_OAUTH_SCOPE = [ "activity", "cardio_fitness", "heartrate", "oxygen_saturation", "profile", "respiratory_rate", "sleep", "weight", ].join(" "); // ─── PKCE ────────────────────────────────────────────────────── // // Fitbit recommends PKCE (S256). The verifier is a high-entropy random string; // the challenge is BASE64URL(SHA256(verifier)). The verifier is stashed on the // OAuth-state row at connect and presented on the token exchange at callback. export interface FitbitPkcePair { verifier: string; challenge: string; } /** * Mint a PKCE verifier + S256 challenge. 64 random bytes → 86 base64url chars, * comfortably inside Fitbit's 43–128 char verifier range and well past the * 256-bit entropy floor. */ export function generatePkcePair(): FitbitPkcePair { const verifier = randomBytes(64).toString("base64url"); const challenge = createHash("sha256").update(verifier).digest("base64url"); return { verifier, challenge }; } /** * Build the Fitbit authorization URL (a browser redirect, not a fetch). `state` * is the opaque CSRF nonce minted by `oauth-state.ts`; `codeChallenge` is the * S256 PKCE challenge whose verifier the callback presents on token exchange. */ export function getAuthorizationUrl( state: string, creds: FitbitCredentials, codeChallenge: string, ): string { const params = new URLSearchParams({ response_type: "code", client_id: creds.clientId, redirect_uri: getFitbitRedirectUri(), scope: FITBIT_OAUTH_SCOPE, code_challenge: codeChallenge, code_challenge_method: "S256", state, }); return `${FITBIT_OAUTH_AUTH_URL}?${params}`; } export interface FitbitTokenResponse { access_token: string; /** * Classic Fitbit refresh tokens ROTATE (one-time use) — every token response * (initial exchange AND every refresh) carries a fresh `refresh_token` that * MUST replace the stored one. Typed optional only to guard a malformed * response defensively; the callers treat an absent value as a hard error / * keep-existing fallback. */ refresh_token?: string; expires_in: number; scope?: string; token_type?: string; user_id?: 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 { const start = performance.now(); const res = await safeFetch(FITBIT_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?.errors?.[0]?.errorType === "string" ? json.errors[0].errorType : typeof json?.error === "string" ? json.error : undefined, }); } // A 2xx with an empty or non-JSON body (captive portal, gateway 200, 204) // leaves `json` null. Casting it straight to the token type handed callers a // null they then dereferenced, surfacing an unclassified TypeError instead of // a handled integration error. Narrow explicitly and classify as transient. if (json === null || typeof json !== "object") { throw new FitbitApiError({ verb, classification: "transient", httpStatus: res.status, reason: "empty_token_body", }); } return json as FitbitTokenResponse; } /** * Exchange an authorization code for the initial token pair, presenting the PKCE * verifier. `redirect_uri` must exactly match the one sent to the authorize * endpoint. */ export async function exchangeCode( code: string, codeVerifier: string, creds: FitbitCredentials, ): Promise { return postToken( new URLSearchParams({ grant_type: "authorization_code", code, client_id: creds.clientId, redirect_uri: getFitbitRedirectUri(), code_verifier: codeVerifier, }), creds, "exchangeCode", ); } /** * Refresh an expired access token. Classic Fitbit ROTATES the refresh token: * the response carries a fresh `access_token` AND a fresh `refresh_token`; the * caller MUST persist the new refresh token (the old one is now invalid). The * grant's scope is preserved, so no `scope` param is re-sent. */ export async function refreshAccessToken( refreshToken: string, creds: FitbitCredentials, ): Promise { return postToken( new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, }), creds, "refreshAccessToken", ); } /** * The Fitbit profile carries the stable external user id (`encodedId`) used as * the connection's `fitbitUserId`. A single GET, not a paginated collection. */ export interface FitbitProfile { user?: { encodedId?: string }; } export async function fetchProfile( accessToken: string, ): Promise { const start = performance.now(); const res = await safeFetch(`${FITBIT_API_BASE}/1/user/-/profile.json`, { 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 Fitbit profile. The `encodedId` is * the canonical anchor; fall back to "me" so the connection always persists a * non-empty `fitbitUserId` even if the field is missing. */ export function resolveFitbitUserId(profile: FitbitProfile): string { return profile.user?.encodedId ?? "me"; } // ─── Date-range reads (classic Fitbit Web API) ───────────────── // // Unlike the Google Health uniform `dataPoints` walker, the classic API exposes // one bespoke endpoint per metric, each returning a date-RANGE array in its own // JSON shape. A single request covers a whole window (no token pagination), so // the 150 req/h budget is spent on a handful of range calls per sync rather than // a per-day fan-out. Each endpoint caps its own range, so a deep backfill is // chunked into per-endpoint windows by the sync layer. /** * The longest date range (in days) the chunking sync layer requests per call. * The tightest launch endpoint cap is 30 days (body-fat, SpO2, respiratory rate, * VO2 max); pin the chunk to 30 so one chunk is always a single valid request. */ export const FITBIT_RANGE_DAYS = 30; /** Sleep is fetched on the same 30-day chunk; one request per chunk. */ export const FITBIT_SLEEP_RANGE_DAYS = 30; /** `YYYY-MM-DD` (UTC) for a Fitbit date-path segment. */ export function fitbitDate(d: Date): string { // eslint-disable-next-line healthlog/no-utc-day-key -- baseline: request window edge for the provider API, not a displayed day; rows carry their own dates return d.toISOString().slice(0, 10); } /** * Longest `Retry-After` delay (seconds) `fitbitGet` will sit out in-process * before retrying a rate-limited request. * * Fitbit's per-user budget resets on the hour, so a 429 raised near the reset * boundary carries a small `Retry-After` that a bounded wait turns into a * completed walk. A large value (an exhausted budget, up to a full hour) must * NOT be slept out: the poll cohort walks users sequentially, so a long hold * starves every other user in the tick, and inside a pg-boss job the default * expiration would lap the wait and admit a second execution. Above the cap the * request fails fast — the classified transient rides the ledger, the watermark * holds, and the next scheduled tick refetches the window. */ export const FITBIT_RETRY_AFTER_WAIT_CAP_S = 120; /** * Read `Retry-After` in its delay-seconds form. Fitbit sends the seconds form; * the HTTP-date form is deliberately ignored (returns undefined → fail fast) * rather than parsed into a wait of unknown length. */ function parseRetryAfterSeconds(res: Response): number | undefined { const raw = res.headers.get("retry-after"); if (typeof raw !== "string") return undefined; const trimmed = raw.trim(); if (!/^\d+$/.test(trimmed)) return undefined; const seconds = Number.parseInt(trimmed, 10); return Number.isFinite(seconds) ? seconds : undefined; } function waitSeconds(seconds: number): Promise { return new Promise((resolve) => setTimeout(resolve, seconds * 1000)); } interface FitbitGetAttempt { json: unknown; verdict: ReturnType; retryAfterSeconds: number | undefined; } /** One GET attempt against a classic Fitbit endpoint, classified but not thrown. */ async function fitbitGetOnce( path: string, accessToken: string, verb: string, ): Promise { const start = performance.now(); const res = await safeFetch(`${FITBIT_API_BASE}${path}`, { method: "GET", headers: { Authorization: `Bearer ${accessToken}`, "Accept-Language": FITBIT_METRIC_LOCALE, }, }); const json = await res.json().catch(() => null); const verdict = classifyFitbitResponse(res.status); const retryAfterSeconds = res.status === 429 ? parseRetryAfterSeconds(res) : undefined; getEvent()?.addExternalCall({ service: "fitbit", method: verb, duration_ms: Math.round(performance.now() - start), status: res.status, error: verdict.classification === "success" ? undefined : retryAfterSeconds === undefined ? verdict.reason : `${verdict.reason} retry_after=${retryAfterSeconds}`, }); return { json, verdict, retryAfterSeconds }; } /** * GET a classic Fitbit endpoint and return the parsed JSON body. * * Honours `Retry-After` on a 429 within `FITBIT_RETRY_AFTER_WAIT_CAP_S`: the * request waits out the delay and retries ONCE. A missing header, a delay above * the cap, or a second 429 throws exactly as an unhandled failure would — the * classified error carries `retryAfterSeconds` so the wide event records what * the upstream asked for. */ async function fitbitGet( path: string, accessToken: string, verb: string, ): Promise { const toError = (attempt: FitbitGetAttempt): FitbitApiError => new FitbitApiError({ verb, classification: attempt.verdict.classification, httpStatus: attempt.verdict.httpStatus, reason: attempt.verdict.reason, retryAfterSeconds: attempt.retryAfterSeconds, }); const first = await fitbitGetOnce(path, accessToken, verb); if (first.verdict.classification === "success") return first.json; const wait = first.retryAfterSeconds; if (wait === undefined || wait > FITBIT_RETRY_AFTER_WAIT_CAP_S) { throw toError(first); } await waitSeconds(wait); const second = await fitbitGetOnce(path, accessToken, verb); if (second.verdict.classification === "success") return second.json; throw toError(second); } // ─── Field → Measurement mapping ─────────────────────────────── // The single source of truth is `src/lib/fitbit/mapping.md` — keep both in sync // when adding entries. Every mapper takes the parsed endpoint body and returns // `FitbitMappedMeasurement[]` (the source + final externalId are stamped by the // sync layer). function round2(n: number): number { return parseFloat(n.toFixed(2)); } /** Finite + strictly-positive guard. */ function positive(n: unknown): n is number { return typeof n === "number" && Number.isFinite(n) && n > 0; } /** 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; } /** * Coerce a Fitbit value that may arrive as a number OR a numeric string (the * activity time-series returns string values, e.g. `"2504"`). Returns null when * not finite. */ function toFiniteNumber(v: unknown): number | null { if (typeof v === "number" && Number.isFinite(v)) return v; if (typeof v === "string" && v.trim() !== "") { const n = Number(v); if (Number.isFinite(n)) return n; } return null; } /** * Validate a Fitbit `YYYY-MM-DD` civil date and return it as a day key, or * null for an unparseable string. The key names the row's externalId; the * row's instant is the key's local noon in the user's zone * (`canonicalDailyTimestamp`), which reads the same calendar day there. * Noon UTC, the previous anchor, is already the next day from UTC+13 on. */ function parseCivilKey(dateStr: unknown): string | null { if (typeof dateStr !== "string") return null; const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateStr.trim()); return m ? `${m[1]}-${m[2]}-${m[3]}` : null; } /** * A single mapped Fitbit reading destined for one `Measurement` row. The * `source` (`FITBIT`) and final `externalId` are stamped by the sync layer; the * mapper emits type/value/unit/measuredAt + the field-tag that forms the * externalId. */ export interface FitbitMappedMeasurement { type: string; value: number; unit: string; measuredAt: Date; /** Full externalId payload for the row (`:` / `:`). */ 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 reads this flag to assemble the * `stats::` overwrite-in-place shape (matching the * Apple-Health daily-total contract). */ cumulativeDaily?: boolean; } /** HealthLog `SleepStage` values a Fitbit sleep stage maps onto. */ export type FitbitSleepStage = "IN_BED" | "AWAKE" | "ASLEEP" | "REM" | "CORE" | "DEEP"; // ── Weight + body fat (/body/log/{weight,fat}) ───────────────── // // GET /1/user/-/body/log/weight/date/{start}/{end}.json // → { weight: [{ date, time, weight, logId, bmi, fat?, source }] } // GET /1/user/-/body/log/fat/date/{start}/{end}.json // → { fat: [{ date, time, fat, logId, source }] } // Weight is kg under the metric Accept-Language. The `logId` is a stable // per-reading id → a re-fetch overwrites in place. interface FitbitBodyLogEntry { date?: string; time?: string; logId?: number; weight?: number; fat?: number; } /** * Resolve the measuredAt for a body-log entry from its `date` + `time`. Both * are the user's local wall clock (Fitbit logs carry no offset), so they are * read in the user's zone. Reading them as UTC put a morning weigh-in hours * off, and on the previous or next day for anyone far from UTC. */ function bodyLogInstant(entry: FitbitBodyLogEntry, tz?: string): Date { const date = typeof entry.date === "string" ? entry.date : undefined; const time = typeof entry.time === "string" ? entry.time : "12:00:00"; if (date) { const d = parseLocalInstant(`${date}T${time}`, tz ?? "UTC"); if (d) return d; const civil = parseCivilKey(date); if (civil) return canonicalDailyTimestamp(civil, tz); } return new Date(); } /** * Stable anchor for a body-log externalId — the logId, else the logged wall * clock. The wall clock is spelled the way earlier versions spelled it (as * if it were UTC), so an entry without a logId keeps its externalId. */ function bodyLogAnchor(entry: FitbitBodyLogEntry): string { if (typeof entry.logId === "number" && Number.isFinite(entry.logId)) { return String(entry.logId); } const date = typeof entry.date === "string" ? entry.date : undefined; const time = typeof entry.time === "string" ? entry.time : "12:00:00"; const asWritten = date ? new Date(`${date}T${time}Z`) : null; return asWritten && !Number.isNaN(asWritten.getTime()) ? asWritten.toISOString() : bodyLogInstant(entry).toISOString(); } export function mapWeight( body: unknown, tz?: string, ): FitbitMappedMeasurement[] { const arr = readArray(body, "weight"); const out: FitbitMappedMeasurement[] = []; for (const raw of arr) { const e = raw as FitbitBodyLogEntry; if (!positive(e.weight)) continue; out.push({ type: "WEIGHT", value: round2(e.weight), unit: "kg", measuredAt: bodyLogInstant(e, tz), fieldTag: `${bodyLogAnchor(e)}:weight`, }); } return out; } export function mapBodyFat( body: unknown, tz?: string, ): FitbitMappedMeasurement[] { const arr = readArray(body, "fat"); const out: FitbitMappedMeasurement[] = []; for (const raw of arr) { const e = raw as FitbitBodyLogEntry; if (!positive(e.fat)) continue; out.push({ type: "BODY_FAT", value: round2(e.fat), unit: "%", measuredAt: bodyLogInstant(e, tz), fieldTag: `${bodyLogAnchor(e)}:body_fat`, }); } return out; } // ── Daily-summary metrics keyed on a civil date ──────────────── // // SpO2, HRV, resting HR, respiratory rate, and VO2 max are one reading per // calendar day. The externalId is day-keyed so a re-fetch of the same day // overwrites in place. The reading is dropped (not zero-filled) when its value // is absent or non-positive — these metrics are all strictly positive. /** A `{ dateTime, value: {...} }` daily-summary row. */ interface FitbitDailyRow { dateTime?: string; value?: unknown; } /** * Map a list of daily-summary rows pulling `value[leaf]` (or an alt leaf) into * one strictly-positive Measurement per day, day-keyed externalId. */ function mapDailySummary( rows: FitbitDailyRow[], spec: { type: string; unit: string; fieldTag: string; leaves: string[]; factor?: number; }, tz: string | undefined, ): FitbitMappedMeasurement[] { const out: FitbitMappedMeasurement[] = []; for (const row of rows) { const day = parseCivilKey(row.dateTime); if (!day) continue; const value = firstPositiveLeaf(row.value, spec.leaves); if (value === null) continue; const scaled = spec.factor ? value * spec.factor : value; out.push({ type: spec.type, value: round2(scaled), unit: spec.unit, measuredAt: canonicalDailyTimestamp(day, tz), fieldTag: `${day}:${spec.fieldTag}`, }); } return out; } /** First strictly-positive `obj[leaf]` across candidate leaves, or null. */ function firstPositiveLeaf(obj: unknown, leaves: string[]): number | null { if (!obj || typeof obj !== "object") return null; const o = obj as Record; for (const leaf of leaves) { if (positive(o[leaf])) return o[leaf] as number; } return null; } export function mapOxygenSaturation( body: unknown, tz?: string, ): FitbitMappedMeasurement[] { // The SpO2 summary endpoint returns a BARE ARRAY of daily rows (not wrapped). const rows = Array.isArray(body) ? (body as FitbitDailyRow[]) : []; return mapDailySummary( rows, { type: "OXYGEN_SATURATION", unit: "%", fieldTag: "spo2", leaves: ["avg"], }, tz, ); } export function mapHeartRateVariability( body: unknown, tz?: string, ): FitbitMappedMeasurement[] { // Fitbit's HRV summary is an RMSSD estimator (`value.dailyRmssd`). It lands in // the canonical `HEART_RATE_VARIABILITY` slot that FITBIT occupies in the // source-priority `hrv` ladder (alongside Apple / Oura), keeping cross-source // comparison on one axis. (`HRV_RMSSD` is reserved for the WHOOP-native slot.) const rows = readArray(body, "hrv") as FitbitDailyRow[]; return mapDailySummary( rows, { type: "HEART_RATE_VARIABILITY", unit: "ms", fieldTag: "hrv", leaves: ["dailyRmssd"], }, tz, ); } export function mapRespiratoryRate( body: unknown, tz?: string, ): FitbitMappedMeasurement[] { const rows = readArray(body, "br") as FitbitDailyRow[]; return mapDailySummary( rows, { type: "RESPIRATORY_RATE", unit: "breaths/min", fieldTag: "resp_rate", leaves: ["breathingRate"], }, tz, ); } /** * Resting heart rate lives inside the heart-rate time series: * GET /1/user/-/activities/heart/date/{start}/{end}.json * → { "activities-heart": [{ dateTime, value: { restingHeartRate } }] } */ export function mapRestingHeartRate( body: unknown, tz?: string, ): FitbitMappedMeasurement[] { const rows = readArray(body, "activities-heart") as FitbitDailyRow[]; return mapDailySummary( rows, { type: "RESTING_HEART_RATE", unit: "bpm", fieldTag: "rhr", leaves: ["restingHeartRate"], }, tz, ); } /** * VO2 max (cardio fitness score): * GET /1/user/-/cardioscore/date/{start}/{end}.json * → { cardioScore: [{ dateTime, value: { vo2Max: "44-48" | "45" } }] } * The value is a STRING that may be a single number or a range; a range resolves * to its midpoint. Daily latest-wins, day-keyed externalId. */ export function mapVo2Max( body: unknown, tz?: string, ): FitbitMappedMeasurement[] { const rows = readArray(body, "cardioScore") as FitbitDailyRow[]; const out: FitbitMappedMeasurement[] = []; for (const row of rows) { const day = parseCivilKey(row.dateTime); if (!day) continue; const v = parseVo2Max( (row.value as Record | undefined)?.vo2Max, ); if (v === null || !positive(v)) continue; out.push({ type: "VO2_MAX", value: round2(v), unit: "mL/(kg·min)", measuredAt: canonicalDailyTimestamp(day, tz), fieldTag: `vo2_max:${day}`, cumulativeDaily: true, }); } return out; } /** Parse a Fitbit VO2max value: a single number, or a "lo-hi" range midpoint. */ export function parseVo2Max(raw: unknown): number | null { if (typeof raw === "number" && Number.isFinite(raw)) return raw; if (typeof raw !== "string") return null; const s = raw.trim(); const range = /^(\d+(?:\.\d+)?)\s*-\s*(\d+(?:\.\d+)?)$/.exec(s); if (range) { const lo = Number(range[1]); const hi = Number(range[2]); if (Number.isFinite(lo) && Number.isFinite(hi)) return (lo + hi) / 2; return null; } const n = Number(s); return Number.isFinite(n) ? n : null; } // ── Daily cumulative activity (activities time series) ───────── // // GET /1/user/-/activities/{steps,distance,floors,activityCalories}/date/{s}/{e}.json // → { "activities-{resource}": [{ dateTime, value: "" }] } // One daily total per civil day. The externalId carries the `stats:` daily-total // prefix (assembled in the sync layer) so a re-fetched day overwrites in place, // matching the Apple-Health `stats::` contract. A rest day of 0 // is preserved (dropping it would leave a chart gap misread as missing data). // distance is reported in km under the metric locale → ×1000 to metres. /** Map an activity time-series body for one cumulative metric. */ function mapActivitySeries( body: unknown, spec: { arrayKey: string; type: string; unit: string; fieldTag: string; factor?: number; }, tz: string | undefined, ): FitbitMappedMeasurement[] { const rows = readArray(body, spec.arrayKey) as FitbitDailyRow[]; const out: FitbitMappedMeasurement[] = []; for (const row of rows) { const day = parseCivilKey(row.dateTime); if (!day) continue; const n = toFiniteNumber(row.value); if (n === null || !nonNegative(n)) continue; const scaled = spec.factor ? n * spec.factor : n; out.push({ type: spec.type, value: round2(scaled), unit: spec.unit, measuredAt: canonicalDailyTimestamp(day, tz), fieldTag: `${spec.fieldTag}:${day}`, cumulativeDaily: true, }); } return out; } export function mapSteps( body: unknown, tz?: string, ): FitbitMappedMeasurement[] { return mapActivitySeries( body, { arrayKey: "activities-steps", type: "ACTIVITY_STEPS", unit: "steps", fieldTag: "steps", }, tz, ); } export function mapDistance( body: unknown, tz?: string, ): FitbitMappedMeasurement[] { // The metric-locale distance series reports kilometres → metres. return mapActivitySeries( body, { arrayKey: "activities-distance", type: "WALKING_RUNNING_DISTANCE", unit: "m", fieldTag: "distance", factor: 1000, }, tz, ); } export function mapActiveCalories( body: unknown, tz?: string, ): FitbitMappedMeasurement[] { // `activityCalories` is the ACTIVE portion (excludes BMR), unlike the total // `calories` resource. return mapActivitySeries( body, { arrayKey: "activities-activityCalories", type: "ACTIVE_ENERGY_BURNED", unit: "kcal", fieldTag: "active_calories", }, tz, ); } export function mapFloors( body: unknown, tz?: string, ): FitbitMappedMeasurement[] { return mapActivitySeries( body, { arrayKey: "activities-floors", type: "FLIGHTS_CLIMBED", unit: "flights", fieldTag: "floors", }, tz, ); } /** * Read a named array off a `{ key: [...] }` envelope; [] on any miss. Tolerates * the endpoint returning a bare array (some endpoints do) by returning it * directly when the key is absent but the body is itself an array. */ function readArray(body: unknown, key: string): unknown[] { if (body && typeof body === "object" && !Array.isArray(body)) { const v = (body as Record)[key]; if (Array.isArray(v)) return v; } if (Array.isArray(body)) return body; return []; } // ─── Field→Measurement mapping table (mirror of mapping.md) ───── export const FITBIT_FIELD_MAP: Record< string, { type: string; unit: string; note?: string } > = { weight: { type: "WEIGHT", unit: "kg", note: "body/log/weight; picker ranks a real Withings scale above Fitbit", }, bodyFat: { type: "BODY_FAT", unit: "%", note: "body/log/fat" }, oxygenSaturation: { type: "OXYGEN_SATURATION", unit: "%", note: "spo2 summary value.avg", }, heartRateVariability: { type: "HEART_RATE_VARIABILITY", unit: "ms", note: "hrv summary value.dailyRmssd (RMSSD estimator); canonical HRV slot", }, restingHeartRate: { type: "RESTING_HEART_RATE", unit: "bpm", note: "activities/heart value.restingHeartRate", }, respiratoryRate: { type: "RESPIRATORY_RATE", unit: "breaths/min", note: "br summary value.breathingRate", }, steps: { type: "ACTIVITY_STEPS", unit: "steps", note: "activities/steps daily total; stats: externalId overwrites; 0 valid", }, distance: { type: "WALKING_RUNNING_DISTANCE", unit: "m", note: "activities/distance (km → m); stats: externalId overwrites", }, activeCalories: { type: "ACTIVE_ENERGY_BURNED", unit: "kcal", note: "activities/activityCalories (ACTIVE portion only); stats: overwrites", }, floors: { type: "FLIGHTS_CLIMBED", unit: "flights", note: "activities/floors daily total; stats: externalId overwrites", }, vo2Max: { type: "VO2_MAX", unit: "mL/(kg·min)", note: "cardioscore value.vo2Max (range → midpoint); daily latest-wins", }, sleep: { type: "SLEEP_DURATION", unit: "minutes", note: "1.2 sleep levels.data per-segment rows; measuredAt = segment END", }, exercise: { type: "Workout", unit: "—", note: "activities list → Workout row (NOT a Measurement); read-time dedup", }, }; // ── Sleep (1.2 sleep log) ────────────────────────────────────── // // GET /1.2/user/-/sleep/date/{start}/{end}.json // → { sleep: [{ logId, startTime, endTime, levels: { data: [ // { dateTime: "", level: "wake|light|deep|rem|asleep|restless|awake", seconds } ] } }] } // One SLEEP_DURATION row per segment, `measuredAt = segment START + seconds` // (the segment END), value = seconds → minutes. Stage labels harmonise onto the // shared SleepStage enum. The timeline is MEASURED (real onsets), so rows are // NOT flagged reconstructed. /** Classic Fitbit sleep `level` → HealthLog `SleepStage`. */ const FITBIT_SLEEP_STAGE_MAP: Record = { // Stages logs. wake: "AWAKE", awake: "AWAKE", light: "CORE", // Fitbit "light" ↔ Apple "core" (same shallow-NREM band) core: "CORE", deep: "DEEP", rem: "REM", // Classic (non-stages) logs. asleep: "ASLEEP", restless: "AWAKE", in_bed: "IN_BED", inbed: "IN_BED", } as const; /** Normalise a raw Fitbit sleep `level` to a `SleepStage`, or null if unknown. */ 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 ); } interface FitbitSleepLevelEntry { dateTime?: string; level?: string; seconds?: number; } interface FitbitSleepSession { logId?: number; startTime?: string; endTime?: string; levels?: { data?: FitbitSleepLevelEntry[] }; } /** * The stable session anchor for a sleep externalId — the logId, else the start * (or end) instant. A re-scored night reuses the same logId → overwrites in * place. The fallback prefers `startTime` because it is the session's identity; * `endTime` moves when Fitbit revises a night's scoring, which would mint a new * externalId for the same session. Pre-existing rows keyed on the old endTime * fallback are rare (logId is normally present); a changed key simply creates * one fresh row. */ function sleepSessionAnchor(s: FitbitSleepSession, tz?: string): string { if (typeof s.logId === "number" && Number.isFinite(s.logId)) { return String(s.logId); } for (const t of [s.startTime, s.endTime]) { if (typeof t === "string") { const d = parseLocalInstant(t, tz); if (d) return d.toISOString(); } } return new Date(0).toISOString(); } /** * Matches an offset-less local ISO wall-clock string Fitbit emits on the classic * sleep + activity logs (`2020-01-26T03: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 Fitbit timestamp into a UTC instant. The classic 1.2 sleep log and the * activities list emit LOCAL wall-clock strings WITHOUT an offset (e.g. * `2020-01-26T03:02:30.000`) — the night/activity belongs to the user's local * clock, so the wall clock must be resolved against the USER'S timezone, not the * process zone. A bare `new Date(iso)` parses an offset-less string in the host * zone (UTC in production), which shifts a non-UTC user's segment by their UTC * offset and can flip the wake-day in the night reconstruction. When `tz` is * omitted (no user zone on the path) the host-local fallback preserves the prior * behaviour. Strings that DO carry an offset/`Z` are absolute and parsed as-is. * 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; } /** * One mapped Fitbit 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 FitbitSleepSessionMapped { /** Stable session anchor — also the `: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: FitbitMappedMeasurement[]; } /** * Map one Fitbit sleep session into per-SEGMENT `SLEEP_DURATION` rows plus its * covered window. Each segment's `dateTime` is its START; END = START + seconds. * value = minutes. Unknown stage labels are skipped; a session with no parseable * segment yields empty `rows` (and a null window). * * Each segment's fieldTag keys off the STABLE session anchor plus the segment's * own START instant — `:sleep:` — NOT a positional index * and NOT the stage label. This is the fix for the re-score duplication: the * index renumbered (and a stage-scoped tag changed) whenever Fitbit re-scored a * night after the fact, minting fresh externalIds so the upsert created parallel * rows the night total then double-counted, with no sweep to clear the strays. 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. Two segments of one session cannot * share a start instant, so the key stays unique within the session; across * sessions the logId anchor separates them. * * The 1.2 sleep log emits OFFSET-LESS local wall-clock timestamps, so `tz` (the * user's stored zone) anchors them to the correct UTC instant — without it a * non-UTC user's near-midnight segment END would shift by their UTC offset and * could land on the wrong wake-day in the night reconstruction. */ export function mapSleepSessionDetailed( session: FitbitSleepSession, tz?: string, ): FitbitSleepSessionMapped { const anchor = sleepSessionAnchor(session, tz); const data = session.levels?.data; const rows: FitbitMappedMeasurement[] = []; let windowStart: Date | null = null; let windowEnd: Date | null = null; if (!Array.isArray(data) || data.length === 0) { return { anchor, windowStart, windowEnd, rows }; } for (const seg of data) { const stage = mapFitbitSleepStage(seg.level); if (!stage) continue; if (!positive(seg.seconds)) continue; const startStr = typeof seg.dateTime === "string" ? seg.dateTime : null; if (!startStr) continue; const start = parseLocalInstant(startStr, tz); if (!start) continue; const minutes = seg.seconds / 60; const end = new Date(start.getTime() + seg.seconds * 1000); if (!windowStart || start < windowStart) windowStart = start; if (!windowEnd || end > windowEnd) windowEnd = end; rows.push({ type: "SLEEP_DURATION", value: round2(minutes), unit: "minutes", measuredAt: end, fieldTag: `${anchor}:sleep:${start.toISOString()}`, sleepStage: stage, }); } return { anchor, windowStart, windowEnd, rows }; } /** Read the sleep-session array off the 1.2 sleep-log envelope. */ export function readSleepSessions(body: unknown): FitbitSleepSession[] { return readArray(body, "sleep") as FitbitSleepSession[]; } // ── Workouts (activities list) ───────────────────────────────── // // GET /1/user/-/activities/list.json?afterDate=...&sort=asc&offset=0&limit=100 // → { activities: [{ logId, activityName/activityTypeId, startTime, // duration(ms), calories, distance(km), averageHeartRate, ... }] } // Each becomes a Workout row keyed on `(userId, source, externalId=logId)`. interface FitbitActivityLogEntry { logId?: number; activityName?: string; activityTypeId?: number; startTime?: string; duration?: number; // milliseconds calories?: number; distance?: number; // km (metric locale) averageHeartRate?: number; } /** Classic Fitbit activity name → HealthLog `WorkoutSportType` label. */ const FITBIT_EXERCISE_TYPE_MAP: Record = { 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 Fitbit activity name 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" ); } /** Raw Fitbit workout identity retained alongside the canonical sport type. */ export interface FitbitWorkoutMetadata { fitbitActivityName?: string; fitbitActivityTypeId?: number; } /** One mapped Fitbit activity 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; metadata: FitbitWorkoutMetadata; } /** * Map one Fitbit activity-list entry into a `Workout` shape. Returns null when * there is no usable start + positive duration. The externalId anchors on the * `logId` (stable) so a re-fetch overwrites the same row. The classic * activities-list endpoint does not surface min/max HR, so those stay null. * * `startTime` is an offset-less local wall-clock string, so `tz` (the user's * stored zone) anchors it to the correct UTC instant rather than the process * zone. */ export function mapWorkout( entry: FitbitActivityLogEntry, tz?: string, ): FitbitMappedWorkout | null { const start = typeof entry.startTime === "string" ? parseLocalInstant(entry.startTime, tz) : null; if (!start) return null; if (!positive(entry.duration)) return null; const durationSec = Math.round(entry.duration / 1000); const endedAt = new Date(start.getTime() + entry.duration); const externalId = typeof entry.logId === "number" && Number.isFinite(entry.logId) ? String(entry.logId) : `exercise:${start.toISOString()}`; const energyKcal = positive(entry.calories) ? Math.round(entry.calories) : null; // distance is km under the metric locale → metres. const distanceM = typeof entry.distance === "number" && entry.distance >= 0 ? round2(entry.distance * 1000) : null; const avgHr = positive(entry.averageHeartRate) ? Math.round(entry.averageHeartRate) : null; const metadata: FitbitWorkoutMetadata = { ...(typeof entry.activityName === "string" ? { fitbitActivityName: entry.activityName } : {}), ...(typeof entry.activityTypeId === "number" && Number.isFinite(entry.activityTypeId) ? { fitbitActivityTypeId: entry.activityTypeId } : {}), }; return { externalId, sportType: mapFitbitSportType(entry.activityName), startedAt: start, endedAt, durationSec, totalEnergyKcal: energyKcal, totalDistanceM: distanceM, avgHeartRate: avgHr, maxHeartRate: null, minHeartRate: null, metadata, }; } /** Read the activity-list array off the activities/list envelope. */ export function readActivityList(body: unknown): FitbitActivityLogEntry[] { return readArray(body, "activities") as FitbitActivityLogEntry[]; } // ─── Per-endpoint fetchers ───────────────────────────────────── // // Each returns the raw parsed body for its date-range window; the per-resource // sync layer maps it. A single request per endpoint per chunk keeps the 150/h // budget healthy. export async function fetchWeightRange( accessToken: string, start: Date, end: Date, ): Promise { return fitbitGet( `/1/user/-/body/log/weight/date/${fitbitDate(start)}/${fitbitDate(end)}.json`, accessToken, "fetchWeight", ); } export async function fetchBodyFatRange( accessToken: string, start: Date, end: Date, ): Promise { return fitbitGet( `/1/user/-/body/log/fat/date/${fitbitDate(start)}/${fitbitDate(end)}.json`, accessToken, "fetchBodyFat", ); } export async function fetchSpo2Range( accessToken: string, start: Date, end: Date, ): Promise { return fitbitGet( `/1/user/-/spo2/date/${fitbitDate(start)}/${fitbitDate(end)}.json`, accessToken, "fetchSpo2", ); } export async function fetchHrvRange( accessToken: string, start: Date, end: Date, ): Promise { return fitbitGet( `/1/user/-/hrv/date/${fitbitDate(start)}/${fitbitDate(end)}.json`, accessToken, "fetchHrv", ); } export async function fetchRestingHeartRateRange( accessToken: string, start: Date, end: Date, ): Promise { return fitbitGet( `/1/user/-/activities/heart/date/${fitbitDate(start)}/${fitbitDate(end)}.json`, accessToken, "fetchRhr", ); } export async function fetchRespiratoryRateRange( accessToken: string, start: Date, end: Date, ): Promise { return fitbitGet( `/1/user/-/br/date/${fitbitDate(start)}/${fitbitDate(end)}.json`, accessToken, "fetchRespiratoryRate", ); } export async function fetchVo2MaxRange( accessToken: string, start: Date, end: Date, ): Promise { return fitbitGet( `/1/user/-/cardioscore/date/${fitbitDate(start)}/${fitbitDate(end)}.json`, accessToken, "fetchVo2Max", ); } /** Activity time-series for one cumulative resource (steps/distance/...). */ export async function fetchActivitySeries( resource: "steps" | "distance" | "floors" | "activityCalories", accessToken: string, start: Date, end: Date, ): Promise { return fitbitGet( `/1/user/-/activities/${resource}/date/${fitbitDate(start)}/${fitbitDate(end)}.json`, accessToken, `fetchActivity:${resource}`, ); } export async function fetchSleepRange( accessToken: string, start: Date, end: Date, ): Promise { return fitbitGet( `/1.2/user/-/sleep/date/${fitbitDate(start)}/${fitbitDate(end)}.json`, accessToken, "fetchSleep", ); } /** * Activity log list since a date. Uses the offset/limit list endpoint with * `afterDate` + `sort=asc`. One page of up to `limit` activities; the sync layer * caps the page count to stay within the rate budget. */ export async function fetchActivityList( accessToken: string, afterDate: Date, opts: { limit?: number; offset?: number } = {}, ): Promise { const limit = opts.limit ?? 100; const offset = opts.offset ?? 0; return fitbitGet( `/1/user/-/activities/list.json?afterDate=${fitbitDate(afterDate)}&sort=asc&offset=${offset}&limit=${limit}`, accessToken, "fetchExercise", ); }