From b53434daa047633df6b393eaa8469e918fe5bfeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 15:06:40 +0200 Subject: [PATCH] fix(fitbit): show the retired card only to an account that had Fitbit After the Web API turndown every account still saw a Fitbit card that can no longer connect. The status entry now publishes retirement.offered: false once the API is gone for an account with no Fitbit connection, no Fitbit app of its own and no Fitbit data, and the connections panel leaves the card out. Accounts that had Fitbit keep it with the retirement notice. --- docs/api/openapi.yaml | 6 ++ .../status/__tests__/route.test.ts | 62 +++++++++++++++- src/app/api/integrations/status/route.ts | 19 ++++- .../connections-panel-coverage.test.tsx | 35 +++++++++ .../integrations/connections-panel.tsx | 72 ++++++++++--------- .../settings/integrations/shared.tsx | 4 +- src/lib/fitbit/retirement.ts | 9 +++ src/lib/openapi/routes/integrations.ts | 5 ++ 8 files changed, 172 insertions(+), 40 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 9e1b3e8a8..691031f34 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -79488,10 +79488,16 @@ components: - google-health description: The integration that reads the same Fitbit and Pixel devices. Moving needs a fresh consent; the Fitbit grant cannot be carried over. + offered: + type: boolean + description: "Whether the Fitbit card is shown. False once `ended` for an account that never connected Fitbit, has no + Fitbit app of its own and holds no Fitbit data: there is nothing to explain the move for. True before + the turndown and for every account that had Fitbit." required: - endsOn - ended - successor + - offered additionalProperties: false health: anyOf: diff --git a/src/app/api/integrations/status/__tests__/route.test.ts b/src/app/api/integrations/status/__tests__/route.test.ts index 43596e00d..907f210cc 100644 --- a/src/app/api/integrations/status/__tests__/route.test.ts +++ b/src/app/api/integrations/status/__tests__/route.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { afterEach, describe, it, expect, vi, beforeEach } from "vitest"; vi.mock("@/lib/api-handler", () => ({ apiHandler: unknown>(fn: T) => fn, @@ -16,6 +16,7 @@ vi.mock("@/lib/db", () => ({ fitbitConnection: { findUnique: vi.fn(async () => null) }, googleHealthConnection: { findUnique: vi.fn(async () => null) }, moodEntry: { count: vi.fn(async () => 0) }, + measurement: { findFirst: vi.fn(async () => null) }, $queryRaw: vi.fn(async () => []), workout: { groupBy: vi.fn(async () => []) }, }, @@ -65,6 +66,7 @@ vi.mock("@/lib/oura/credentials", async () => ({ })); import { GET } from "../route"; +import { isFitbitRetired } from "@/lib/fitbit/retirement"; import { prisma } from "@/lib/db"; import { listLiveMeasurementTypes } from "@/lib/measurements/live-types"; import { ATTEMPT_STALE_AFTER_MS } from "@/lib/integrations/sync-verdict"; @@ -508,3 +510,61 @@ describe("/api/integrations/status — one round of reads", () => { expect(envelope.integrations.length).toBeGreaterThan(0); }); }); + +describe("/api/integrations/status — the Fitbit card after the turndown", () => { + const fitbitFind = prisma.fitbitConnection.findUnique as ReturnType< + typeof vi.fn + >; + const fitbitDataFind = prisma.measurement.findFirst as ReturnType< + typeof vi.fn + >; + const offered = async () => + ( + (await fetchEntries()).find((e) => e.integration === "fitbit") as { + retirement?: { ended: boolean; offered: boolean }; + } + ).retirement; + + beforeEach(() => { + vi.clearAllMocks(); + userFind.mockResolvedValue({}); + fitbitFind.mockResolvedValue(null); + fitbitDataFind.mockResolvedValue(null); + // The suites run before the turndown by default (`vitest.setup.ts`). + vi.mocked(isFitbitRetired).mockReturnValue(true); + }); + afterEach(() => { + vi.mocked(isFitbitRetired).mockReturnValue(false); + }); + + it("is not offered to an account that never had Fitbit", async () => { + expect(await offered()).toMatchObject({ ended: true, offered: false }); + expect(fitbitDataFind).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ userId: "u1", source: "FITBIT" }), + }), + ); + }); + + it("stays, with its notice, for an account holding Fitbit data", async () => { + fitbitDataFind.mockResolvedValue({ id: "m1" }); + expect(await offered()).toMatchObject({ ended: true, offered: true }); + }); + + it("stays for an account still holding its Fitbit connection", async () => { + fitbitFind.mockResolvedValue({ + createdAt: new Date("2026-01-01T00:00:00.000Z"), + tokenExpiresAt: new Date("2026-11-01T00:00:00.000Z"), + lastSyncedAt: null, + backfillCompletedAt: null, + }); + expect(await offered()).toMatchObject({ ended: true, offered: true }); + expect(fitbitDataFind).not.toHaveBeenCalled(); + }); + + it("is offered to everyone before the turndown", async () => { + vi.mocked(isFitbitRetired).mockReturnValue(false); + expect(await offered()).toMatchObject({ ended: false, offered: true }); + expect(fitbitDataFind).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/integrations/status/route.ts b/src/app/api/integrations/status/route.ts index 04917d270..1fe7cf99c 100644 --- a/src/app/api/integrations/status/route.ts +++ b/src/app/api/integrations/status/route.ts @@ -59,7 +59,7 @@ import { import type { ConnectionHealth } from "@/lib/integrations/connection-health"; import { fitbitRetirement, - type FitbitRetirement, + type PublishedFitbitRetirement, } from "@/lib/fitbit/retirement"; export const dynamic = "force-dynamic"; @@ -295,6 +295,19 @@ export const GET = apiHandler(async () => { !!dbUser?.fitbitClientIdEncrypted && !!dbUser?.fitbitClientSecretEncrypted; const fitbitLegacyLastSyncedAt = fitbitConn?.lastSyncedAt?.toISOString() ?? null; + // Once the Web API is gone the Fitbit card only explains the move to an + // account that had something to move: a connection, its own Fitbit app, or + // imported Fitbit data. Anyone else is shown no dead card. The data probe + // runs only after the turndown, for an account with neither of the others. + const retirementNow = fitbitRetirement(); + const fitbitOffered = + !retirementNow.ended || + fitbitConnected || + fitbitConfigured || + (await prisma.measurement.findFirst({ + where: { userId: user.id, source: "FITBIT", deletedAt: null }, + select: { id: true }, + })) !== null; const googleHealthConnected = !!googleHealthConn; const googleHealthConfigured = !!dbUser?.googleHealthClientIdEncrypted && @@ -382,7 +395,7 @@ export const GET = apiHandler(async () => { ? fitbitConn.tokenExpiresAt.getTime() <= now : null, backfillCompleted: fitbitConn ? !!fitbitConn.backfillCompletedAt : null, - retirement: fitbitRetirement(), + retirement: { ...retirementNow, offered: fitbitOffered }, health: health["fitbit"], ...resolve("fitbit", fitbitStatus, { connected: fitbitConnected, @@ -564,7 +577,7 @@ interface FitbitExtras { tokenExpired: boolean | null; backfillCompleted: boolean | null; /** The end of the legacy Fitbit Web API (`@/lib/fitbit/retirement`). */ - retirement: FitbitRetirement; + retirement: PublishedFitbitRetirement; } // v1.27.0 — Google Health mirrors the Fitbit shape plus `needsReauth`: Google diff --git a/src/components/settings/integrations/__tests__/connections-panel-coverage.test.tsx b/src/components/settings/integrations/__tests__/connections-panel-coverage.test.tsx index 0c3e448a6..26b828490 100644 --- a/src/components/settings/integrations/__tests__/connections-panel-coverage.test.tsx +++ b/src/components/settings/integrations/__tests__/connections-panel-coverage.test.tsx @@ -181,3 +181,38 @@ describe("ConnectionsPanel — every envelope entry surfaces", () => { expect(render()).not.toContain('data-testid="integration-fallback-row"'); }); }); + +describe("ConnectionsPanel — the Fitbit card after the turndown", () => { + const withFitbit = (retirement: Record) => ({ + threshold: 3, + integrations: fullEnvelope().integrations.map((e) => + e.integration === "fitbit" + ? { ...e, connected: false, configured: false, retirement } + : e, + ), + }); + + it("shows no Fitbit card when the server does not offer it", () => { + envelope = withFitbit({ + endsOn: "2026-10-30", + ended: true, + successor: "google-health", + offered: false, + }); + const html = render(); + expect(html).not.toContain('id="fitbit"'); + expect(html).not.toContain('data-testid="fitbit-retirement-notice"'); + }); + + it("keeps the card and its notice for an account that had Fitbit", () => { + envelope = withFitbit({ + endsOn: "2026-10-30", + ended: true, + successor: "google-health", + offered: true, + }); + const html = render(); + expect(html).toContain('id="fitbit"'); + expect(html).toContain('data-testid="fitbit-retirement-notice"'); + }); +}); diff --git a/src/components/settings/integrations/connections-panel.tsx b/src/components/settings/integrations/connections-panel.tsx index 6382cdd54..b79f1e442 100644 --- a/src/components/settings/integrations/connections-panel.tsx +++ b/src/components/settings/integrations/connections-panel.tsx @@ -337,40 +337,44 @@ export function ConnectionsPanel({ callbackUrl={callbackUrls.whoop} /> -
- - - {t("settings.fitbitTag")} - {fitbitStatus?.retirement ? ( - - {fitbitRetired - ? t("settings.fitbitRetirement.badgeEnded") - : t("settings.fitbitRetirement.badgeEnding")} - - ) : ( - - {t("settings.fitbitExperimentalBadge")} - - )} - - } - /> -
+ {/* After the turndown the card is offered only to an account that had + Fitbit; the server decides (`retirement.offered`). */} + {fitbitStatus?.retirement?.offered !== false && ( +
+ + + {t("settings.fitbitTag")} + {fitbitStatus?.retirement ? ( + + {fitbitRetired + ? t("settings.fitbitRetirement.badgeEnded") + : t("settings.fitbitRetirement.badgeEnding")} + + ) : ( + + {t("settings.fitbitExperimentalBadge")} + + )} + + } + /> +
+ )}