feat(fitbit): retarget the client to the classic Fitbit Web API

The shipped Fitbit integration spoke the Google Health API, whose Restricted
scopes need brand verification plus an annual CASA assessment before leaving
testing mode — a wall most self-hosters cannot clear. Retarget the transport to
the classic api.fitbit.com Web API: a self-hoster registers an app at
dev.fitbit.com in minutes, no CASA.

Forks only the transport; the upsert/dedup/rollup tail, source-priority,
backfill, poll worker, and status ledger are reused unchanged.

- OAuth: www.fitbit.com/oauth2/authorize + api.fitbit.com/oauth2/token, PKCE
  (S256), classic self-serve scopes.
- Refresh tokens rotate (one-time use): persist the new token every refresh,
  with a defensive keep-existing guard on a malformed reply.
- Per-endpoint date-range fetchers chunked to the API range caps; the backfill
  horizon and poll concurrency are tuned for the 150 req/h-per-user budget.
- Verified classic-shape extractors for weight, body fat, SpO2, HRV (dailyRmssd),
  resting HR, respiratory rate, steps, distance, active calories, floors, VO2 max
  (range midpoint), per-segment sleep stages, and workouts.
- Skips intraday HR (needs a per-app grant; daily resting HR covers it) and skin
  temperature (a baseline delta with no honest absolute slot).
This commit is contained in:
Marc-André Bombeck committed 2026-06-22 07:20:58 +02:00
1 parent 062354ab57
commit 4ff0e4ca0c
11 files changed
+1493 -1825

No files matched your search

+284 -408
View File
@@ -1,12 +1,14 @@
import { createHash } from "node:crypto";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
FITBIT_API_BASE,
FITBIT_DATA_TYPES,
FITBIT_FIELD_MAP,
FITBIT_OAUTH_SCOPE,
exchangeCode,
fetchDataPoints,
fetchProfile,
fetchSleepRange,
fetchSpo2Range,
fetchWeightRange,
generatePkcePair,
getAuthorizationUrl,
mapActiveCalories,
mapBodyFat,
@@ -15,14 +17,17 @@ import {
mapFitbitSportType,
mapFloors,
mapHeartRateVariability,
mapHeightCm,
mapOxygenSaturation,
mapRespiratoryRate,
mapRestingHeartRate,
mapSleepSession,
mapSteps,
mapVo2Max,
mapWeight,
mapWorkout,
parseVo2Max,
readActivityList,
readSleepSessions,
refreshAccessToken,
resolveFitbitUserId,
} from "../client";
@@ -44,8 +49,7 @@ function installFetchMock(pages: Array<{ status: number; body: unknown }>) {
return fetchMock;
}
// v1.12.1 — the redirect_uri allowlist (M-5) requires a configured, absolute
// https app origin. Pin one for the OAuth-handshake cases.
// The redirect_uri allowlist requires a configured, absolute https app origin.
const PRIOR_APP_URL = process.env.NEXT_PUBLIC_APP_URL;
beforeEach(() => {
process.env.NEXT_PUBLIC_APP_URL = "https://app.example.test";
@@ -58,48 +62,61 @@ afterEach(() => {
vi.restoreAllMocks();
});
describe("generatePkcePair", () => {
it("mints a high-entropy verifier and the matching S256 challenge", () => {
const { verifier, challenge } = generatePkcePair();
// 64 random bytes → 86 base64url chars; inside Fitbit's 43–128 range.
expect(verifier.length).toBeGreaterThanOrEqual(43);
expect(verifier.length).toBeLessThanOrEqual(128);
expect(verifier).toMatch(/^[A-Za-z0-9_-]+$/);
// challenge = BASE64URL(SHA256(verifier)).
const expected = createHash("sha256").update(verifier).digest("base64url");
expect(challenge).toBe(expected);
});
it("mints a fresh pair each call", () => {
expect(generatePkcePair().verifier).not.toBe(generatePkcePair().verifier);
});
});
describe("getAuthorizationUrl", () => {
it("builds the Google authorize URL with offline access + consent prompt", () => {
const url = getAuthorizationUrl("nonce123", CREDS);
expect(url).toContain("accounts.google.com/o/oauth2/v2/auth");
it("builds the classic Fitbit authorize URL with PKCE S256", () => {
const url = getAuthorizationUrl("nonce123", CREDS, "chal-xyz");
expect(url).toContain("www.fitbit.com/oauth2/authorize");
expect(url).toContain("response_type=code");
expect(url).toContain("client_id=cid");
expect(url).toContain("state=nonce123");
expect(url).toContain("access_type=offline");
expect(url).toContain("prompt=consent");
// URLSearchParams encodes the space-separated scope; compare the parsed
// `scope` param back to the canonical constant.
const scope = new URL(url).searchParams.get("scope");
expect(scope).toBe(FITBIT_OAUTH_SCOPE);
expect(FITBIT_OAUTH_SCOPE).toContain("googlehealth.profile.readonly");
const parsed = new URL(url);
expect(parsed.searchParams.get("code_challenge")).toBe("chal-xyz");
expect(parsed.searchParams.get("code_challenge_method")).toBe("S256");
expect(parsed.searchParams.get("scope")).toBe(FITBIT_OAUTH_SCOPE);
});
it("requests exactly the four launch Restricted read bundles", () => {
it("requests the classic self-serve scopes and omits temperature", () => {
const scopes = FITBIT_OAUTH_SCOPE.split(" ");
expect(scopes).toHaveLength(4);
expect(scopes).toContain(
"https://www.googleapis.com/auth/googlehealth.activity_and_fitness.readonly",
);
expect(scopes).toContain(
"https://www.googleapis.com/auth/googlehealth.health_metrics_and_measurements.readonly",
);
expect(scopes).toContain(
"https://www.googleapis.com/auth/googlehealth.sleep.readonly",
);
// ECG / nutrition / location are deliberately omitted from the launch set.
expect(FITBIT_OAUTH_SCOPE).not.toContain("ecg");
expect(scopes).toContain("activity");
expect(scopes).toContain("heartrate");
expect(scopes).toContain("sleep");
expect(scopes).toContain("weight");
expect(scopes).toContain("oxygen_saturation");
expect(scopes).toContain("respiratory_rate");
expect(scopes).toContain("cardio_fitness");
expect(scopes).toContain("profile");
// temperature deliberately omitted — the classic skin-temp reading is a
// baseline delta with no honest absolute slot.
expect(FITBIT_OAUTH_SCOPE).not.toContain("temperature");
expect(FITBIT_OAUTH_SCOPE).not.toContain("nutrition");
expect(FITBIT_OAUTH_SCOPE).not.toContain("location");
});
it("carries the configured app origin in the redirect_uri", () => {
const url = getAuthorizationUrl("nonce123", CREDS);
const url = getAuthorizationUrl("nonce123", CREDS, "c");
const redirect = new URL(url).searchParams.get("redirect_uri");
expect(redirect).toBe("https://app.example.test/api/fitbit/callback");
});
});
describe("redirect_uri allowlist (M-5)", () => {
describe("redirect_uri allowlist", () => {
const PRIOR_REDIRECT = process.env.FITBIT_REDIRECT_URI;
afterEach(() => {
if (PRIOR_REDIRECT === undefined) delete process.env.FITBIT_REDIRECT_URI;
@@ -108,12 +125,12 @@ describe("redirect_uri allowlist (M-5)", () => {
it("rejects a non-https app origin", () => {
process.env.NEXT_PUBLIC_APP_URL = "http://app.example.test";
expect(() => getAuthorizationUrl("n", CREDS)).toThrow(/must be https/);
expect(() => getAuthorizationUrl("n", CREDS, "c")).toThrow(/must be https/);
});
it("allows http on localhost (dev)", () => {
process.env.NEXT_PUBLIC_APP_URL = "http://localhost:3000";
const url = getAuthorizationUrl("n", CREDS);
const url = getAuthorizationUrl("n", CREDS, "c");
expect(new URL(url).searchParams.get("redirect_uri")).toBe(
"http://localhost:3000/api/fitbit/callback",
);
@@ -121,7 +138,7 @@ describe("redirect_uri allowlist (M-5)", () => {
it("rejects an explicit redirect that targets the wrong path", () => {
process.env.FITBIT_REDIRECT_URI = "https://app.example.test/evil";
expect(() => getAuthorizationUrl("n", CREDS)).toThrow(
expect(() => getAuthorizationUrl("n", CREDS, "c")).toThrow(
/must target \/api\/fitbit\/callback/,
);
});
@@ -130,80 +147,72 @@ describe("redirect_uri allowlist (M-5)", () => {
process.env.NEXT_PUBLIC_APP_URL = "https://app.example.test";
process.env.FITBIT_REDIRECT_URI =
"https://attacker.example.test/api/fitbit/callback";
expect(() => getAuthorizationUrl("n", CREDS)).toThrow(/does not match/);
});
it("accepts an explicit same-origin redirect", () => {
process.env.NEXT_PUBLIC_APP_URL = "https://app.example.test";
process.env.FITBIT_REDIRECT_URI =
"https://app.example.test/api/fitbit/callback";
const url = getAuthorizationUrl("n", CREDS);
expect(new URL(url).searchParams.get("redirect_uri")).toBe(
"https://app.example.test/api/fitbit/callback",
expect(() => getAuthorizationUrl("n", CREDS, "c")).toThrow(
/does not match/,
);
});
});
describe("token exchange + refresh", () => {
it("exchanges an authorization code for a token pair via Basic auth", async () => {
it("exchanges an authorization code WITH the PKCE verifier via Basic auth", async () => {
const fetchMock = installFetchMock([
{
status: 200,
body: {
access_token: "at",
refresh_token: "rt",
expires_in: 3600,
scope: "https://www.googleapis.com/auth/googlehealth.sleep.readonly",
expires_in: 28800,
scope: "activity sleep weight",
user_id: "ABC123",
},
},
]);
const tok = await exchangeCode("code", CREDS);
const tok = await exchangeCode("code", "verifier-123", CREDS);
expect(tok.access_token).toBe("at");
expect(tok.refresh_token).toBe("rt");
expect(tok.expires_in).toBe(3600);
expect(tok.expires_in).toBe(28800);
const [url, init] = fetchMock.mock.calls[0] as unknown as [
string,
{ headers: Record<string, string>; body: string },
];
expect(url).toContain("oauth2.googleapis.com/token");
expect(url).toContain("api.fitbit.com/oauth2/token");
expect(init.body).toContain("grant_type=authorization_code");
// Confidential client credentials ride in the Basic-auth header.
expect(init.body).toContain("code_verifier=verifier-123");
const expected = `Basic ${Buffer.from("cid:csecret").toString("base64")}`;
expect(init.headers.Authorization).toBe(expected);
});
it("refreshes WITHOUT re-sending scope (Google preserves the grant)", async () => {
it("refreshes and returns the ROTATED refresh token (classic rotates)", async () => {
const fetchMock = installFetchMock([
{
status: 200,
body: { access_token: "at2", expires_in: 3600 },
body: {
access_token: "at2",
refresh_token: "rt2",
expires_in: 28800,
},
},
]);
const tok = await refreshAccessToken("rt1", CREDS);
expect(tok.access_token).toBe("at2");
// Classic Fitbit rotates: a fresh refresh token is returned every time.
expect(tok.refresh_token).toBe("rt2");
const [, init] = fetchMock.mock.calls[0] as unknown as [
string,
{ body: string },
];
expect(init.body).toContain("grant_type=refresh_token");
expect(init.body).toContain("refresh_token=rt1");
// Google preserves the original grant — no scope param is re-sent.
// The grant's scope is preserved — no scope param re-sent.
expect(init.body).not.toContain("scope=");
});
it("returns an absent refresh_token unchanged (Google does not rotate)", async () => {
installFetchMock([
{ status: 200, body: { access_token: "at3", expires_in: 3600 } },
]);
const tok = await refreshAccessToken("rt1", CREDS);
expect(tok.access_token).toBe("at3");
expect(tok.refresh_token).toBeUndefined();
});
it("throws a classified FitbitApiError on a 401 token response", async () => {
installFetchMock([{ status: 401, body: { error: "invalid_grant" } }]);
await expect(exchangeCode("bad", CREDS)).rejects.toMatchObject({
installFetchMock([
{ status: 401, body: { errors: [{ errorType: "invalid_grant" }] } },
]);
await expect(exchangeCode("bad", "v", CREDS)).rejects.toMatchObject({
name: "FitbitApiError",
classification: "reauth_required",
});
@@ -211,131 +220,51 @@ describe("token exchange + refresh", () => {
});
describe("fetchProfile + resolveFitbitUserId", () => {
it("fetches the profile from the Google Health base", async () => {
const fetchMock = installFetchMock([
{ status: 200, body: { name: "users/abc123" } },
]);
const profile = await fetchProfile("at");
expect(profile.name).toBe("users/abc123");
const [url] = fetchMock.mock.calls[0] as unknown as [string];
expect(url).toBe(`${FITBIT_API_BASE}/users/me/profile`);
});
it("throws a classified error on a 403 profile read", async () => {
installFetchMock([{ status: 403, body: {} }]);
await expect(fetchProfile("at")).rejects.toMatchObject({
name: "FitbitApiError",
classification: "reauth_required",
});
});
it("resolves the external user id from a users/{id} resource name", () => {
expect(resolveFitbitUserId({ name: "users/abc123" })).toBe("abc123");
});
it("falls back to a bare id, then to 'me'", () => {
expect(resolveFitbitUserId({ id: "xyz" })).toBe("xyz");
it("resolves the external user id from the profile encodedId", () => {
expect(resolveFitbitUserId({ user: { encodedId: "ABC123" } })).toBe(
"ABC123",
);
expect(resolveFitbitUserId({})).toBe("me");
expect(resolveFitbitUserId({ user: {} })).toBe("me");
});
});
describe("fetchDataPoints", () => {
it("encodes the data type kebab-case in the path and snake_case in the filter, walking nextPageToken", async () => {
const fetchMock = installFetchMock([
{
status: 200,
body: {
dataPoints: [{ a: 1 }],
nextPageToken: "tok2",
},
},
{ status: 200, body: { dataPoints: [{ a: 2 }] } },
]);
const points = await fetchDataPoints(
FITBIT_DATA_TYPES.bodyFat,
describe("date-range fetchers", () => {
it("encodes the weight endpoint path with the metric Accept-Language", async () => {
const fetchMock = installFetchMock([{ status: 200, body: { weight: [] } }]);
await fetchWeightRange(
"at",
"fetchBodyFat",
{ start: new Date("2026-01-01T00:00:00.000Z") },
new Date("2026-05-01T00:00:00Z"),
new Date("2026-05-30T00:00:00Z"),
);
expect(points).toHaveLength(2);
const [url1] = fetchMock.mock.calls[0] as unknown as [string];
// Path uses kebab-case; filter uses snake_case.
expect(url1).toContain(
`${FITBIT_API_BASE}/users/me/dataTypes/body-fat/dataPoints`,
const [url, init] = fetchMock.mock.calls[0] as unknown as [
string,
{ headers: Record<string, string> },
];
expect(url).toBe(
`${FITBIT_API_BASE}/1/user/-/body/log/weight/date/2026-05-01/2026-05-30.json`,
);
const parsed = new URL(url1);
expect(parsed.searchParams.get("filter")).toBe(
'body_fat.sample_time.physical_time >= "2026-01-01T00:00:00.000Z"',
);
// Second page carries the pageToken from the first response.
const [url2] = fetchMock.mock.calls[1] as unknown as [string];
expect(new URL(url2).searchParams.get("pageToken")).toBe("tok2");
expect(init.headers["Accept-Language"]).toBe("en_GB");
expect(init.headers.Authorization).toBe("Bearer at");
});
it("filters daily summaries on the civil date, not the sample time", async () => {
const fetchMock = installFetchMock([
{ status: 200, body: { dataPoints: [] } },
]);
await fetchDataPoints(
FITBIT_DATA_TYPES.restingHeartRate,
it("encodes the sleep endpoint on the 1.2 version path", async () => {
const fetchMock = installFetchMock([{ status: 200, body: { sleep: [] } }]);
await fetchSleepRange(
"at",
"fetchRhr",
{ start: new Date("2026-03-04T10:00:00.000Z") },
new Date("2026-05-01T00:00:00Z"),
new Date("2026-05-30T00:00:00Z"),
);
const [url] = fetchMock.mock.calls[0] as unknown as [string];
expect(new URL(url).searchParams.get("filter")).toBe(
'daily_resting_heart_rate.date >= "2026-03-04"',
expect(url).toBe(
`${FITBIT_API_BASE}/1.2/user/-/sleep/date/2026-05-01/2026-05-30.json`,
);
});
it("filters INTERVAL types on interval.start_time, not a sample_time or a bare date", async () => {
// steps / distance / calories / floors, sleep and exercise are INTERVAL data
// types — Google anchors them on `interval.start_time`. A sample_time filter
// 400s/empties for these and stalls incremental sync.
const stepsMock = installFetchMock([
{ status: 200, body: { dataPoints: [] } },
]);
await fetchDataPoints(FITBIT_DATA_TYPES.steps, "at", "fetchSteps", {
start: new Date("2026-03-04T10:00:00.000Z"),
});
expect(
new URL(
(stepsMock.mock.calls[0] as unknown as [string])[0],
).searchParams.get("filter"),
).toBe('steps.interval.start_time >= "2026-03-04T10:00:00.000Z"');
const sleepMock = installFetchMock([
{ status: 200, body: { dataPoints: [] } },
]);
await fetchDataPoints(FITBIT_DATA_TYPES.sleep, "at", "fetchSleep", {
start: new Date("2026-03-04T10:00:00.000Z"),
});
expect(
new URL(
(sleepMock.mock.calls[0] as unknown as [string])[0],
).searchParams.get("filter"),
).toBe('sleep.interval.start_time >= "2026-03-04T10:00:00.000Z"');
const exMock = installFetchMock([
{ status: 200, body: { dataPoints: [] } },
]);
await fetchDataPoints(FITBIT_DATA_TYPES.exercise, "at", "fetchExercise", {
start: new Date("2026-03-04T10:00:00.000Z"),
});
expect(
new URL(
(exMock.mock.calls[0] as unknown as [string])[0],
).searchParams.get("filter"),
).toBe('exercise.interval.start_time >= "2026-03-04T10:00:00.000Z"');
});
it("throws a classified FitbitApiError on a non-2xx page", async () => {
it("throws a classified FitbitApiError on a 403 page", async () => {
installFetchMock([{ status: 403, body: {} }]);
await expect(
fetchDataPoints(FITBIT_DATA_TYPES.weight, "at", "fetchWeight"),
fetchSpo2Range("at", new Date(), new Date()),
).rejects.toMatchObject({
name: "FitbitApiError",
classification: "reauth_required",
@@ -344,150 +273,145 @@ describe("fetchDataPoints", () => {
});
});
describe("metric mappers", () => {
it("maps weight in kg with a stable sample-time anchor + field-tag externalId", () => {
const point = {
weight: {
kilograms: 81.456,
sample_time: { physical_time: "2026-05-10T07:30:00.000Z" },
},
};
const out = mapWeight(point);
describe("metric mappers (classic shapes)", () => {
it("maps weight in kg, anchoring the externalId on the logId", () => {
const out = mapWeight({
weight: [
{
date: "2026-05-10",
time: "07:30:00",
weight: 81.46,
logId: 1551080804000,
source: "Aria",
},
],
});
expect(out).toHaveLength(1);
expect(out[0]).toMatchObject({
type: "WEIGHT",
value: 81.46,
unit: "kg",
});
expect(out[0]).toMatchObject({ type: "WEIGHT", value: 81.46, unit: "kg" });
expect(out[0]!.measuredAt.toISOString()).toBe("2026-05-10T07:30:00.000Z");
// externalId field-tag: <anchor>:<tag>; anchor is the sample time ISO string.
expect(out[0]!.fieldTag).toBe("2026-05-10T07:30:00.000Z:weight");
expect(out[0]!.fieldTag).toBe("1551080804000:weight");
});
it("maps body fat percentage", () => {
const out = mapBodyFat({ body_fat: { percentage: 18.2 } });
expect(out[0]).toMatchObject({ type: "BODY_FAT", value: 18.2, unit: "%" });
});
it("maps daily SpO2 with a civil-date anchor", () => {
const out = mapOxygenSaturation({
daily_oxygen_saturation: {
average_percentage: 97.4,
date: { year: 2026, month: 5, day: 10 },
},
it("maps body fat percentage from the fat array", () => {
const out = mapBodyFat({
fat: [{ date: "2026-05-10", time: "07:30:00", fat: 18.2, logId: 42 }],
});
expect(out[0]).toMatchObject({ type: "BODY_FAT", value: 18.2, unit: "%" });
expect(out[0]!.fieldTag).toBe("42:body_fat");
});
it("maps daily SpO2 from value.avg with a day-keyed externalId (bare array)", () => {
const out = mapOxygenSaturation([
{ dateTime: "2026-05-10", value: { avg: 97.4, min: 94, max: 100 } },
]);
expect(out[0]).toMatchObject({
type: "OXYGEN_SATURATION",
value: 97.4,
unit: "%",
});
// Daily anchor is the civil date (YYYY-MM-DD), so a re-fetch of the same day
// overwrites in place.
expect(out[0]!.fieldTag).toBe("2026-05-10:spo2");
});
it("maps Fitbit HRV into the SDNN HEART_RATE_VARIABILITY slot, NOT HRV_RMSSD", () => {
it("maps HRV dailyRmssd into the canonical HEART_RATE_VARIABILITY slot", () => {
const out = mapHeartRateVariability({
daily_heart_rate_variability: {
rmssd_milliseconds: 42.7,
date: { year: 2026, month: 5, day: 10 },
},
hrv: [
{ dateTime: "2026-05-10", value: { dailyRmssd: 42.7, deepRmssd: 50 } },
],
});
expect(out[0]!.type).toBe("HEART_RATE_VARIABILITY");
expect(out[0]!.type).not.toBe("HRV_RMSSD");
expect(out[0]).toMatchObject({ value: 42.7, unit: "ms" });
expect(out[0]!.fieldTag).toBe("2026-05-10:hrv");
});
it("maps daily resting heart rate", () => {
it("maps resting HR from the activities-heart value.restingHeartRate", () => {
const out = mapRestingHeartRate({
daily_resting_heart_rate: {
beats_per_minute: 54,
date: { year: 2026, month: 5, day: 10 },
},
"activities-heart": [
{ dateTime: "2026-05-10", value: { restingHeartRate: 54 } },
],
});
expect(out[0]).toMatchObject({ type: "RESTING_HEART_RATE", value: 54 });
});
it("drops a garbage / non-positive reading rather than minting a row", () => {
expect(mapWeight({ weight: { kilograms: 0 } })).toHaveLength(0);
expect(mapWeight({ weight: { kilograms: Number.NaN } })).toHaveLength(0);
expect(mapWeight({})).toHaveLength(0);
it("maps respiratory rate from value.breathingRate", () => {
const out = mapRespiratoryRate({
br: [{ dateTime: "2026-05-10", value: { breathingRate: 17.8 } }],
});
expect(out[0]).toMatchObject({
type: "RESPIRATORY_RATE",
value: 17.8,
unit: "breaths/min",
});
});
it("resolves height to cm from either centimetres or metres, never as a Measurement", () => {
expect(mapHeightCm({ height: { centimeters: 178 } })).toBe(178);
expect(mapHeightCm({ height: { meters: 1.78 } })).toBe(178);
expect(mapHeightCm({ height: {} })).toBeNull();
it("drops a garbage / non-positive / absent reading rather than minting a row", () => {
expect(
mapWeight({ weight: [{ date: "2026-05-10", weight: 0 }] }),
).toHaveLength(0);
expect(mapWeight({ weight: [] })).toHaveLength(0);
expect(mapWeight({})).toHaveLength(0);
expect(
mapOxygenSaturation([{ dateTime: "2026-05-10", value: {} }]),
).toHaveLength(0);
});
});
describe("FITBIT_FIELD_MAP ↔ FITBIT_DATA_TYPES casing parity", () => {
it("keeps the kebab-path / snake-filter pair consistent across both tables", () => {
for (const [, entry] of Object.entries(FITBIT_FIELD_MAP)) {
// Path is kebab-case, filter is snake_case — they must never carry the
// wrong separator (the design §A.1 casing gotcha).
expect(entry.path).not.toContain("_");
expect(entry.filter).not.toContain("-");
}
// Every FITBIT_DATA_TYPES entry has matching casing too.
for (const dt of Object.values(FITBIT_DATA_TYPES)) {
expect(dt.path).not.toContain("_");
expect(dt.filter).not.toContain("-");
}
describe("VO2 max", () => {
it("parses a single numeric string and a range midpoint", () => {
expect(parseVo2Max("45")).toBe(45);
expect(parseVo2Max("44-48")).toBe(46);
expect(parseVo2Max(50)).toBe(50);
expect(parseVo2Max("not-a-number")).toBeNull();
});
it("maps cardioScore value.vo2Max (range → midpoint) with a day-keyed stats externalId", () => {
const out = mapVo2Max({
cardioScore: [
{ dateTime: "2026-05-10", value: { vo2Max: "44-48" } },
{ dateTime: "2026-05-11", value: { vo2Max: "45" } },
],
});
expect(out).toHaveLength(2);
expect(out[0]).toMatchObject({ type: "VO2_MAX", value: 46 });
expect(out[0]!.cumulativeDaily).toBe(true);
expect(out[0]!.fieldTag).toBe("vo2_max:2026-05-10");
expect(out[1]!.value).toBe(45);
});
});
describe("activity mappers (cumulative daily)", () => {
// INTERVAL data types: Google buckets the daily total into an `interval` with
// a `start_time` (physical instant) — NOT a bare civil `date`. The mapper
// anchors measuredAt on `interval.start_time` and day-keys the externalId.
const interval = { start_time: "2026-05-10T00:00:00.000Z" };
// A civil-only fallback shape (no physical start_time) still day-keys cleanly.
const civilInterval = {
civil_start_time: { year: 2026, month: 5, day: 10 },
};
it("maps steps with a stats:-shaped daily externalId and preserves a zero", () => {
const out = mapSteps({ steps: { count: 8421, interval } });
expect(out).toHaveLength(1);
it("maps steps from the string value, preserving a zero, with a day-keyed externalId", () => {
const out = mapSteps({
"activities-steps": [
{ dateTime: "2026-05-10", value: "8421" },
{ dateTime: "2026-05-11", value: "0" },
],
});
expect(out).toHaveLength(2);
expect(out[0]).toMatchObject({
type: "ACTIVITY_STEPS",
value: 8421,
unit: "steps",
cumulativeDaily: true,
});
// fieldTag carries <tag>:<YYYY-MM-DD>; the sync layer prefixes `stats:`.
expect(out[0]!.fieldTag).toBe("steps:2026-05-10");
// measuredAt anchored on the interval start instant.
expect(out[0]!.measuredAt.toISOString()).toBe("2026-05-10T00:00:00.000Z");
// The civil-only fallback day-keys the externalId at UTC midday.
const civil = mapSteps({ steps: { count: 100, interval: civilInterval } });
expect(civil[0]!.fieldTag).toBe("steps:2026-05-10");
// A rest day records 0 steps — that is real data, not a gap.
const rest = mapSteps({ steps: { count: 0, interval } });
expect(rest).toHaveLength(1);
expect(rest[0]!.value).toBe(0);
// A rest day of 0 steps is real data.
expect(out[1]!.value).toBe(0);
});
it("maps distance in metres, converting km → m when reported in km", () => {
expect(
mapDistance({ distance: { meters: 6200, interval } })[0],
).toMatchObject({
it("maps distance km → metres", () => {
const out = mapDistance({
"activities-distance": [{ dateTime: "2026-05-10", value: "6.2" }],
});
expect(out[0]).toMatchObject({
type: "WALKING_RUNNING_DISTANCE",
value: 6200,
unit: "m",
});
// km path multiplies by 1000.
expect(
mapDistance({ distance: { kilometers: 6.2, interval } })[0]!.value,
).toBe(6200);
});
it("maps the ACTIVE-calories portion into ACTIVE_ENERGY_BURNED", () => {
const out = mapActiveCalories({
active_calories: { active_kilocalories: 540, interval },
"activities-activityCalories": [{ dateTime: "2026-05-10", value: "540" }],
});
expect(out[0]).toMatchObject({
type: "ACTIVE_ENERGY_BURNED",
@@ -497,201 +421,141 @@ describe("activity mappers (cumulative daily)", () => {
});
it("maps floors and preserves a zero", () => {
expect(mapFloors({ floors: { count: 12, interval } })[0]).toMatchObject({
type: "FLIGHTS_CLIMBED",
value: 12,
unit: "flights",
const out = mapFloors({
"activities-floors": [
{ dateTime: "2026-05-10", value: "12" },
{ dateTime: "2026-05-11", value: "0" },
],
});
expect(mapFloors({ floors: { count: 0, interval } })[0]!.value).toBe(0);
});
it("maps VO2 max (strictly positive, daily latest-wins) and drops a zero", () => {
// VO2 max is a daily-summary metric (civil `date` anchor), not an interval.
const day = { year: 2026, month: 5, day: 10 };
const out = mapVo2Max({
vo2_max: { milliliters_per_kilogram_per_minute: 47.3, date: day },
});
expect(out[0]).toMatchObject({ type: "VO2_MAX", value: 47.3 });
// VO2 max of 0 is garbage — dropped (unlike the running totals).
expect(
mapVo2Max({
vo2_max: { milliliters_per_kilogram_per_minute: 0, date: day },
}),
).toHaveLength(0);
expect(out[0]).toMatchObject({ type: "FLIGHTS_CLIMBED", value: 12 });
expect(out[1]!.value).toBe(0);
});
});
describe("sleep-stage mapping", () => {
it("harmonises Google stage labels onto the shared SleepStage enum", () => {
describe("sleep mapping (1.2 levels.data)", () => {
it("harmonises classic Fitbit level labels onto the shared SleepStage enum", () => {
expect(mapFitbitSleepStage("light")).toBe("CORE"); // Fitbit light ↔ Apple core
expect(mapFitbitSleepStage("deep")).toBe("DEEP");
expect(mapFitbitSleepStage("rem")).toBe("REM");
expect(mapFitbitSleepStage("awake")).toBe("AWAKE");
expect(mapFitbitSleepStage("wake")).toBe("AWAKE");
expect(mapFitbitSleepStage("awake")).toBe("AWAKE");
expect(mapFitbitSleepStage("restless")).toBe("AWAKE");
expect(mapFitbitSleepStage("in-bed")).toBe("IN_BED");
expect(mapFitbitSleepStage("asleep")).toBe("ASLEEP");
// Unknown / non-string → null (skipped, never mis-bucketed).
expect(mapFitbitSleepStage("snoring")).toBeNull();
expect(mapFitbitSleepStage(42)).toBeNull();
});
it("maps a session into per-SEGMENT SLEEP_DURATION rows with measuredAt = segment END (real timeline)", () => {
const session = {
sleep: {
startTime: "2026-05-10T22:00:00.000Z",
endTime: "2026-05-11T06:00:00.000Z",
segments: [
{
stage: "light",
startTime: "2026-05-10T22:00:00.000Z",
endTime: "2026-05-10T22:30:00.000Z",
it("maps a session into per-SEGMENT rows with measuredAt = segment END (real timeline)", () => {
const sessions = readSleepSessions({
sleep: [
{
logId: 999,
startTime: "2026-05-10T22:00:00.000",
endTime: "2026-05-11T06:00:00.000",
levels: {
data: [
{
dateTime: "2026-05-10T22:00:00.000",
level: "light",
seconds: 1800,
},
{
dateTime: "2026-05-10T22:30:00.000",
level: "deep",
seconds: 3600,
},
{
dateTime: "2026-05-10T23:30:00.000",
level: "rem",
seconds: 2700,
},
],
},
{
stage: "deep",
startTime: "2026-05-10T22:30:00.000Z",
endTime: "2026-05-10T23:30:00.000Z",
},
{
stage: "light",
startTime: "2026-05-10T23:30:00.000Z",
endTime: "2026-05-11T00:15:00.000Z",
},
{
stage: "rem",
startTime: "2026-05-11T00:15:00.000Z",
endTime: "2026-05-11T01:00:00.000Z",
},
],
},
};
const out = mapSleepSession(session);
},
],
});
expect(sessions).toHaveLength(1);
const out = mapSleepSession(sessions[0]!);
// One row PER segment (no collapse) — 4 segments → 4 rows.
expect(out).toHaveLength(4);
// One row per segment.
expect(out).toHaveLength(3);
expect(out.every((m) => m.type === "SLEEP_DURATION")).toBe(true);
expect(out.every((m) => m.unit === "minutes")).toBe(true);
// Each row carries its OWN end instant + duration — a real clock-time
// timeline, not a stage collapse.
const core = out.filter((m) => m.sleepStage === "CORE");
expect(core.map((m) => m.value)).toEqual([30, 45]);
expect(core.map((m) => m.measuredAt.toISOString())).toEqual([
"2026-05-10T22:30:00.000Z",
"2026-05-11T00:15:00.000Z",
]);
const core = out.find((m) => m.sleepStage === "CORE")!;
expect(core.value).toBe(30); // 1800s → 30 min
// measuredAt = segment START + seconds = END (local instant).
expect(core.measuredAt.getTime()).toBe(
new Date("2026-05-10T22:30:00.000").getTime(),
);
const deep = out.find((m) => m.sleepStage === "DEEP")!;
expect(deep.value).toBe(60);
expect(deep.measuredAt.toISOString()).toBe("2026-05-10T23:30:00.000Z");
const rem = out.find((m) => m.sleepStage === "REM")!;
expect(rem.value).toBe(45);
// Every fieldTag is distinct (session anchor + stage + segment index) so
// the several segments of one stage never collide under the dedup key.
// Every fieldTag distinct (logId anchor + stage + segment index).
const tags = out.map((m) => m.fieldTag);
expect(new Set(tags).size).toBe(tags.length);
expect(tags).toContain("2026-05-11T06:00:00.000Z:sleep_deep:1");
});
it("anchors the session externalId on sleep.interval.end_time for an INTERVAL-shaped point", () => {
const out = mapSleepSession({
sleep: {
interval: {
start_time: "2026-05-10T22:00:00.000Z",
end_time: "2026-05-11T06:00:00.000Z",
},
segments: [
{
stage: "deep",
startTime: "2026-05-10T22:30:00.000Z",
endTime: "2026-05-10T23:30:00.000Z",
},
],
},
});
const deep = out.find((m) => m.sleepStage === "DEEP");
expect(deep!.fieldTag).toBe("2026-05-11T06:00:00.000Z:sleep_deep:0");
expect(deep.fieldTag).toBe("999:sleep_deep:1");
});
it("yields nothing for a session with no parseable segments", () => {
expect(mapSleepSession({ sleep: {} })).toHaveLength(0);
expect(mapSleepSession({ levels: { data: [] } })).toHaveLength(0);
expect(mapSleepSession({})).toHaveLength(0);
});
});
describe("workout mapping", () => {
it("maps an exercise session into a Workout shape with a stable externalId", () => {
const w = mapWorkout({
exercise: {
session_id: "ex-123",
activity_type: "run",
startTime: "2026-05-10T07:00:00.000Z",
endTime: "2026-05-10T07:45:00.000Z",
active_kilocalories: 410,
distance: { meters: 7800 },
average_heart_rate: { beats_per_minute: 148 },
maximum_heart_rate: { beats_per_minute: 172 },
},
describe("workout mapping (activities list)", () => {
it("maps an activity-list entry into a Workout keyed on the logId", () => {
const entries = readActivityList({
activities: [
{
logId: 123456,
activityName: "Run",
startTime: "2026-05-10T07:00:00.000",
duration: 45 * 60 * 1000,
calories: 410,
distance: 7.8, // km
averageHeartRate: 148,
},
],
});
expect(entries).toHaveLength(1);
const w = mapWorkout(entries[0]!);
expect(w).not.toBeNull();
expect(w).toMatchObject({
externalId: "ex-123",
externalId: "123456",
sportType: "running",
durationSec: 45 * 60,
totalEnergyKcal: 410,
totalDistanceM: 7800,
totalDistanceM: 7800, // km → m
avgHeartRate: 148,
maxHeartRate: 172,
maxHeartRate: null,
minHeartRate: null,
});
expect(w!.startedAt.toISOString()).toBe("2026-05-10T07:00:00.000Z");
expect(w!.startedAt.getTime()).toBe(
new Date("2026-05-10T07:00:00.000").getTime(),
);
});
it("falls back to a start-anchored externalId + 'other' sport when fields are absent", () => {
const w = mapWorkout({
exercise: {
startTime: "2026-05-10T07:00:00.000Z",
endTime: "2026-05-10T07:30:00.000Z",
},
startTime: "2026-05-10T07:00:00.000",
duration: 30 * 60 * 1000,
});
expect(w!.externalId).toBe("exercise:2026-05-10T07:00:00.000Z");
expect(w!.externalId).toMatch(/^exercise:/);
expect(w!.sportType).toBe("other");
expect(w!.totalEnergyKcal).toBeNull();
expect(w!.avgHeartRate).toBeNull();
});
it("reads the start/end from exercise.interval for an INTERVAL-shaped point", () => {
const w = mapWorkout({
exercise: {
session_id: "ex-iv",
activity_type: "run",
interval: {
start_time: "2026-05-10T07:00:00.000Z",
end_time: "2026-05-10T07:45:00.000Z",
},
},
});
expect(w).not.toBeNull();
expect(w!.startedAt.toISOString()).toBe("2026-05-10T07:00:00.000Z");
expect(w!.endedAt.toISOString()).toBe("2026-05-10T07:45:00.000Z");
expect(w!.durationSec).toBe(45 * 60);
});
it("returns null for a session with no usable time span", () => {
expect(
mapWorkout({ exercise: { startTime: "2026-05-10T07:00:00.000Z" } }),
).toBeNull();
expect(
mapWorkout({
exercise: {
startTime: "2026-05-10T07:30:00.000Z",
endTime: "2026-05-10T07:00:00.000Z", // end before start
},
}),
).toBeNull();
it("returns null for an entry with no usable time span", () => {
expect(mapWorkout({ startTime: "2026-05-10T07:00:00.000" })).toBeNull();
expect(mapWorkout({ duration: 1000 })).toBeNull();
expect(mapWorkout({})).toBeNull();
});
it("resolves Google activity types to canonical sport labels", () => {
it("resolves activity names to canonical sport labels", () => {
expect(mapFitbitSportType("Walk")).toBe("walking");
expect(mapFitbitSportType("biking")).toBe("cycling");
expect(mapFitbitSportType("weights")).toBe("strength");
@@ -699,3 +563,15 @@ describe("workout mapping", () => {
expect(mapFitbitSportType("")).toBe("other");
});
});
describe("FITBIT_FIELD_MAP", () => {
it("documents a type + unit for every launch metric", () => {
for (const [, entry] of Object.entries(FITBIT_FIELD_MAP)) {
expect(typeof entry.type).toBe("string");
expect(entry.type.length).toBeGreaterThan(0);
expect(typeof entry.unit).toBe("string");
}
expect(FITBIT_FIELD_MAP.weight!.type).toBe("WEIGHT");
expect(FITBIT_FIELD_MAP.vo2Max!.type).toBe("VO2_MAX");
});
});
@@ -241,19 +241,26 @@ describe("syncUserFitbit — single cycle-wide watermark", () => {
expect(recordSyncSuccess).not.toHaveBeenCalled();
});
it("passes fullSync + deferRollup through with an undefined start on a backfill cycle", async () => {
it("passes fullSync + deferRollup through with a bounded backfill start", async () => {
syncUserMetrics.mockResolvedValue(2);
await syncUserFitbit("user1", { fullSync: true });
const opts = syncUserMetrics.mock.calls[0]![1] as {
start?: Date;
end?: Date;
fullSync?: boolean;
deferRollup?: boolean;
};
expect(opts.fullSync).toBe(true);
// Full sync walks deep history with no lower bound.
expect(opts.start).toBeUndefined();
// The classic Web API range caps mean the backfill walks a bounded horizon
// (~1 year back), not an unbounded deep history.
expect(opts.start).toBeInstanceOf(Date);
expect(opts.end).toBeInstanceOf(Date);
const spanDays =
(opts.end!.getTime() - opts.start!.getTime()) / (24 * 60 * 60 * 1000);
expect(spanDays).toBeGreaterThan(360);
expect(spanDays).toBeLessThan(370);
// The backfill defers the per-write rollup hook to one end-of-cycle pass.
expect(opts.deferRollup).toBe(true);
});
+10 -8
View File
@@ -1,7 +1,8 @@
/**
* v1.12.0 — Fitbit token-management tests (mocked). Covers `getValidToken`:
* - the NO-ROTATION refresh branch (refresh token preserved when the response
* omits it; overwritten only when a fresh one is returned);
* Fitbit token-management tests (mocked). Covers `getValidToken`:
* - the ROTATING refresh branch (classic Fitbit returns a fresh refresh token
* on every refresh; persist it, replacing the stored one);
* - the defensive keep-existing guard when a malformed response omits it;
* - the stored-token fast path when not near expiry;
* - a reauth failure recorded when credentials are missing on refresh.
*/
@@ -86,8 +87,8 @@ afterEach(() => {
vi.clearAllMocks();
});
describe("getValidToken — non-rotating refresh", () => {
it("persists the new access token and KEEPS the stored refresh token when the response omits one", async () => {
describe("getValidToken — rotating refresh", () => {
it("defensively KEEPS the stored refresh token when a malformed response omits one", async () => {
prismaMock.fitbitConnection.findUnique.mockResolvedValue({
id: "conn1",
fitbitUserId: "abc123",
@@ -96,7 +97,8 @@ describe("getValidToken — non-rotating refresh", () => {
// Expired so the refresh path fires.
tokenExpiresAt: new Date(Date.now() - 1000),
});
// Google does NOT return a refresh_token on a routine refresh.
// Classic Fitbit always rotates; a missing refresh_token is a malformed
// reply. Guard it by keeping the existing token rather than writing a blank.
refreshAccessTokenMock.mockResolvedValue({
access_token: "new-access",
expires_in: 3600,
@@ -112,11 +114,11 @@ describe("getValidToken — non-rotating refresh", () => {
});
const updateArg = prismaMock.fitbitConnection.update.mock.calls[0]![0];
expect(updateArg.data.accessToken).toBe("enc(new-access)");
// The stored refresh token must NOT be touched when the response omits one.
// The stored refresh token must NOT be wiped when the response omits one.
expect(updateArg.data).not.toHaveProperty("refreshToken");
});
it("overwrites the stored refresh token only when the response carries a fresh one", async () => {
it("persists the ROTATED refresh token when the response carries a fresh one", async () => {
prismaMock.fitbitConnection.findUnique.mockResolvedValue({
id: "conn1",
fitbitUserId: "abc123",
+785 -1015
View File
File diff suppressed because it is too large. Load diff
+93 -87
View File
@@ -1,111 +1,117 @@
# Fitbit / Google Health field → Measurement mapping
# Fitbit Web API field → Measurement mapping
Single source of truth for the Fitbit (Google Health API v4) data-type →
Single source of truth for the classic Fitbit Web API (`api.fitbit.com`) →
HealthLog `Measurement` mapping. Keep this in sync with `FITBIT_FIELD_MAP` and
the per-type mappers in `client.ts`. Every Fitbit row ingests server-side with
`source = FITBIT` and `externalId = <anchor>:<fieldTag>` (the anchor is the data
point's sample time for spot readings, its civil date for daily summaries, or its
`interval.start_time` for INTERVAL types — steps/distance/calories/floors, sleep,
exercise; the field-tag disambiguates the metric).
`source = FITBIT`; the `externalId` is a stable per-reading key so a re-fetch of
the same window overwrites in place rather than minting a duplicate.
## Time anchor by grain (design §A.1)
Transport: v1.20.0 retargeted Fitbit from the Google Health API (Restricted
scopes behind brand-verification + CASA — a self-hoster adoption wall) onto the
classic Web API. A self-hoster registers an app at **dev.fitbit.com** in minutes
(no CASA). Auth: Authorization Code + **PKCE (S256)**;
`www.fitbit.com/oauth2/authorize` + `api.fitbit.com/oauth2/token`. Refresh tokens
**rotate** (one-time use) — the new token must be persisted every refresh.
Google Health exposes three time-anchor shapes. The incremental `filter` and the
read-time `measuredAt`/externalId resolution must target the right one or the
incremental sync stalls (a `sample_time` filter 400s/empties for an INTERVAL type):
Deprecation note: the classic 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 settings card keeps its experimental badge.
- **sample** — spot reading; anchor `{filter}.sample_time.physical_time`.
- **date** — daily-summary metric (SpO2, HRV, RHR, respiratory rate, wrist temp,
VO2 max); anchor `{filter}.date` (a civil date / `{year,month,day}`).
- **interval** — INTERVAL type (steps/distance/calories/floors daily totals, plus
sleep + exercise sessions); anchor `{filter}.interval.start_time` (a physical
instant) with `{filter}.interval.civil_start_time` as the civil fallback.
All data reads send `Accept-Language: en_GB` so weight + distance arrive in
metric (kg / km); the US default is imperial.
Transport docs: `developers.google.com/health` (v4, post-Fitbit-Web-API). The
per-type value-field JSON is **NOT fully published** — the mappers read a small
set of candidate value paths defensively (`firstNumber` walks each shape and
takes the first finite-positive hit). **Re-verify against a live test account at
build** and tighten the extractors. Design: `.planning/v1.12.0-google-health-design.md`.
## Health-metrics endpoints
## Casing gotcha (design §A.1)
Scope: `weight`, `oxygen_saturation`, `heartrate`, `respiratory_rate`. Verified
against the dev.fitbit.com reference (response shapes quoted there).
The data-type id is **kebab-case in the request path** (`body-fat`) and
**snake_case in the `filter` predicate** (`body_fat`). `FITBIT_DATA_TYPES` pins
both forms per type so a fetcher can never encode the wrong one.
| Endpoint | MeasurementType | Unit | Source field | Grain | Note |
| ---------------------------------------------- | ------------------------ | ----------- | ------------------------------------------- | ----- | ----------------------------------------------------------- |
| `/1/user/-/body/log/weight/date/{s}/{e}.json` | `WEIGHT` | kg | `weight[].weight` | log | externalId anchors on `logId`. Picker ranks Withings above. |
| `/1/user/-/body/log/fat/date/{s}/{e}.json` | `BODY_FAT` | % | `fat[].fat` | log | externalId anchors on `logId`. |
| `/1/user/-/spo2/date/{s}/{e}.json` | `OXYGEN_SATURATION` | % | `[].value.avg` (bare array) | daily | day-keyed externalId. |
| `/1/user/-/hrv/date/{s}/{e}.json` | `HEART_RATE_VARIABILITY` | ms | `hrv[].value.dailyRmssd` | daily | RMSSD estimator → canonical HRV slot (not `HRV_RMSSD`). |
| `/1/user/-/activities/heart/date/{s}/{e}.json` | `RESTING_HEART_RATE` | bpm | `activities-heart[].value.restingHeartRate` | daily | rows with no resting HR for the day are skipped. |
| `/1/user/-/br/date/{s}/{e}.json` | `RESPIRATORY_RATE` | breaths/min | `br[].value.breathingRate` | daily | |
## Health-metrics bundle (W3)
Each daily-summary value passes a finite + strictly-positive guard (a 0/NaN is a
garbage/empty reading and is dropped). Each endpoint caps its own date range
(30–31 days for these), so the sync chunks `[start, end]` into ≤30-day windows.
Scope: `googlehealth.health_metrics_and_measurements.readonly`.
## Activity + VO2 max endpoints
| Data type (path / filter) | MeasurementType | Unit | fieldTag | Grain | Note |
| ----------------------------------------------------------------------------- | ------------------------ | ----------- | ------------ | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `weight` / `weight` | `WEIGHT` | kg | `weight` | sample | Picker ranks a real Withings scale above Fitbit. |
| `body-fat` / `body_fat` | `BODY_FAT` | % | `body_fat` | sample | |
| `daily-oxygen-saturation` / `daily_oxygen_saturation` | `OXYGEN_SATURATION` | % | `spo2` | daily | Already 0–100. |
| `daily-heart-rate-variability` / `daily_heart_rate_variability` | `HEART_RATE_VARIABILITY` | ms | `hrv` | daily | **Decision:** SDNN slot (Apple-comparable), NOT `HRV_RMSSD`. Fitbit reports an RMSSD-style nightly HRV — confirm the estimator at build and reconsider `HRV_RMSSD` if warranted. |
| `daily-resting-heart-rate` / `daily_resting_heart_rate` | `RESTING_HEART_RATE` | bpm | `rhr` | daily | |
| `daily-respiratory-rate` / `daily_respiratory_rate` | `RESPIRATORY_RATE` | breaths/min | `resp_rate` | daily | |
| `heart-rate` / `heart_rate` | `PULSE` | bpm | `hr` | sample | Intraday spot HR. |
| `daily-sleep-temperature-derivations` / `daily_sleep_temperature_derivations` | `WRIST_TEMPERATURE` | celsius | `wrist_temp` | daily | Closest semantic slot to Apple sleeping-wrist-temp. Confirm absolute-vs-baseline at build; the positive guard rejects a baseline-delta reading. |
| `height` / `height` | `User.heightCm` | cm | — | sample | Profile seed — written to `User.heightCm` ONLY when null; never minted as a Measurement (WHOOP `mapBody` pattern). m→cm when reported in metres. |
Scope: `activity`, `cardio_fitness`. Per-day summaries; the externalId carries
the `stats:` daily-total prefix — `stats:<fieldTag>:<YYYY-MM-DD>` — so a
re-fetched day **overwrites** in place (Apple-Health `stats:<HK>:<YYYY-MM-DD>`
contract). The running totals **preserve a 0** (a rest day is real data); VO2 max
stays strictly positive.
Every value passes a finite + strictly-positive guard (the launch metrics are
all positive — a zero/NaN is a garbage/empty reading and is dropped).
| Endpoint | MeasurementType | Unit | Source field | Note |
| --------------------------------------------------------- | -------------------------- | ----------- | ------------------------------------- | ------------------------------------------------------------ |
| `/1/user/-/activities/steps/date/{s}/{e}.json` | `ACTIVITY_STEPS` | steps | `activities-steps[].value` (string) | daily total; 0 valid |
| `/1/user/-/activities/distance/date/{s}/{e}.json` | `WALKING_RUNNING_DISTANCE` | m | `activities-distance[].value` | km → m (×1000) under the metric locale |
| `/1/user/-/activities/activityCalories/date/{s}/{e}.json` | `ACTIVE_ENERGY_BURNED` | kcal | `activities-activityCalories[].value` | ACTIVE portion only (excludes BMR) |
| `/1/user/-/activities/floors/date/{s}/{e}.json` | `FLIGHTS_CLIMBED` | flights | `activities-floors[].value` | daily total; 0 valid |
| `/1/user/-/cardioscore/date/{s}/{e}.json` | `VO2_MAX` | mL/(kg·min) | `cardioScore[].value.vo2Max` | **string** — a single number or a `"lo-hi"` range → midpoint |
## Deferred (Google Health Q3-2026 roadmap — slots exist, NOT in launch set)
Activity time-series values arrive as numeric **strings** (`"2504"`). VO2 max
arrives as a string that may be a single value (`"45"`) or a range (`"44-48"`,
when no GPS run data exists) — the range resolves to its midpoint.
`blood-glucose` → `BLOOD_GLUCOSE`, `blood-pressure` → `BLOOD_PRESSURE_SYS`+`_DIA`,
`basal-body-temperature` → `BODY_TEMPERATURE`. These light up for free when
Google ships the data types.
## Sleep endpoint
## Activity bundle (W5) — daily cumulative
Scope: `sleep`. `/1.2/user/-/sleep/date/{s}/{e}.json` →
`sleep[].levels.data[]` (`{ dateTime, level, seconds }`). One `SLEEP_DURATION`
row **per segment**, `measuredAt = segment START + seconds = segment END`, value =
seconds → minutes. The 1.2 log carries a real per-segment series, so the rows lay
each block at its true clock time (a MEASURED timeline — rows are NOT flagged
reconstructed). externalId = `<logId>:sleep_<stage>:<i>` (indexed so several
segments of one stage stay distinct), so a re-scored night overwrites in place.
The segment `dateTime` is local wall-clock ISO without an offset (the night
belongs to the user's local clock) — parsed as local time.
Scope: `googlehealth.activity_and_fitness.readonly`. Each is a per-day summary
(one value per calendar day). The externalId carries the `stats:` daily-total
prefix — `stats:<fieldTag>:<YYYY-MM-DD>` — so a re-fetched day **overwrites** in
place rather than minting a duplicate, matching the Apple-Health
`stats:<HK>:<YYYY-MM-DD>` daily-total overwrite contract. The running totals
(steps/distance/floors/active-energy) **preserve a 0** (a rest day is real data,
not a gap); VO2 max stays strictly positive (daily latest-wins).
Stage map (stages logs + classic logs): `light → CORE` (Fitbit "light" ↔ Apple
"core" shallow-NREM band), `deep → DEEP`, `rem → REM`, `wake`/`awake`/`restless →
AWAKE`, classic `asleep → ASLEEP`, `in_bed → IN_BED`. Unknown labels are skipped,
not mis-bucketed.
| Data type (path / filter) | MeasurementType | Unit | fieldTag | Note |
| ------------------------------------- | -------------------------- | ----------- | ----------------- | -------------------------------------------------------------------- |
| `steps` / `steps` | `ACTIVITY_STEPS` | steps | `steps` | daily total; 0 valid |
| `distance` / `distance` | `WALKING_RUNNING_DISTANCE` | m | `distance` | metres (km → m when reported in km) |
| `active-calories` / `active_calories` | `ACTIVE_ENERGY_BURNED` | kcal | `active_calories` | **ACTIVE portion only** — NOT total caloriesOut (which folds in BMR) |
| `floors` / `floors` | `FLIGHTS_CLIMBED` | flights | `floors` | daily total; 0 valid |
| `vo2-max` / `vo2_max` | `VO2_MAX` | mL/(kg·min) | `vo2_max` | daily latest-wins; strictly positive |
## Workouts (activity log list)
## Sleep bundle (W5)
Scope: `activity`. `/1/user/-/activities/list.json?afterDate=...&sort=asc&offset&limit`
→ `activities[]`. Each → one `Workout` row (NOT a Measurement), keyed
`(userId, source: "FITBIT", externalId=logId)`. Fields: `sportType`
(`activityName` → canonical `WorkoutSportType`, `other` fallback),
`startedAt`/`endedAt` (`startTime` + `duration` ms), `durationSec`,
`totalEnergyKcal` (`calories`), `totalDistanceM` (`distance` km → m),
`avgHeartRate` (`averageHeartRate`). The classic list endpoint does NOT surface
min/max HR, so those stay null. A Fitbit run and the same run via Apple Health /
WHOOP stay distinct rows; the read-time `pickCanonicalWorkoutRows` picker
collapses the cross-source twin (FITBIT ranks just below WHOOP in the default
ladder).
Scope: `googlehealth.sleep.readonly`. A sleep session carries per-stage segments
(stage label + start + end). HealthLog stores one `SLEEP_DURATION` row per stage
(summed minutes), `measuredAt = the stage's latest END instant`, harmonised onto
the shared `SleepStage` enum the night-total + hypnogram readers consume.
externalId = `<session-anchor>:sleep_<stage>` (session end ISO instant), so a
re-scored night overwrites in place. Stage map: `light → CORE` (Fitbit "light" ↔
Apple "core" shallow-NREM band), `deep → DEEP`, `rem → REM`, `awake`/`wake`/
`restless → AWAKE`, `in_bed → IN_BED`, classic `asleep → ASLEEP`. Unknown stage
labels are skipped, not mis-bucketed.
## Deliberately skipped
## Exercise bundle (W5) — Workouts
Scope: `googlehealth.activity_and_fitness.readonly`. Each exercise session → one
`Workout` row (NOT a Measurement), keyed `(userId, source: "FITBIT",
externalId)` where externalId is the session id (or `exercise:<startISO>` when
absent). Fields: sportType (Google activity type → canonical `WorkoutSportType`,
`other` fallback), startedAt/endedAt, durationSec, totalEnergyKcal (active
session energy), totalDistanceM, avg/max/min HR (optional). A Fitbit run and the
same run via Apple Health / WHOOP stay distinct rows; the read-time
`pickCanonicalWorkoutRows` picker collapses the cross-source twin (FITBIT ranks
just below WHOOP in the default ladder). `active-zone-minutes` is **skipped** at
launch (no slot; not blocking).
- **Intraday heart rate → `PULSE`.** The classic API gates intraday HR + step
series behind a Personal-app type or a per-app intraday access request at
dev.fitbit.com that most self-hosters do not have. Daily resting HR is always
available and is synced; intraday is left out so a missing grant never parks
the connection. (A per-endpoint 403 also soft-skips, as a safety net.)
- **Skin temperature.** `/1/user/-/temp/skin/...` returns
`value.nightlyRelative` — a **delta from the user's personal baseline**, not an
absolute temperature. Mapping a baseline delta into the absolute
`WRIST_TEMPERATURE`/`BODY_TEMPERATURE` slot would write misleading values, so
the metric is **not mapped** (and `temperature` is not requested). Revisit only
if a relative-temperature slot is added.
- **Blood glucose / blood pressure.** No classic self-serve endpoint maps cleanly
to the HealthLog slots at launch.
## Idempotency
`(userId, type, source: "FITBIT", externalId)` unique. Spot/daily metric rows:
`externalId = <anchor>:<fieldTag>`. Daily cumulative activity rows:
`externalId = stats:<fieldTag>:<YYYY-MM-DD>` (Apple-Health overwrite shape).
Workouts: `(userId, source: "FITBIT", externalId)` on the `Workout` table. A
re-fetch of the same window (daily summaries re-roll after the fact, so the
incremental overlap is 24 h) overwrites in place.
`(userId, type, source: "FITBIT", externalId)` unique. Log-anchored metric rows:
`externalId = <logId>:<fieldTag>`. Daily-summary rows: `externalId =
<YYYY-MM-DD>:<fieldTag>`. Daily cumulative activity rows: `externalId =
stats:<fieldTag>:<YYYY-MM-DD>` (Apple-Health overwrite shape). Sleep:
`<logId>:sleep_<stage>:<i>`. Workouts: `(userId, source: "FITBIT", externalId)`
on the `Workout` table. A re-fetch of the same window (daily summaries settle
after the fact, so the incremental overlap is 24 h) overwrites in place.
+5 -3
View File
@@ -1,6 +1,8 @@
/**
* v1.12.0 — shared constants + mint helper for the Fitbit / Google Health OAuth
* state nonce ledger. Mirrors `src/lib/whoop/oauth-state.ts`.
* Shared constants + mint helper for the Fitbit OAuth state nonce ledger.
* Mirrors `src/lib/whoop/oauth-state.ts`. (v1.20.0 — the PKCE `code_verifier`
* lives on the `FitbitOAuthState` row; the connect route mints it via the
* client's `generatePkcePair`.)
*
* The nonce is random + opaque; a short-lived `FitbitOAuthState` row carries
* the `(nonce → userId)` mapping so the user id never leaks into request logs
@@ -19,7 +21,7 @@ export const FITBIT_OAUTH_STATE_COOKIE = "fitbit_state" as const;
/**
* 10-minute TTL on the state row. Matches the cookie `maxAge`. Long enough to
* cover a user approving the Google consent prompt; short enough that an
* cover a user approving the Fitbit consent prompt; short enough that an
* abandoned handshake doesn't strand a row for hours.
*/
export const FITBIT_OAUTH_STATE_TTL_MS = 10 * 60 * 1000;
+55 -48
View File
@@ -1,32 +1,29 @@
/**
* Fitbit / Google Health activity-bundle sync (v1.12.0, W5).
* Fitbit Web API activity-bundle sync.
*
* Reads the daily cumulative-activity data types from the
* `activity_and_fitness.readonly` Restricted bundle and upserts each mapped
* daily total as `source = FITBIT`:
* Reads the daily cumulative-activity time series + VO2 max from the classic
* Fitbit Web API and upserts each mapped daily total as `source = FITBIT`:
*
* - steps → ACTIVITY_STEPS (count)
* - distance → WALKING_RUNNING_DISTANCE (metres)
* - active-calories → ACTIVE_ENERGY_BURNED (kcal — ACTIVE portion only)
* - floors → FLIGHTS_CLIMBED (count)
* - vo2-max → VO2_MAX (mL/(kg·min); daily latest-wins)
* - activities/steps → ACTIVITY_STEPS (count)
* - activities/distance → WALKING_RUNNING_DISTANCE (metres, km→m)
* - activities/activityCalories → ACTIVE_ENERGY_BURNED (kcal, ACTIVE only)
* - activities/floors → FLIGHTS_CLIMBED (count)
* - cardioscore → VO2_MAX (mL/(kg·min))
*
* These are per-day summaries (one value per calendar day). The externalId is
* minted with the `stats:` daily-total prefix — `stats:<fieldTag>:<YYYY-MM-DD>`
* — so a re-fetched day OVERWRITES the existing row rather than minting a
* duplicate, matching the Apple-Health `stats:<HK>:<YYYY-MM-DD>` overwrite
* contract. A day of rest legitimately records 0 steps / 0 floors / 0 active
* kcal, so the cumulative mappers preserve a zero (dropping it would leave a
* hole the chart misreads as missing data); VO2 max stays strictly positive.
* — so a re-fetched day OVERWRITES the existing row, matching the Apple-Health
* `stats:<HK>:<YYYY-MM-DD>` overwrite contract. A rest day records a real 0
* (dropping it would leave a chart gap); VO2 max stays strictly positive.
*
* A per-data-class 403 soft-skips THAT class (returns 0, leaves the connection
* connected) — the six Restricted bundles are granted independently.
* Each endpoint is a date-RANGE call chunked into ≤30-day windows. A per-endpoint
* 403 soft-skips THAT metric only.
*/
import {
FITBIT_DATA_TYPES,
type FitbitDataType,
FITBIT_RANGE_DAYS,
type FitbitMappedMeasurement,
fetchDataPoints,
fetchActivitySeries,
fetchVo2MaxRange,
mapActiveCalories,
mapDistance,
mapFloors,
@@ -34,6 +31,7 @@ import {
mapVo2Max,
} from "./client";
import {
chunkDateRanges,
getValidToken,
handleCollectionFetchError,
upsertFitbitMeasurements,
@@ -42,34 +40,42 @@ import {
} from "./sync";
import { annotate } from "@/lib/logging/context";
/** One mappable activity metric: its data-type encoding + the per-point mapper + a verb. */
/** One mappable activity metric: a range fetcher + a body mapper + a verb. */
interface ActivityResource {
dataType: FitbitDataType;
map: (point: Record<string, unknown>) => FitbitMappedMeasurement[];
fetch: (accessToken: string, start: Date, end: Date) => Promise<unknown>;
map: (body: unknown) => FitbitMappedMeasurement[];
verb: string;
}
const ACTIVITY_RESOURCES: ActivityResource[] = [
{ dataType: FITBIT_DATA_TYPES.steps, map: mapSteps, verb: "fetchSteps" },
{
dataType: FITBIT_DATA_TYPES.distance,
fetch: (t, s, e) => fetchActivitySeries("steps", t, s, e),
map: mapSteps,
verb: "fetchSteps",
},
{
fetch: (t, s, e) => fetchActivitySeries("distance", t, s, e),
map: mapDistance,
verb: "fetchDistance",
},
{
dataType: FITBIT_DATA_TYPES.activeCalories,
fetch: (t, s, e) => fetchActivitySeries("activityCalories", t, s, e),
map: mapActiveCalories,
verb: "fetchActiveCalories",
},
{ dataType: FITBIT_DATA_TYPES.floors, map: mapFloors, verb: "fetchFloors" },
{ dataType: FITBIT_DATA_TYPES.vo2Max, map: mapVo2Max, verb: "fetchVo2Max" },
{
fetch: (t, s, e) => fetchActivitySeries("floors", t, s, e),
map: mapFloors,
verb: "fetchFloors",
},
{ fetch: fetchVo2MaxRange, map: mapVo2Max, verb: "fetchVo2Max" },
];
/**
* Assemble the stored externalId for a mapped activity reading. A cumulative
* daily metric carries the `cumulativeDaily` flag from its mapper — its
* externalId gets the `stats:` daily-total prefix so a re-fetched day overwrites
* in place (the mapper's `fieldTag` is already the `<tag>:<YYYY-MM-DD>` form).
* daily metric carries the `cumulativeDaily` flag — its externalId gets the
* `stats:` daily-total prefix so a re-fetched day overwrites in place (the
* mapper's `fieldTag` is already the `<tag>:<YYYY-MM-DD>` form).
*/
function externalIdFor(m: FitbitMappedMeasurement): string {
return m.cumulativeDaily ? `stats:${m.fieldTag}` : m.fieldTag;
@@ -82,28 +88,28 @@ export async function syncUserActivity(
const tokenInfo = await getValidToken(userId);
if (!tokenInfo) return 0;
// Cycle-wide watermark snapshotted once by `syncUserFitbit`; undefined on a
// full/backfill run.
const start = opts.start;
const start = opts.start ?? new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const end = opts.end ?? new Date();
const windows = chunkDateRanges(start, end, FITBIT_RANGE_DAYS);
let imported = 0;
for (const resource of ACTIVITY_RESOURCES) {
let points: Record<string, unknown>[];
try {
points = await fetchDataPoints(
resource.dataType,
tokenInfo.accessToken,
resource.verb,
{ start },
);
} catch (err) {
imported += await handleCollectionFetchError(resource.verb, userId, err);
continue;
}
const readings: FitbitMeasurementUpsert[] = [];
for (const point of points) {
for (const m of resource.map(point)) {
let forbidden = false;
for (const w of windows) {
let body: unknown;
try {
body = await resource.fetch(tokenInfo.accessToken, w.start, w.end);
} catch (err) {
imported += await handleCollectionFetchError(
resource.verb,
userId,
err,
);
forbidden = true;
break;
}
for (const m of resource.map(body)) {
readings.push({
type: m.type,
value: m.value,
@@ -113,6 +119,7 @@ export async function syncUserActivity(
});
}
}
if (forbidden) continue;
imported += (
await upsertFitbitMeasurements(userId, readings, {
deferRollup: opts.deferRollup,
+58 -124
View File
@@ -1,100 +1,74 @@
/**
* Fitbit / Google Health "health metrics & measurements" bundle sync (v1.12.0).
* Fitbit Web API health-metrics sync.
*
* Reads the launch metric data types from the
* `health_metrics_and_measurements.readonly` Restricted bundle and upserts each
* mapped reading as `source = FITBIT`:
* Reads the launch metric endpoints from the classic Fitbit Web API and upserts
* each mapped reading as `source = FITBIT`:
*
* - weight → WEIGHT
* - body-fat → BODY_FAT
* - daily-oxygen-saturation→ OXYGEN_SATURATION
* - daily-HRV → HEART_RATE_VARIABILITY (SDNN slot; see mapper)
* - daily-resting-HR → RESTING_HEART_RATE
* - daily-respiratory-rate → RESPIRATORY_RATE
* - heart-rate (intraday) → PULSE
* - daily sleep-temp deriv.→ WRIST_TEMPERATURE
* - height → User.heightCm (profile seed, NOT a Measurement)
* - body/log/weight → WEIGHT (kg)
* - body/log/fat → BODY_FAT (%)
* - spo2 summary → OXYGEN_SATURATION (%)
* - hrv summary → HEART_RATE_VARIABILITY (ms, dailyRmssd)
* - activities/heart → RESTING_HEART_RATE (bpm)
* - br summary → RESPIRATORY_RATE (breaths/min)
*
* Each data point yields at most one Measurement row, disambiguated by the
* per-point anchor + field-tag in the externalId (`<anchor>:<fieldTag>`) so a
* re-fetch of the same window overwrites in place rather than duplicating.
* Each endpoint is a date-RANGE call (one request per ≤30-day window), so the
* sync chunks `[start, end]` into per-endpoint windows to respect the API range
* caps and the 150 req/h budget. Each metric is fetched independently so a
* per-endpoint 403 (a scope the user did not grant) soft-skips only that metric.
*
* A per-data-class 403 soft-skips THAT class (returns 0, leaves the connection
* connected) rather than parking the whole integration — the six Restricted
* bundles can be granted independently in the Google consent flow.
*
* Activity / sleep / workout land in W5; this is the first (and W3-only) resource.
* NOTE: intraday heart rate (`PULSE`) is intentionally NOT synced — the classic
* API gates intraday series behind a Personal-app / explicit per-app grant most
* self-hosters do not have, and the daily resting HR is always available. Skin
* temperature is also skipped: the classic reading is a baseline DELTA, not an
* absolute value, so it has no honest canonical slot (see mapping.md).
*/
import {
FITBIT_DATA_TYPES,
type FitbitDataType,
FITBIT_RANGE_DAYS,
type FitbitMappedMeasurement,
fetchDataPoints,
fetchBodyFatRange,
fetchHrvRange,
fetchRespiratoryRateRange,
fetchRestingHeartRateRange,
fetchSpo2Range,
fetchWeightRange,
mapBodyFat,
mapHeartRate,
mapHeartRateVariability,
mapHeightCm,
mapOxygenSaturation,
mapRespiratoryRate,
mapRestingHeartRate,
mapSleepTemperature,
mapWeight,
} from "./client";
import {
chunkDateRanges,
getValidToken,
handleCollectionFetchError,
upsertFitbitMeasurements,
type FitbitMeasurementUpsert,
type FitbitResourceSyncOptions,
} from "./sync";
import { getEvent } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
/** One mappable metric: its data-type encoding + the per-point mapper + a verb. */
/** One mappable metric: a range fetcher + a body mapper + a verb. */
interface MetricResource {
dataType: FitbitDataType;
map: (point: Record<string, unknown>) => FitbitMappedMeasurement[];
fetch: (accessToken: string, start: Date, end: Date) => Promise<unknown>;
map: (body: unknown) => FitbitMappedMeasurement[];
verb: string;
}
/** The launch metric resources (Measurement-producing). Height is handled separately. */
const METRIC_RESOURCES: MetricResource[] = [
{ dataType: FITBIT_DATA_TYPES.weight, map: mapWeight, verb: "fetchWeight" },
{ fetch: fetchWeightRange, map: mapWeight, verb: "fetchWeight" },
{ fetch: fetchBodyFatRange, map: mapBodyFat, verb: "fetchBodyFat" },
{ fetch: fetchSpo2Range, map: mapOxygenSaturation, verb: "fetchSpo2" },
{ fetch: fetchHrvRange, map: mapHeartRateVariability, verb: "fetchHrv" },
{
dataType: FITBIT_DATA_TYPES.bodyFat,
map: mapBodyFat,
verb: "fetchBodyFat",
},
{
dataType: FITBIT_DATA_TYPES.oxygenSaturation,
map: mapOxygenSaturation,
verb: "fetchSpo2",
},
{
dataType: FITBIT_DATA_TYPES.heartRateVariability,
map: mapHeartRateVariability,
verb: "fetchHrv",
},
{
dataType: FITBIT_DATA_TYPES.restingHeartRate,
fetch: fetchRestingHeartRateRange,
map: mapRestingHeartRate,
verb: "fetchRhr",
},
{
dataType: FITBIT_DATA_TYPES.respiratoryRate,
fetch: fetchRespiratoryRateRange,
map: mapRespiratoryRate,
verb: "fetchRespiratoryRate",
},
{
dataType: FITBIT_DATA_TYPES.heartRate,
map: mapHeartRate,
verb: "fetchHeartRate",
},
{
dataType: FITBIT_DATA_TYPES.sleepTemperature,
map: mapSleepTemperature,
verb: "fetchSleepTemperature",
},
];
export async function syncUserMetrics(
@@ -104,32 +78,32 @@ export async function syncUserMetrics(
const tokenInfo = await getValidToken(userId);
if (!tokenInfo) return 0;
// The incremental lower bound is the cycle-wide watermark snapshotted once by
// `syncUserFitbit` — never re-read here, so a sibling resource's stamp can't
// shrink this one's window. Undefined on a full/backfill run.
const start = opts.start;
const start = opts.start ?? new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const end = opts.end ?? new Date();
const windows = chunkDateRanges(start, end, FITBIT_RANGE_DAYS);
let imported = 0;
// Each metric data type is fetched + mapped independently so a per-class 403
// soft-skips only that class.
// Each metric is fetched + mapped independently so a per-endpoint 403
// soft-skips only that metric. A 403 short-circuits THAT metric (skips its
// remaining windows) without aborting the others.
for (const resource of METRIC_RESOURCES) {
let points: Record<string, unknown>[];
try {
points = await fetchDataPoints(
resource.dataType,
tokenInfo.accessToken,
resource.verb,
{ start },
);
} catch (err) {
imported += await handleCollectionFetchError(resource.verb, userId, err);
continue;
}
const readings: FitbitMeasurementUpsert[] = [];
for (const point of points) {
for (const m of resource.map(point)) {
let forbidden = false;
for (const w of windows) {
let body: unknown;
try {
body = await resource.fetch(tokenInfo.accessToken, w.start, w.end);
} catch (err) {
imported += await handleCollectionFetchError(
resource.verb,
userId,
err,
);
forbidden = true;
break;
}
for (const m of resource.map(body)) {
readings.push({
type: m.type,
value: m.value,
@@ -139,6 +113,7 @@ export async function syncUserMetrics(
});
}
}
if (forbidden) continue;
imported += (
await upsertFitbitMeasurements(userId, readings, {
deferRollup: opts.deferRollup,
@@ -146,47 +121,6 @@ export async function syncUserMetrics(
).imported;
}
// Height → User.heightCm, only when the user has no height yet. Never mint a
// Measurement; never overwrite a user-set value. Mirrors WHOOP's mapBody seed.
try {
let heightPoints: Record<string, unknown>[];
try {
heightPoints = await fetchDataPoints(
FITBIT_DATA_TYPES.height,
tokenInfo.accessToken,
"fetchHeight",
{ start },
);
} catch (err) {
heightPoints = [];
await handleCollectionFetchError("fetchHeight", userId, err);
}
// Latest non-null parsed height wins (the points are time-ordered ascending).
let heightCm: number | null = null;
for (const point of heightPoints) {
const cm = mapHeightCm(point);
if (cm !== null) heightCm = cm;
}
if (heightCm !== null) {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { heightCm: true },
});
if (user && user.heightCm === null) {
await prisma.user.update({
where: { id: userId },
data: { heightCm },
});
}
}
} catch (err) {
getEvent()?.addWarning(
`fitbit: failed to seed heightCm for ${userId}: ${err}`,
);
}
// `markSynced` is owned by the orchestrator (`syncUserFitbit`), stamped once
// at the end of the cycle — never here, so the watermark can't move mid-cycle.
// `markSynced` is owned by the orchestrator (`syncUserFitbit`).
return imported;
}
+38 -43
View File
@@ -1,31 +1,30 @@
/**
* Fitbit / Google Health sleep-bundle sync (v1.12.0, W5).
* Fitbit Web API sleep sync.
*
* Reads sleep sessions from the `sleep.readonly` Restricted bundle and maps
* each into per-SEGMENT `SLEEP_DURATION` rows via `mapSleepSession` (one row
* per stage segment; `measuredAt = that segment's END instant; harmonised onto
* the shared `SleepStage` enum IN_BED/AWAKE/ASLEEP/REM/CORE/DEEP that the
* night-total + hypnogram readers already consume for WHOOP / Apple). Google
* carries a real per-segment series, so the rows lay each block at its true
* clock time (a MEASURED timeline, not reconstructed). Upserts as
* `source = FITBIT`.
* Reads sleep sessions from the classic `1.2/sleep/date` log and maps each into
* per-SEGMENT `SLEEP_DURATION` rows via `mapSleepSession` (one row per stage
* segment; `measuredAt = segment START + seconds = segment END`; harmonised onto
* the shared `SleepStage` enum IN_BED/AWAKE/ASLEEP/REM/CORE/DEEP the night-total
* + hypnogram readers already consume for WHOOP / Apple). The 1.2 log carries a
* real per-segment series, so the rows lay each block at its true clock time (a
* MEASURED timeline, not reconstructed). Upserts as `source = FITBIT`.
*
* Each segment carries the `sleepStage` axis and an indexed fieldTag so the
* several segments of one stage stay distinct under the
* `(userId, type, source, externalId)` dedup key. externalId =
* `<session-anchor>:sleep_<stage>:<i>` — a re-scored night overwrites in place.
* A 24 h overlap covers Google's after-the-fact re-score.
* several segments of one stage stay distinct under the dedup key. externalId =
* `<logId>:sleep_<stage>:<i>` — a re-scored night overwrites in place. A 24 h
* overlap covers Fitbit's after-the-fact re-score.
*
* A per-data-class 403 soft-skips the resource — the sleep bundle is granted
* independently of activity / metrics in the Google consent flow.
* A per-endpoint 403 soft-skips the resource — the `sleep` scope is granted
* independently in the consent flow.
*/
import {
FITBIT_ACTIVITY_PAGE_SIZE,
FITBIT_DATA_TYPES,
fetchDataPoints,
FITBIT_SLEEP_RANGE_DAYS,
fetchSleepRange,
mapSleepSession,
readSleepSessions,
} from "./client";
import {
chunkDateRanges,
getValidToken,
handleCollectionFetchError,
upsertFitbitMeasurements,
@@ -41,33 +40,29 @@ export async function syncUserSleep(
const tokenInfo = await getValidToken(userId);
if (!tokenInfo) return 0;
// Cycle-wide watermark snapshotted once by `syncUserFitbit`; undefined on a
// full/backfill run.
const start = opts.start;
let points: Record<string, unknown>[];
try {
points = await fetchDataPoints(
FITBIT_DATA_TYPES.sleep,
tokenInfo.accessToken,
"fetchSleep",
{ start, pageSize: FITBIT_ACTIVITY_PAGE_SIZE },
);
} catch (err) {
return handleCollectionFetchError("fetchSleep", userId, err);
}
const start = opts.start ?? new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const end = opts.end ?? new Date();
const windows = chunkDateRanges(start, end, FITBIT_SLEEP_RANGE_DAYS);
const readings: FitbitMeasurementUpsert[] = [];
for (const point of points) {
for (const m of mapSleepSession(point)) {
readings.push({
type: m.type,
value: m.value,
unit: m.unit,
measuredAt: m.measuredAt,
externalId: m.fieldTag,
sleepStage: m.sleepStage ?? null,
});
for (const w of windows) {
let body: unknown;
try {
body = await fetchSleepRange(tokenInfo.accessToken, w.start, w.end);
} catch (err) {
return handleCollectionFetchError("fetchSleep", userId, err);
}
for (const session of readSleepSessions(body)) {
for (const m of mapSleepSession(session)) {
readings.push({
type: m.type,
value: m.value,
unit: m.unit,
measuredAt: m.measuredAt,
externalId: m.fieldTag,
sleepStage: m.sleepStage ?? null,
});
}
}
}
+40 -27
View File
@@ -1,24 +1,25 @@
/**
* Fitbit / Google Health exercise-session sync (v1.12.0, W5).
* Fitbit Web API exercise-session sync.
*
* Reads exercise sessions from the `activity_and_fitness.readonly` Restricted
* bundle and upserts each into the `Workout` table as `source = FITBIT`, keyed
* on `(userId, source, externalId)` so a re-fetch overwrites in place.
* Reads exercise sessions from the classic `activities/list` endpoint
* (`afterDate` + ascending offset/limit pagination) and upserts each into the
* `Workout` table as `source = FITBIT`, keyed on `(userId, source, externalId)`
* (externalId = the Fitbit `logId`) so a re-fetch overwrites in place.
*
* A Fitbit run and the same run via Apple Health (or WHOOP) stay distinct
* `Workout` rows (different `source`); the read-time `pickCanonicalWorkoutRows`
* picker collapses the cross-source twin at read time per the user's source
* ladder (FITBIT is already in the default ladder, ranked just below WHOOP).
* There is no ingest-time collapse for a server-owned source pair.
* picker collapses the cross-source twin per the user's source ladder (FITBIT is
* already in the default ladder, ranked just below WHOOP). No ingest-time
* collapse for a server-owned source pair.
*
* A per-data-class 403 soft-skips the resource — the activity/fitness bundle is
* granted independently in the Google consent flow.
* The page-walk is bounded (the 150 req/h budget is tight): a small page cap
* covers the incremental window comfortably; a deep backfill is bounded by the
* backfill horizon `afterDate`. A per-endpoint 403 soft-skips the resource.
*/
import {
FITBIT_ACTIVITY_PAGE_SIZE,
FITBIT_DATA_TYPES,
fetchDataPoints,
fetchActivityList,
mapWorkout,
readActivityList,
type FitbitMappedWorkout,
} from "./client";
import {
@@ -29,6 +30,11 @@ import {
import { prisma } from "@/lib/db";
import { annotate, getEvent } from "@/lib/logging/context";
/** Activities per page on the `activities/list` walk. */
const WORKOUT_PAGE_SIZE = 100;
/** Hard ceiling on pages walked per cycle (rate-budget guard). */
const WORKOUT_MAX_PAGES = 12;
export async function syncUserWorkout(
userId: string,
opts: FitbitResourceSyncOptions = {},
@@ -36,25 +42,32 @@ export async function syncUserWorkout(
const tokenInfo = await getValidToken(userId);
if (!tokenInfo) return 0;
// Cycle-wide watermark snapshotted once by `syncUserFitbit`; undefined on a
// full/backfill run.
const start = opts.start;
const afterDate =
opts.start ?? new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
let points: Record<string, unknown>[];
try {
points = await fetchDataPoints(
FITBIT_DATA_TYPES.exercise,
tokenInfo.accessToken,
"fetchExercise",
{ start, pageSize: FITBIT_ACTIVITY_PAGE_SIZE },
);
} catch (err) {
return handleCollectionFetchError("fetchExercise", userId, err);
const entries: ReturnType<typeof readActivityList> = [];
let offset = 0;
let pages = 0;
while (pages < WORKOUT_MAX_PAGES) {
let body: unknown;
try {
body = await fetchActivityList(tokenInfo.accessToken, afterDate, {
limit: WORKOUT_PAGE_SIZE,
offset,
});
} catch (err) {
return handleCollectionFetchError("fetchExercise", userId, err);
}
const page = readActivityList(body);
entries.push(...page);
pages += 1;
if (page.length < WORKOUT_PAGE_SIZE) break;
offset += WORKOUT_PAGE_SIZE;
}
let imported = 0;
for (const point of points) {
const w: FitbitMappedWorkout | null = mapWorkout(point);
for (const entry of entries) {
const w: FitbitMappedWorkout | null = mapWorkout(entry);
if (!w) continue; // no usable time span — not a workout
try {
+115 -59
View File
@@ -1,17 +1,19 @@
/**
* Fitbit / Google Health sync service (v1.12.0).
* Fitbit Web API sync service.
*
* This wave (OAuth + credentials) lands the token-management half:
* - `getValidToken` decrypts the stored token, refreshes at
* `tokenExpiresAt - 5 min`, persists the new access token + expiry, and —
* UNLIKE WHOOP — only overwrites the stored refresh token when the response
* carries a fresh one (Google does not rotate refresh tokens).
* `tokenExpiresAt - 5 min`, and persists the new access token + expiry AND
* the rotated refresh token. Classic Fitbit ROTATES refresh tokens
* (one-time use), so the new `refresh_token` MUST replace the stored one —
* the inverse of the prior Google transport, which did not rotate.
* - `recordFitbitSyncFailure` / `classificationToFailureKind` map a
* classified API error onto the shared integration-status ledger.
*
* The per-resource data sync (`upsertFitbitMeasurements`, `syncUserFitbit`, the
* collection walkers, and the 403 soft-skip orchestration) lands in a later
* wave and extends this file.
* - `upsertFitbitMeasurements` + `syncUserFitbit` own the write tail (dedup,
* overwrite contract, rollup fold) — reused unchanged across the transport
* fork; only the per-resource fetch/map differs.
* - `chunkDateRanges` slices a deep backfill into the per-endpoint date-range
* windows the classic Web API caps each call at (30 days), so the per-resource
* syncs issue one valid request per window.
*/
import { AsyncLocalStorage } from "node:async_hooks";
import pLimit from "p-limit";
@@ -43,13 +45,22 @@ import {
const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000;
/**
* Overlap window for the incremental sync, in ms. Google re-rolls daily
* summaries after the fact (a daily SpO2 / HRV / RHR can finalise hours after
* the night), so the default overlap is a full 24 h to make sure the re-rolled
* row is re-fetched on the next tick. Mirrors WHOOP's recovery/sleep overlap.
* Overlap window for the incremental sync, in ms. Fitbit finalises daily
* summaries after the fact (a daily SpO2 / HRV / RHR can settle hours after the
* night), so the default overlap is a full 24 h to make sure the re-rolled row
* is re-fetched on the next tick. Mirrors WHOOP's recovery/sleep overlap.
*/
export const FITBIT_DEFAULT_OVERLAP_MS = 24 * 60 * 60 * 1000;
/**
* How far back a `fullSync` backfill walks. The classic Web API per-endpoint
* range cap (30 days) means a deep backfill issues many requests against the
* tight 150/h budget, so the backfill horizon is bounded to keep one user's
* backfill inside a couple of hourly windows (~12 chunks/resource). One year of
* daily summaries is the launch horizon.
*/
export const FITBIT_BACKFILL_DAYS = 365;
export interface FitbitTokenInfo {
accessToken: string;
connection: { id: string; fitbitUserId: string };
@@ -61,10 +72,12 @@ export interface FitbitTokenInfo {
* credentials, or the refresh fails (the failure is recorded so scheduled syncs
* back off).
*
* KEY DELTA vs WHOOP: Google does not rotate refresh tokens. On refresh, persist
* the new access token + expiry and overwrite the stored `refreshToken` ONLY
* when the response carries a fresh one — otherwise keep the existing refresh
* token so the next refresh still authenticates.
* Classic Fitbit ROTATES refresh tokens (one-time use): every refresh response
* carries a fresh `refresh_token` that invalidates the old one. Persist the new
* access token + expiry AND the new refresh token unconditionally. If a refresh
* response ever omits the new refresh token (a malformed reply), keep the stored
* one rather than wiping it — a blank refresh token would brick every future
* refresh.
*/
export async function getValidToken(
userId: string,
@@ -100,10 +113,11 @@ export async function getValidToken(
const newTokens = await refreshAccessToken(refreshToken, creds);
const expiresAt = new Date(Date.now() + newTokens.expires_in * 1000);
// Google does NOT rotate refresh tokens: a routine refresh returns a new
// access token but usually omits `refresh_token`. Persist the new access
// token + expiry and only overwrite the stored refresh token when the
// response actually carries a fresh one — otherwise keep the existing one.
// Classic Fitbit ROTATES refresh tokens: the refresh response carries a
// fresh `refresh_token` that replaces the (now-invalid) stored one. Persist
// it unconditionally. Guard the malformed-reply case: if the response omits
// the new refresh token, keep the existing one rather than writing a blank,
// which would brick every future refresh.
await prisma.fitbitConnection.update({
where: { id: connection.id },
data: {
@@ -141,14 +155,14 @@ export async function getValidToken(
}
/**
* True when a caught error is a per-data-class 403 (forbidden). The six Google
* Health Restricted bundles are granted independently in the consent flow, so a
* 403 on ONE data class is a scope gate on THAT class — soft-skip it and keep
* the connection connected rather than parking the whole integration at
* `error_reauth`. Connection-wide reauth is reserved for a 401 (token rejected)
* and for a 403 on the token-refresh / profile path (a genuine grant revoke),
* which run outside the per-resource catch blocks. Matters more here than for
* WHOOP because partial grants are likely with six independent bundles.
* True when a caught error is a per-endpoint 403 (forbidden). The classic Fitbit
* scopes are granted independently in the consent flow (and intraday needs an
* extra per-app grant), so a 403 on ONE endpoint is a scope gate on THAT metric
* — soft-skip it and keep the connection connected rather than parking the whole
* integration at `error_reauth`. Connection-wide reauth is reserved for a 401
* (token rejected) and a 403 on the token-refresh / profile path (a genuine
* grant revoke), which run outside the per-resource catch blocks. Matters here
* because partial grants are likely across the independent scopes.
*/
export function isCollectionForbidden(err: unknown): boolean {
return err instanceof FitbitApiError && err.httpStatus === 403;
@@ -203,16 +217,18 @@ export async function handleCollectionFetchError(
/**
* Options threaded from `syncUserFitbit` to each per-resource sync. The `start`
* watermark is snapshotted ONCE by the orchestrator (a single `lastSyncedAt`
* read) so every resource fetches from the same lower bound; no resource re-reads
* `lastSyncedAt` or stamps `markSynced` itself. On a full/backfill run `start`
* is undefined (no lower bound). The orchestrator owns the single end-of-cycle
* `markSynced`.
* + `end` window is snapshotted ONCE by the orchestrator (a single `lastSyncedAt`
* read + a single `now`) so every resource fetches the same window; no resource
* re-reads `lastSyncedAt` or stamps `markSynced` itself. The per-resource sync
* chunks `[start, end]` into the per-endpoint date-range cap. The orchestrator
* owns the single end-of-cycle `markSynced`.
*/
export interface FitbitResourceSyncOptions {
fullSync?: boolean;
/** The incremental lower bound, snapshotted once by the orchestrator. */
/** The lower bound of the fetch window, snapshotted once by the orchestrator. */
start?: Date;
/** The upper bound of the fetch window (now), snapshotted once. */
end?: Date;
/**
* When true, `upsertFitbitMeasurements` writes the rows but SKIPS the inline
* per-(type,day) DAY-rollup recompute + status-insight invalidate, returning
@@ -227,19 +243,53 @@ export interface FitbitResourceSyncOptions {
}
/**
* Compute the incremental `start` for a resource sync. `fullSync` returns
* undefined (the backfill walks deep history without a lower bound). Otherwise
* start from `lastSyncedAt - overlap`, or 30 days back on the very first
* incremental tick. Mirrors WHOOP's `incrementalStart`.
* Compute the incremental `start` for a resource sync. `fullSync` walks back the
* bounded backfill horizon (the classic Web API caps each call's date range, so
* an unbounded backfill would blow the 150/h budget). Otherwise start from
* `lastSyncedAt - overlap`, or 30 days back on the very first incremental tick.
*/
export function incrementalStart(
lastSyncedAt: Date | null,
opts: { fullSync?: boolean; overlapMs?: number } = {},
): Date | undefined {
if (opts.fullSync) return undefined;
opts: { fullSync?: boolean; overlapMs?: number; now?: Date } = {},
): Date {
const now = opts.now ?? new Date();
if (opts.fullSync) {
return new Date(now.getTime() - FITBIT_BACKFILL_DAYS * 24 * 60 * 60 * 1000);
}
const overlap = opts.overlapMs ?? FITBIT_DEFAULT_OVERLAP_MS;
if (lastSyncedAt) return new Date(lastSyncedAt.getTime() - overlap);
return new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
}
/**
* Slice `[start, end]` into consecutive inclusive windows no wider than
* `maxDays` calendar days, the per-endpoint range cap the classic Web API
* enforces. Each window is `{ start, end }` with `end` no later than the overall
* `end`; the per-resource syncs issue one request per window. Returns a single
* window when the span already fits.
*/
export function chunkDateRanges(
start: Date,
end: Date,
maxDays: number,
): Array<{ start: Date; end: Date }> {
const windows: Array<{ start: Date; end: Date }> = [];
if (end.getTime() < start.getTime()) return windows;
const stepMs = maxDays * 24 * 60 * 60 * 1000;
let cursor = start.getTime();
const endMs = end.getTime();
// Guard against a pathological span minting an unbounded loop.
let guard = 0;
const MAX_WINDOWS = 1000;
while (cursor <= endMs && guard < MAX_WINDOWS) {
// Each window spans up to `maxDays` days inclusive, so subtract one day from
// the step to keep the inclusive end inside the cap.
const windowEnd = Math.min(cursor + stepMs - 24 * 60 * 60 * 1000, endMs);
windows.push({ start: new Date(cursor), end: new Date(windowEnd) });
cursor = windowEnd + 24 * 60 * 60 * 1000;
guard += 1;
}
return windows;
}
/**
@@ -486,14 +536,13 @@ export async function markSynced(userId: string): Promise<void> {
* and exercise workouts (W5). Each runs independently so a per-resource 403
* (a Restricted bundle the user did not grant) soft-skips only that resource.
*
* WATERMARK: the incremental `start` is snapshotted ONCE here (from the single
* `lastSyncedAt` read at the top of the cycle) and threaded to every resource,
* and `markSynced` is stamped ONCE at the end. A per-resource read+stamp would
* let the first resource move `lastSyncedAt` to now() so later resources only
* see the last overlap window — silently dropping the gap after an outage longer
* than the overlap. The full/backfill path passes `fullSync: true`, which makes
* `incrementalStart` return undefined regardless of the snapshot, so the deep
* history walk is unaffected.
* WATERMARK: the incremental `[start, end]` window is snapshotted ONCE here
* (from the single `lastSyncedAt` read + a single `now` at the top of the cycle)
* and threaded to every resource, and `markSynced` is stamped ONCE at the end. A
* per-resource read+stamp would let the first resource move `lastSyncedAt` to
* now() so later resources only see the last overlap window — silently dropping
* the gap after an outage longer than the overlap. The full/backfill path passes
* `fullSync: true`, which widens `start` back to the bounded backfill horizon.
*/
export async function syncUserFitbit(
userId: string,
@@ -506,21 +555,23 @@ export async function syncUserFitbit(
return 0;
}
// Snapshot the incremental watermark ONCE for the whole cycle. Every resource
// sees the same `start`; no resource re-reads `lastSyncedAt` mid-cycle. On a
// full/backfill run `incrementalStart` ignores the snapshot and returns
// undefined (no lower bound).
// Snapshot the incremental window ONCE for the whole cycle. Every resource
// sees the same `[start, end]`; no resource re-reads `lastSyncedAt` mid-cycle.
// A full/backfill run widens `start` back to the bounded backfill horizon.
const connection = await prisma.fitbitConnection.findUnique({
where: { userId },
select: { lastSyncedAt: true },
});
if (!connection) return 0;
const end = new Date();
const start = incrementalStart(connection.lastSyncedAt, {
fullSync: opts.fullSync,
now: end,
});
const resourceOpts: FitbitResourceSyncOptions = {
fullSync: opts.fullSync,
start,
end,
// A backfill walks years of daily summaries — defer the per-(type,day)
// rollup hook on every write and run ONE range-recompute at the end of the
// cycle. The hourly incremental keeps the inline hook (small touched set).
@@ -656,12 +707,17 @@ export function classificationToFailureKind(
/**
* Bounded fan-out width for the hourly Fitbit cohort poll. The cron tick carries
* no `userId`, so the worker resolves every connection and hands the cohort here.
* A small pool (rather than a strict serial loop) means one slow Google response
* A small pool (rather than a strict serial loop) means one slow Fitbit response
* can't stall the whole cohort, while the cap keeps the burst of per-user
* resource fetches + rollup writes from crowding the worker's DB pool — matching
* the `p-limit` ceilings the analytics fan-out + insight warm pass use elsewhere.
* resource fetches + rollup writes from crowding the worker's DB pool.
*
* Tuned to 2 for the classic Web API: each per-user cycle issues ~a dozen
* date-range requests against a tight 150 req/h-per-user budget, so a narrower
* pool keeps the concurrent outbound burst (and the DB-write tail behind it)
* modest. The per-user budget is independent, so the cap is about worker
* pressure, not the API ceiling — but 2 keeps both comfortable.
*/
export const FITBIT_POLL_CONCURRENCY = 4;
export const FITBIT_POLL_CONCURRENCY = 2;
/**
* Run a Fitbit hourly-poll cohort with bounded concurrency + per-user error