Files
HealthLog/src/lib/fitbit/client.ts
T
Marc-André Bombeck 7c5ff89def feat(lint): refuse UTC day keys outside the time zone module
`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.
2026-10-01 09:59:57 +02:00

1532 lines
50 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Fitbit 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",
);
}