mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
Merge branch 'fix/v14311-integ' into release/v1.43.11
This commit is contained in:
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 {
|
||||
|
||||
Reference in new issue
Block a user