mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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.
This commit is contained in:
1 parent
bf3830203f
commit
b53434daa0
8 files changed
+172
-40
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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: <T extends (...args: unknown[]) => 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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, unknown>) => ({
|
||||
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"');
|
||||
});
|
||||
});
|
||||
@@ -337,40 +337,44 @@ export function ConnectionsPanel({
|
||||
callbackUrl={callbackUrls.whoop}
|
||||
/>
|
||||
</div>
|
||||
<div id="fitbit" className="scroll-mt-28">
|
||||
<FitbitRetirementNotice retirement={fitbitStatus?.retirement} />
|
||||
<OAuthIntegrationCard
|
||||
config={OAUTH_INTEGRATIONS.fitbit}
|
||||
viewModel={fitbitStatus}
|
||||
callbackUrl={callbackUrls.fitbit}
|
||||
syncDisabled={fitbitRetired}
|
||||
connectDisabled={fitbitRetired}
|
||||
titleAccessory={
|
||||
<>
|
||||
<TagChip>{t("settings.fitbitTag")}</TagChip>
|
||||
{fitbitStatus?.retirement ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
data-testid="fitbit-retirement-badge"
|
||||
className="border-warning/50 text-warning"
|
||||
>
|
||||
{fitbitRetired
|
||||
? t("settings.fitbitRetirement.badgeEnded")
|
||||
: t("settings.fitbitRetirement.badgeEnding")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="outline"
|
||||
data-testid="fitbit-experimental-badge"
|
||||
className="border-warning/50 text-warning"
|
||||
>
|
||||
{t("settings.fitbitExperimentalBadge")}
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{/* After the turndown the card is offered only to an account that had
|
||||
Fitbit; the server decides (`retirement.offered`). */}
|
||||
{fitbitStatus?.retirement?.offered !== false && (
|
||||
<div id="fitbit" className="scroll-mt-28">
|
||||
<FitbitRetirementNotice retirement={fitbitStatus?.retirement} />
|
||||
<OAuthIntegrationCard
|
||||
config={OAUTH_INTEGRATIONS.fitbit}
|
||||
viewModel={fitbitStatus}
|
||||
callbackUrl={callbackUrls.fitbit}
|
||||
syncDisabled={fitbitRetired}
|
||||
connectDisabled={fitbitRetired}
|
||||
titleAccessory={
|
||||
<>
|
||||
<TagChip>{t("settings.fitbitTag")}</TagChip>
|
||||
{fitbitStatus?.retirement ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
data-testid="fitbit-retirement-badge"
|
||||
className="border-warning/50 text-warning"
|
||||
>
|
||||
{fitbitRetired
|
||||
? t("settings.fitbitRetirement.badgeEnded")
|
||||
: t("settings.fitbitRetirement.badgeEnding")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="outline"
|
||||
data-testid="fitbit-experimental-badge"
|
||||
className="border-warning/50 text-warning"
|
||||
>
|
||||
{t("settings.fitbitExperimentalBadge")}
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div id="google-health" className="scroll-mt-28">
|
||||
<GoogleHealthCard
|
||||
viewModel={pickStatus(integrationStatus, "google-health")}
|
||||
|
||||
@@ -14,7 +14,7 @@ import type { GoogleHealthBackfillEndedEntry } from "@/lib/google-health/backfil
|
||||
import type { ConnectionHealth } from "@/lib/integrations/connection-health";
|
||||
import type { MetricFreshnessEntry } from "@/lib/integrations/sync-verdict";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { FitbitRetirement } from "@/lib/fitbit/retirement";
|
||||
import type { PublishedFitbitRetirement } from "@/lib/fitbit/retirement";
|
||||
|
||||
export type IntegrationKey =
|
||||
| "withings"
|
||||
@@ -73,7 +73,7 @@ export interface IntegrationStatusViewModel {
|
||||
/** Per-metric last-seen timestamps with the server-computed stale flag. */
|
||||
metricFreshness?: MetricFreshnessEntry[];
|
||||
/** Fitbit: the end of the legacy Web API, resolved on the server. */
|
||||
retirement?: FitbitRetirement;
|
||||
retirement?: PublishedFitbitRetirement;
|
||||
}
|
||||
|
||||
export interface IntegrationStatusEnvelope {
|
||||
|
||||
@@ -42,6 +42,15 @@ export interface FitbitRetirement {
|
||||
successor: "google-health";
|
||||
}
|
||||
|
||||
/**
|
||||
* The entry as published, with whether the card is offered at all: after the
|
||||
* turndown only to an account that had Fitbit (a connection, its own Fitbit
|
||||
* app, or imported Fitbit data). Decided by the status route.
|
||||
*/
|
||||
export interface PublishedFitbitRetirement extends FitbitRetirement {
|
||||
offered: boolean;
|
||||
}
|
||||
|
||||
export function fitbitRetirement(now: Date = new Date()): FitbitRetirement {
|
||||
return {
|
||||
endsOn: FITBIT_WEB_API_TURNDOWN_DATE,
|
||||
|
||||
@@ -1167,6 +1167,11 @@ const integrationEnvelopeEntry = z
|
||||
.describe(
|
||||
"The integration that reads the same Fitbit and Pixel devices. Moving needs a fresh consent; the Fitbit grant cannot be carried over.",
|
||||
),
|
||||
offered: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"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.",
|
||||
),
|
||||
})
|
||||
.optional()
|
||||
.describe("Fitbit only. The end of the legacy Fitbit Web API."),
|
||||
|
||||
Reference in new issue
Block a user