mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
`instant.toISOString().slice(0, 10)` and parsing a key back as UTC midnight are the idioms behind every day-boundary fix this year: the evening west of UTC and the small hours east of it land on the neighbouring day. A new rule, healthlog/no-utc-day-key, refuses them in src/ outside src/lib/tz and tests, and refuses a disable directive for itself that is not next-line or gives no reason. The time zone module now names every legitimate form: userDayKey for a person's day, dateOnlyKey / dateOnlyAtNoonUtc / dayKeyAsUtcMidnight for date-only values and day labels, daysBetweenDateKeys / weekdayOfDateKey / isCalendarDateKey / statedDateKey for key arithmetic and parsing. Of the 133 sites the rule found, 84 are gone. Most moved onto a named helper with no change in behaviour; these changed behaviour: - cycle calendar, insights, Coach snapshot and prediction refresh start their lookbacks at the first instant of the user's day - a model-transcribed date like "June 10, 2026" no longer lands a day early in a process east of UTC (documents and lab scans) - a custom report range covers exactly the picked days in the profile zone instead of starting the evening before west of UTC - date-of-birth pickers cap at the user's today, not the UTC one - an allergy onset date is stored at noon UTC like other date-only values - the report's cycle summary, the Coach's usual BP range and the intake projector's rollup recompute use the user's day - vaccination dates render as dates instead of the previous evening 49 remain, each with its reason on the line above: 39 UTC by design (15 file name stamps, rollup and object keys, the UTC-day score engines) and 10 marked baseline. A guard test requires a classified reason on every directive and holds the baseline to a ceiling that only goes down.
1532 lines
50 KiB
TypeScript
1532 lines
50 KiB
TypeScript
/**
|
||
* 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<FitbitTokenResponse> {
|
||
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<FitbitTokenResponse> {
|
||
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<FitbitTokenResponse> {
|
||
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<FitbitProfile> {
|
||
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<void> {
|
||
return new Promise((resolve) => setTimeout(resolve, seconds * 1000));
|
||
}
|
||
|
||
interface FitbitGetAttempt {
|
||
json: unknown;
|
||
verdict: ReturnType<typeof classifyFitbitResponse>;
|
||
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<FitbitGetAttempt> {
|
||
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<unknown> {
|
||
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 (`<anchor>:<tag>` / `<tag>:<day>`). */
|
||
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:<tag>:<YYYY-MM-DD>` 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<string, unknown>;
|
||
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<string, unknown> | 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: "<number-string>" }] }
|
||
// 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:<HK>:<YYYY-MM-DD>` 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<string, unknown>)[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: "<local ISO>", 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<string, FitbitSleepStage> = {
|
||
// 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 `<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: 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 — `<anchor>:sleep:<segment-start>` — 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<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 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<unknown> {
|
||
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<unknown> {
|
||
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<unknown> {
|
||
return fitbitGet(
|
||
`/1/user/-/spo2/date/${fitbitDate(start)}/${fitbitDate(end)}.json`,
|
||
accessToken,
|
||
"fetchSpo2",
|
||
);
|
||
}
|
||
|
||
export async function fetchHrvRange(
|
||
accessToken: string,
|
||
start: Date,
|
||
end: Date,
|
||
): Promise<unknown> {
|
||
return fitbitGet(
|
||
`/1/user/-/hrv/date/${fitbitDate(start)}/${fitbitDate(end)}.json`,
|
||
accessToken,
|
||
"fetchHrv",
|
||
);
|
||
}
|
||
|
||
export async function fetchRestingHeartRateRange(
|
||
accessToken: string,
|
||
start: Date,
|
||
end: Date,
|
||
): Promise<unknown> {
|
||
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<unknown> {
|
||
return fitbitGet(
|
||
`/1/user/-/br/date/${fitbitDate(start)}/${fitbitDate(end)}.json`,
|
||
accessToken,
|
||
"fetchRespiratoryRate",
|
||
);
|
||
}
|
||
|
||
export async function fetchVo2MaxRange(
|
||
accessToken: string,
|
||
start: Date,
|
||
end: Date,
|
||
): Promise<unknown> {
|
||
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<unknown> {
|
||
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<unknown> {
|
||
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<unknown> {
|
||
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",
|
||
);
|
||
}
|