From 062354ab57c25ffc4cf21730ba280f5dcd07bee9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 22 Jun 2026 07:20:41 +0200 Subject: [PATCH 1/4] feat(fitbit): add PKCE code_verifier column to the OAuth-state ledger The classic Fitbit Web API authorises with PKCE (Authorization Code + S256), so the connect route mints a code_verifier and the callback presents it on the token exchange. Carry it on the short-lived FitbitOAuthState row. Additive + nullable (migration 0191): a legacy in-flight row carries no verifier, so the change is reversible and rerun-safe with no data loss. --- .../migration.sql | 8 ++++++++ prisma/schema.prisma | 17 +++++++++++++---- 2 files changed, 21 insertions(+), 4 deletions(-) create mode 100644 prisma/migrations/0191_v1200_fitbit_pkce_verifier/migration.sql diff --git a/prisma/migrations/0191_v1200_fitbit_pkce_verifier/migration.sql b/prisma/migrations/0191_v1200_fitbit_pkce_verifier/migration.sql new file mode 100644 index 000000000..edb1e15e1 --- /dev/null +++ b/prisma/migrations/0191_v1200_fitbit_pkce_verifier/migration.sql @@ -0,0 +1,8 @@ +-- v1.20.0 — classic Fitbit Web API (api.fitbit.com) authorises with PKCE +-- (Authorization Code + S256). Carry the per-handshake `code_verifier` from the +-- connect route to the callback on the short-lived OAuth-state ledger row. +-- +-- Additive + nullable: a legacy in-flight row (or any non-PKCE path) simply +-- carries no verifier, so this is reversible and rerun-safe with no data loss. +-- `IF NOT EXISTS` guards a prod rerun. +ALTER TABLE "fitbit_oauth_states" ADD COLUMN IF NOT EXISTS "code_verifier" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e6839cdce..1e1660403 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -2644,11 +2644,20 @@ model FitbitConnection { /// v1.12.0 — short-lived `(nonce → userId)` ledger backing the Fitbit OAuth /// state check, identical in shape + lifecycle to `WhoopOAuthState`. +/// +/// v1.20.0 — the classic Fitbit Web API (`api.fitbit.com`) authorises with PKCE +/// (Authorization Code + S256), so the connect route mints a `code_verifier` +/// alongside the nonce and stashes it here; the callback reads it back to present +/// on the token exchange. Nullable + additive: a legacy row (or a non-PKCE path) +/// simply carries no verifier. model FitbitOAuthState { - nonce String @id - userId String @map("user_id") - createdAt DateTime @default(now()) @map("created_at") - expiresAt DateTime @map("expires_at") + nonce String @id + userId String @map("user_id") + /// PKCE code_verifier carried from connect to callback (v1.20.0). Null when no + /// PKCE handshake is in flight (legacy rows). + codeVerifier String? @map("code_verifier") + createdAt DateTime @default(now()) @map("created_at") + expiresAt DateTime @map("expires_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) From 4ff0e4ca0cc14ed7035c8ba263849d1b0119c1e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 22 Jun 2026 07:20:58 +0200 Subject: [PATCH 2/4] feat(fitbit): retarget the client to the classic Fitbit Web API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- src/lib/fitbit/__tests__/client.test.ts | 692 +++---- .../fitbit/__tests__/sync-user-fitbit.test.ts | 13 +- src/lib/fitbit/__tests__/sync.test.ts | 18 +- src/lib/fitbit/client.ts | 1800 +++++++---------- src/lib/fitbit/mapping.md | 180 +- src/lib/fitbit/oauth-state.ts | 8 +- src/lib/fitbit/sync-activity.ts | 103 +- src/lib/fitbit/sync-metrics.ts | 182 +- src/lib/fitbit/sync-sleep.ts | 81 +- src/lib/fitbit/sync-workout.ts | 67 +- src/lib/fitbit/sync.ts | 174 +- 11 files changed, 1493 insertions(+), 1825 deletions(-) diff --git a/src/lib/fitbit/__tests__/client.test.ts b/src/lib/fitbit/__tests__/client.test.ts index 04d215110..23bbf4081 100644 --- a/src/lib/fitbit/__tests__/client.test.ts +++ b/src/lib/fitbit/__tests__/client.test.ts @@ -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; 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 }, + ]; + 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 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 :; 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"); + }); +}); diff --git a/src/lib/fitbit/__tests__/sync-user-fitbit.test.ts b/src/lib/fitbit/__tests__/sync-user-fitbit.test.ts index f9c3f1d16..e138ab964 100644 --- a/src/lib/fitbit/__tests__/sync-user-fitbit.test.ts +++ b/src/lib/fitbit/__tests__/sync-user-fitbit.test.ts @@ -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); }); diff --git a/src/lib/fitbit/__tests__/sync.test.ts b/src/lib/fitbit/__tests__/sync.test.ts index 38dc851c4..58be146a8 100644 --- a/src/lib/fitbit/__tests__/sync.test.ts +++ b/src/lib/fitbit/__tests__/sync.test.ts @@ -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", diff --git a/src/lib/fitbit/client.ts b/src/lib/fitbit/client.ts index 9021c2143..22a59049f 100644 --- a/src/lib/fitbit/client.ts +++ b/src/lib/fitbit/client.ts @@ -1,33 +1,58 @@ /** - * Fitbit / Google Health API client — OAuth half (v1.12.0). - * Docs: https://developers.google.com/health (re-verify at build — the Google - * Health API is young; v4, post-Fitbit-Web-API). + * Fitbit Web API client — classic `api.fitbit.com` transport (v1.20.0). + * Docs: https://dev.fitbit.com/build/reference/web-api/ * - * Mirrors the WHOOP client structure (`src/lib/whoop/client.ts`): hand-rolled - * fetch over `safeFetch` (no SDK), an OAuth handshake (`getAuthorizationUrl` / - * `exchangeCode` / `refreshAccessToken`), and a single profile fetch for the - * connection's external user id. The data-fetch half (paginated `dataPoints` - * walker + per-type mappers + `FITBIT_FIELD_MAP`) lands in a later wave. + * 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. * - * KEY DELTA vs WHOOP — Google does NOT rotate refresh tokens. WHOOP invalidates - * the prior refresh token on every refresh; Google's refresh tokens are stable - * (long-lived in production, 7-day expiry only in Testing mode). So - * `refreshAccessToken` may return a token response WITHOUT a `refresh_token`, - * and the sync layer keeps the stored one in that case (see `getValidToken` in - * `sync.ts`). + * 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. * - * Confidential web-server client: HealthLog holds the client secret - * server-side, so PKCE is omitted (matches WHOOP). Client credentials are sent - * to the token endpoint via HTTP Basic auth (RFC 6749 §2.3.1), the way Google's - * token endpoint accepts a confidential client. + * 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 { FitbitApiError, classifyFitbitResponse } from "./response-classifier"; -export const FITBIT_API_BASE = "https://health.googleapis.com/v4"; -const GOOGLE_OAUTH_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"; -const GOOGLE_OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token"; +/** 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; @@ -36,18 +61,16 @@ export interface FitbitCredentials { /** * Resolve the OAuth `redirect_uri`, then assert it against an allowlist - * (v1.12.1 — M-5, defence-in-depth). + * (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, so Google's registered-redirect + * 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 here so a malformed origin fails fast at the handshake rather - * than silently redirecting elsewhere: + * 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, which - * Google itself permits over http), + * - 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. */ @@ -111,56 +134,82 @@ function getRedirectUri(): string { } /** - * OAuth scopes HealthLog requests (space-separated on the wire). The four - * launch Restricted read bundles cover every v1.12.0 metric; ECG / nutrition / - * location / IRN are deliberately omitted to keep the Restricted-scope CASA - * review surface minimal. Every scope is Restricted → the operator's OAuth - * client needs Google brand verification + an annual CASA assessment before it - * leaves Testing mode. + * 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 = [ - "https://www.googleapis.com/auth/googlehealth.activity_and_fitness.readonly", - "https://www.googleapis.com/auth/googlehealth.health_metrics_and_measurements.readonly", - "https://www.googleapis.com/auth/googlehealth.sleep.readonly", - "https://www.googleapis.com/auth/googlehealth.profile.readonly", + "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; +} + /** - * Generate the Google OAuth authorization URL (a browser redirect, not a - * fetch). `state` is the opaque CSRF nonce minted by `oauth-state.ts`. - * - * `access_type=offline` + `prompt=consent` are Google's requirement to receive - * a refresh token (the equivalent of WHOOP's `offline` scope); `prompt=consent` - * forces the consent screen so a re-connect always returns a fresh refresh - * token even if the user previously granted. + * 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: getRedirectUri(), scope: FITBIT_OAUTH_SCOPE, - access_type: "offline", - prompt: "consent", + code_challenge: codeChallenge, + code_challenge_method: "S256", state, }); - return `${GOOGLE_OAUTH_AUTH_URL}?${params}`; + return `${FITBIT_OAUTH_AUTH_URL}?${params}`; } export interface FitbitTokenResponse { access_token: string; /** - * Present on the initial code exchange and whenever Google issues a new - * refresh token; ABSENT on a routine refresh because Google does not rotate. - * The sync layer keeps the stored token when this is missing. + * 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. */ @@ -175,7 +224,7 @@ async function postToken( verb: string, ): Promise { const start = performance.now(); - const res = await safeFetch(GOOGLE_OAUTH_TOKEN_URL, { + const res = await safeFetch(FITBIT_OAUTH_TOKEN_URL, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", @@ -199,15 +248,25 @@ async function postToken( classification: verdict.classification, httpStatus: verdict.httpStatus, reason: verdict.reason, - upstreamError: typeof json?.error === "string" ? json.error : undefined, + upstreamError: + typeof json?.errors?.[0]?.errorType === "string" + ? json.errors[0].errorType + : typeof json?.error === "string" + ? json.error + : undefined, }); } return json as FitbitTokenResponse; } -/** Exchange an authorization code for the initial token pair. */ +/** + * Exchange an authorization code for the initial token pair, presenting the PKCE + * verifier. `redirect_uri` must exactly match the one sent to the authorize + * endpoint. + */ export async function exchangeCode( code: string, + codeVerifier: string, creds: FitbitCredentials, ): Promise { return postToken( @@ -216,6 +275,7 @@ export async function exchangeCode( code, client_id: creds.clientId, redirect_uri: getRedirectUri(), + code_verifier: codeVerifier, }), creds, "exchangeCode", @@ -223,11 +283,10 @@ export async function exchangeCode( } /** - * Refresh an expired access token. Google does NOT rotate refresh tokens — the - * response carries a fresh `access_token` + `expires_in` but usually omits - * `refresh_token`. The caller persists the new access token + expiry and keeps - * the stored refresh token unless a new one is returned. The original scope is - * preserved by Google, so no `scope` param is re-sent. + * 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, @@ -237,7 +296,6 @@ export async function refreshAccessToken( new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, - client_id: creds.clientId, }), creds, "refreshAccessToken", @@ -245,27 +303,18 @@ export async function refreshAccessToken( } /** - * The Google Health profile carries the external user id used as the - * connection's `fitbitUserId`. Mirrors WHOOP's `fetchProfile` (a single GET, - * not a paginated collection). + * 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 { - /** - * External user identifier. Google returns `me`-relative profile data; the - * stable id is surfaced under `name` (a `users/{id}` resource name) or `id` - * depending on the API surface — both are captured here and resolved at the - * call site. Re-verify the exact field against a live account at the data-sync - * wave. - */ - name?: string; - id?: string; + user?: { encodedId?: string }; } export async function fetchProfile( accessToken: string, ): Promise { const start = performance.now(); - const res = await safeFetch(`${FITBIT_API_BASE}/users/me/profile`, { + const res = await safeFetch(`${FITBIT_API_BASE}/1/user/-/profile.json`, { method: "GET", headers: { Authorization: `Bearer ${accessToken}` }, }); @@ -290,294 +339,138 @@ export async function fetchProfile( } /** - * Resolve the stable external user id from a Google Health profile. The - * `users/{id}` resource name is the canonical anchor; fall back to a bare `id` - * or "me" so the connection always persists a non-empty `fitbitUserId`. + * 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 { - if (profile.name) { - const tail = profile.name.split("/").pop(); - if (tail) return tail; - } - if (profile.id) return profile.id; - return "me"; + return profile.user?.encodedId ?? "me"; } -// ─── Data-point reads (Google Health `dataPoints.list`) ──────── +// ─── Date-range reads (classic Fitbit Web API) ───────────────── // -// The Google Health API exposes one uniform `DataPoint` resource shape across -// every data type, read through `GET /v4/users/me/dataTypes/{dataType}/dataPoints` -// with `nextPageToken` pagination. This mirrors the WHOOP `fetchCollection` -// page-walk (`whoop/client.ts:270`), but Google's value-field JSON is NOT fully -// published yet — so the mappers below are intentionally defensive: every value -// is pulled out of a small set of candidate field shapes and guarded by a -// finite-positive check before it becomes a Measurement. Capture the real -// per-type value-field JSON into `src/lib/fitbit/mapping.md` against a live test -// account at build and tighten the extractors then. -// -// Casing gotcha (design §A.1): the data-type id is **kebab-case in the path** -// (`body-fat`) and **snake_case in the `filter`** (`body_fat`). `FITBIT_DATA_TYPES` -// pins both forms so a fetcher can never encode the wrong one. +// 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. /** - * Page-size ceiling for `dataPoints.list`. The daily/intraday reads default to - * 1440 (one-per-minute) and cap at 10 000; sleep/exercise cap at 25 (same as - * WHOOP's `WHOOP_PAGE_LIMIT`). The launch metrics use the daily/spot reads, so - * the default page size is the larger value. + * 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_PAGE_SIZE = 1000; -/** Sleep/exercise read cap — matches the Google Health 25-cap for those types. */ -export const FITBIT_ACTIVITY_PAGE_SIZE = 25; +export const FITBIT_RANGE_DAYS = 30; -/** - * One data type's two on-the-wire encodings. `path` is the kebab-case segment - * spliced into the request URL; `filter` is the snake_case prefix used to build - * the incremental `filter=` predicate. The `timeField` names the value object's - * time anchor (a sample time for spot readings, a date for daily summaries) so - * the incremental filter targets the right field. - */ -export interface FitbitDataType { - /** kebab-case segment for the request path. */ - path: string; - /** snake_case prefix for the `filter` predicate. */ - filter: string; - /** - * Which time anchor the filter / measuredAt resolution targets: - * - `sample` → spot reading (`{type}.sample_time.physical_time`). - * - `date` → daily summary keyed on a civil date (`{type}.date`). - * - `interval` → an INTERVAL data type (steps / distance / calories / floors, - * sleep, exercise). Google Health anchors these on `{type}.interval.start_time` - * (the physical instant) with `{type}.interval.civil_start_time` as the civil - * fallback — NOT on a `sample_time` (which 400s/empties for interval types and - * stalls the incremental filter) and NOT on a bare `date`. - */ - timeField: "sample" | "date" | "interval"; +/** 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 { + return d.toISOString().slice(0, 10); } -/** - * The launch (W3 metrics) data types. Each entry pins the kebab-path + snake-filter - * pair so the two encodings can never drift. Activity/sleep/workout types land in - * W5; only the health-metrics bundle is read here. - */ -export const FITBIT_DATA_TYPES = { - weight: { path: "weight", filter: "weight", timeField: "sample" }, - bodyFat: { path: "body-fat", filter: "body_fat", timeField: "sample" }, - oxygenSaturation: { - path: "daily-oxygen-saturation", - filter: "daily_oxygen_saturation", - timeField: "date", - }, - heartRateVariability: { - path: "daily-heart-rate-variability", - filter: "daily_heart_rate_variability", - timeField: "date", - }, - restingHeartRate: { - path: "daily-resting-heart-rate", - filter: "daily_resting_heart_rate", - timeField: "date", - }, - respiratoryRate: { - path: "daily-respiratory-rate", - filter: "daily_respiratory_rate", - timeField: "date", - }, - heartRate: { path: "heart-rate", filter: "heart_rate", timeField: "sample" }, - height: { path: "height", filter: "height", timeField: "sample" }, - sleepTemperature: { - path: "daily-sleep-temperature-derivations", - filter: "daily_sleep_temperature_derivations", - timeField: "date", - }, - // ── Activity bundle (W5) — daily cumulative totals ───────────── - // Scope: `googlehealth.activity_and_fitness.readonly`. These are INTERVAL data - // types: Google buckets a daily total into an `interval` (a `start_time` + - // `end_time`, with `civil_start_time` for the calendar-day grain). The - // incremental filter targets `interval.start_time` and the externalId carries - // the `stats:` prefix so a re-fetched day overwrites in place (mirrors the - // Apple-Health `stats::` daily-total overwrite contract). - steps: { path: "steps", filter: "steps", timeField: "interval" }, - distance: { path: "distance", filter: "distance", timeField: "interval" }, - activeCalories: { - path: "active-calories", - filter: "active_calories", - timeField: "interval", - }, - floors: { path: "floors", filter: "floors", timeField: "interval" }, - // VO2 max is a daily-summary metric (one civil-date reading), not an interval - // bucket — keep the `date` anchor. - vo2Max: { path: "vo2-max", filter: "vo2_max", timeField: "date" }, - // ── Sleep bundle (W5) ────────────────────────────────────────── - // Scope: `googlehealth.sleep.readonly`. A sleep session is an INTERVAL data - // type (a start + end span carrying a per-stage breakdown); the incremental - // filter must target `interval.start_time`, not a `sample_time` (which 400s - // for interval types). Mapped to per-stage SLEEP_DURATION rows. - sleep: { path: "sleep", filter: "sleep", timeField: "interval" }, - // ── Exercise bundle (W5) ─────────────────────────────────────── - // Scope: `googlehealth.activity_and_fitness.readonly`. An exercise session is - // an INTERVAL data type (a start + end span) → a `Workout` row (NOT a - // Measurement). The incremental filter targets `interval.start_time`. - exercise: { path: "exercise", filter: "exercise", timeField: "interval" }, -} as const satisfies Record; - -/** Google Health `DataPoint` — value object is type-keyed + carries a time anchor. */ -export interface FitbitDataPoint { - [key: string]: unknown; -} - -/** `dataPoints.list` envelope: `{ dataPoints, nextPageToken }`. */ -interface FitbitDataPointPage { - dataPoints?: FitbitDataPoint[]; - nextPageToken?: string | null; -} - -interface DataPointQuery { - /** Lower-bound incremental cursor; omitted on a full backfill. */ - start?: Date; - /** Page size (defaults to `FITBIT_PAGE_SIZE`). */ - pageSize?: number; - /** Hard ceiling on pages walked (defence against a runaway cursor). */ - maxPages?: number; -} - -/** - * Build the incremental `filter` field + bound for one data type. Centralised so - * the predicate and the read-time anchor resolution can never drift on which - * field a given `timeField` targets. - * - `sample` → spot reading, filter on `{filter}.sample_time.physical_time`. - * - `interval` → INTERVAL type, filter on `{filter}.interval.start_time` - * (Google's anchor for steps/distance/calories/floors, sleep, exercise — a - * `sample_time` filter 400s/empties for these and stalls incremental sync). - * - `date` → daily civil-date summary, filter on `{filter}.date`. - */ -export function incrementalFilter( - dataType: FitbitDataType, - start: Date, -): { field: string; bound: string } { - switch (dataType.timeField) { - case "sample": - return { - field: `${dataType.filter}.sample_time.physical_time`, - bound: start.toISOString(), - }; - case "interval": - return { - field: `${dataType.filter}.interval.start_time`, - bound: start.toISOString(), - }; - case "date": - return { - field: `${dataType.filter}.date`, - bound: start.toISOString().slice(0, 10), - }; - } -} - -/** - * Walk every `DataPoint` for one data type since the incremental cursor. - * Mirrors the WHOOP `fetchCollection` page-loop: `nextPageToken` pagination, a - * 1000-page `maxPages` ceiling, and per-page `addExternalCall` telemetry. The - * data-type id is kebab-cased in the path; the `filter` predicate is built from - * the snake_case form against the type's time anchor. - */ -export async function fetchDataPoints( - dataType: FitbitDataType, +/** GET a classic Fitbit endpoint and return the parsed JSON body. */ +async function fitbitGet( + path: string, accessToken: string, verb: string, - query: DataPointQuery = {}, -): Promise { - const points: FitbitDataPoint[] = []; - let pageToken: string | null | undefined; - let pageCount = 0; - const maxPages = query.maxPages ?? 1000; - const pageSize = query.pageSize ?? FITBIT_PAGE_SIZE; - - do { - const params = new URLSearchParams({ pageSize: String(pageSize) }); - if (query.start) { - // snake_case filter prefix. The filter field + bound shape follow the - // data type's time anchor: - // - sample → `{filter}.sample_time.physical_time` >= an ISO instant - // - interval → `{filter}.interval.start_time` >= an ISO instant - // - date → `{filter}.date` >= a civil date - const { field, bound } = incrementalFilter(dataType, query.start); - params.set("filter", `${field} >= "${bound}"`); - } - if (pageToken) params.set("pageToken", pageToken); - - const pageStart = performance.now(); - const res = await safeFetch( - `${FITBIT_API_BASE}/users/me/dataTypes/${dataType.path}/dataPoints?${params}`, - { - method: "GET", - headers: { Authorization: `Bearer ${accessToken}` }, - }, - ); - - const json = (await res - .json() - .catch(() => null)) as FitbitDataPointPage | null; - const verdict = classifyFitbitResponse(res.status); - getEvent()?.addExternalCall({ - service: "fitbit", - method: `${verb}(page=${pageCount})`, - duration_ms: Math.round(performance.now() - pageStart), - status: res.status, - error: verdict.classification === "success" ? undefined : verdict.reason, +): Promise { + const start = performance.now(); + const res = await safeFetch(`${FITBIT_API_BASE}${path}`, { + method: "GET", + headers: { + Authorization: `Bearer ${accessToken}`, + "Accept-Language": FITBIT_METRIC_LOCALE, + }, + }); + const json = await res.json().catch(() => null); + const verdict = classifyFitbitResponse(res.status); + 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, }); - if (verdict.classification !== "success") { - throw new FitbitApiError({ - verb, - classification: verdict.classification, - httpStatus: verdict.httpStatus, - reason: verdict.reason, - }); - } - - for (const p of json?.dataPoints ?? []) points.push(p); - pageToken = json?.nextPageToken ?? null; - pageCount += 1; - } while (pageToken && pageCount < maxPages); - - return points; + } + return json; } // ─── Field → Measurement mapping ─────────────────────────────── -// The single source of truth is `src/lib/fitbit/mapping.md` — keep both in -// sync when adding entries. - -/** Metres → centimetres (Fitbit `height` → `User.heightCm`). */ -const M_TO_CM = 100; +// 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 — the WHOOP `positive()` discipline. */ +/** 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; +} + +/** + * Parse a Fitbit `YYYY-MM-DD` civil date into a UTC-midday Date. Anchored at + * midday so a timezone shift can't roll the civil day across a boundary. Returns + * null for an unparseable string. + */ +function parseCivilDate(dateStr: unknown): Date | null { + if (typeof dateStr !== "string") return null; + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateStr.trim()); + if (!m) return null; + return new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]), 12)); +} + /** * A single mapped Fitbit reading destined for one `Measurement` row. The - * `source` (`FITBIT`) and `externalId` (`:`) are stamped by the - * sync layer; the mapper emits only type/value/unit/measuredAt + the field-tag - * that disambiguates the externalId. Mirrors WHOOP's `MappedMeasurement`. + * `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; - /** Disambiguator appended to the per-point anchor to form the externalId. */ + /** Full externalId payload for the row (`:` / `:`). */ fieldTag: string; /** Per-stage sleep rows carry the SleepStage; everything else omits it. */ sleepStage?: FitbitSleepStage; /** * `true` when the externalId carries the `stats:` daily-total prefix (the - * cumulative activity metrics). The sync layer stamps the externalId; this - * flag lets it pick the right shape (`stats::` vs the - * `:` spot shape) without re-deriving the grain. + * cumulative activity metrics). The sync layer reads this flag to assemble the + * `stats::` overwrite-in-place shape (matching the + * Apple-Health daily-total contract). */ cumulativeDaily?: boolean; } @@ -591,590 +484,421 @@ export type FitbitSleepStage = | "CORE" | "DEEP"; -/** - * Pull the first finite number out of a list of candidate value paths on a - * `DataPoint`. The Google value-field JSON is undocumented, so each mapper hands - * the small set of shapes the field is likely to take (a bare `value`, a typed - * sub-object, a `{ value: { fpVal } }` wrapper, …); the first finite hit wins. - */ -function firstNumber(point: FitbitDataPoint, paths: string[]): number | null { - for (const path of paths) { - const v = readPath(point, path); - if (positive(v)) return v; - // A value can legitimately be zero for some metrics, but the launch metrics - // (weight, body-fat, SpO2, HRV, RHR, respiratory rate, HR, height) are all - // strictly positive — a zero is a garbage/empty reading, so `positive` is - // the right guard. - } - return null; +// ── 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 a dotted path against a nested object; undefined on any miss. */ -function readPath(obj: unknown, path: string): unknown { - let cur: unknown = obj; - for (const seg of path.split(".")) { - if (cur === null || typeof cur !== "object") return undefined; - cur = (cur as Record)[seg]; +/** Resolve the measuredAt for a body-log entry from its `date` + `time`. */ +function bodyLogInstant(entry: FitbitBodyLogEntry): 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 = new Date(`${date}T${time}Z`); + if (!Number.isNaN(d.getTime())) return d; + const civil = parseCivilDate(date); + if (civil) return civil; } - return cur; + return new Date(); } -/** Parse a `{year,month,day}` civil-date object into a UTC-midday Date, or null. */ -function parseCivilDateObject(val: unknown): Date | null { - if (!val || typeof val !== "object") return null; - const o = val as Record; - if ( - typeof o.year === "number" && - typeof o.month === "number" && - typeof o.day === "number" - ) { - // Google civil dates are 1-based months; anchor at UTC midday so a - // timezone shift can't roll the civil day across a boundary. - return new Date(Date.UTC(o.year, o.month - 1, o.day, 12)); +/** Stable anchor for a body-log externalId — the logId, else the instant. */ +function bodyLogAnchor(entry: FitbitBodyLogEntry): string { + if (typeof entry.logId === "number" && Number.isFinite(entry.logId)) { + return String(entry.logId); } - return null; + return bodyLogInstant(entry).toISOString(); +} + +export function mapWeight(body: unknown): 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), + fieldTag: `${bodyLogAnchor(e)}:weight`, + }); + } + return out; +} + +export function mapBodyFat(body: unknown): 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), + 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; } /** - * Resolve a `DataPoint`'s measurement timestamp. - * - `sample` → `sample_time.physical_time` (a spot ISO instant). - * - `interval` → `interval.start_time` (the bucket's start ISO instant), with - * `interval.civil_start_time` (a civil string / `{year,month,day}`) as the - * fallback. This is Google's anchor for the INTERVAL types; the prior `date` - * read returned undefined for them and forced the fetch-time fallback. - * - `date` → `date` (a civil string or `{year,month,day}` object). - * Falls back to `fallback` only when nothing parses, so a row is never dropped - * for a missing anchor. + * 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 resolveMeasuredAt( - point: FitbitDataPoint, - dataType: FitbitDataType, - fallback: Date, -): Date { - if (dataType.timeField === "sample") { - const t = readPath(point, `${dataType.filter}.sample_time.physical_time`); - if (typeof t === "string") { - const d = new Date(t); - if (!Number.isNaN(d.getTime())) return d; - } - } else if (dataType.timeField === "interval") { - const t = readPath(point, `${dataType.filter}.interval.start_time`); - if (typeof t === "string") { - const d = new Date(t); - if (!Number.isNaN(d.getTime())) return d; - } - const civil = readPath( - point, - `${dataType.filter}.interval.civil_start_time`, - ); - if (typeof civil === "string") { - const d = new Date(civil); - if (!Number.isNaN(d.getTime())) return d; - } - const civilObj = parseCivilDateObject(civil); - if (civilObj) return civilObj; - } else { - const dateVal = readPath(point, `${dataType.filter}.date`); - if (typeof dateVal === "string") { - const d = new Date(dateVal); - if (!Number.isNaN(d.getTime())) return d; - } - const civilObj = parseCivilDateObject(dateVal); - if (civilObj) return civilObj; - } - return fallback; -} - -/** - * Stable anchor for a `DataPoint`'s externalId. A spot reading anchors on its - * sample time; a daily summary on its civil date. Combined with a type-specific - * field-tag this makes the upsert key idempotent across re-fetches (a re-sync of - * the same window overwrites in place rather than minting a duplicate). - */ -function externalAnchor( - point: FitbitDataPoint, - dataType: FitbitDataType, -): string { - const at = resolveMeasuredAt(point, dataType, new Date(0)); - // The mapper-driven interval types (steps/distance/calories/floors) are daily - // totals, so they share the civil-day externalId grain with `date` summaries — - // a re-fetched day overwrites in place. (Sleep/exercise are interval too but - // mint their own per-session anchors and never reach this helper.) - if (dataType.timeField === "date" || dataType.timeField === "interval") { - return at.toISOString().slice(0, 10); - } - return at.toISOString(); -} - -/** - * Map one data point of a simple single-value metric into a Measurement reading. - * `valuePaths` lists the candidate value shapes; the first finite-positive hit - * wins. Returns an empty array when no value parses (an empty/garbage point). - */ -function mapSimple( - point: FitbitDataPoint, - dataType: FitbitDataType, +function mapDailySummary( + rows: FitbitDailyRow[], spec: { type: string; unit: string; fieldTag: string; - valuePaths: string[]; + leaves: string[]; factor?: number; }, ): FitbitMappedMeasurement[] { - let value = firstNumber(point, spec.valuePaths); - if (value === null) return []; - if (spec.factor) value = value * spec.factor; - return [ - { + const out: FitbitMappedMeasurement[] = []; + for (const row of rows) { + const day = parseCivilDate(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(value), + value: round2(scaled), unit: spec.unit, - measuredAt: resolveMeasuredAt(point, dataType, new Date()), - fieldTag: `${externalAnchor(point, dataType)}:${spec.fieldTag}`, - }, - ]; + measuredAt: day, + fieldTag: `${fitbitDate(day)}:${spec.fieldTag}`, + }); + } + return out; } -/** Candidate value-field shapes a Google `DataPoint` is likely to carry. */ -function valuePaths(filterKey: string, leaf: string): string[] { - return [ - `${filterKey}.${leaf}`, - `${filterKey}.value.${leaf}`, - `${filterKey}.value`, - `value.${leaf}`, - `value`, - ]; +/** First strictly-positive `obj[leaf]` across candidate leaves, or null. */ +function firstPositiveLeaf(obj: unknown, leaves: string[]): number | null { + if (!obj || typeof obj !== "object") return null; + const o = obj as Record; + for (const leaf of leaves) { + if (positive(o[leaf])) return o[leaf] as number; + } + return null; } -export function mapWeight(point: FitbitDataPoint): FitbitMappedMeasurement[] { - const dt = FITBIT_DATA_TYPES.weight; - return mapSimple(point, dt, { - type: "WEIGHT", - unit: "kg", - fieldTag: "weight", - valuePaths: valuePaths(dt.filter, "kilograms"), - }); -} - -export function mapBodyFat(point: FitbitDataPoint): FitbitMappedMeasurement[] { - const dt = FITBIT_DATA_TYPES.bodyFat; - return mapSimple(point, dt, { - type: "BODY_FAT", - unit: "%", - fieldTag: "body_fat", - valuePaths: valuePaths(dt.filter, "percentage"), - }); -} - -export function mapOxygenSaturation( - point: FitbitDataPoint, -): FitbitMappedMeasurement[] { - const dt = FITBIT_DATA_TYPES.oxygenSaturation; - return mapSimple(point, dt, { +export function mapOxygenSaturation(body: unknown): 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", - valuePaths: [ - ...valuePaths(dt.filter, "average_percentage"), - ...valuePaths(dt.filter, "percentage"), - ], + leaves: ["avg"], }); } export function mapHeartRateVariability( - point: FitbitDataPoint, + body: unknown, ): FitbitMappedMeasurement[] { - const dt = FITBIT_DATA_TYPES.heartRateVariability; - // Fitbit reports a nightly RMSSD-style HRV. Per the design decision it lands - // in the SDNN-lineage `HEART_RATE_VARIABILITY` slot (Apple-comparable), NOT - // WHOOP's `HRV_RMSSD` (reserved for the WHOOP-native estimator). Re-confirm - // the estimator against a live account and revisit if it warrants `HRV_RMSSD`. - return mapSimple(point, dt, { + // 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", - valuePaths: [ - ...valuePaths(dt.filter, "rmssd_milliseconds"), - ...valuePaths(dt.filter, "milliseconds"), - ], + leaves: ["dailyRmssd"], }); } -export function mapRestingHeartRate( - point: FitbitDataPoint, -): FitbitMappedMeasurement[] { - const dt = FITBIT_DATA_TYPES.restingHeartRate; - return mapSimple(point, dt, { - type: "RESTING_HEART_RATE", - unit: "bpm", - fieldTag: "rhr", - valuePaths: valuePaths(dt.filter, "beats_per_minute"), - }); -} - -export function mapRespiratoryRate( - point: FitbitDataPoint, -): FitbitMappedMeasurement[] { - const dt = FITBIT_DATA_TYPES.respiratoryRate; - return mapSimple(point, dt, { +export function mapRespiratoryRate(body: unknown): FitbitMappedMeasurement[] { + const rows = readArray(body, "br") as FitbitDailyRow[]; + return mapDailySummary(rows, { type: "RESPIRATORY_RATE", unit: "breaths/min", fieldTag: "resp_rate", - valuePaths: [ - ...valuePaths(dt.filter, "breaths_per_minute"), - ...valuePaths(dt.filter, "average_breaths_per_minute"), - ], - }); -} - -export function mapHeartRate( - point: FitbitDataPoint, -): FitbitMappedMeasurement[] { - const dt = FITBIT_DATA_TYPES.heartRate; - return mapSimple(point, dt, { - type: "PULSE", - unit: "bpm", - fieldTag: "hr", - valuePaths: valuePaths(dt.filter, "beats_per_minute"), - }); -} - -export function mapSleepTemperature( - point: FitbitDataPoint, -): FitbitMappedMeasurement[] { - const dt = FITBIT_DATA_TYPES.sleepTemperature; - // Google surfaces a sleeping skin/wrist temperature derivation. `WRIST_TEMPERATURE` - // is the closest semantic slot (Apple sleeping-wrist-temp). Confirm absolute-vs- - // baseline at build; the guard rejects a non-positive (baseline-delta) reading. - return mapSimple(point, dt, { - type: "WRIST_TEMPERATURE", - unit: "celsius", - fieldTag: "wrist_temp", - valuePaths: [ - ...valuePaths(dt.filter, "nightly_temperature_celsius"), - ...valuePaths(dt.filter, "celsius"), - ], + leaves: ["breathingRate"], }); } /** - * Extract the profile height (in cm) from a Fitbit `height` data point, or null - * when nothing parses. Height is a one-time `User.heightCm` profile seed (written - * only when the user has no height yet) — NOT a Measurement, mirroring WHOOP's - * `mapBody` height handling. Returns cm. + * 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 mapHeightCm(point: FitbitDataPoint): number | null { - const dt = FITBIT_DATA_TYPES.height; - // Google may report height in metres or centimetres; try cm-direct first, then - // metres × 100. Both pass through the positive guard. - const cm = firstNumber(point, valuePaths(dt.filter, "centimeters")); - if (cm !== null) return round2(cm); - const m = firstNumber(point, valuePaths(dt.filter, "meters")); - if (m !== null) return round2(m * M_TO_CM); - return null; -} - -/** - * Field→Measurement mapping table (mirror of `mapping.md`). Documents which - * Fitbit/Google-Health source field becomes which MeasurementType + unit, and - * pins the kebab-path / snake-filter pair for each. Used as the single-glance - * reference and by the mapper tests; the mappers above are the executable form. - */ -export const FITBIT_FIELD_MAP: Record< - string, - { type: string; unit: string; path: string; filter: string; note?: string } -> = { - weight: { - type: "WEIGHT", - unit: "kg", - path: "weight", - filter: "weight", - note: "picker ranks a real Withings scale above Fitbit", - }, - bodyFat: { - type: "BODY_FAT", - unit: "%", - path: "body-fat", - filter: "body_fat", - }, - oxygenSaturation: { - type: "OXYGEN_SATURATION", - unit: "%", - path: "daily-oxygen-saturation", - filter: "daily_oxygen_saturation", - }, - heartRateVariability: { - type: "HEART_RATE_VARIABILITY", - unit: "ms", - path: "daily-heart-rate-variability", - filter: "daily_heart_rate_variability", - note: "SDNN slot (Apple-comparable), NOT HRV_RMSSD; confirm estimator at build", - }, - restingHeartRate: { +export function mapRestingHeartRate(body: unknown): FitbitMappedMeasurement[] { + const rows = readArray(body, "activities-heart") as FitbitDailyRow[]; + return mapDailySummary(rows, { type: "RESTING_HEART_RATE", unit: "bpm", - path: "daily-resting-heart-rate", - filter: "daily_resting_heart_rate", - }, - respiratoryRate: { - type: "RESPIRATORY_RATE", - unit: "breaths/min", - path: "daily-respiratory-rate", - filter: "daily_respiratory_rate", - }, - heartRate: { - type: "PULSE", - unit: "bpm", - path: "heart-rate", - filter: "heart_rate", - note: "intraday spot HR", - }, - height: { - type: "User.heightCm", - unit: "cm", - path: "height", - filter: "height", - note: "profile seed — written to User.heightCm only when null, never as a Measurement", - }, - sleepTemperature: { - type: "WRIST_TEMPERATURE", - unit: "celsius", - path: "daily-sleep-temperature-derivations", - filter: "daily_sleep_temperature_derivations", - note: "overnight wrist-temp; confirm absolute-vs-baseline at build", - }, - // ── Activity bundle (W5) — daily cumulative ───────────────────── - steps: { - type: "ACTIVITY_STEPS", - unit: "steps", - path: "steps", - filter: "steps", - note: "daily total; stats: externalId overwrites on re-fetch; 0 is a valid rest day", - }, - distance: { - type: "WALKING_RUNNING_DISTANCE", - unit: "m", - path: "distance", - filter: "distance", - note: "daily total metres; stats: externalId overwrites on re-fetch", - }, - activeCalories: { - type: "ACTIVE_ENERGY_BURNED", - unit: "kcal", - path: "active-calories", - filter: "active_calories", - note: "ACTIVE portion only (NOT total caloriesOut); stats: externalId overwrites on re-fetch", - }, - floors: { - type: "FLIGHTS_CLIMBED", - unit: "flights", - path: "floors", - filter: "floors", - note: "daily total floors; stats: externalId overwrites on re-fetch", - }, - vo2Max: { - type: "VO2_MAX", - unit: "mL/(kg·min)", - path: "vo2-max", - filter: "vo2_max", - note: "daily latest-wins; daily-anchor externalId overwrites on re-fetch", - }, - sleep: { - type: "SLEEP_DURATION", - unit: "minutes", - path: "sleep", - filter: "sleep", - note: "per-stage rows (IN_BED/AWAKE/REM/CORE/DEEP); measuredAt = stage END", - }, - exercise: { - type: "Workout", - unit: "—", - path: "exercise", - filter: "exercise", - note: "exercise session → Workout row (NOT a Measurement); cross-source dedup at read time", - }, -}; - -// ─── W5 mappers: activity (cumulative), sleep stages, workouts ── - -/** Finite + non-negative guard — cumulative metrics admit a legitimate 0. */ -function nonNegative(n: unknown): n is number { - return typeof n === "number" && Number.isFinite(n) && n >= 0; + fieldTag: "rhr", + leaves: ["restingHeartRate"], + }); } /** - * Pull the first finite NON-negative number out of a list of candidate value - * paths. Unlike `firstNumber` (strictly positive), this admits a legitimate - * zero — a day of rest still records 0 steps / 0 floors / 0 active kcal, and - * dropping the zero would leave a hole the chart misreads as missing data. + * 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. */ -function firstNonNegativeNumber( - point: FitbitDataPoint, - paths: string[], -): number | null { - for (const path of paths) { - const v = readPath(point, path); - if (nonNegative(v)) return v; +export function mapVo2Max(body: unknown): FitbitMappedMeasurement[] { + const rows = readArray(body, "cardioScore") as FitbitDailyRow[]; + const out: FitbitMappedMeasurement[] = []; + for (const row of rows) { + const day = parseCivilDate(row.dateTime); + if (!day) continue; + const v = parseVo2Max( + (row.value as Record | undefined)?.vo2Max, + ); + if (v === null || !positive(v)) continue; + out.push({ + type: "VO2_MAX", + value: round2(v), + unit: "mL/(kg·min)", + measuredAt: day, + fieldTag: `vo2_max:${fitbitDate(day)}`, + cumulativeDaily: true, + }); } - return null; + return out; } -/** - * Map one daily cumulative-activity data point into a single Measurement - * reading. The externalId is the `stats:`-prefixed daily-total shape so a - * re-fetched day overwrites in place rather than minting a duplicate — the same - * overwrite contract the Apple-Health `stats::` daily totals - * use. A zero is preserved (a rest day is real data, not a gap). `latestWins` - * (VO2 max) carries the same daily anchor; it is daily latest-wins rather than a - * running sum, but the per-day overwrite key is identical. - */ -function mapDailyCumulative( - point: FitbitDataPoint, - dataType: FitbitDataType, +/** Parse a Fitbit VO2max value: a single number, or a "lo-hi" range midpoint. */ +export function parseVo2Max(raw: unknown): number | null { + if (typeof raw === "number" && Number.isFinite(raw)) return raw; + if (typeof raw !== "string") return null; + const s = raw.trim(); + const range = /^(\d+(?:\.\d+)?)\s*-\s*(\d+(?:\.\d+)?)$/.exec(s); + if (range) { + const lo = Number(range[1]); + const hi = Number(range[2]); + if (Number.isFinite(lo) && Number.isFinite(hi)) return (lo + hi) / 2; + return null; + } + const n = Number(s); + return Number.isFinite(n) ? n : null; +} + +// ── Daily cumulative activity (activities time series) ───────── +// +// GET /1/user/-/activities/{steps,distance,floors,activityCalories}/date/{s}/{e}.json +// → { "activities-{resource}": [{ dateTime, value: "" }] } +// One daily total per civil day. The externalId carries the `stats:` daily-total +// prefix (assembled in the sync layer) so a re-fetched day overwrites in place, +// matching the Apple-Health `stats::` contract. A rest day of 0 +// is preserved (dropping it would leave a chart gap misread as missing data). +// distance is reported in km under the metric locale → ×1000 to metres. + +/** Map an activity time-series body for one cumulative metric. */ +function mapActivitySeries( + body: unknown, spec: { + arrayKey: string; type: string; unit: string; fieldTag: string; - valuePaths: string[]; factor?: number; - /** VO2 max is daily latest-wins, not a running total — still one row/day. */ - latestWins?: boolean; }, ): FitbitMappedMeasurement[] { - // VO2 max is strictly positive; the running totals admit a legitimate zero. - let value = spec.latestWins - ? firstNumber(point, spec.valuePaths) - : firstNonNegativeNumber(point, spec.valuePaths); - if (value === null) return []; - if (spec.factor) value = value * spec.factor; - const dayKey = externalAnchor(point, dataType); // YYYY-MM-DD for a daily type - return [ - { + const rows = readArray(body, spec.arrayKey) as FitbitDailyRow[]; + const out: FitbitMappedMeasurement[] = []; + for (const row of rows) { + const day = parseCivilDate(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(value), + value: round2(scaled), unit: spec.unit, - measuredAt: resolveMeasuredAt(point, dataType, new Date()), - // `stats::` — the sync layer reads `cumulativeDaily` - // to assemble the externalId, matching the Apple-Health daily-total shape. - fieldTag: `${spec.fieldTag}:${dayKey}`, + measuredAt: day, + fieldTag: `${spec.fieldTag}:${fitbitDate(day)}`, cumulativeDaily: true, - }, - ]; + }); + } + return out; } -export function mapSteps(point: FitbitDataPoint): FitbitMappedMeasurement[] { - const dt = FITBIT_DATA_TYPES.steps; - return mapDailyCumulative(point, dt, { +export function mapSteps(body: unknown): FitbitMappedMeasurement[] { + return mapActivitySeries(body, { + arrayKey: "activities-steps", type: "ACTIVITY_STEPS", unit: "steps", fieldTag: "steps", - valuePaths: [ - ...valuePaths(dt.filter, "count"), - ...valuePaths(dt.filter, "steps"), - ], }); } -export function mapDistance(point: FitbitDataPoint): FitbitMappedMeasurement[] { - const dt = FITBIT_DATA_TYPES.distance; - // Google may report metres or kilometres; prefer the explicit-metres field, - // else convert km → m. Both pass the non-negative guard. - const meters = firstNonNegativeNumber(point, valuePaths(dt.filter, "meters")); - if (meters !== null) { - return [ - { - type: "WALKING_RUNNING_DISTANCE", - value: round2(meters), - unit: "m", - measuredAt: resolveMeasuredAt(point, dt, new Date()), - fieldTag: `distance:${externalAnchor(point, dt)}`, - cumulativeDaily: true, - }, - ]; - } - return mapDailyCumulative(point, dt, { +export function mapDistance(body: unknown): FitbitMappedMeasurement[] { + // The metric-locale distance series reports kilometres → metres. + return mapActivitySeries(body, { + arrayKey: "activities-distance", type: "WALKING_RUNNING_DISTANCE", unit: "m", fieldTag: "distance", - valuePaths: valuePaths(dt.filter, "kilometers"), factor: 1000, }); } -export function mapActiveCalories( - point: FitbitDataPoint, -): FitbitMappedMeasurement[] { - const dt = FITBIT_DATA_TYPES.activeCalories; - // ACTIVE energy only — NOT total caloriesOut (which folds in BMR). The - // candidate paths target the active-portion field explicitly. - return mapDailyCumulative(point, dt, { +export function mapActiveCalories(body: unknown): 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", - valuePaths: [ - ...valuePaths(dt.filter, "active_kilocalories"), - ...valuePaths(dt.filter, "kilocalories"), - ...valuePaths(dt.filter, "calories"), - ], }); } -export function mapFloors(point: FitbitDataPoint): FitbitMappedMeasurement[] { - const dt = FITBIT_DATA_TYPES.floors; - return mapDailyCumulative(point, dt, { +export function mapFloors(body: unknown): FitbitMappedMeasurement[] { + return mapActivitySeries(body, { + arrayKey: "activities-floors", type: "FLIGHTS_CLIMBED", unit: "flights", fieldTag: "floors", - valuePaths: [ - ...valuePaths(dt.filter, "count"), - ...valuePaths(dt.filter, "floors"), - ], }); } -export function mapVo2Max(point: FitbitDataPoint): FitbitMappedMeasurement[] { - const dt = FITBIT_DATA_TYPES.vo2Max; - return mapDailyCumulative(point, dt, { - type: "VO2_MAX", - unit: "mL/(kg·min)", - fieldTag: "vo2_max", - valuePaths: [ - ...valuePaths(dt.filter, "milliliters_per_kilogram_per_minute"), - ...valuePaths(dt.filter, "value"), - ], - latestWins: true, // strictly positive, one daily reading; not a running sum - }); -} - -// ── Sleep ────────────────────────────────────────────────────── -// -// A Google Health sleep session carries a list of per-stage segments, each -// with a stage label + a start + end. HealthLog stores one SLEEP_DURATION row -// per stage with `measuredAt = stage END` (so the night-total + hypnogram -// readers consume the same enum WHOOP / Apple write). The stage labels are -// harmonised onto the shared `SleepStage` enum. - -/** Google Health sleep stage label → HealthLog `SleepStage`. */ -const FITBIT_SLEEP_STAGE_MAP: Record = { - // Canonical Google Health stage names (snake / lower variants accepted). - in_bed: "IN_BED", - inbed: "IN_BED", - awake: "AWAKE", - wake: "AWAKE", - light: "CORE", // Fitbit "light" ↔ Apple "core" (same shallow-NREM band) - core: "CORE", - rem: "REM", - deep: "DEEP", - // Fitbit's classic (non-stages) sleep log uses asleep/restless/wake. - asleep: "ASLEEP", - restless: "AWAKE", -} as const; - /** - * Normalise a raw Google sleep-stage label to a `SleepStage`, or null for an - * unknown label (skipped rather than mis-bucketed). + * Read a named array off a `{ key: [...] }` envelope; [] on any miss. Tolerates + * the endpoint returning a bare array (some endpoints do) by returning it + * directly when the key is absent but the body is itself an array. */ +function readArray(body: unknown, key: string): unknown[] { + if (body && typeof body === "object" && !Array.isArray(body)) { + const v = (body as Record)[key]; + if (Array.isArray(v)) return v; + } + if (Array.isArray(body)) return body; + return []; +} + +// ─── Field→Measurement mapping table (mirror of mapping.md) ───── + +export const FITBIT_FIELD_MAP: Record< + string, + { type: string; unit: string; note?: string } +> = { + weight: { + type: "WEIGHT", + unit: "kg", + note: "body/log/weight; picker ranks a real Withings scale above Fitbit", + }, + bodyFat: { type: "BODY_FAT", unit: "%", note: "body/log/fat" }, + oxygenSaturation: { + type: "OXYGEN_SATURATION", + unit: "%", + note: "spo2 summary value.avg", + }, + heartRateVariability: { + type: "HEART_RATE_VARIABILITY", + unit: "ms", + note: "hrv summary value.dailyRmssd (RMSSD estimator); canonical HRV slot", + }, + restingHeartRate: { + type: "RESTING_HEART_RATE", + unit: "bpm", + note: "activities/heart value.restingHeartRate", + }, + respiratoryRate: { + type: "RESPIRATORY_RATE", + unit: "breaths/min", + note: "br summary value.breathingRate", + }, + steps: { + type: "ACTIVITY_STEPS", + unit: "steps", + note: "activities/steps daily total; stats: externalId overwrites; 0 valid", + }, + distance: { + type: "WALKING_RUNNING_DISTANCE", + unit: "m", + note: "activities/distance (km → m); stats: externalId overwrites", + }, + activeCalories: { + type: "ACTIVE_ENERGY_BURNED", + unit: "kcal", + note: "activities/activityCalories (ACTIVE portion only); stats: overwrites", + }, + floors: { + type: "FLIGHTS_CLIMBED", + unit: "flights", + note: "activities/floors daily total; stats: externalId overwrites", + }, + vo2Max: { + type: "VO2_MAX", + unit: "mL/(kg·min)", + note: "cardioscore value.vo2Max (range → midpoint); daily latest-wins", + }, + sleep: { + type: "SLEEP_DURATION", + unit: "minutes", + note: "1.2 sleep levels.data per-segment rows; measuredAt = segment END", + }, + exercise: { + type: "Workout", + unit: "—", + note: "activities list → Workout row (NOT a Measurement); read-time dedup", + }, +}; + +// ── Sleep (1.2 sleep log) ────────────────────────────────────── +// +// GET /1.2/user/-/sleep/date/{start}/{end}.json +// → { sleep: [{ logId, startTime, endTime, levels: { data: [ +// { dateTime: "", level: "wake|light|deep|rem|asleep|restless|awake", seconds } ] } }] } +// One SLEEP_DURATION row per segment, `measuredAt = segment START + seconds` +// (the segment END), value = seconds → minutes. Stage labels harmonise onto the +// shared SleepStage enum. The timeline is MEASURED (real onsets), so rows are +// NOT flagged reconstructed. + +/** Classic Fitbit sleep `level` → HealthLog `SleepStage`. */ +const FITBIT_SLEEP_STAGE_MAP: Record = { + // Stages logs. + wake: "AWAKE", + awake: "AWAKE", + light: "CORE", // Fitbit "light" ↔ Apple "core" (same shallow-NREM band) + core: "CORE", + deep: "DEEP", + rem: "REM", + // Classic (non-stages) logs. + asleep: "ASLEEP", + restless: "AWAKE", + in_bed: "IN_BED", + inbed: "IN_BED", +} as const; + +/** Normalise a raw Fitbit sleep `level` to a `SleepStage`, or null if unknown. */ export function mapFitbitSleepStage(raw: unknown): FitbitSleepStage | null { if (typeof raw !== "string") return null; const key = raw @@ -1188,127 +912,76 @@ export function mapFitbitSleepStage(raw: unknown): FitbitSleepStage | null { ); } -/** One sleep-stage segment pulled defensively off a Google sleep session. */ -interface FitbitSleepSegment { - stage: string; +interface FitbitSleepLevelEntry { + dateTime?: string; + level?: string; + seconds?: number; +} + +interface FitbitSleepSession { + logId?: number; startTime?: string; endTime?: string; -} - -/** Minutes between two ISO instants, or null if either is unparseable. */ -function minutesBetween(startIso?: string, endIso?: string): number | null { - if (!startIso || !endIso) return null; - const s = new Date(startIso).getTime(); - const e = new Date(endIso).getTime(); - if (Number.isNaN(s) || Number.isNaN(e) || e <= s) return null; - return (e - s) / 60_000; + levels?: { data?: FitbitSleepLevelEntry[] }; } /** - * Read the per-stage segments off a Google sleep `DataPoint`, tolerating the - * undocumented JSON shape. Candidate locations: `sleep.segments`, - * `sleep.stages`, `sleep.levels.data`, or a bare top-level `segments`/`stages`. + * The stable session anchor for a sleep externalId — the logId, else the end (or + * start) instant. A re-scored night reuses the same logId → overwrites in place. */ -function readSleepSegments(point: FitbitDataPoint): FitbitSleepSegment[] { - const candidates = [ - readPath(point, "sleep.segments"), - readPath(point, "sleep.stages"), - readPath(point, "sleep.levels.data"), - readPath(point, "segments"), - readPath(point, "stages"), - readPath(point, "levels.data"), - ]; - for (const c of candidates) { - if (!Array.isArray(c)) continue; - const out: FitbitSleepSegment[] = []; - for (const raw of c) { - if (!raw || typeof raw !== "object") continue; - const o = raw as Record; - const stage = - (typeof o.stage === "string" && o.stage) || - (typeof o.level === "string" && o.level) || - (typeof o.type === "string" && o.type) || - ""; - const startTime = - (typeof o.startTime === "string" && o.startTime) || - (typeof o.start_time === "string" && o.start_time) || - (typeof o.dateTime === "string" && o.dateTime) || - undefined; - const endTime = - (typeof o.endTime === "string" && o.endTime) || - (typeof o.end_time === "string" && o.end_time) || - undefined; - if (stage) out.push({ stage, startTime, endTime }); +function sleepSessionAnchor(s: FitbitSleepSession): string { + if (typeof s.logId === "number" && Number.isFinite(s.logId)) { + return String(s.logId); + } + for (const t of [s.endTime, s.startTime]) { + if (typeof t === "string") { + const d = parseLocalInstant(t); + if (d) return d.toISOString(); } - if (out.length > 0) return out; - } - return []; -} - -/** - * The stable session anchor for a sleep `DataPoint`'s externalId. The session - * end (or start) ISO instant is unique per night, so per-stage rows key as - * `:sleep_` — a re-scored night overwrites in place. - */ -function sleepSessionAnchor(point: FitbitDataPoint): string { - const end = - readPath(point, "sleep.interval.end_time") ?? - readPath(point, "interval.end_time") ?? - readPath(point, "sleep.endTime") ?? - readPath(point, "sleep.end_time") ?? - readPath(point, "endTime") ?? - readPath(point, "end_time"); - if (typeof end === "string") { - const d = new Date(end); - if (!Number.isNaN(d.getTime())) return d.toISOString(); - } - const start = - readPath(point, "sleep.interval.start_time") ?? - readPath(point, "interval.start_time") ?? - readPath(point, "sleep.startTime") ?? - readPath(point, "sleep.start_time") ?? - readPath(point, "startTime"); - if (typeof start === "string") { - const d = new Date(start); - if (!Number.isNaN(d.getTime())) return d.toISOString(); } return new Date(0).toISOString(); } /** - * Map one Google sleep session into per-SEGMENT `SLEEP_DURATION` rows. The - * Google sleep payload carries a real per-stage segment series (each with its - * own start/end), so one row is emitted PER SEGMENT — `measuredAt = that - * segment's END` — rather than collapsing a stage's segments onto a single - * lastEnd instant. The collapsed shape stamped every stage total on its last - * end, so the hypnogram reconstructed all of a stage's time as one block ending - * at the night's right edge (the stacked-bar artefact); per-segment rows lay - * each block at its true clock time. The timeline is MEASURED (real onsets), so - * these rows are NOT flagged reconstructed — unlike WHOOP, which has no onsets. - * + * Parse a Fitbit sleep timestamp. The 1.2 sleep log emits LOCAL wall-clock ISO + * strings WITHOUT an offset (e.g. `2020-01-26T03:02:30.000`); `new Date(...)` + * treats an offset-less ISO string as local time, which is the user's intent + * here (the night belongs to the user's local clock). Returns null on a miss. + */ +function parseLocalInstant(iso: string): Date | null { + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? null : d; +} + +/** + * Map one Fitbit sleep session into per-SEGMENT `SLEEP_DURATION` rows. Each + * segment's `dateTime` is its START; END = START + seconds. value = minutes. * Each segment carries a stage-scoped, INDEXED fieldTag so the several segments * of one stage stay distinct under the `(userId, type, source, externalId)` - * dedup key. Unknown stage labels are skipped; a session with no parseable - * segment yields nothing. + * dedup key. Unknown stage labels are skipped. */ export function mapSleepSession( - point: FitbitDataPoint, + session: FitbitSleepSession, ): FitbitMappedMeasurement[] { - const segments = readSleepSegments(point); - if (segments.length === 0) return []; + const data = session.levels?.data; + if (!Array.isArray(data) || data.length === 0) return []; - const anchor = sleepSessionAnchor(point); + const anchor = sleepSessionAnchor(session); const out: FitbitMappedMeasurement[] = []; let segIndex = 0; - for (const seg of segments) { - const stage = mapFitbitSleepStage(seg.stage); + for (const seg of data) { + const stage = mapFitbitSleepStage(seg.level); if (!stage) continue; - const mins = minutesBetween(seg.startTime, seg.endTime); - if (mins === null || !(mins > 0)) continue; - const end = new Date(seg.endTime as string); + if (!positive(seg.seconds)) continue; + const startStr = typeof seg.dateTime === "string" ? seg.dateTime : null; + if (!startStr) continue; + const start = parseLocalInstant(startStr); + if (!start) continue; + const minutes = seg.seconds / 60; + const end = new Date(start.getTime() + seg.seconds * 1000); out.push({ type: "SLEEP_DURATION", - value: round2(mins), + value: round2(minutes), unit: "minutes", measuredAt: end, fieldTag: `${anchor}:sleep_${stage.toLowerCase()}:${segIndex}`, @@ -1319,13 +992,30 @@ export function mapSleepSession( return out; } -// ── Workouts (exercise sessions) ─────────────────────────────── +/** Read the sleep-session array off the 1.2 sleep-log envelope. */ +export function readSleepSessions(body: unknown): FitbitSleepSession[] { + return readArray(body, "sleep") as FitbitSleepSession[]; +} -/** - * Google Health exercise-activity-type → HealthLog `WorkoutSportType`. Unknown - * types fall through to a generic label; the column is free-text so an unmapped - * type still persists (just not under a canonical sport bucket). - */ +// ── Workouts (activities list) ───────────────────────────────── +// +// GET /1/user/-/activities/list.json?afterDate=...&sort=asc&offset=0&limit=100 +// → { activities: [{ logId, activityName/activityTypeId, startTime, +// duration(ms), calories, distance(km), averageHeartRate, ... }] } +// Each becomes a Workout row keyed on `(userId, source, externalId=logId)`. + +interface FitbitActivityLogEntry { + logId?: number; + activityName?: string; + activityTypeId?: number; + startTime?: string; + duration?: number; // milliseconds + calories?: number; + distance?: number; // km (metric locale) + averageHeartRate?: number; +} + +/** Classic Fitbit activity name → HealthLog `WorkoutSportType` label. */ const FITBIT_EXERCISE_TYPE_MAP: Record = { walk: "walking", walking: "walking", @@ -1360,7 +1050,7 @@ const FITBIT_EXERCISE_TYPE_MAP: Record = { sport: "mixedCardio", } as const; -/** Resolve a Google exercise activity type to a canonical sport label. */ +/** 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 @@ -1374,7 +1064,7 @@ export function mapFitbitSportType(raw: unknown): string { ); } -/** One mapped Fitbit exercise session destined for a `Workout` row. */ +/** One mapped Fitbit activity destined for a `Workout` row. */ export interface FitbitMappedWorkout { externalId: string; sportType: string; @@ -1388,112 +1078,192 @@ export interface FitbitMappedWorkout { minHeartRate: number | null; } -/** Read a finite number off a list of candidate paths (any sign), or null. */ -function readNumber(point: FitbitDataPoint, paths: string[]): number | null { - for (const path of paths) { - const v = readPath(point, path); - if (typeof v === "number" && Number.isFinite(v)) return v; - } - return null; -} - -/** Read an ISO/string instant off a list of candidate paths, or null. */ -function readInstant(point: FitbitDataPoint, paths: string[]): Date | null { - for (const path of paths) { - const v = readPath(point, path); - if (typeof v === "string") { - const d = new Date(v); - if (!Number.isNaN(d.getTime())) return d; - } - } - return null; -} - /** - * Map one Google exercise session `DataPoint` into a `Workout` shape. Returns - * null when there is no usable start/end (a session with no time span is not a - * workout). The externalId anchors on the session id when present, else on the - * start instant, so a re-fetch overwrites the same `Workout` row in place. - * Energy is the active session energy in kcal; HR fields are optional. + * 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. */ -export function mapWorkout(point: FitbitDataPoint): FitbitMappedWorkout | null { - const f = FITBIT_DATA_TYPES.exercise.filter; - const startedAt = readInstant(point, [ - `${f}.interval.start_time`, - `${f}.startTime`, - `${f}.start_time`, - `${f}.sample_time.physical_time`, - "interval.start_time", - "startTime", - "start_time", - ]); - const endedAt = readInstant(point, [ - `${f}.interval.end_time`, - `${f}.endTime`, - `${f}.end_time`, - "interval.end_time", - "endTime", - "end_time", - ]); - if (!startedAt || !endedAt || endedAt <= startedAt) return null; +export function mapWorkout( + entry: FitbitActivityLogEntry, +): FitbitMappedWorkout | null { + const start = + typeof entry.startTime === "string" + ? parseLocalInstant(entry.startTime) + : null; + if (!start) return null; + if (!positive(entry.duration)) return null; - const durationSec = Math.round( - (endedAt.getTime() - startedAt.getTime()) / 1000, - ); + const durationSec = Math.round(entry.duration / 1000); + const endedAt = new Date(start.getTime() + entry.duration); - const sessionId = - (typeof readPath(point, `${f}.session_id`) === "string" && - (readPath(point, `${f}.session_id`) as string)) || - (typeof readPath(point, `${f}.id`) === "string" && - (readPath(point, `${f}.id`) as string)) || - (typeof readPath(point, "name") === "string" && - (readPath(point, "name") as string)) || - null; - const externalId = sessionId ?? `exercise:${startedAt.toISOString()}`; + const externalId = + typeof entry.logId === "number" && Number.isFinite(entry.logId) + ? String(entry.logId) + : `exercise:${start.toISOString()}`; - const sportRaw = - readPath(point, `${f}.activity_type`) ?? - readPath(point, `${f}.exercise_type`) ?? - readPath(point, `${f}.type`) ?? - readPath(point, "activityType"); - - const energyKcal = readNumber(point, [ - `${f}.active_kilocalories`, - `${f}.calories`, - `${f}.energy.kilocalories`, - ]); - const distanceM = readNumber(point, [ - `${f}.distance.meters`, - `${f}.distance_meters`, - `${f}.distance`, - ]); - const avgHr = readNumber(point, [ - `${f}.average_heart_rate.beats_per_minute`, - `${f}.average_heart_rate`, - `${f}.heart_rate.average`, - ]); - const maxHr = readNumber(point, [ - `${f}.maximum_heart_rate.beats_per_minute`, - `${f}.max_heart_rate`, - `${f}.heart_rate.maximum`, - ]); - const minHr = readNumber(point, [ - `${f}.minimum_heart_rate.beats_per_minute`, - `${f}.min_heart_rate`, - `${f}.heart_rate.minimum`, - ]); + 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; return { externalId, - sportType: mapFitbitSportType(sportRaw), - startedAt, + sportType: mapFitbitSportType(entry.activityName), + startedAt: start, endedAt, durationSec, - totalEnergyKcal: energyKcal !== null ? Math.round(energyKcal) : null, - totalDistanceM: - distanceM !== null && distanceM >= 0 ? round2(distanceM) : null, - avgHeartRate: avgHr !== null && avgHr > 0 ? Math.round(avgHr) : null, - maxHeartRate: maxHr !== null && maxHr > 0 ? Math.round(maxHr) : null, - minHeartRate: minHr !== null && minHr > 0 ? Math.round(minHr) : null, + totalEnergyKcal: energyKcal, + totalDistanceM: distanceM, + avgHeartRate: avgHr, + maxHeartRate: null, + minHeartRate: null, }; } + +/** Read the activity-list array off the activities/list envelope. */ +export function readActivityList(body: unknown): FitbitActivityLogEntry[] { + return readArray(body, "activities") as FitbitActivityLogEntry[]; +} + +// ─── Per-endpoint fetchers ───────────────────────────────────── +// +// Each returns the raw parsed body for its date-range window; the per-resource +// sync layer maps it. A single request per endpoint per chunk keeps the 150/h +// budget healthy. + +export async function fetchWeightRange( + accessToken: string, + start: Date, + end: Date, +): Promise { + return fitbitGet( + `/1/user/-/body/log/weight/date/${fitbitDate(start)}/${fitbitDate(end)}.json`, + accessToken, + "fetchWeight", + ); +} + +export async function fetchBodyFatRange( + accessToken: string, + start: Date, + end: Date, +): Promise { + return fitbitGet( + `/1/user/-/body/log/fat/date/${fitbitDate(start)}/${fitbitDate(end)}.json`, + accessToken, + "fetchBodyFat", + ); +} + +export async function fetchSpo2Range( + accessToken: string, + start: Date, + end: Date, +): Promise { + return fitbitGet( + `/1/user/-/spo2/date/${fitbitDate(start)}/${fitbitDate(end)}.json`, + accessToken, + "fetchSpo2", + ); +} + +export async function fetchHrvRange( + accessToken: string, + start: Date, + end: Date, +): Promise { + return fitbitGet( + `/1/user/-/hrv/date/${fitbitDate(start)}/${fitbitDate(end)}.json`, + accessToken, + "fetchHrv", + ); +} + +export async function fetchRestingHeartRateRange( + accessToken: string, + start: Date, + end: Date, +): Promise { + return fitbitGet( + `/1/user/-/activities/heart/date/${fitbitDate(start)}/${fitbitDate(end)}.json`, + accessToken, + "fetchRhr", + ); +} + +export async function fetchRespiratoryRateRange( + accessToken: string, + start: Date, + end: Date, +): Promise { + return fitbitGet( + `/1/user/-/br/date/${fitbitDate(start)}/${fitbitDate(end)}.json`, + accessToken, + "fetchRespiratoryRate", + ); +} + +export async function fetchVo2MaxRange( + accessToken: string, + start: Date, + end: Date, +): Promise { + return fitbitGet( + `/1/user/-/cardioscore/date/${fitbitDate(start)}/${fitbitDate(end)}.json`, + accessToken, + "fetchVo2Max", + ); +} + +/** Activity time-series for one cumulative resource (steps/distance/...). */ +export async function fetchActivitySeries( + resource: "steps" | "distance" | "floors" | "activityCalories", + accessToken: string, + start: Date, + end: Date, +): Promise { + return fitbitGet( + `/1/user/-/activities/${resource}/date/${fitbitDate(start)}/${fitbitDate(end)}.json`, + accessToken, + `fetchActivity:${resource}`, + ); +} + +export async function fetchSleepRange( + accessToken: string, + start: Date, + end: Date, +): Promise { + return fitbitGet( + `/1.2/user/-/sleep/date/${fitbitDate(start)}/${fitbitDate(end)}.json`, + accessToken, + "fetchSleep", + ); +} + +/** + * Activity log list since a date. Uses the offset/limit list endpoint with + * `afterDate` + `sort=asc`. One page of up to `limit` activities; the sync layer + * caps the page count to stay within the rate budget. + */ +export async function fetchActivityList( + accessToken: string, + afterDate: Date, + opts: { limit?: number; offset?: number } = {}, +): Promise { + const limit = opts.limit ?? 100; + const offset = opts.offset ?? 0; + return fitbitGet( + `/1/user/-/activities/list.json?afterDate=${fitbitDate(afterDate)}&sort=asc&offset=${offset}&limit=${limit}`, + accessToken, + "fetchExercise", + ); +} diff --git a/src/lib/fitbit/mapping.md b/src/lib/fitbit/mapping.md index 804db05bc..5f73a452c 100644 --- a/src/lib/fitbit/mapping.md +++ b/src/lib/fitbit/mapping.md @@ -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 = :` (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::` — so a +re-fetched day **overwrites** in place (Apple-Health `stats::` +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 = `:sleep_:` (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::` — so a re-fetched day **overwrites** in -place rather than minting a duplicate, matching the Apple-Health -`stats::` 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 = `:sleep_` (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:` 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 = :`. Daily cumulative activity rows: -`externalId = stats::` (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 = :`. Daily-summary rows: `externalId = +:`. Daily cumulative activity rows: `externalId = +stats::` (Apple-Health overwrite shape). Sleep: +`:sleep_:`. 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. diff --git a/src/lib/fitbit/oauth-state.ts b/src/lib/fitbit/oauth-state.ts index 2985c722f..215a49d7b 100644 --- a/src/lib/fitbit/oauth-state.ts +++ b/src/lib/fitbit/oauth-state.ts @@ -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; diff --git a/src/lib/fitbit/sync-activity.ts b/src/lib/fitbit/sync-activity.ts index 7806f252d..d9dec20db 100644 --- a/src/lib/fitbit/sync-activity.ts +++ b/src/lib/fitbit/sync-activity.ts @@ -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::` - * — so a re-fetched day OVERWRITES the existing row rather than minting a - * duplicate, matching the Apple-Health `stats::` 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::` 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) => FitbitMappedMeasurement[]; + fetch: (accessToken: string, start: Date, end: Date) => Promise; + 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 `:` 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 `:` 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[]; - 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, diff --git a/src/lib/fitbit/sync-metrics.ts b/src/lib/fitbit/sync-metrics.ts index c772c360a..f05ae2fb8 100644 --- a/src/lib/fitbit/sync-metrics.ts +++ b/src/lib/fitbit/sync-metrics.ts @@ -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 (`:`) 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) => FitbitMappedMeasurement[]; + fetch: (accessToken: string, start: Date, end: Date) => Promise; + 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[]; - 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[]; - 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; } diff --git a/src/lib/fitbit/sync-sleep.ts b/src/lib/fitbit/sync-sleep.ts index d7ac50f1f..2215d4ef8 100644 --- a/src/lib/fitbit/sync-sleep.ts +++ b/src/lib/fitbit/sync-sleep.ts @@ -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 = - * `:sleep_:` — 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 = + * `:sleep_:` — 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[]; - 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, + }); + } } } diff --git a/src/lib/fitbit/sync-workout.ts b/src/lib/fitbit/sync-workout.ts index 7fa923084..7e38a3872 100644 --- a/src/lib/fitbit/sync-workout.ts +++ b/src/lib/fitbit/sync-workout.ts @@ -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[]; - 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 = []; + 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 { diff --git a/src/lib/fitbit/sync.ts b/src/lib/fitbit/sync.ts index e8e4437df..f85eda2ad 100644 --- a/src/lib/fitbit/sync.ts +++ b/src/lib/fitbit/sync.ts @@ -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 { * 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 From e748a91a36160137a8420155ab75d1ae299992c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 22 Jun 2026 07:21:06 +0200 Subject: [PATCH 3/4] feat(fitbit): wire PKCE through connect/callback and fix the test probe Connect mints the PKCE pair, stashes the verifier on the OAuth-state row, and sends the S256 challenge to the authorize endpoint. Callback reads the verifier back and presents it on the token exchange, refusing cleanly when it is absent. The connection-test probe hits the classic /1/user/-/profile.json endpoint. --- src/app/api/fitbit/callback/route.ts | 40 +++++++++++++------ src/app/api/fitbit/connect/route.ts | 18 ++++++--- src/app/api/fitbit/sync/route.ts | 14 +++---- .../fitbit/test/__tests__/route.test.ts | 3 +- src/app/api/integrations/fitbit/test/route.ts | 16 ++++---- 5 files changed, 55 insertions(+), 36 deletions(-) diff --git a/src/app/api/fitbit/callback/route.ts b/src/app/api/fitbit/callback/route.ts index 95b83ce63..984698e3c 100644 --- a/src/app/api/fitbit/callback/route.ts +++ b/src/app/api/fitbit/callback/route.ts @@ -19,9 +19,9 @@ import { NextRequest, NextResponse } from "next/server"; import { timingSafeEqual } from "node:crypto"; /** - * OAuth callback from Google for Fitbit / Google Health (v1.12.0). Mirrors the - * WHOOP callback: the `state` param is a random base64url nonce keyed against - * the `FitbitOAuthState` ledger. The in-flight user is resolved via the row's + * OAuth callback from Fitbit (classic Web API, v1.20.0). Mirrors the WHOOP + * callback: the `state` param is a random base64url nonce keyed against the + * `FitbitOAuthState` ledger. The in-flight user is resolved via the row's * `userId` (never parsed from the cookie value) and cross-checked against the * session. The row is consumed (deleted) atomically on every exit branch so a * replay of the same nonce fails the second time. @@ -31,9 +31,12 @@ import { timingSafeEqual } from "node:crypto"; * nonce already consumed), `expired` (valid row, TTL elapsed), and `cross_user` * (valid row, session userId mismatch). * - * On success: exchange the code, fetch the Google Health profile for the - * external `fitbitUserId`, persist the encrypted `FitbitConnection`, clear any - * prior reauth state, and enqueue the self-converging history backfill. + * v1.20.0 — the deleted ledger row carries the PKCE `code_verifier` minted at + * connect; it is presented on the token exchange. + * + * On success: exchange the code, fetch the Fitbit profile for the external + * `fitbitUserId`, persist the encrypted `FitbitConnection`, clear any prior + * reauth state, and enqueue the self-converging history backfill. */ const ERR = (reason: string) => NextResponse.redirect( @@ -70,7 +73,11 @@ export const GET = apiHandler(async (request: NextRequest) => { // payload. P2025 means the nonce was already consumed (replay) or never // existed. Atomic at the Postgres row level: two concurrent callbacks with the // same nonce can't both pass before either deletes. - let stateRow: { userId: string; expiresAt: Date } | null = null; + let stateRow: { + userId: string; + expiresAt: Date; + codeVerifier: string | null; + } | null = null; try { stateRow = await prisma.fitbitOAuthState.delete({ where: { nonce: state }, @@ -102,19 +109,26 @@ export const GET = apiHandler(async (request: NextRequest) => { return ERR("nocode"); } + // The PKCE verifier minted at connect rides on the ledger row. Its absence + // means a malformed / legacy handshake — refuse rather than send a code + // exchange Fitbit will reject for a missing verifier. + if (!stateRow.codeVerifier) { + annotate({ meta: { reason: "no_verifier" } }); + return ERR("no_verifier"); + } + try { const creds = await getUserFitbitCredentials(user.id); if (!creds) { return ERR("nocreds"); } - const tokens = await exchangeCode(code, creds); + const tokens = await exchangeCode(code, stateRow.codeVerifier, creds); - // The initial code exchange MUST return a refresh token (the connect route - // forces `prompt=consent` + `access_type=offline`). If Google ever omits it, - // refuse to persist — a stored empty refresh token silently bricks every - // future token refresh (the refresh POST then 400s and the connection dies - // the moment the access token expires). Error out cleanly so the user + // The initial code exchange MUST return a refresh token. If Fitbit ever + // omits it, refuse to persist — a stored empty refresh token silently bricks + // every future token refresh (the refresh POST then 400s and the connection + // dies the moment the access token expires). Error out cleanly so the user // re-consents rather than landing a dead connection. if (!tokens.refresh_token) { getEvent()?.addWarning( diff --git a/src/app/api/fitbit/connect/route.ts b/src/app/api/fitbit/connect/route.ts index 93a30fb5a..cf1e33ae6 100644 --- a/src/app/api/fitbit/connect/route.ts +++ b/src/app/api/fitbit/connect/route.ts @@ -3,7 +3,7 @@ import { apiError } from "@/lib/api-response"; import { prisma } from "@/lib/db"; import { annotate, getEvent } from "@/lib/logging/context"; import { checkRateLimit } from "@/lib/rate-limit"; -import { getAuthorizationUrl } from "@/lib/fitbit/client"; +import { getAuthorizationUrl, generatePkcePair } from "@/lib/fitbit/client"; import { getUserFitbitCredentials } from "@/lib/fitbit/credentials"; import { FITBIT_OAUTH_STATE_COOKIE, @@ -14,8 +14,8 @@ import { NextRequest, NextResponse } from "next/server"; import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; /** - * Redirect the user to the Google OAuth consent page for Fitbit / Google Health - * (v1.12.0). + * Redirect the user to the Fitbit OAuth consent page (classic + * `www.fitbit.com/oauth2/authorize`, v1.20.0). * * Mirrors the WHOOP connect route: a fully-random base64url state nonce backed * by a 10-minute `FitbitOAuthState` ledger row carries the `(nonce → userId)` @@ -23,6 +23,12 @@ import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; * land in request logs / network captures). The httpOnly + Secure cookie * carries JUST the nonce; the callback resolves the user via the row's `userId`. * + * v1.20.0 — the classic Fitbit Web API uses PKCE (S256). The route mints a + * `code_verifier`/`code_challenge` pair, stashes the verifier on the ledger row + * (server-side, never exposed to the browser), and sends the challenge to the + * authorize endpoint. The callback reads the verifier back to present on the + * token exchange. + * * Rate-limited per user (10 calls / 60 s) so a logged-in session can't spam * ledger rows for the full 10-min TTL window. Both the rate-limit and * create-failure paths redirect (not JSON) because the entry point is a browser @@ -53,17 +59,19 @@ export const GET = apiHandler(async (req: NextRequest) => { const creds = await getUserFitbitCredentials(user.id); if (!creds) { return apiError( - "Please configure your Google OAuth Client ID and Client Secret in Settings first.", + "Please configure your Fitbit OAuth Client ID and Client Secret in Settings first.", 400, ); } const nonce = mintFitbitOAuthStateNonce(); + const pkce = generatePkcePair(); try { await prisma.fitbitOAuthState.create({ data: { nonce, userId: user.id, + codeVerifier: pkce.verifier, expiresAt: new Date(Date.now() + FITBIT_OAUTH_STATE_TTL_MS), }, }); @@ -75,7 +83,7 @@ export const GET = apiHandler(async (req: NextRequest) => { ); } - const url = getAuthorizationUrl(nonce, creds); + const url = getAuthorizationUrl(nonce, creds, pkce.challenge); const response = NextResponse.redirect(url); response.cookies.set(FITBIT_OAUTH_STATE_COOKIE, nonce, { diff --git a/src/app/api/fitbit/sync/route.ts b/src/app/api/fitbit/sync/route.ts index 02c1b1b2d..5197ef8e8 100644 --- a/src/app/api/fitbit/sync/route.ts +++ b/src/app/api/fitbit/sync/route.ts @@ -6,17 +6,17 @@ import { syncUserFitbit } from "@/lib/fitbit/sync"; import { NextRequest } from "next/server"; /** - * Manually trigger a Fitbit / Google Health sync for the current user (v1.12.0). + * Manually trigger a Fitbit sync for the current user. * * Mirrors the WHOOP manual-sync route: incremental by default, full history when * `{ fullSync: true }` is posted. * - * v1.12.1 — rate-limited (M-1). Every sibling Fitbit route (connect / test / - * resume) is limited; this one was the outlier, while `{ fullSync: true }` - * drives four paginated Google walkers (each capped at 1000 pages). A tight - * loop or a stolen native token could pin Prisma + the Google Health quota. - * A baseline 5/60s bucket (matching the test route) gates the route, and the - * expensive `fullSync` path carries a tighter 1/hour bucket of its own. + * Rate-limited: every sibling Fitbit route (connect / test / resume) is limited; + * this one was the outlier, while `{ fullSync: true }` drives four resource syncs + * over the classic Web API's tight 150 req/h-per-user budget. A tight loop or a + * stolen native token could exhaust that budget and pin Prisma. A baseline 5/60s + * bucket (matching the test route) gates the route, and the expensive `fullSync` + * path carries a tighter 1/hour bucket of its own. */ export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); diff --git a/src/app/api/integrations/fitbit/test/__tests__/route.test.ts b/src/app/api/integrations/fitbit/test/__tests__/route.test.ts index bef0ba2ba..eade1195d 100644 --- a/src/app/api/integrations/fitbit/test/__tests__/route.test.ts +++ b/src/app/api/integrations/fitbit/test/__tests__/route.test.ts @@ -206,7 +206,7 @@ describe("POST /api/integrations/fitbit/test", () => { new Response( JSON.stringify({ error: "Authorization: Bearer sk-secret echoed", - url: "https://health.googleapis.com/v4/users/me/profile", + url: "https://api.fitbit.com/1/user/-/profile.json", }), { status: 401 }, ), @@ -215,6 +215,5 @@ describe("POST /api/integrations/fitbit/test", () => { const text = await response.text(); expect(response.status).toBe(502); expect(text).not.toMatch(/sk-/); - expect(text).not.toMatch(/health\.googleapis\.com/); }); }); diff --git a/src/app/api/integrations/fitbit/test/route.ts b/src/app/api/integrations/fitbit/test/route.ts index 899576742..b13e7dfbf 100644 --- a/src/app/api/integrations/fitbit/test/route.ts +++ b/src/app/api/integrations/fitbit/test/route.ts @@ -13,7 +13,7 @@ export const dynamic = "force-dynamic"; // Cheap authenticated probe — the single (non-paginated) profile endpoint the // Fitbit client already calls via `fetchProfile`. // Base from `src/lib/fitbit/client.ts` (`FITBIT_API_BASE`). -const FITBIT_PROFILE_URL = `${FITBIT_API_BASE}/users/me/profile`; +const FITBIT_PROFILE_URL = `${FITBIT_API_BASE}/1/user/-/profile.json`; const TIMEOUT_MS = 5_000; type CategorisedError = { code: string; message: string }; @@ -22,24 +22,24 @@ function categoriseHttpStatus(status: number): CategorisedError { if (status === 401 || status === 403) { return { code: "credentials_rejected", - message: "Google Health rejected the credentials", + message: "Fitbit rejected the credentials", }; } if (status === 429) { return { code: "rate_limited", - message: "Google Health rate-limited the request", + message: "Fitbit rate-limited the request", }; } if (status >= 500) { return { code: "upstream_error", - message: "Google Health returned a server error", + message: "Fitbit returned a server error", }; } return { code: "connection_failed", - message: "Google Health connection failed", + message: "Fitbit connection failed", }; } @@ -57,7 +57,7 @@ export const POST = apiHandler(async (request: NextRequest) => { const tokenInfo = await getValidToken(user.id); if (!tokenInfo) { - return apiError("Google Health not connected", 422, { + return apiError("Fitbit not connected", 422, { errorCode: "not_configured", }); } @@ -115,9 +115,7 @@ export const POST = apiHandler(async (request: NextRequest) => { }, }); return apiError( - isAbort - ? "Google Health request timed out" - : "Google Health connection failed", + isAbort ? "Fitbit request timed out" : "Fitbit connection failed", 502, { errorCode: code }, ); From 488ca72d126494fd1cea7b1fbced93aa91e241fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 22 Jun 2026 07:21:12 +0200 Subject: [PATCH 4/4] chore(fitbit): document FITBIT_REDIRECT_URI and rename the card copy to Fitbit Add the optional FITBIT_REDIRECT_URI to .env.production.example (BYO-key shape, derives from NEXT_PUBLIC_APP_URL) so check-env documents it. Rename the settings-card copy from "Google Health" to "Fitbit" across all six locales now that the transport is the classic Fitbit Web API. --- .env.production.example | 13 +++++++++++++ messages/de.json | 28 ++++++++++++++-------------- messages/en.json | 28 ++++++++++++++-------------- messages/es.json | 28 ++++++++++++++-------------- messages/fr.json | 28 ++++++++++++++-------------- messages/it.json | 28 ++++++++++++++-------------- messages/pl.json | 28 ++++++++++++++-------------- 7 files changed, 97 insertions(+), 84 deletions(-) diff --git a/.env.production.example b/.env.production.example index c07d9e292..dcfe5afeb 100644 --- a/.env.production.example +++ b/.env.production.example @@ -50,6 +50,19 @@ API_TOKEN_HMAC_KEY="" # WHOOP_WEBHOOK_SECRET="" # WHOOP_REDIRECT_URI="" +# ----------------------------------------------------------------------------- +# Fitbit -- optional integration (classic Web API, per-user BYO-keys) +# ----------------------------------------------------------------------------- +# Each user registers their own app at https://dev.fitbit.com (self-serve, no +# brand verification) and pastes the OAuth Client ID + Client Secret into +# Settings (stored encrypted in the DB). There is therefore no +# FITBIT_CLIENT_ID/SECRET here. The OAuth flow uses Authorization Code + PKCE. +# +# FITBIT_REDIRECT_URI overrides the derived +# `${NEXT_PUBLIC_APP_URL}/api/fitbit/callback` when the public URL differs; it +# must exactly match the Callback URL registered in the dev.fitbit.com app. +# FITBIT_REDIRECT_URI="" + # ----------------------------------------------------------------------------- # Polar AccessLink -- optional integration (full OAuth, shared app from env) # ----------------------------------------------------------------------------- diff --git a/messages/de.json b/messages/de.json index e5629cc51..8b5ee78b3 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4454,9 +4454,9 @@ "token": "Der Token-Austausch mit WHOOP ist fehlgeschlagen. Bitte starte die Verbindung erneut.", "generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut." }, - "fitbit": "Google Health", + "fitbit": "Fitbit", "fitbitTag": "Fitbit & Pixel", - "fitbitDescription": "Verbinde Google Health, um deine Fitbit- und Pixel-Watch-Werte zu synchronisieren — Gewicht, Herzfrequenz und mehr.", + "fitbitDescription": "Verbinde Fitbit, um deine Fitbit- und Pixel-Watch-Werte zu synchronisieren — Gewicht, Herzfrequenz und mehr.", "fitbitExperimentalBadge": "Experimentell", "fitbitCredentials": "API-Zugangsdaten", "fitbitClientId": "Client-ID", @@ -4468,26 +4468,26 @@ "fitbitSync": "Jetzt synchronisieren", "fitbitFullSync": "Alle Daten synchronisieren", "fitbitFullSyncTitle": "Vollständige Synchronisierung?", - "fitbitFullSyncDescription": "Der gesamte verfügbare Google-Health-Verlauf wird vollständig synchronisiert. Das kann je nach Umfang deines Verlaufs einige Zeit dauern.", + "fitbitFullSyncDescription": "Der gesamte verfügbare Fitbit-Verlauf wird vollständig synchronisiert. Das kann je nach Umfang deines Verlaufs einige Zeit dauern.", "fitbitSyncResult": "{count} Messungen synchronisiert", "fitbitSyncFailed": "Synchronisierung fehlgeschlagen", "fitbitSynchronize": "Synchronisieren", "fitbitDisconnect": "Trennen", - "fitbitDisconnectTitle": "Google Health trennen?", - "fitbitDisconnectDescription": "Die Verbindung zu Google Health wird getrennt. Zuvor synchronisierte Daten bleiben erhalten.", - "fitbitConnect": "Mit Google Health verbinden", - "fitbitNoCredentials": "Bitte gib oben deine API-Zugangsdaten ein, um Google Health zu verbinden.", - "fitbitBackfillInProgress": "Dein Google-Health-Verlauf wird im Hintergrund importiert…", - "fitbitOauthConnected": "Google Health verbunden — die erste Synchronisierung startet im Hintergrund.", - "fitbitOauthFailed": "Google-Health-Verbindung fehlgeschlagen", + "fitbitDisconnectTitle": "Fitbit trennen?", + "fitbitDisconnectDescription": "Die Verbindung zu Fitbit wird getrennt. Zuvor synchronisierte Daten bleiben erhalten.", + "fitbitConnect": "Mit Fitbit verbinden", + "fitbitNoCredentials": "Bitte gib oben deine API-Zugangsdaten ein, um Fitbit zu verbinden.", + "fitbitBackfillInProgress": "Dein Fitbit-Verlauf wird im Hintergrund importiert…", + "fitbitOauthConnected": "Fitbit verbunden — die erste Synchronisierung startet im Hintergrund.", + "fitbitOauthFailed": "Fitbit-Verbindung fehlgeschlagen", "fitbitOauthError": { "csrf1": "Die Sicherheitsprüfung ist fehlgeschlagen, weil das Browser-Cookie fehlte. Bitte starte die Verbindung im selben Browser erneut.", - "state": "Die Antwort von Google passte zu keiner offenen Verbindungsanfrage. Bitte starte die Verbindung erneut.", + "state": "Die Antwort von Fitbit passte zu keiner offenen Verbindungsanfrage. Bitte starte die Verbindung erneut.", "expired": "Die Verbindungsanfrage ist abgelaufen, bevor sie abgeschlossen wurde. Bitte starte die Verbindung erneut.", "cross_user": "Die Antwort gehört zu einer Anfrage eines anderen Kontos. Bitte starte die Verbindung aus deinem eigenen Konto erneut.", - "nocode": "Google hat keinen Autorisierungscode zurückgegeben. Versuche es erneut und erlaube den Zugriff im Google-Dialog.", - "nocreds": "Google Health ist nicht konfiguriert. Hinterlege oben deine Google-OAuth-Zugangsdaten und versuche es erneut.", - "token": "Der Token-Austausch mit Google ist fehlgeschlagen. Bitte starte die Verbindung erneut.", + "nocode": "Fitbit hat keinen Autorisierungscode zurückgegeben. Versuche es erneut und erlaube den Zugriff im Fitbit-Dialog.", + "nocreds": "Fitbit ist nicht konfiguriert. Hinterlege oben deine Fitbit-OAuth-Zugangsdaten und versuche es erneut.", + "token": "Der Token-Austausch mit Fitbit ist fehlgeschlagen. Bitte starte die Verbindung erneut.", "generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut." }, "integrations": { diff --git a/messages/en.json b/messages/en.json index a44932a9f..d35328c08 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4454,9 +4454,9 @@ "token": "The token exchange with WHOOP failed. Please start the connection again.", "generic": "Unknown error while connecting. Please start the connection again." }, - "fitbit": "Google Health", + "fitbit": "Fitbit", "fitbitTag": "Fitbit & Pixel", - "fitbitDescription": "Connect Google Health to sync your Fitbit and Pixel Watch vitals — weight, heart rate, and more.", + "fitbitDescription": "Connect Fitbit to sync your Fitbit and Pixel Watch vitals — weight, heart rate, and more.", "fitbitExperimentalBadge": "Experimental", "fitbitCredentials": "API Credentials", "fitbitClientId": "Client ID", @@ -4468,26 +4468,26 @@ "fitbitSync": "Sync now", "fitbitFullSync": "Sync all data", "fitbitFullSyncTitle": "Full synchronization?", - "fitbitFullSyncDescription": "All available Google Health history will be fully synchronized. This may take some time depending on your history.", + "fitbitFullSyncDescription": "All available Fitbit history will be fully synchronized. This may take some time depending on your history.", "fitbitSyncResult": "{count} measurements synchronized", "fitbitSyncFailed": "Sync failed", "fitbitSynchronize": "Synchronize", "fitbitDisconnect": "Disconnect", - "fitbitDisconnectTitle": "Disconnect Google Health?", - "fitbitDisconnectDescription": "The connection to Google Health will be disconnected. Previously synced data will be preserved.", - "fitbitConnect": "Connect with Google Health", - "fitbitNoCredentials": "Please enter your API credentials above to connect Google Health.", - "fitbitBackfillInProgress": "Importing your Google Health history in the background…", - "fitbitOauthConnected": "Google Health connected — the first sync starts in the background.", - "fitbitOauthFailed": "Google Health connection failed", + "fitbitDisconnectTitle": "Disconnect Fitbit?", + "fitbitDisconnectDescription": "The connection to Fitbit will be disconnected. Previously synced data will be preserved.", + "fitbitConnect": "Connect with Fitbit", + "fitbitNoCredentials": "Please enter your API credentials above to connect Fitbit.", + "fitbitBackfillInProgress": "Importing your Fitbit history in the background…", + "fitbitOauthConnected": "Fitbit connected — the first sync starts in the background.", + "fitbitOauthFailed": "Fitbit connection failed", "fitbitOauthError": { "csrf1": "The security check failed because the browser cookie was missing. Please start the connection again in the same browser.", - "state": "The response from Google did not match any open connection request. Please start the connection again.", + "state": "The response from Fitbit did not match any open connection request. Please start the connection again.", "expired": "The connection request expired before it completed. Please start the connection again.", "cross_user": "The response belongs to a request from a different account. Please restart the connection from your own account.", - "nocode": "Google did not return an authorization code. Try again and allow access in the Google dialog.", - "nocreds": "Google Health is not configured. Add your Google OAuth credentials above and try again.", - "token": "The token exchange with Google failed. Please start the connection again.", + "nocode": "Fitbit did not return an authorization code. Try again and allow access in the Fitbit dialog.", + "nocreds": "Fitbit is not configured. Add your Fitbit OAuth credentials above and try again.", + "token": "The token exchange with Fitbit failed. Please start the connection again.", "generic": "Unknown error while connecting. Please start the connection again." }, "integrations": { diff --git a/messages/es.json b/messages/es.json index e8f6b9456..37aa16366 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4454,9 +4454,9 @@ "token": "El intercambio de token con WHOOP falló. Inicia la conexión de nuevo.", "generic": "Error desconocido al conectar. Inicia la conexión de nuevo." }, - "fitbit": "Google Health", + "fitbit": "Fitbit", "fitbitTag": "Fitbit y Pixel", - "fitbitDescription": "Conecta Google Health para sincronizar tus datos de Fitbit y Pixel Watch — peso, frecuencia cardíaca y más.", + "fitbitDescription": "Conecta Fitbit para sincronizar tus datos de Fitbit y Pixel Watch — peso, frecuencia cardíaca y más.", "fitbitExperimentalBadge": "Experimental", "fitbitCredentials": "Credenciales de API", "fitbitClientId": "Client ID", @@ -4468,26 +4468,26 @@ "fitbitSync": "Sincronizar ahora", "fitbitFullSync": "Sincronizar todos los datos", "fitbitFullSyncTitle": "¿Sincronización completa?", - "fitbitFullSyncDescription": "Se sincronizará por completo todo el historial disponible de Google Health. Esto puede tardar un tiempo según tu historial.", + "fitbitFullSyncDescription": "Se sincronizará por completo todo el historial disponible de Fitbit. Esto puede tardar un tiempo según tu historial.", "fitbitSyncResult": "{count} mediciones sincronizadas", "fitbitSyncFailed": "Error de sincronización", "fitbitSynchronize": "Sincronizar", "fitbitDisconnect": "Desconectar", - "fitbitDisconnectTitle": "¿Desconectar Google Health?", - "fitbitDisconnectDescription": "Se desconectará la conexión con Google Health. Los datos sincronizados previamente se conservarán.", - "fitbitConnect": "Conectar con Google Health", - "fitbitNoCredentials": "Introduce tus credenciales de API arriba para conectar Google Health.", - "fitbitBackfillInProgress": "Importando tu historial de Google Health en segundo plano…", - "fitbitOauthConnected": "Google Health conectado: la primera sincronización empieza en segundo plano.", - "fitbitOauthFailed": "Error al conectar con Google Health", + "fitbitDisconnectTitle": "¿Desconectar Fitbit?", + "fitbitDisconnectDescription": "Se desconectará la conexión con Fitbit. Los datos sincronizados previamente se conservarán.", + "fitbitConnect": "Conectar con Fitbit", + "fitbitNoCredentials": "Introduce tus credenciales de API arriba para conectar Fitbit.", + "fitbitBackfillInProgress": "Importando tu historial de Fitbit en segundo plano…", + "fitbitOauthConnected": "Fitbit conectado: la primera sincronización empieza en segundo plano.", + "fitbitOauthFailed": "Error al conectar con Fitbit", "fitbitOauthError": { "csrf1": "La verificación de seguridad falló porque faltaba la cookie del navegador. Inicia la conexión de nuevo en el mismo navegador.", - "state": "La respuesta de Google no coincidió con ninguna solicitud de conexión abierta. Inicia la conexión de nuevo.", + "state": "La respuesta de Fitbit no coincidió con ninguna solicitud de conexión abierta. Inicia la conexión de nuevo.", "expired": "La solicitud de conexión caducó antes de completarse. Inicia la conexión de nuevo.", "cross_user": "La respuesta pertenece a una solicitud de otra cuenta. Reinicia la conexión desde tu propia cuenta.", - "nocode": "Google no devolvió un código de autorización. Inténtalo de nuevo y permite el acceso en el diálogo de Google.", - "nocreds": "Google Health no está configurado. Añade tus credenciales OAuth de Google arriba e inténtalo de nuevo.", - "token": "El intercambio de token con Google falló. Inicia la conexión de nuevo.", + "nocode": "Fitbit no devolvió un código de autorización. Inténtalo de nuevo y permite el acceso en el diálogo de Fitbit.", + "nocreds": "Fitbit no está configurado. Añade tus credenciales OAuth de Fitbit arriba e inténtalo de nuevo.", + "token": "El intercambio de token con Fitbit falló. Inicia la conexión de nuevo.", "generic": "Error desconocido al conectar. Inicia la conexión de nuevo." }, "integrations": { diff --git a/messages/fr.json b/messages/fr.json index 1c58b4827..619b87a1a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4454,9 +4454,9 @@ "token": "L'échange de jeton avec WHOOP a échoué. Veuillez relancer la connexion.", "generic": "Erreur inconnue lors de la connexion. Veuillez relancer la connexion." }, - "fitbit": "Google Health", + "fitbit": "Fitbit", "fitbitTag": "Fitbit et Pixel", - "fitbitDescription": "Connecte Google Health pour synchroniser tes données Fitbit et Pixel Watch — poids, fréquence cardiaque et plus.", + "fitbitDescription": "Connecte Fitbit pour synchroniser tes données Fitbit et Pixel Watch — poids, fréquence cardiaque et plus.", "fitbitExperimentalBadge": "Expérimental", "fitbitCredentials": "Identifiants d'API", "fitbitClientId": "Client ID", @@ -4468,26 +4468,26 @@ "fitbitSync": "Synchroniser maintenant", "fitbitFullSync": "Tout synchroniser", "fitbitFullSyncTitle": "Synchronisation complète ?", - "fitbitFullSyncDescription": "Tout l'historique Google Health disponible sera entièrement synchronisé. Cela peut prendre un certain temps selon votre historique.", + "fitbitFullSyncDescription": "Tout l'historique Fitbit disponible sera entièrement synchronisé. Cela peut prendre un certain temps selon votre historique.", "fitbitSyncResult": "{count} mesures synchronisées", "fitbitSyncFailed": "Échec de la synchronisation", "fitbitSynchronize": "Synchroniser", "fitbitDisconnect": "Déconnecter", - "fitbitDisconnectTitle": "Déconnecter Google Health ?", - "fitbitDisconnectDescription": "La connexion à Google Health sera déconnectée. Les données déjà synchronisées seront conservées.", - "fitbitConnect": "Se connecter à Google Health", - "fitbitNoCredentials": "Veuillez saisir vos identifiants d'API ci-dessus pour connecter Google Health.", - "fitbitBackfillInProgress": "Importation de votre historique Google Health en arrière-plan…", - "fitbitOauthConnected": "Google Health connecté — la première synchronisation démarre en arrière-plan.", - "fitbitOauthFailed": "Échec de la connexion à Google Health", + "fitbitDisconnectTitle": "Déconnecter Fitbit ?", + "fitbitDisconnectDescription": "La connexion à Fitbit sera déconnectée. Les données déjà synchronisées seront conservées.", + "fitbitConnect": "Se connecter à Fitbit", + "fitbitNoCredentials": "Veuillez saisir vos identifiants d'API ci-dessus pour connecter Fitbit.", + "fitbitBackfillInProgress": "Importation de votre historique Fitbit en arrière-plan…", + "fitbitOauthConnected": "Fitbit connecté — la première synchronisation démarre en arrière-plan.", + "fitbitOauthFailed": "Échec de la connexion à Fitbit", "fitbitOauthError": { "csrf1": "La vérification de sécurité a échoué car le cookie du navigateur était absent. Veuillez relancer la connexion dans le même navigateur.", - "state": "La réponse de Google ne correspondait à aucune demande de connexion en cours. Veuillez relancer la connexion.", + "state": "La réponse de Fitbit ne correspondait à aucune demande de connexion en cours. Veuillez relancer la connexion.", "expired": "La demande de connexion a expiré avant d'aboutir. Veuillez relancer la connexion.", "cross_user": "La réponse appartient à une demande d'un autre compte. Veuillez relancer la connexion depuis votre propre compte.", - "nocode": "Google n'a pas renvoyé de code d'autorisation. Réessayez et autorisez l'accès dans la fenêtre Google.", - "nocreds": "Google Health n'est pas configuré. Ajoutez vos identifiants OAuth Google ci-dessus et réessayez.", - "token": "L'échange de jeton avec Google a échoué. Veuillez relancer la connexion.", + "nocode": "Fitbit n'a pas renvoyé de code d'autorisation. Réessayez et autorisez l'accès dans la fenêtre Fitbit.", + "nocreds": "Fitbit n'est pas configuré. Ajoutez vos identifiants OAuth Fitbit ci-dessus et réessayez.", + "token": "L'échange de jeton avec Fitbit a échoué. Veuillez relancer la connexion.", "generic": "Erreur inconnue lors de la connexion. Veuillez relancer la connexion." }, "integrations": { diff --git a/messages/it.json b/messages/it.json index 6778c0ec7..7b175c0eb 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4454,9 +4454,9 @@ "token": "Lo scambio del token con WHOOP è fallito. Riavvia la connessione.", "generic": "Errore sconosciuto durante la connessione. Riavvia la connessione." }, - "fitbit": "Google Health", + "fitbit": "Fitbit", "fitbitTag": "Fitbit e Pixel", - "fitbitDescription": "Collega Google Health per sincronizzare i dati di Fitbit e Pixel Watch — peso, frequenza cardiaca e altro.", + "fitbitDescription": "Collega Fitbit per sincronizzare i dati di Fitbit e Pixel Watch — peso, frequenza cardiaca e altro.", "fitbitExperimentalBadge": "Sperimentale", "fitbitCredentials": "Credenziali API", "fitbitClientId": "Client ID", @@ -4468,26 +4468,26 @@ "fitbitSync": "Sincronizza ora", "fitbitFullSync": "Sincronizza tutti i dati", "fitbitFullSyncTitle": "Sincronizzazione completa?", - "fitbitFullSyncDescription": "Tutto lo storico disponibile di Google Health verrà sincronizzato completamente. L'operazione può richiedere del tempo a seconda del tuo storico.", + "fitbitFullSyncDescription": "Tutto lo storico disponibile di Fitbit verrà sincronizzato completamente. L'operazione può richiedere del tempo a seconda del tuo storico.", "fitbitSyncResult": "{count} misurazioni sincronizzate", "fitbitSyncFailed": "Sincronizzazione non riuscita", "fitbitSynchronize": "Sincronizza", "fitbitDisconnect": "Disconnetti", - "fitbitDisconnectTitle": "Disconnettere Google Health?", - "fitbitDisconnectDescription": "La connessione a Google Health verrà disconnessa. I dati già sincronizzati saranno conservati.", - "fitbitConnect": "Connetti con Google Health", - "fitbitNoCredentials": "Inserisci le tue credenziali API qui sopra per connettere Google Health.", - "fitbitBackfillInProgress": "Importazione dello storico Google Health in background…", - "fitbitOauthConnected": "Google Health collegato — la prima sincronizzazione parte in background.", - "fitbitOauthFailed": "Connessione a Google Health non riuscita", + "fitbitDisconnectTitle": "Disconnettere Fitbit?", + "fitbitDisconnectDescription": "La connessione a Fitbit verrà disconnessa. I dati già sincronizzati saranno conservati.", + "fitbitConnect": "Connetti con Fitbit", + "fitbitNoCredentials": "Inserisci le tue credenziali API qui sopra per connettere Fitbit.", + "fitbitBackfillInProgress": "Importazione dello storico Fitbit in background…", + "fitbitOauthConnected": "Fitbit collegato — la prima sincronizzazione parte in background.", + "fitbitOauthFailed": "Connessione a Fitbit non riuscita", "fitbitOauthError": { "csrf1": "Il controllo di sicurezza è fallito perché il cookie del browser era assente. Riavvia la connessione nello stesso browser.", - "state": "La risposta di Google non corrispondeva a nessuna richiesta di connessione aperta. Riavvia la connessione.", + "state": "La risposta di Fitbit non corrispondeva a nessuna richiesta di connessione aperta. Riavvia la connessione.", "expired": "La richiesta di connessione è scaduta prima di completarsi. Riavvia la connessione.", "cross_user": "La risposta appartiene a una richiesta di un altro account. Riavvia la connessione dal tuo account.", - "nocode": "Google non ha restituito un codice di autorizzazione. Riprova e concedi l'accesso nella finestra di Google.", - "nocreds": "Google Health non è configurato. Aggiungi le tue credenziali OAuth di Google qui sopra e riprova.", - "token": "Lo scambio del token con Google è fallito. Riavvia la connessione.", + "nocode": "Fitbit non ha restituito un codice di autorizzazione. Riprova e concedi l'accesso nella finestra di Fitbit.", + "nocreds": "Fitbit non è configurato. Aggiungi le tue credenziali OAuth di Fitbit qui sopra e riprova.", + "token": "Lo scambio del token con Fitbit è fallito. Riavvia la connessione.", "generic": "Errore sconosciuto durante la connessione. Riavvia la connessione." }, "integrations": { diff --git a/messages/pl.json b/messages/pl.json index 638f8a520..aca2120ae 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4454,9 +4454,9 @@ "token": "Wymiana tokena z WHOOP nie powiodła się. Rozpocznij połączenie ponownie.", "generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie." }, - "fitbit": "Google Health", + "fitbit": "Fitbit", "fitbitTag": "Fitbit i Pixel", - "fitbitDescription": "Połącz Google Health, aby synchronizować dane z Fitbit i Pixel Watch — wagę, tętno i więcej.", + "fitbitDescription": "Połącz Fitbit, aby synchronizować dane z Fitbit i Pixel Watch — wagę, tętno i więcej.", "fitbitExperimentalBadge": "Eksperymentalne", "fitbitCredentials": "Dane uwierzytelniające API", "fitbitClientId": "Client ID", @@ -4468,26 +4468,26 @@ "fitbitSync": "Synchronizuj teraz", "fitbitFullSync": "Synchronizuj wszystkie dane", "fitbitFullSyncTitle": "Pełna synchronizacja?", - "fitbitFullSyncDescription": "Cała dostępna historia Google Health zostanie w pełni zsynchronizowana. W zależności od historii może to chwilę potrwać.", + "fitbitFullSyncDescription": "Cała dostępna historia Fitbit zostanie w pełni zsynchronizowana. W zależności od historii może to chwilę potrwać.", "fitbitSyncResult": "Zsynchronizowano {count} pomiarów", "fitbitSyncFailed": "Synchronizacja nie powiodła się", "fitbitSynchronize": "Synchronizuj", "fitbitDisconnect": "Rozłącz", - "fitbitDisconnectTitle": "Rozłączyć Google Health?", - "fitbitDisconnectDescription": "Połączenie z Google Health zostanie rozłączone. Wcześniej zsynchronizowane dane zostaną zachowane.", - "fitbitConnect": "Połącz z Google Health", - "fitbitNoCredentials": "Wprowadź powyżej swoje dane uwierzytelniające API, aby połączyć Google Health.", - "fitbitBackfillInProgress": "Importowanie historii Google Health w tle…", - "fitbitOauthConnected": "Google Health połączony — pierwsza synchronizacja startuje w tle.", - "fitbitOauthFailed": "Połączenie z Google Health nie powiodło się", + "fitbitDisconnectTitle": "Rozłączyć Fitbit?", + "fitbitDisconnectDescription": "Połączenie z Fitbit zostanie rozłączone. Wcześniej zsynchronizowane dane zostaną zachowane.", + "fitbitConnect": "Połącz z Fitbit", + "fitbitNoCredentials": "Wprowadź powyżej swoje dane uwierzytelniające API, aby połączyć Fitbit.", + "fitbitBackfillInProgress": "Importowanie historii Fitbit w tle…", + "fitbitOauthConnected": "Fitbit połączony — pierwsza synchronizacja startuje w tle.", + "fitbitOauthFailed": "Połączenie z Fitbit nie powiodło się", "fitbitOauthError": { "csrf1": "Kontrola bezpieczeństwa nie powiodła się, ponieważ brakowało pliku cookie przeglądarki. Rozpocznij połączenie ponownie w tej samej przeglądarce.", - "state": "Odpowiedź z Google nie pasowała do żadnego otwartego żądania połączenia. Rozpocznij połączenie ponownie.", + "state": "Odpowiedź z Fitbit nie pasowała do żadnego otwartego żądania połączenia. Rozpocznij połączenie ponownie.", "expired": "Żądanie połączenia wygasło przed zakończeniem. Rozpocznij połączenie ponownie.", "cross_user": "Odpowiedź należy do żądania z innego konta. Rozpocznij połączenie ponownie z własnego konta.", - "nocode": "Google nie zwrócił kodu autoryzacji. Spróbuj ponownie i zezwól na dostęp w oknie Google.", - "nocreds": "Google Health nie jest skonfigurowany. Dodaj powyżej swoje dane OAuth Google i spróbuj ponownie.", - "token": "Wymiana tokena z Google nie powiodła się. Rozpocznij połączenie ponownie.", + "nocode": "Fitbit nie zwrócił kodu autoryzacji. Spróbuj ponownie i zezwól na dostęp w oknie Fitbit.", + "nocreds": "Fitbit nie jest skonfigurowany. Dodaj powyżej swoje dane OAuth Fitbit i spróbuj ponownie.", + "token": "Wymiana tokena z Fitbit nie powiodła się. Rozpocznij połączenie ponownie.", "generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie." }, "integrations": {