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")} + + )} + + } + /> +
+ )}