Merge branch 'v1423/integrations' into release/v1.42.3

This commit is contained in:
Marc-André Bombeck committed 2026-10-14 19:20:16 +02:00
commit 035ae37ee4
49 files changed
+2715 -27

No files matched your search

@@ -232,3 +232,43 @@ describe("OAuthProviderCard — sync-now action", () => {
).not.toContain('data-testid="polar-sync"');
});
});
describe("OAuthProviderCard — Oura scope note", () => {
const scopeGap = {
connected: true,
configured: true,
available: true,
state: "connected" as const,
lastSuccessAt: "2026-10-14T00:00:00.000Z",
lastError: null,
syncHealth: { verdict: "fresh" as const, since: null },
scopeReconnect: ["spo2" as const, "vo2max" as const],
};
function renderOura(viewModel: ViewModel) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<OAuthProviderCard
provider="oura"
statusQueryKey={["oura"]}
i18nPrefix="settings.oura"
icon={Watch}
dataHref="/insights/sleep"
viewModel={viewModel}
callbackUrl="https://app.example/api/oura/callback"
/>
</I18nProvider>,
);
}
it("asks for one reconnect beside a healthy pill, not an error", () => {
const html = renderOura(scopeGap);
expect(html).toContain('data-testid="oura-scope-note"');
expect(html).toContain("Reconnect Oura once to allow SpO2 and VO2 max.");
expect(html).not.toContain('data-testid="oura-error"');
});
it("stays away from the other OAuth providers", () => {
expect(render({ viewModel: scopeGap })).not.toContain("scope-note");
});
});
@@ -0,0 +1,65 @@
/**
* The two "withheld by contract" notes: the Withings card names what the
* account's API plan refuses, the Oura card asks for one reconnect when the
* grant lacks a scope. Both render nothing when nothing is withheld, and both
* render the server-resolved list rather than deriving one.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { WithingsPlanNote } from "../withings-plan-note";
import { OuraScopeNote } from "../oura-scope-note";
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
describe("<WithingsPlanNote>", () => {
it("lists the withheld readings and links the Withings plans page", () => {
const html = render(
<WithingsPlanNote features={["spo2", "vascularAge", "ecg"]} />,
);
expect(html).toContain('data-testid="withings-plan-note"');
expect(html).toContain("SpO2, vascular age, and ECG and AFib");
expect(html).toContain("Weight, blood pressure, body composition");
expect(html).toContain(
"https://static.withings.com/content/api/plans/plans.html",
);
});
it("speaks the reader's language", () => {
const html = render(<WithingsPlanNote features={["vascularAge"]} />, "de");
expect(html).toContain("Gefäßalter");
expect(html).toContain("kostenpflichtigen API-Plan");
});
it("renders nothing when the plan withholds nothing", () => {
expect(render(<WithingsPlanNote features={[]} />)).toBe("");
expect(render(<WithingsPlanNote features={undefined} />)).toBe("");
});
});
describe("<OuraScopeNote>", () => {
it("asks for one reconnect naming the collections the grant lacks", () => {
const html = render(
<OuraScopeNote
collections={["spo2", "vo2max", "cardio_age", "resilience"]}
onReconnect={vi.fn()}
/>,
);
expect(html).toContain('data-testid="oura-scope-note"');
expect(html).toContain(
"Reconnect Oura once to allow SpO2, VO2 max, cardiovascular age, and resilience.",
);
expect(html).toContain('data-testid="oura-scope-reconnect"');
});
it("renders nothing when the grant covers everything", () => {
expect(
render(<OuraScopeNote collections={[]} onReconnect={vi.fn()} />),
).toBe("");
});
});
@@ -54,6 +54,7 @@ import {
queryKeys,
} from "@/lib/query-keys";
import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure";
import { OuraScopeNote } from "./oura-scope-note";
import {
readSyncOutcome,
useSyncOutcomeMessage,
@@ -72,6 +73,7 @@ import {
type IntegrationDocsProvider,
} from "./setup-guide-link";
import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls";
import type { OuraScopeGatedCollection } from "@/lib/oura/scope";
import { loadDocument } from "@/lib/navigation/load-document";
export interface OAuthProviderStatus {
@@ -99,6 +101,8 @@ export interface OAuthProviderStatus {
/** The server-resolved liveness verdict; the pill is a projection of it. */
syncHealth?: SyncHealth;
metricFreshness?: MetricFreshnessEntry[];
/** Oura: collections the stored grant does not cover. */
scopeReconnect?: OuraScopeGatedCollection[];
}
export interface OAuthProviderCardProps {
@@ -330,6 +334,14 @@ export function OAuthProviderCard({
</div>
)}
{provider === "oura" && status?.connected && (
<OuraScopeNote
collections={status.scopeReconnect}
onReconnect={handleConnect}
disabled={serverUnavailable}
/>
)}
{status?.connected && (
<MetricFreshnessDisclosure
entries={status.metricFreshness}
@@ -0,0 +1,60 @@
"use client";
import { Link2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import type { OuraScopeGatedCollection } from "@/lib/oura/scope";
/**
* A calm note on the Oura card when the stored grant does not cover some
* collections (they answer 401 while their siblings succeed). Not an error and
* not counted toward the reconnect threshold: everything else keeps syncing,
* and one reconnect asks Oura for the missing scopes.
*/
export function OuraScopeNote({
collections,
onReconnect,
disabled,
}: {
collections: readonly OuraScopeGatedCollection[] | undefined;
onReconnect: () => void;
disabled?: boolean;
}) {
const { t, locale } = useTranslations();
if (!collections || collections.length === 0) return null;
const labels: Record<OuraScopeGatedCollection, string> = {
spo2: t("settings.integrations.oura.scope.items.spo2"),
vo2max: t("settings.integrations.oura.scope.items.vo2max"),
cardio_age: t("settings.integrations.oura.scope.items.cardio_age"),
resilience: t("settings.integrations.oura.scope.items.resilience"),
};
const list = new Intl.ListFormat(locale, {
style: "long",
type: "conjunction",
}).format(collections.map((c) => labels[c]));
return (
<div
data-testid="oura-scope-note"
className="border-info/30 bg-info/10 flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm"
>
<p className="min-w-0 break-words">
{t("settings.integrations.oura.scope.body", { list })}
</p>
<Button
type="button"
size="sm"
variant="outline"
onClick={onReconnect}
disabled={disabled}
data-testid="oura-scope-reconnect"
className="min-h-11"
>
<Link2 className="h-3.5 w-3.5" />
{t("settings.integrations.oura.scope.action")}
</Button>
</div>
);
}
@@ -18,6 +18,8 @@ import type {
SyncVerdict,
} from "@/lib/integrations/sync-verdict";
import { queryKeys } from "@/lib/query-keys";
import type { WithingsPlanFeature } from "@/lib/withings/plan-features";
import type { OuraScopeGatedCollection } from "@/lib/oura/scope";
// v1.4.15 Phase B2: shared status payload for both integration cards.
// v1.4.19 Phase A5: the redundant in-card status banner is gone — the
@@ -70,6 +72,10 @@ export interface IntegrationStatusViewModel {
// Withings activity-scope reconnect banner.
scope?: string | null;
hasActivityScope?: boolean;
// Withings: readings the account's API plan withholds (server-resolved).
planUnavailable?: WithingsPlanFeature[];
// Oura: collections the stored grant does not cover (server-resolved).
scopeReconnect?: OuraScopeGatedCollection[];
// WHOOP / Fitbit backfill-in-progress note.
backfillCompleted?: boolean | null;
// v1.27.0 — Google Health surfaces `needsReauth` when the refresh token has
@@ -49,6 +49,7 @@ import {
type IntegrationStatusViewModel,
} from "./shared";
import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure";
import { WithingsPlanNote } from "./withings-plan-note";
import {
readSyncOutcome,
useSyncOutcomeMessage,
@@ -316,6 +317,10 @@ export function WithingsCard({
</p>
)}
{status?.connected && (
<WithingsPlanNote features={status.planUnavailable} />
)}
{status?.connected && (
<MetricFreshnessDisclosure
entries={viewModel?.metricFreshness}
@@ -0,0 +1,84 @@
"use client";
import { ExternalLink } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import {
WITHINGS_API_PLANS_URL,
type WithingsPlanFeature,
} from "@/lib/withings/plan-features";
/**
* A calm note on the Withings card when the account's API plan withholds some
* readings (status 277 since the 2026-10-12 plan split). Not an error: the
* sync skips those readings and keeps importing the rest, so this sits in the
* card body in the info wash, never in the pill.
*/
export function WithingsPlanNote({
features,
}: {
features: readonly WithingsPlanFeature[] | undefined;
}) {
const { t, locale } = useTranslations();
if (!features || features.length === 0) return null;
const labels: Record<WithingsPlanFeature, string> = {
spo2: t("settings.integrations.withings.plan.items.spo2"),
vascularAge: t("settings.integrations.withings.plan.items.vascularAge"),
pulseWaveVelocity: t(
"settings.integrations.withings.plan.items.pulseWaveVelocity",
),
vo2Max: t("settings.integrations.withings.plan.items.vo2Max"),
visceralFat: t("settings.integrations.withings.plan.items.visceralFat"),
skinTemperature: t(
"settings.integrations.withings.plan.items.skinTemperature",
),
ecg: t("settings.integrations.withings.plan.items.ecg"),
sleepScore: t("settings.integrations.withings.plan.items.sleepScore"),
sleepHrv: t("settings.integrations.withings.plan.items.sleepHrv"),
sleepHeartRate: t(
"settings.integrations.withings.plan.items.sleepHeartRate",
),
sleepRespiratoryRate: t(
"settings.integrations.withings.plan.items.sleepRespiratoryRate",
),
sleepSpo2: t("settings.integrations.withings.plan.items.sleepSpo2"),
bodyTemperature: t(
"settings.integrations.withings.plan.items.bodyTemperature",
),
bodyFat: t("settings.integrations.withings.plan.items.bodyFat"),
fatMass: t("settings.integrations.withings.plan.items.fatMass"),
fatFreeMass: t("settings.integrations.withings.plan.items.fatFreeMass"),
muscleMass: t("settings.integrations.withings.plan.items.muscleMass"),
bodyWater: t("settings.integrations.withings.plan.items.bodyWater"),
boneMass: t("settings.integrations.withings.plan.items.boneMass"),
heartRate: t("settings.integrations.withings.plan.items.heartRate"),
bloodPressure: t("settings.integrations.withings.plan.items.bloodPressure"),
weight: t("settings.integrations.withings.plan.items.weight"),
};
const list = new Intl.ListFormat(locale, {
style: "long",
type: "conjunction",
}).format(features.map((f) => labels[f]));
return (
<div
data-testid="withings-plan-note"
className="border-info/30 bg-info/10 space-y-1 rounded-md border px-3 py-2 text-sm"
>
<p className="font-medium">
{t("settings.integrations.withings.plan.title")}
</p>
<p>{t("settings.integrations.withings.plan.body", { list })}</p>
<a
href={WITHINGS_API_PLANS_URL}
target="_blank"
rel="noopener noreferrer"
className="text-primary inline-flex items-center gap-1 text-xs font-medium underline-offset-2 hover:underline"
>
{t("settings.integrations.withings.plan.link")}
<ExternalLink className="h-3 w-3" aria-hidden="true" />
</a>
</div>
);
}