Merge branch 'fix/v14311-integ' into release/v1.43.11

This commit is contained in:
Marc-André Bombeck committed 2026-10-21 14:13:37 +02:00
commit eaaff23858
88 files changed
+3311 -384

No files matched your search

@@ -71,6 +71,15 @@ describe("oauthReasonKey", () => {
);
});
it("names the Fitbit turndown refusal with its own copy, and only for Fitbit", () => {
expect(oauthReasonKey("fitbit", "retired")).toBe(
"settings.fitbitOauthError.retired",
);
expect(oauthReasonKey("whoop", "retired")).toBe(
"settings.whoopOauthError.generic",
);
});
it("falls back to generic for an unknown tag", () => {
expect(oauthReasonKey("whoop", "totally_unknown")).toBe(
"settings.whoopOauthError.generic",
@@ -0,0 +1,47 @@
/**
* The Fitbit card's notice about the end of the legacy Web API: before the
* day it says when the connection stops, from the day on that it has, and
* both times it points at Google Health and says the data stays.
*/
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { FitbitRetirementNotice } from "../fitbit-retirement-notice";
function render(ended: boolean | null) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<FitbitRetirementNotice
retirement={
ended === null
? undefined
: { endsOn: "2026-10-30", ended, successor: "google-health" }
}
/>
</I18nProvider>,
);
}
describe("<FitbitRetirementNotice>", () => {
it("names the day the connection stops, and the way on", () => {
const html = render(false);
expect(html).toContain('data-ended="false"');
expect(html).toContain("Google turns the Fitbit Web API off on");
expect(html).toContain("Google Health reads the same Fitbit and Pixel");
expect(html).toContain("Everything already imported stays.");
expect(html).toContain('href="#google-health"');
expect(html).toContain("/google-health");
});
it("says the connection has stopped once the day has come", () => {
const html = render(true);
expect(html).toContain('data-ended="true"');
expect(html).toContain("Google turned the Fitbit Web API off on");
expect(html).not.toContain("Google turns the Fitbit Web API off on");
});
it("renders nothing without a retirement on the status entry", () => {
expect(render(null)).toBe("");
});
});
@@ -19,6 +19,7 @@ import { showErrorToast } from "@/lib/errors/error-toast";
import { AppleHealthCard } from "@/components/settings/integrations/apple-health-card";
import { DawarichCard } from "@/components/settings/integrations/dawarich-card";
import { FitbitRetirementNotice } from "@/components/settings/integrations/fitbit-retirement-notice";
import { GarminInfoNote } from "@/components/settings/integrations/garmin-info-note";
import { GoogleHealthCard } from "@/components/settings/integrations/google-health-card";
import { IntegrationFallbackRow } from "@/components/settings/integrations/integration-fallback-row";
@@ -136,6 +137,9 @@ const PROVIDER_ONLY_REASONS: Partial<
Record<OAuthOutcomeProvider, ReadonlySet<string>>
> = {
oura: new Set(["issuer"]),
// Fitbit's `retired`: Google has turned the Web API off, so the connect
// route refuses a new grant.
fitbit: new Set(["retired"]),
};
type OAuthOutcome =
@@ -216,6 +220,8 @@ export function ConnectionsPanel({
const queryClient = useQueryClient();
const { data: integrationStatus } = useIntegrationStatuses(isAuthenticated);
const fitbitStatus = pickStatus(integrationStatus, "fitbit");
const fitbitRetired = fitbitStatus?.retirement?.ended === true;
// OAuth callback handler — reads `?withings=connected|error&reason=…`
// from the URL (lazy initialiser, same shape as the Codex handler in
@@ -332,20 +338,35 @@ export function ConnectionsPanel({
/>
</div>
<div id="fitbit" className="scroll-mt-28">
<FitbitRetirementNotice retirement={fitbitStatus?.retirement} />
<OAuthIntegrationCard
config={OAUTH_INTEGRATIONS.fitbit}
viewModel={pickStatus(integrationStatus, "fitbit")}
viewModel={fitbitStatus}
callbackUrl={callbackUrls.fitbit}
syncDisabled={fitbitRetired}
connectDisabled={fitbitRetired}
titleAccessory={
<>
<TagChip>{t("settings.fitbitTag")}</TagChip>
<Badge
variant="outline"
data-testid="fitbit-experimental-badge"
className="border-warning/50 text-warning"
>
{t("settings.fitbitExperimentalBadge")}
</Badge>
{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>
)}
</>
}
/>
@@ -0,0 +1,61 @@
"use client";
import { ArrowDown, ExternalLink } from "lucide-react";
import { InlineAction } from "@/components/ui/inline-action";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { FitbitRetirement } from "@/lib/fitbit/retirement";
import { integrationDocsHref } from "./setup-guide-link";
/**
* The end of the legacy Fitbit Web API, on the Fitbit card. Before the day it
* says when the connection stops; from the day on, that it has stopped. Both
* name the way on: Google Health reads the same devices, needs one more
* consent, and leaves everything already imported in place. The server owns
* the date and whether it has passed (`retirement` on the status envelope).
*/
export function FitbitRetirementNotice({
retirement,
}: {
retirement: FitbitRetirement | undefined;
}) {
const { t } = useTranslations();
const fmt = useFormatters();
if (!retirement) return null;
const date = fmt.dateShortSmartCalendar(retirement.endsOn);
return (
<div
role="note"
data-testid="fitbit-retirement-notice"
data-ended={retirement.ended ? "true" : "false"}
className="border-warning/40 bg-warning/5 text-foreground mb-3 space-y-2 rounded-md border px-3 py-2 text-sm"
>
<p>
{retirement.ended
? t("settings.fitbitRetirement.ended", { date })
: t("settings.fitbitRetirement.ending", { date })}
</p>
<p>{t("settings.fitbitRetirement.move")}</p>
<div className="flex flex-wrap gap-x-4 gap-y-1">
<InlineAction asChild size="sm">
<a href="#google-health" data-testid="fitbit-retirement-to-google">
{t("settings.fitbitRetirement.toCard")}
<ArrowDown className="size-3" aria-hidden="true" />
</a>
</InlineAction>
<InlineAction asChild size="sm">
<a
href={integrationDocsHref("google-health")}
target="_blank"
rel="noopener noreferrer"
data-testid="fitbit-retirement-guide"
>
{t("settings.fitbitRetirement.guide")}
<ExternalLink className="size-3" aria-hidden="true" />
</a>
</InlineAction>
</div>
</div>
);
}
@@ -220,6 +220,10 @@ export function OAuthIntegrationCard(props: {
callbackUrl: IntegrationCallbackUrls[OAuthIntegrationKey];
icon?: LucideIcon;
titleAccessory?: ReactNode;
/** The provider cannot sync any more (Fitbit after its API turndown). */
syncDisabled?: boolean;
/** …and cannot be connected either. */
connectDisabled?: boolean;
}) {
const { config } = props;
const sync = useStandardSync({
@@ -229,7 +233,13 @@ export function OAuthIntegrationCard(props: {
fullSyncResult: config.fullSyncResult,
statusQueryKey: config.statusQueryKey(),
});
return <OAuthIntegrationCardView {...props} sync={sync} />;
return (
<OAuthIntegrationCardView
{...props}
sync={sync}
fullSyncDisabled={props.syncDisabled}
/>
);
}
/**
@@ -248,6 +258,7 @@ export function OAuthIntegrationCardView({
fullSyncDisabled,
syncResults,
details,
connectDisabled,
}: {
config: OAuthIntegrationConfig;
viewModel: IntegrationStatusViewModel | undefined;
@@ -263,6 +274,8 @@ export function OAuthIntegrationCardView({
syncResults?: ReactNode;
/** Provider extras for the details section. */
details?: ReactNode;
/** Greys out "Connect" whatever the credentials say. */
connectDisabled?: boolean;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
@@ -374,7 +387,7 @@ export function OAuthIntegrationCardView({
type="button"
size="sm"
className="min-h-11 w-full sm:min-h-9 sm:w-auto"
disabled={!configured}
disabled={!configured || connectDisabled}
onClick={connect}
data-slot="integration-primary-action"
data-action="connect"
@@ -14,6 +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";
export type IntegrationKey =
| "withings"
@@ -71,6 +72,8 @@ export interface IntegrationStatusViewModel {
hasToken?: boolean;
/** 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;
}
export interface IntegrationStatusEnvelope {