Merge branch 'v1424/cards' into release/v1.42.4

This commit is contained in:
Marc-André Bombeck committed 2026-10-15 15:22:12 +02:00
commit f95c2bfd41
60 files changed
+5248 -5087

No files matched your search

@@ -126,12 +126,10 @@ const NO_REFETCH_ALLOWLIST = [
// Background integration-sync completion on the Settings surface — the user
// is configuring an integration, not acting on the hero; convergence via the
// daily reads' own poll/focus refetch is the intended behaviour.
"components/settings/integrations/fitbit-card.tsx",
"components/settings/integrations/google-health-card.tsx",
"components/settings/integrations/integration-actions.tsx",
"components/settings/integrations/nightscout-card.tsx",
"components/settings/integrations/oauth-provider-card.tsx",
"components/settings/integrations/whoop-card.tsx",
"components/settings/integrations/withings-card.tsx",
"components/settings/integrations/oauth-integration-card.tsx",
// Cycle writes: `cycleDependentKeys` carries no daily-read key and neither
// the dashboard snapshot nor the Today digest reads cycle rows today, so
// the forced refetch would be a no-op round-trip. The bundle is in the
+18 -14
View File
@@ -616,21 +616,25 @@ const REGISTRY: DestructiveEntry[] = [
},
// ── Integrations ────────────────────────────────────────────────────────
...(
[
["withings-card.tsx", "Withings"],
["fitbit-card.tsx", "Fitbit"],
["whoop-card.tsx", "WHOOP"],
["google-health-card.tsx", "Google Health"],
["nightscout-card.tsx", "Nightscout"],
["oauth-provider-card.tsx", "the generic OAuth providers"],
] as const
).map(([f, name]): DestructiveEntry => ({
file: `components/settings/integrations/${f}`,
destroys: `the stored ${name} credentials — reconnecting means authorising again`,
// Every connection renders the shared integration card, whose three-dot
// menu raises the confirmation for the destructive entry; the controllers
// below only hold the request.
{
file: "components/settings/integrations/oauth-integration-card.tsx",
destroys:
"the stored Withings, WHOOP, Fitbit, Google Health, Polar, Oura or Strava grant — reconnecting means authorising again",
triggers: ["components/settings/integrations/integration-card.tsx"],
recovery: "permanent",
confirm: ["AlertDialog"],
})),
confirm: ["ConfirmDialog"],
},
{
file: "components/settings/integrations/nightscout-card.tsx",
destroys:
"the stored Nightscout address and token — reconnecting means typing them again",
triggers: ["components/settings/integrations/integration-card.tsx"],
recovery: "permanent",
confirm: ["ConfirmDialog"],
},
{
file: "components/settings/integrations/document-sources-card.tsx",
destroys:
@@ -441,7 +441,7 @@ function assertBucketConsumerLineage(
const declaration = file.statements.find(
(statement): statement is ts.FunctionDeclaration =>
ts.isFunctionDeclaration(statement) &&
statement.name?.text === "pillFailurePropsFor",
statement.name?.text === "failureStreakFor",
);
expect(declaration?.body).toBeDefined();
let bucketInitializer: string | null = null;
@@ -473,7 +473,7 @@ function assertBucketConsumerLineage(
for (const kind of kinds) {
expect(
accesses,
`pillFailurePropsFor must read buckets.${kind} from that response value`,
`failureStreakFor must read buckets.${kind} from that response value`,
).toContain(`buckets.${kind}`);
}
}
+17 -29
View File
@@ -369,20 +369,26 @@ const PINNED_AFFORDANCES: Record<
"src/components/settings/integrations/connections-panel.tsx": {
"toast.success": 2,
},
"src/components/settings/integrations/fitbit-card.tsx": { "text-success": 2 },
"src/components/settings/integrations/google-health-card.tsx": {
// The saved-credentials confirmation line.
"src/components/settings/integrations/credentials-form.tsx": {
"text-success": 1,
},
// "Test connection" from the menu: the same ok line the button shows.
"src/components/settings/integrations/integration-actions.tsx": {
CheckCircle2: 2,
"text-success": 1,
},
// The "Working" badge tone, and "Copied" beside the diagnostics copy button.
"src/components/settings/integrations/integration-card.tsx": {
"text-success": 2,
},
"src/components/settings/integrations/nightscout-card.tsx": {
"text-success": 1,
},
"src/components/settings/integrations/oauth-provider-card.tsx": {
// A paused connection resumed.
"src/components/settings/integrations/oauth-integration-card.tsx": {
"text-success": 1,
},
"src/components/settings/integrations/whoop-card.tsx": { "text-success": 2 },
"src/components/settings/integrations/withings-card.tsx": {
"text-success": 2,
},
"src/components/settings/mcp-section.tsx": {
"text-success": 4,
"toast.success": 2,
@@ -466,9 +472,11 @@ const RESULT_SURFACES: ReadonlyArray<{
through: /IntakeImportSkipGroups/,
forbidsMarkers: true,
},
// The seven surfaces that were still gated on the transport.
// The provider sync surfaces that were still gated on the transport. Every
// integration card reports a sync through the shared action lines; Google
// Health keeps its own because a run can outlive its request.
{
path: "src/components/settings/integrations/fitbit-card.tsx",
path: "src/components/settings/integrations/integration-actions.tsx",
through: /WrittenOutcomeLine/,
forbidsMarkers: false,
},
@@ -477,26 +485,6 @@ const RESULT_SURFACES: ReadonlyArray<{
through: /WrittenOutcomeLine/,
forbidsMarkers: false,
},
{
path: "src/components/settings/integrations/nightscout-card.tsx",
through: /WrittenOutcomeLine/,
forbidsMarkers: false,
},
{
path: "src/components/settings/integrations/whoop-card.tsx",
through: /WrittenOutcomeLine/,
forbidsMarkers: false,
},
{
path: "src/components/settings/integrations/withings-card.tsx",
through: /WrittenOutcomeLine/,
forbidsMarkers: false,
},
{
path: "src/components/settings/integrations/oauth-provider-card.tsx",
through: /WrittenOutcomeLine/,
forbidsMarkers: false,
},
{
path: "src/components/labs/ocr-review-dialog.tsx",
through: /toastWrittenOutcome/,
+3 -1
View File
@@ -30,7 +30,9 @@ const REQUIRED_KEYS = [
"common.networkError",
"settings.googleHealthSyncResult",
"settings.googleHealthSyncFailed",
"settings.googleHealthBackfillInProgress",
// `settings.googleHealthBackfillInProgress` left this list in v1.42.4: the
// history-loading line became a sentence variant of the shared integration
// card (`settings.integrationCard.reason.dataArrivingHistory`).
"settings.syncOutcome.empty",
"settings.syncOutcome.partial",
"settings.syncOutcome.failed",
@@ -644,7 +644,7 @@ const SYNC_ROUTES: readonly SyncRouteSpec[] = [
id: "fitbit",
path: join(ROOT, "src/app/api/fitbit/sync/route.ts"),
responseClass: "resolved-with-full-sync-echo",
card: join(SYNC_CARD_ROOT, "fitbit-card.tsx"),
card: join(SYNC_CARD_ROOT, "integration-actions.tsx"),
exceptions: { fullSync: FULL_SYNC_EXCEPTION },
},
{
@@ -663,42 +663,42 @@ const SYNC_ROUTES: readonly SyncRouteSpec[] = [
id: "nightscout",
path: join(ROOT, "src/app/api/nightscout/sync/route.ts"),
responseClass: "resolved",
card: join(SYNC_CARD_ROOT, "nightscout-card.tsx"),
card: join(SYNC_CARD_ROOT, "integration-actions.tsx"),
exceptions: {},
},
{
id: "oura",
path: join(ROOT, "src/app/api/oura/sync/route.ts"),
responseClass: "resolved",
card: join(SYNC_CARD_ROOT, "oauth-provider-card.tsx"),
card: join(SYNC_CARD_ROOT, "integration-actions.tsx"),
exceptions: {},
},
{
id: "polar",
path: join(ROOT, "src/app/api/polar/sync/route.ts"),
responseClass: "resolved",
card: join(SYNC_CARD_ROOT, "oauth-provider-card.tsx"),
card: join(SYNC_CARD_ROOT, "integration-actions.tsx"),
exceptions: {},
},
{
id: "strava",
path: join(ROOT, "src/app/api/strava/sync/route.ts"),
responseClass: "resolved",
card: join(SYNC_CARD_ROOT, "oauth-provider-card.tsx"),
card: join(SYNC_CARD_ROOT, "integration-actions.tsx"),
exceptions: {},
},
{
id: "whoop",
path: join(ROOT, "src/app/api/whoop/sync/route.ts"),
responseClass: "resolved-with-full-sync-echo",
card: join(SYNC_CARD_ROOT, "whoop-card.tsx"),
card: join(SYNC_CARD_ROOT, "integration-actions.tsx"),
exceptions: { fullSync: FULL_SYNC_EXCEPTION },
},
{
id: "withings",
path: join(ROOT, "src/app/api/withings/sync/route.ts"),
responseClass: "resolved-with-full-sync-echo",
card: join(SYNC_CARD_ROOT, "withings-card.tsx"),
card: join(SYNC_CARD_ROOT, "integration-actions.tsx"),
exceptions: { fullSync: FULL_SYNC_EXCEPTION },
},
] as const;
@@ -908,7 +908,7 @@ function assertSyncCardConsumesFetchedResponse(
return;
}
const endpoint = responseInitializer.arguments[0];
const expectedEndpoint = path.endsWith("oauth-provider-card.tsx")
const expectedEndpoint = path.endsWith("integration-actions.tsx")
? "`/api/${provider}/sync`"
: `"/api/${routeId}/sync"`;
expect(
@@ -9,6 +9,7 @@ import {
getIntegrationStatus,
type IntegrationKey,
} from "@/lib/integrations/status";
import { loadConnectionHealth } from "@/lib/integrations/connection-health-load";
import { annotate } from "@/lib/logging/context";
import {
resolveGuardianRecordSettingsAccess,
@@ -70,6 +71,13 @@ export const GET = apiHandler(async () => {
oura: context.user.ouraAccessTokenEncrypted !== null,
strava: context.user.stravaAccessTokenEncrypted !== null,
};
// The one server-resolved state per connection, from the same loader the
// owner's own envelope uses, so a guardian reads the record's connections in
// the words the record's owner would.
const health = await loadConnectionHealth(
access.recordId,
MANAGED_INTEGRATIONS,
);
const integrations = statuses.map((status) => ({
integration: status.integration,
state: resolveManagedIntegrationState(
@@ -79,6 +87,7 @@ export const GET = apiHandler(async () => {
),
lastSuccessAt: status.lastSuccessAt,
lastAttemptAt: status.lastAttemptAt,
health: health[status.integration],
}));
annotate({ action: { name: "record-settings.integrations.get" } });
@@ -1,28 +1,17 @@
/**
* v1.4.19 Phase A5 — Settings → Integrations status consolidation.
* Settings → Integrations: one status display per card.
*
* The maintainer's verbatim concern (paraphrased):
* - Withings card had 3 status displays — top-right "connected"
* badge, mid-card "connected / last successful / last attempt"
* trio container, and the badge inside that container.
* - Mood Log card had 4 — same as Withings PLUS a "letzter Sync"
* line at the very bottom.
* v1.4.19 consolidated three and four status surfaces per card into one pill.
* v1.42.4 replaces the pill with the shared card's badge, rendered from the
* server's `health`, and removes what had grown back around it: the raw
* provider error line, the failure counter, the per-provider banners.
*
* After A5: each card has EXACTLY ONE status display (the pill, top
* right). Error context (the failure message itself, when actionable)
* is allowed to surface below the pill since it carries information
* the pill can't fit. Timestamp repetition is gone.
*
* The tests pin:
* 1. Healthy Withings + Mood Log: ONE pill per card, no banner.
* 2. Withings transient error: ONE pill per card + error message,
* NO redundant "{n}/{threshold} consecutive failures" badge,
* NO "Last successful sync / Last attempt" trio.
* 3. Withings reauth: pill carries the "Error — reconnect" label.
* 4. Mood Log disconnected: pill says "Not connected", no other
* status display surfaces.
* 5. no card paints a divider under its header
* — consistency the maintainer explicitly called out.
* The tests pin, on the whole section:
* 1. one badge per connected card, and nothing else that states a status;
* 2. the stored provider error text never reaches the page;
* 3. a paused connection offers exactly one Resume;
* 4. no card paints a divider under its header;
* 5. every not-connected card links its setup guide.
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
@@ -104,227 +93,162 @@ function count(html: string, needle: string): number {
return n;
}
describe("IntegrationsSection — single-status-display contract (A5)", () => {
const PROVIDERS = [
"withings",
"whoop",
"fitbit",
"google-health",
"polar",
"oura",
"strava",
"nightscout",
] as const;
function health(overrides: Record<string, unknown> = {}) {
return {
state: "ok",
reason: "data_arriving",
since: null,
action: null,
unavailable: [],
lastSuccessAt: "2026-05-09T18:00:00.000Z",
lastDataAt: "2026-05-09T17:00:00.000Z",
diagnostics: {
ledgerState: "connected",
failingLegs: [],
failingSince: null,
lastAttemptAt: "2026-05-09T18:00:00.000Z",
verdict: "fresh",
recentCalls: [],
},
...overrides,
};
}
function disconnected(integration: string) {
return {
integration,
lastError: null,
connected: false,
configured: false,
available: false,
health: null,
};
}
/** The Withings card's markup, up to the next card. */
function withingsCard(html: string): string {
const start = html.indexOf('data-testid="withings-card"');
const next = html.indexOf('data-slot="integration-card"', start + 1);
return html.slice(start, next === -1 ? undefined : next);
}
/** Withings as given, every other provider present and not connected. */
function envelopeWith(withings: Record<string, unknown>) {
return {
threshold: 3,
integrations: [
{
integration: "withings",
lastError: null,
connected: true,
configured: true,
...withings,
},
...PROVIDERS.filter((p) => p !== "withings").map(disconnected),
],
};
}
describe("IntegrationsSection — one status display per card", () => {
beforeEach(() => {
integrationStatusPayload = null;
});
it("renders exactly ONE status pill per card when both integrations are healthy", () => {
setIntegrationStatus({
threshold: 3,
integrations: [
{
integration: "withings",
state: "connected",
lastSuccessAt: "2026-05-09T18:00:00.000Z",
lastAttemptAt: "2026-05-09T18:00:00.000Z",
lastError: null,
connected: true,
configured: true,
legacyLastSyncedAt: "2026-05-09T18:00:00.000Z",
hasActivityScope: true,
syncHealth: { verdict: "fresh", since: null },
},
],
});
it("renders exactly one badge for the connected card and no other status surface", () => {
setIntegrationStatus(envelopeWith({ health: health() }));
const html = render();
// Exactly one pill per card → 8 (Withings, WHOOP, Fitbit, Google Health,
// Polar, Oura, Strava, Nightscout). The Garmin info note carries no pill;
// the Apple Health card's pill keeps its own testid.
expect(count(html, 'data-testid="integration-status-pill"')).toBe(8);
// The redundant banner from v1.4.15 is gone.
expect(count(html, 'data-slot="integration-health-badge"')).toBe(1);
expect(html).not.toContain('data-testid="integration-status-pill"');
expect(html).not.toContain('data-testid="integration-status-banner"');
// Card-body "letzter Sync" repetition is gone — no
// "Last sync:" label outside the pill.
expect(html).not.toContain("Last sync:");
// No "Last successful sync" / "Last attempt" trio.
expect(html).not.toContain("Last successful sync");
expect(html).not.toContain("Last attempt");
// The seven other providers and Apple Health are not connected and say so
// once each.
expect(count(html, ">Not connected.<")).toBe(8);
});
it("Withings transient error keeps the pill + error text but drops the {n}/{threshold} duplicate badge", () => {
setIntegrationStatus({
threshold: 3,
integrations: [
{
integration: "withings",
state: "error_transient",
lastSuccessAt: "2026-05-09T08:00:00.000Z",
lastAttemptAt: "2026-05-09T18:00:00.000Z",
lastError: "Withings refresh error: 503 - upstream",
connected: true,
configured: true,
legacyLastSyncedAt: "2026-05-09T08:00:00.000Z",
hasActivityScope: true,
syncHealth: { verdict: "failing", since: "2026-05-09T08:00:00.000Z" },
it("never shows the stored provider error or a failure counter", () => {
setIntegrationStatus(
envelopeWith({
lastError: "Withings refresh error: 503 - upstream",
consecutiveFailuresByKind: {
transient: 2,
reauth_required: 0,
persistent: 0,
},
],
});
health: health({
state: "degraded",
reason: "provider_errors",
since: "2026-05-09T08:00:00.000Z",
}),
}),
);
const html = render();
// A 503 is not the user's to fix, so the pill carries the warning tone —
// red is reserved for "reconnect actually helps".
expect(html).toContain('data-state="warning"');
expect(html).not.toContain('data-state="error"');
// Actionable error message still surfaces (we kept it because
// the pill can't fit "Withings refresh error: 503 - upstream").
expect(html).toContain("Withings refresh error: 503 - upstream");
// …but the consecutive-failure badge that doubled the pill's
// signal is gone.
expect(html).toMatch(
/data-slot="integration-health-badge"[^>]*data-state="degraded"/,
);
expect(html).not.toContain("Withings refresh error");
expect(html).not.toContain("2/3 consecutive failures");
expect(html).not.toContain("Last successful sync");
expect(html).not.toContain("Last attempt");
// Not the user's to fix: the Withings card offers no button.
expect(withingsCard(html)).toContain('data-testid="withings-menu"');
expect(withingsCard(html)).not.toContain(
'data-slot="integration-primary-action"',
);
});
it("Withings reauth state surfaces 'Error — reconnect' on the pill (the maintainer's wording)", () => {
setIntegrationStatus({
threshold: 3,
integrations: [
{
integration: "withings",
state: "error_reauth",
lastSuccessAt: "2026-05-08T12:00:00.000Z",
lastAttemptAt: "2026-05-09T18:00:00.000Z",
lastError: "Withings refresh error: 100 - invalid_grant",
connected: true,
configured: true,
legacyLastSyncedAt: "2026-05-08T12:00:00.000Z",
tokenExpired: true,
hasActivityScope: true,
syncHealth: {
verdict: "reauth_required",
since: "2026-05-09T18:00:00.000Z",
},
},
],
});
it("a paused connection offers exactly one Resume", () => {
setIntegrationStatus(
envelopeWith({
lastError: "Withings activity error: 293",
health: health({
state: "action_required",
reason: "paused_after_errors",
since: "2026-05-09T18:00:00.000Z",
action: "resume",
}),
}),
);
const html = render();
expect(html).toContain('data-state="error"');
// Old "Reconnect required" banner is gone — the pill is now
// the canonical surface.
expect(html).not.toContain('data-testid="integration-status-banner"');
});
it("disconnected state shows 'Not connected' on the pill, no extra status surfaces", () => {
setIntegrationStatus({
threshold: 3,
integrations: [
{
integration: "withings",
state: "disconnected",
lastSuccessAt: "2026-05-08T10:00:00.000Z",
lastAttemptAt: "2026-05-08T10:00:00.000Z",
lastError: null,
syncHealth: { verdict: "disconnected", since: null },
},
],
});
const html = render();
expect(html).toContain("Not connected");
expect(html).not.toContain('data-testid="integration-status-banner"');
// No trailing "letzter Sync" line outside the pill.
expect(html).not.toContain("Last sync:");
});
// v1.4.43 W14 — parked-state surface check.
// The pill flips to `data-state="parked"` and the resume banner
// renders with the reconnect CTA. The legacy `error` reconnect text
// (red) must NOT appear because the user-facing copy is the lighter
// "Paused: reconnect" string, not "Error — reconnect".
it("Withings parked state surfaces the parked pill + resume CTA", () => {
setIntegrationStatus({
threshold: 3,
integrations: [
{
integration: "withings",
state: "parked",
lastSuccessAt: "2026-05-08T12:00:00.000Z",
lastAttemptAt: "2026-05-09T18:00:00.000Z",
lastError: "Withings activity error: 293",
connected: true,
configured: true,
legacyLastSyncedAt: "2026-05-08T12:00:00.000Z",
hasActivityScope: true,
syncHealth: { verdict: "parked", since: "2026-05-09T18:00:00.000Z" },
},
],
});
const html = render();
expect(html).toContain('data-state="parked"');
expect(html).toContain("Paused");
expect(html).toContain("Paused: reconnect");
expect(html).toContain('data-testid="withings-parked-banner"');
expect(html).toMatch(
/data-slot="integration-health-badge"[^>]*data-state="action_required"/,
);
expect(html).toContain('data-testid="withings-resume-button"');
// The lastError is surfaced under the pill so the operator sees
// the contract-mismatch reason without opening Audit Log.
expect(html).toContain("Withings activity error: 293");
expect(count(html, 'data-action="resume"')).toBe(1);
expect(html).not.toContain('data-testid="withings-parked-banner"');
expect(html).not.toContain("Withings activity error: 293");
});
it("no integration card paints a divider under its header", () => {
setIntegrationStatus({
threshold: 3,
integrations: [
{
integration: "withings",
state: "connected",
lastSuccessAt: "2026-05-09T18:00:00.000Z",
lastAttemptAt: "2026-05-09T18:00:00.000Z",
lastError: null,
connected: true,
configured: true,
legacyLastSyncedAt: "2026-05-09T18:00:00.000Z",
hasActivityScope: true,
syncHealth: { verdict: "fresh", since: null },
},
],
});
const html = render();
// The `<hr>` under the header existed only in `integrations/` — nine
// cards carrying a rule no other Settings card has. The card's own gap
// does the separating now, identically here and everywhere else.
expect(count(html, 'data-testid="integration-card-divider"')).toBe(0);
setIntegrationStatus(envelopeWith({ health: health() }));
expect(count(render(), 'data-testid="integration-card-divider"')).toBe(0);
});
// v1.17.1 — every integration card carries the same discreet "Setup guide"
// doc-link pointing at its runbook under the single shared docs base. The
// affordance is one family across all six providers.
it("every integration card carries the shared Setup-guide doc-link", () => {
// Every card that is not connected yet carries the same discreet "Setup
// guide" link to its runbook under the single shared docs base. Once
// connected, the link moves into the card's menu.
it("every not-connected integration card carries the shared Setup-guide link", () => {
setIntegrationStatus({
threshold: 3,
integrations: [
{
integration: "withings",
state: "connected",
lastSuccessAt: "2026-05-09T18:00:00.000Z",
lastAttemptAt: "2026-05-09T18:00:00.000Z",
lastError: null,
connected: true,
configured: true,
legacyLastSyncedAt: "2026-05-09T18:00:00.000Z",
hasActivityScope: true,
},
],
integrations: PROVIDERS.map(disconnected),
});
const html = render();
// One setup-guide link per card → eight providers (the Garmin info note is
// not an OAuth card and carries its own docs link, not a setup-guide).
expect(count(html, 'data-slot="integration-setup-guide"')).toBe(8);
for (const provider of [
"withings",
"whoop",
"fitbit",
"google-health",
"polar",
"oura",
"strava",
"nightscout",
]) {
for (const provider of PROVIDERS) {
expect(html).toContain(`data-testid="${provider}-setup-guide"`);
expect(html).toContain(
`href="https://docs.healthlog.dev/integrations/${provider}"`,
@@ -0,0 +1,133 @@
/**
* A managed record's connections on the shared integration card, read-only.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import type { ConnectionHealth } from "@/lib/integrations/connection-health";
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: {
id: "guardian",
accountAccess: {
active: {
accountId: "record-1",
recordKind: "managed",
displayName: "Managed record",
},
},
},
isAuthenticated: true,
isLoading: false,
}),
}));
let payload: unknown;
let loading = false;
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({
data: payload,
isLoading: loading,
isError: false,
refetch: vi.fn(),
}),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { ManagedIntegrationStatus } from "../managed-integration-status";
import { healthFixture } from "../integrations/__tests__/health-fixture";
const KEYS = [
"withings",
"whoop",
"fitbit",
"nightscout",
"polar",
"oura",
"google-health",
"strava",
];
function render() {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<ManagedIntegrationStatus />
</I18nProvider>,
);
}
function withHealth(map: Record<string, ConnectionHealth | null>) {
return {
recordId: "record-1",
integrations: KEYS.map((integration) => ({
integration,
// The legacy fields still ride the wire; the view must not use them.
state: "connected",
lastSuccessAt: null,
lastAttemptAt: null,
health: map[integration] ?? null,
})),
};
}
describe("<ManagedIntegrationStatus>", () => {
beforeEach(() => {
payload = undefined;
loading = false;
});
it("renders one shared card per provider from health, with no control on any", () => {
payload = withHealth({
oura: healthFixture({
state: "action_required",
reason: "reauth_required",
action: "reconnect",
}),
withings: healthFixture(),
});
const html = render();
expect(html).toContain('data-record-settings-family="integrations"');
expect(html).toContain(
'aria-labelledby="managed-integration-status-title"',
);
expect(html).toContain('data-record-id="record-1"');
expect(html.match(/data-slot="integration-card"/g)).toHaveLength(8);
for (const key of KEYS) {
expect(html).toContain(`data-testid="managed-integration-${key}"`);
}
expect(html).toMatch(
/data-testid="managed-integration-oura"[\s\S]*?data-slot="integration-health-badge"[^>]*data-state="action_required"/,
);
// Read-only even where the state asks for an action.
expect(html).not.toContain('data-slot="integration-primary-action"');
expect(html).not.toContain('data-slot="integration-menu-trigger"');
// The legacy `state: "connected"` on every row does not make a card
// connected; only `health` does.
expect(html.match(/>Not connected\.</g)).toHaveLength(6);
});
it("says once why nothing here can be changed, and links the explanation", () => {
payload = withHealth({});
const html = render();
expect(html).toContain('data-slot="managed-integration-read-only"');
expect(html).toContain(
"A managed profile shows its connected services as a status only.",
);
expect(html).toContain(
'href="https://docs.healthlog.dev/self-hosting/account-sharing#records-with-no-login"',
);
});
it("paints the cards while loading without claiming a state", () => {
loading = true;
const html = render();
expect(html).toContain('aria-busy="true"');
expect(html).not.toContain(
'aria-labelledby="managed-integration-status-title"',
);
expect(html).not.toContain('data-slot="integration-health-badge"');
expect(html).not.toContain("Not connected.");
});
});
@@ -14,7 +14,7 @@ type Payload = {
lastSyncedAt: string | null;
lastSyncTrigger?: "foreground" | "background" | "push" | null;
lastBackgroundSyncAt?: string | null;
syncHealth?: { verdict: string; since: string | null };
health?: ConnectionHealth | null;
metricFreshness?: Array<{
type: string;
lastSeenAt: string;
@@ -38,7 +38,10 @@ vi.mock("@tanstack/react-query", () => ({
}),
}));
import type { ConnectionHealth } from "@/lib/integrations/connection-health";
import { AppleHealthCard, ArrivalDetail } from "../apple-health-card";
import { healthFixture } from "./health-fixture";
function render(locale: Locale = "en") {
return renderToStaticMarkup(
@@ -56,76 +59,62 @@ describe("<AppleHealthCard>", () => {
});
it("explains that live sync uses the iOS app rather than OAuth", () => {
statusPayload = {
lastSyncedAt: null,
syncHealth: { verdict: "pending_first_sync", since: null },
};
statusPayload = { lastSyncedAt: null, health: null };
const html = render();
expect(html).toContain('data-testid="apple-health-card"');
expect(html).toContain('data-state="not_connected"');
expect(html).toContain("HealthLog iOS app");
expect(html).toContain("not from a web or OAuth connection");
expect(html).toContain("Settings → Apple Health");
expect(html).toContain("when iOS grants background time");
expect(html).not.toContain(">Connected<");
expect(html).toContain("Not connected.");
expect(html).not.toContain('data-slot="integration-health-badge"');
});
/**
* The defect this card carried: any `lastSyncedAt` at all painted the green
* "data received" chip, so a pipe dead for three weeks looked exactly like
* one that delivered this morning. The verdict now decides.
*/
it("shows a week-old delivery as stale, not as data received", () => {
it("says when data stopped and what to do, from the server's health", () => {
const eightDaysAgo = new Date(
Date.now() - 8 * 24 * 60 * 60 * 1000,
).toISOString();
statusPayload = {
lastSyncedAt: eightDaysAgo,
syncHealth: { verdict: "stale", since: eightDaysAgo },
health: healthFixture({
state: "degraded",
reason: "no_new_data",
since: eightDaysAgo,
lastDataAt: eightDaysAgo,
}),
};
const html = render();
expect(html).toContain('data-testid="apple-health-status"');
expect(html).toContain('data-state="stale"');
expect(html).toContain("no recent data");
// And it says what to do about it.
expect(html).toContain('data-testid="apple-health-stale"');
expect(html).not.toContain('data-state="connected"');
expect(html).toMatch(
/data-slot="integration-health-badge"[^>]*data-state="degraded"/,
);
expect(html).toContain("Delayed");
// Apple Health's own variant: the iPhone app is what moves the data.
expect(html).toContain("Open HealthLog on your iPhone once.");
});
it("shows a recent delivery as connected with its relative time", () => {
it("reads a recent delivery as working", () => {
const anHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
statusPayload = {
lastSyncedAt: anHourAgo,
syncHealth: { verdict: "fresh", since: null },
health: healthFixture({ lastDataAt: anHourAgo }),
};
const html = render();
expect(html).toContain('data-state="connected"');
expect(html).toMatch(/1\s+h\s+ago/);
expect(html).not.toContain('data-testid="apple-health-stale"');
expect(html).toContain('data-state="ok"');
expect(html).toContain("Data is arriving.");
expect(html).toContain("Last data: today,");
});
it("waits for the first delivery rather than claiming a connection", () => {
statusPayload = {
lastSyncedAt: null,
syncHealth: { verdict: "pending_first_sync", since: null },
};
const html = render();
expect(html).toContain('data-state="pending-setup"');
expect(html).toContain("Waiting for first data");
expect(html).not.toContain('data-state="connected"');
});
it("renders per-metric freshness so one dead permission is visible", () => {
it("keeps per-metric freshness in the details so one dead permission stays findable", () => {
statusPayload = {
lastSyncedAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
syncHealth: { verdict: "fresh", since: null },
health: healthFixture(),
metricFreshness: [
{
type: "RESPIRATORY_RATE",
@@ -143,26 +132,14 @@ describe("<AppleHealthCard>", () => {
};
const html = render();
const details = html.slice(html.indexOf('data-slot="integration-details"'));
expect(html).toContain('data-slot="metric-freshness"');
// The collapsed summary already names the problem — the signal must not
// hide behind a click.
expect(html).toContain("quiet");
});
it("carries no card divider — the card gap does that job", () => {
statusPayload = {
lastSyncedAt: null,
syncHealth: { verdict: "pending_first_sync", since: null },
};
expect(render()).not.toContain('data-testid="integration-card-divider"');
expect(details).toContain('data-slot="metric-freshness"');
expect(details).toContain("1 quiet");
});
it("links to the existing one-shot Apple Health import fallback", () => {
statusPayload = {
lastSyncedAt: null,
syncHealth: { verdict: "pending_first_sync", since: null },
};
statusPayload = { lastSyncedAt: null, health: null };
const html = render();
@@ -173,14 +150,15 @@ describe("<AppleHealthCard>", () => {
expect(html).toContain("Open one-shot import");
});
it("claims no status at all while the read is in flight", () => {
it("paints the card and its header while the read is in flight, and no state", () => {
statusPayload = undefined;
statusLoading = true;
const html = render();
expect(html).not.toContain('data-testid="apple-health-status"');
expect(html).not.toContain("Connected");
expect(html).toContain('data-state="loading"');
expect(html).not.toContain('data-slot="integration-health-badge"');
expect(html).not.toContain("Not connected.");
});
it("treats a failed status read as an error line, not a status", () => {
@@ -189,9 +167,8 @@ describe("<AppleHealthCard>", () => {
const html = render();
expect(html).toContain("Apple Health status unavailable");
expect(html).toContain('data-testid="integration-error-message"');
expect(html).not.toContain('data-testid="apple-health-status"');
expect(html).not.toContain("Connected");
expect(html).not.toContain('data-slot="integration-health-badge"');
expect(html).not.toContain("Not connected.");
});
it("defines every card key in all six locale catalogs", () => {
@@ -203,7 +180,6 @@ describe("<AppleHealthCard>", () => {
"backgroundStep",
"importNote",
"importAction",
"staleNote",
] as const;
for (const catalog of [en, de, es, fr, itMessages, pl]) {
@@ -212,8 +188,6 @@ describe("<AppleHealthCard>", () => {
expect(appleHealth[key]).toBeTypeOf("string");
expect(appleHealth[key].length).toBeGreaterThan(0);
}
// The status copy the pill now owns is gone from the catalog; only the
// failed-read line survives here.
expect(Object.keys(appleHealth.status).sort()).toEqual(["unavailable"]);
}
});
@@ -221,10 +195,7 @@ describe("<AppleHealthCard>", () => {
it.each(["en", "de", "es", "fr", "it", "pl", "ko"] as const)(
"renders localized Apple Health copy for %s without leaking keys",
(locale) => {
statusPayload = {
lastSyncedAt: null,
syncHealth: { verdict: "pending_first_sync", since: null },
};
statusPayload = { lastSyncedAt: null, health: null };
const html = render(locale);
@@ -246,7 +217,7 @@ describe("<AppleHealthCard> — delivery diagnostic (#586)", () => {
lastSyncedAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
lastSyncTrigger: "background",
lastBackgroundSyncAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
syncHealth: { verdict: "fresh", since: null },
health: healthFixture(),
};
const html = render();
@@ -261,7 +232,7 @@ describe("<AppleHealthCard> — delivery diagnostic (#586)", () => {
lastSyncedAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
lastSyncTrigger: "foreground",
lastBackgroundSyncAt: null,
syncHealth: { verdict: "fresh", since: null },
health: healthFixture(),
};
const html = render();
@@ -277,7 +248,7 @@ describe("<AppleHealthCard> — delivery diagnostic (#586)", () => {
lastSyncedAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
lastSyncTrigger: null,
lastBackgroundSyncAt: null,
syncHealth: { verdict: "fresh", since: null },
health: healthFixture(),
};
const html = render();
@@ -287,15 +258,16 @@ describe("<AppleHealthCard> — delivery diagnostic (#586)", () => {
);
});
it("says there is nothing to report when no sync has ever arrived", () => {
it("shows the setup steps instead of an empty delivery report before any sync", () => {
statusPayload = {
lastSyncedAt: null,
syncHealth: { verdict: "pending_first_sync", since: null },
health: null,
};
const html = render();
expect(html).toContain("No sync has arrived yet");
expect(html).toContain("Turn on live sync");
expect(html).not.toContain('data-testid="apple-health-delivery"');
expect(html).not.toContain("Last background sync");
});
});
@@ -312,7 +284,7 @@ describe("<AppleHealthCard> — sync progress (#778)", () => {
it("shows the accepted-row count and the oldest reading reached", () => {
statusPayload = {
lastSyncedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
syncHealth: { verdict: "fresh", since: null },
health: healthFixture(),
syncProgress: {
recordsAccepted: 12345,
oldestMeasuredAt: "2019-05-04T06:00:00.000Z",
@@ -334,7 +306,7 @@ describe("<AppleHealthCard> — sync progress (#778)", () => {
it("reads as flowing while batches keep arriving", () => {
statusPayload = {
lastSyncedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
syncHealth: { verdict: "fresh", since: null },
health: healthFixture(),
syncProgress: { recordsAccepted: 100, oldestMeasuredAt: null },
};
@@ -348,7 +320,7 @@ describe("<AppleHealthCard> — sync progress (#778)", () => {
it("waits honestly instead of inventing throttle state when batches pause", () => {
statusPayload = {
lastSyncedAt: new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString(),
syncHealth: { verdict: "fresh", since: null },
health: healthFixture(),
syncProgress: { recordsAccepted: 100, oldestMeasuredAt: null },
};
@@ -364,7 +336,7 @@ describe("<AppleHealthCard> — sync progress (#778)", () => {
it("renders no progress section before anything has arrived", () => {
statusPayload = {
lastSyncedAt: null,
syncHealth: { verdict: "pending_first_sync", since: null },
health: null,
syncProgress: { recordsAccepted: 0, oldestMeasuredAt: null },
};
@@ -377,7 +349,7 @@ describe("<AppleHealthCard> — sync progress (#778)", () => {
it("renders no progress section when the server read failed", () => {
statusPayload = {
lastSyncedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
syncHealth: { verdict: "fresh", since: null },
health: healthFixture(),
syncProgress: null,
};
@@ -472,7 +444,7 @@ describe("<AppleHealthCard> — per-type arrivals (#1173)", () => {
it("titles the list by data type", () => {
statusPayload = {
lastSyncedAt: hoursAgo(1),
syncHealth: { verdict: "fresh", since: null },
health: healthFixture(),
metricFreshness: [{ ...base }],
};
expect(render()).toContain("By data type");
@@ -0,0 +1,160 @@
/**
* The integration cards render the server's `health` and nothing else.
*
* Before v1.42.4 each card combined the ledger state, the liveness verdict,
* token expiry, plan and scope lists and a reauth flag in its own way, so one
* fact could read as a red error on one card and a footnote on the next. The
* server now folds all of it into `health`, and this pins that no card under
* this folder starts reading the old fields again, which is the one way a
* second status dialect could come back. The managed-profile status view is
* held to the same rule.
*/
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import ts from "typescript";
import { walkSourceFiles } from "@/__tests__/helpers/source-files";
import { failureStreakFor, pickStatus } from "../shared";
describe("failureStreakFor", () => {
it("pairs the largest per-kind streak with the envelope threshold", () => {
const status = pickStatus(
{
threshold: 3,
integrations: [
{
integration: "withings",
consecutiveFailuresByKind: {
transient: 1,
reauth_required: 2,
persistent: 0,
},
},
],
},
"withings",
);
expect(failureStreakFor(status)).toEqual({ count: 2, threshold: 3 });
});
it("reports nothing for a healthy zero-count status", () => {
const status = pickStatus(
{
threshold: 3,
integrations: [
{
integration: "withings",
consecutiveFailuresByKind: {
transient: 0,
reauth_required: 0,
persistent: 0,
},
},
],
},
"withings",
);
expect(failureStreakFor(status)).toBeNull();
});
});
describe("structural guard: cards read health, not the retired fields", () => {
const SRC = join(process.cwd(), "src");
const CARDS = "components/settings/integrations";
const MANAGED = "components/settings/managed-integration-status.tsx";
/**
* Fields the server keeps on the wire for one transition release and that
* `health` replaces. Matched as property reads in the parsed source, so a
* comment or an i18n key that happens to contain the word is not a read.
*/
const RETIRED = new Set([
"lastError",
"state",
"needsReauth",
"planUnavailable",
"scopeReconnect",
"syncHealth",
"tokenExpired",
"tokenExpiresAt",
"hasActivityScope",
"legacyLastSyncedAt",
"consecutiveFailures",
]);
/**
* `.state` is also the name of `health.state` and of a Google Health sync
* run's progress state. Those bases are the allowed readers; any other base
* reading `.state` is a ledger state finding its way back onto a card.
*/
const STATE_READERS = /(^|\.)(health|progress|syncProgress|record)$/;
const files = [
...walkSourceFiles(join(SRC, CARDS), { floor: 15 })
.filter((p) => !p.includes("__tests__") && !p.endsWith(".test.ts"))
.map((p) => `${CARDS}/${p}`),
MANAGED,
];
function retiredReads(rel: string): string[] {
const path = join(SRC, rel);
const file = ts.createSourceFile(
path,
readFileSync(path, "utf8"),
ts.ScriptTarget.Latest,
true,
path.endsWith("x") ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
);
const found: string[] = [];
function visit(node: ts.Node): void {
if (ts.isPropertyAccessExpression(node)) {
const name = node.name.text;
const base = node.expression.getText(file).replace(/\?$/, "");
if (
RETIRED.has(name) &&
!(name === "state" && STATE_READERS.test(base))
) {
found.push(`${rel}: ${base}.${name}`);
}
}
ts.forEachChild(node, visit);
}
visit(file);
return found;
}
it("walks the cards and the managed view", () => {
expect(files.length).toBeGreaterThan(10);
expect(files).toContain(`${CARDS}/integration-card.tsx`);
expect(files).toContain(`${CARDS}/oauth-integration-card.tsx`);
expect(files).toContain(MANAGED);
});
it("no card reads a retired health-shaped field", () => {
expect(files.flatMap(retiredReads)).toEqual([]);
});
it("the card data types do not declare them either", () => {
// The compiler is the stronger half of this guard: a field the view model
// does not declare cannot be read without a cast.
const shared = readFileSync(join(SRC, CARDS, "shared.tsx"), "utf8");
const viewModel = shared.slice(
shared.indexOf("export interface IntegrationStatusViewModel"),
shared.indexOf("export interface IntegrationStatusEnvelope"),
);
expect(viewModel.length).toBeGreaterThan(100);
for (const field of RETIRED) {
expect(viewModel, field).not.toMatch(new RegExp(`\\b${field}\\??:`));
}
});
it("no card paints the old status pill", () => {
const offenders = files.filter((rel) =>
/<IntegrationStatusPill\b/.test(readFileSync(join(SRC, rel), "utf8")),
);
expect(offenders).toEqual([]);
});
});
@@ -49,6 +49,7 @@ vi.mock("@tanstack/react-query", () => ({
import { I18nProvider } from "@/lib/i18n/context";
import { ConnectionsPanel } from "../connections-panel";
import { INTEGRATION_DISPLAY_NAMES } from "../integration-fallback-row";
import { healthFixture } from "./health-fixture";
const MONTH_AGO = new Date(Date.now() - 28 * 24 * 60 * 60 * 1000).toISOString();
@@ -64,14 +65,16 @@ const CARD_LESS_KEY = "example-provider";
function entry(integration: string) {
return {
integration,
state: "error_transient",
lastSuccessAt: MONTH_AGO,
lastAttemptAt: MONTH_AGO,
lastError: null,
configured: true,
connected: true,
available: true,
syncHealth: { verdict: "stalled", since: MONTH_AGO },
health: healthFixture({
state: "degraded",
reason: "sync_stalled",
since: MONTH_AGO,
lastDataAt: MONTH_AGO,
}),
metricFreshness: [],
};
}
@@ -117,7 +120,7 @@ describe("ConnectionsPanel — every envelope entry surfaces", () => {
}
});
it("gives a card-less provider a fallback row with the stalled pill", () => {
it("gives a card-less provider the shared card, read-only, with its server state", () => {
// The shape the mechanism exists for. With no display name registered the
// row falls back to the raw key, which is still an honest surface — far
// better than the provider rendering nowhere at all.
@@ -130,9 +133,15 @@ describe("ConnectionsPanel — every envelope entry surfaces", () => {
expect(html).toContain('data-testid="integration-fallback-row"');
expect(html).toContain(`data-integration="${CARD_LESS_KEY}"`);
expect(html).toContain(CARD_LESS_KEY);
expect(html).toContain('data-state="stalled"');
expect(html).toContain("Sync stopped");
expect(html).toContain("Last attempt");
const row = html.slice(
html.indexOf('data-testid="integration-fallback-row"'),
);
expect(row).toMatch(
/data-slot="integration-health-badge"[^>]*data-state="degraded"/,
);
expect(row).toContain("HealthLog has not synced since");
// Read-only: no menu and no button for a dropped integration.
expect(html).not.toContain(`data-testid="${CARD_LESS_KEY}-menu"`);
});
it("renders no fallback row when every entry is card-backed", () => {
@@ -149,12 +158,9 @@ describe("ConnectionsPanel — every envelope entry surfaces", () => {
integrations: [
{
...entry(CARD_LESS_KEY),
state: "disconnected",
lastSuccessAt: null,
lastAttemptAt: null,
configured: false,
connected: false,
syncHealth: { verdict: "disconnected", since: null },
health: null,
},
],
};
@@ -0,0 +1,25 @@
import type { ConnectionHealth } from "@/lib/integrations/connection-health";
/** A `ConnectionHealth` as the server would publish it, with overrides. */
export function healthFixture(
overrides: Partial<ConnectionHealth> = {},
): ConnectionHealth {
return {
state: "ok",
reason: "data_arriving",
since: null,
action: null,
unavailable: [],
lastSuccessAt: "2026-10-15T08:12:00.000Z",
lastDataAt: "2026-10-15T07:12:00.000Z",
diagnostics: {
ledgerState: "connected",
failingLegs: [],
failingSince: null,
lastAttemptAt: "2026-10-15T08:12:00.000Z",
verdict: "fresh",
recentCalls: [],
},
...overrides,
};
}
@@ -0,0 +1,403 @@
/**
* The shared integration card, one test per state the server can publish.
*
* Every assertion reads the markup's data attributes or the bundle's own
* sentence, never a computed state: the card renders `health` and nothing
* else, so what is checked here is that each `health` lands on the right
* badge, sentence and (at most one) button.
*/
import { describe, expect, it, vi } from "vitest";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("@tanstack/react-query", () => ({
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
}));
let lastCardProps: IntegrationCardProps | null = null;
vi.mock("../integration-card", async (importOriginal) => {
const actual = await importOriginal<typeof import("../integration-card")>();
return {
...actual,
IntegrationCard: (props: IntegrationCardProps) => {
lastCardProps = props;
return createElement(actual.IntegrationCard, props);
},
};
});
import type { Locale } from "@/lib/i18n/config";
import { I18nProvider } from "@/lib/i18n/context";
import type { ConnectionHealth } from "@/lib/integrations/connection-health";
import type { IntegrationCardProps } from "../integration-card";
import {
OAUTH_INTEGRATIONS,
OAuthIntegrationCard,
type OAuthIntegrationKey,
} from "../oauth-integration-card";
import type { IntegrationStatusViewModel } from "../shared";
import { healthFixture } from "./health-fixture";
const NOW = Date.now();
const hoursAgo = (h: number) => new Date(NOW - h * 3_600_000).toISOString();
function render(
provider: OAuthIntegrationKey,
viewModel: Partial<IntegrationStatusViewModel> | undefined,
locale: Locale = "en",
) {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<OAuthIntegrationCard
config={OAUTH_INTEGRATIONS[provider]}
viewModel={
viewModel
? ({
integration: provider,
lastError: null,
...viewModel,
} as IntegrationStatusViewModel)
: undefined
}
callbackUrl={`https://app.example/api/${provider}/callback`}
/>
</I18nProvider>,
);
}
function connected(health: ConnectionHealth, extra = {}) {
return {
connected: true,
configured: true,
available: true,
health,
...extra,
};
}
function badgeState(html: string): string | null {
return (
html.match(
/data-slot="integration-health-badge"[^>]*data-state="(\w+)"/,
)?.[1] ?? null
);
}
function primaryActions(html: string): string[] {
return [
...html.matchAll(
/data-slot="integration-primary-action"[^>]*data-action="(\w+)"/g,
),
].map((m) => m[1]);
}
describe("<IntegrationCard> per state", () => {
it("ok: Working, one sentence, last data, no button", () => {
const html = render(
"oura",
connected(healthFixture({ lastDataAt: hoursAgo(1) })),
);
expect(html).toMatch(/data-slot="integration-card"[^>]*data-state="ok"/);
expect(badgeState(html)).toBe("ok");
expect(html).toContain(">Working<");
expect(html).toContain("Data is arriving.");
expect(html).toContain('data-slot="integration-last-data"');
expect(html).toContain("Last data: today,");
expect(primaryActions(html)).toEqual([]);
// Details are always there, collapsed.
expect(html).toContain('data-slot="integration-details-toggle"');
expect(html).toMatch(
/data-slot="integration-details"[^>]*data-state="closed"/,
);
expect(html).toMatch(
/<div[^>]*hidden=""[^>]*data-slot="integration-details"/,
);
});
it("ok while older history loads: the sentence says so", () => {
const html = render(
"whoop",
connected(healthFixture(), { backfillCompleted: false }),
);
expect(html).toContain(
"Data is arriving. Older history is loading in the background.",
);
});
it("limited by plan: the sentence names up to three, counts the rest, no button", () => {
const html = render(
"withings",
connected(
healthFixture({
state: "limited",
reason: "plan_limited",
unavailable: [
{ item: "spo2", cause: "plan" },
{ item: "vascularAge", cause: "plan" },
{ item: "pulseWaveVelocity", cause: "plan" },
{ item: "vo2Max", cause: "plan" },
{ item: "ecg", cause: "plan" },
],
}),
),
);
expect(badgeState(html)).toBe("limited");
expect(html).toContain(">Limited<");
expect(html).toContain(
"Data is arriving. Your Withings plan does not include SpO2, vascular age, and 3 more.",
);
expect(primaryActions(html)).toEqual([]);
// The full list lives in the details, with the plans page one tap away.
const details = html.slice(html.indexOf('data-slot="integration-details"'));
expect(details).toContain("ECG and AFib");
expect(details).toContain('data-testid="withings-plan-link"');
});
it("limited by permission: the sentence names it and offers the one reconnect", () => {
const html = render(
"oura",
connected(
healthFixture({
state: "limited",
reason: "permission_limited",
action: "reconnect",
unavailable: [
{ item: "spo2", cause: "permission" },
{ item: "vo2max", cause: "permission" },
],
}),
),
);
expect(badgeState(html)).toBe("limited");
expect(html).toContain(
"Data is arriving. Permission is missing for SpO2 and VO2 max.",
);
expect(primaryActions(html)).toEqual(["reconnect"]);
expect(html).toContain(">Reconnect<");
});
it("degraded: Delayed, dated, reassuring, no button", () => {
const html = render(
"withings",
connected(
healthFixture({
state: "degraded",
reason: "provider_errors",
since: hoursAgo(2),
}),
),
);
expect(badgeState(html)).toBe("degraded");
expect(html).toContain(">Delayed<");
expect(html).toMatch(
/Withings has not answered reliably since (today|yesterday), [^.]+\. HealthLog keeps trying; nothing to do on your side\./,
);
expect(primaryActions(html)).toEqual([]);
});
it("action required, reauth: Action needed and exactly one Reconnect", () => {
const html = render(
"google-health",
connected(
healthFixture({
state: "action_required",
reason: "reauth_required",
action: "reconnect",
}),
),
);
expect(badgeState(html)).toBe("action_required");
expect(html).toContain(">Action needed<");
expect(html).toContain(
"The Google Health connection has expired. Reconnect to keep data coming.",
);
expect(primaryActions(html)).toEqual(["reconnect"]);
// The re-consent banner it replaces is gone.
expect(html).not.toContain("google-health-reauth-banner");
});
it("action required, paused: exactly one Resume, wired to the resume endpoint", () => {
const html = render(
"whoop",
connected(
healthFixture({
state: "action_required",
reason: "paused_after_errors",
since: hoursAgo(30),
action: "resume",
}),
),
);
expect(badgeState(html)).toBe("action_required");
expect(html).toContain("WHOOP is paused after failing since");
expect(primaryActions(html)).toEqual(["resume"]);
expect(html).toContain(">Resume<");
expect(html).toContain('data-testid="whoop-resume-button"');
expect(html).not.toContain("whoop-parked-banner");
});
it("not connected: no badge, one sentence, the BYO form and Connect", () => {
const html = render("withings", {
connected: false,
configured: false,
health: null,
});
expect(html).toMatch(
/data-slot="integration-card"[^>]*data-state="not_connected"/,
);
expect(badgeState(html)).toBeNull();
expect(html).toContain("Not connected.");
expect(html).toContain('data-testid="withings-setup-guide"');
expect(html).toContain('data-testid="withings-redirect-guide"');
expect(html).toContain('id="withings-clientid"');
expect(html).toContain('data-testid="withings-connect"');
// Connecting needs stored credentials first, and the card says so.
expect(html).toMatch(
/data-testid="withings-connect"[^>]*disabled=""|disabled=""[^>]*data-testid="withings-connect"/,
);
expect(html).toContain('data-testid="integration-unavailable"');
expect(html).not.toContain('data-slot="integration-details-toggle"');
});
it("loading: paints the card and its header, says nothing about state", () => {
const html = render("polar", undefined);
expect(html).toMatch(
/data-slot="integration-card"[^>]*data-state="loading"/,
);
expect(badgeState(html)).toBeNull();
expect(html).not.toContain("Not connected.");
});
it("speaks German with the maintainer's words", () => {
const html = render(
"oura",
connected(
healthFixture({
state: "degraded",
reason: "unexpected_silence",
since: hoursAgo(50),
}),
),
"de",
);
expect(html).toContain(">Verzögert<");
expect(html).toContain("obwohl Oura sonst täglich liefert.");
expect(html).toContain("Details für eine Fehlermeldung");
});
});
describe("<IntegrationCard> menu and actions", () => {
it("moves sync, full reload, test, data, credentials, guide and disconnect into the menu", () => {
render("withings", connected(healthFixture()));
const menu = lastCardProps?.menu ?? [];
expect(menu.map((i) => i.id)).toEqual([
"sync",
"full-sync",
"test",
"view-data",
"credentials",
"setup-guide",
"disconnect",
]);
// The heavy and the destructive entries keep their confirmation.
expect(menu.find((i) => i.id === "full-sync")?.confirm).toBeTruthy();
const disconnect = menu.find((i) => i.id === "disconnect");
expect(disconnect?.destructive).toBe(true);
expect(disconnect?.confirm?.confirmLabel).toBe("Disconnect");
});
it("offers no full reload where the provider has none", () => {
render("strava", connected(healthFixture()));
expect((lastCardProps?.menu ?? []).map((i) => i.id)).not.toContain(
"full-sync",
);
});
it("names the menu trigger for screen readers and keeps the 44 px floor", () => {
const html = render("oura", connected(healthFixture()));
expect(html).toContain('aria-label="More actions for Oura"');
const trigger = html.match(
/<button[^>]*data-slot="integration-menu-trigger"[^>]*>/,
)?.[0];
expect(trigger).toBeDefined();
expect(trigger).toMatch(/\bmin-h-11\b/);
expect(trigger).toMatch(/\bmin-w-11\b/);
expect(trigger).toContain('aria-haspopup="menu"');
});
it("shows the badge word as readable text, not an icon or a hidden label", () => {
const html = render(
"oura",
connected(healthFixture({ state: "degraded", reason: "no_new_data" })),
);
const badge = html.match(
/<span[^>]*data-slot="integration-health-badge"[^>]*>([\s\S]*?)<\/span>(?=<\/div>)/,
)?.[1];
expect(badge).toContain("Delayed");
expect(html).not.toMatch(
/data-slot="integration-health-badge"[^>]*aria-hidden="true"/,
);
});
});
describe("<IntegrationCard> details for a bug report", () => {
it("shows exactly the redacted diagnostics the copy button copies", () => {
const secret = "Oura error: token=abc.def.ghi for user user-42";
const html = render(
"oura",
connected(
healthFixture({
state: "degraded",
reason: "provider_errors",
since: "2026-10-14T18:40:00.000Z",
lastSuccessAt: "2026-10-14T18:12:00.000Z",
lastDataAt: "2026-10-14T07:55:00.000Z",
diagnostics: {
ledgerState: "error_transient",
failingLegs: ["sleep"],
failingSince: "2026-10-14T18:40:00.000Z",
lastAttemptAt: "2026-10-15T09:15:00.000Z",
verdict: "failing",
recentCalls: [
{
endpoint: "sleep",
status: "transient",
at: "2026-10-15T09:15:00.000Z",
errorCode: "503",
},
],
},
}),
{
lastError: secret,
consecutiveFailuresByKind: {
transient: 2,
reauth_required: 0,
persistent: 0,
},
failureThreshold: 3,
},
),
);
const pre = html.match(
/<pre[^>]*data-slot="integration-diagnostics"[^>]*>([\s\S]*?)<\/pre>/,
)?.[1];
expect(pre).toBeDefined();
expect(pre).toContain("HealthLog");
expect(pre).toContain("Oura");
expect(pre).toContain(
"State: degraded / provider_errors since 2026-10-14 18:40 UTC",
);
expect(pre).toContain("Last sync: 2026-10-14 18:12 UTC");
expect(pre).toContain("Ledger: error_transient, failing parts: sleep");
expect(pre).toContain("Failure streak: 2 of 3");
expect(pre).toMatch(/2026-10-15 09:15 {2}sleep {2}transient +503/);
// The stored provider message, its token and the account never appear,
// neither in the block nor anywhere else on the card.
expect(html).not.toContain("token=abc");
expect(html).not.toContain("user-42");
expect(html).not.toContain("Oura error");
});
});
@@ -0,0 +1,275 @@
import { describe, expect, it } from "vitest";
import { locales, type Locale } from "@/lib/i18n/config";
import { makeFormatters } from "@/lib/format-locale";
import {
CONNECTION_HEALTH_REASONS,
CONNECTION_HEALTH_STATES,
} from "@/lib/integrations/connection-health";
import de from "../../../../../messages/de.json";
import en from "../../../../../messages/en.json";
import es from "../../../../../messages/es.json";
import fr from "../../../../../messages/fr.json";
import itMessages from "../../../../../messages/it.json";
import ko from "../../../../../messages/ko.json";
import pl from "../../../../../messages/pl.json";
import {
diagnosticsText,
formatHealthInstant,
healthBadgeLabel,
healthSentence,
namedItemList,
} from "../integration-health-copy";
import { healthFixture } from "./health-fixture";
const CATALOGS: Record<Locale, unknown> = {
de,
en,
es,
fr,
it: itMessages,
pl,
ko,
};
function translator(locale: Locale) {
return (key: string, params?: Record<string, string | number>) => {
const value = key
.split(".")
.reduce<unknown>(
(node, part) => (node as Record<string, unknown> | undefined)?.[part],
CATALOGS[locale],
);
if (typeof value !== "string") return key;
return value.replace(/\{(\w+)\}/g, (_, name: string) =>
String(params?.[name] ?? `{${name}}`),
);
};
}
const NOW = new Date("2026-10-15T12:00:00.000Z");
const fmt = makeFormatters("de", "Europe/Berlin", "AUTO", "AUTO");
const t = translator("de");
describe("formatHealthInstant", () => {
it("reads today and yesterday with the time, older instants with the date", () => {
expect(formatHealthInstant("2026-10-15T05:12:00.000Z", t, fmt, NOW)).toBe(
"heute, 07:12",
);
expect(formatHealthInstant("2026-10-14T16:40:00.000Z", t, fmt, NOW)).toBe(
"gestern, 18:40",
);
expect(formatHealthInstant("2026-10-12T12:00:00.000Z", t, fmt, NOW)).toBe(
"12.10., 14:00",
);
});
it("buckets the day in the reader's zone, not UTC", () => {
// 23:30 UTC on the 14th is already the 15th in Berlin.
expect(formatHealthInstant("2026-10-14T23:30:00.000Z", t, fmt, NOW)).toBe(
"heute, 01:30",
);
});
});
describe("namedItemList", () => {
it("names up to three types", () => {
expect(
namedItemList(
[
{ item: "spo2", cause: "plan" },
{ item: "vascularAge", cause: "plan" },
{ item: "ecg", cause: "plan" },
],
t,
"de",
),
).toBe("SpO2, Gefäßalter und EKG und Vorhofflimmern");
});
it("names two and counts the rest beyond three", () => {
expect(
namedItemList(
["spo2", "vascularAge", "ecg", "vo2Max", "weight"].map((item) => ({
item,
cause: "plan" as const,
})),
t,
"de",
),
).toBe("SpO2, Gefäßalter und 3 weitere Werte");
});
it("names the Withings activity scope gap", () => {
expect(
namedItemList(
[
{ item: "activity", cause: "permission" },
{ item: "sleep", cause: "permission" },
],
t,
"de",
),
).toBe("Aktivität und Schlaf");
});
});
describe("healthSentence", () => {
const opts = { provider: "Oura", locale: "de" as const, fmt, now: NOW };
it("uses the dated sentence when the server dates the condition", () => {
expect(
healthSentence(
healthFixture({
state: "degraded",
reason: "no_new_data",
since: "2026-10-14T16:40:00.000Z",
}),
t,
opts,
),
).toBe("Seit gestern, 18:40 sind keine neuen Daten gekommen.");
});
it("falls back to an undated sentence rather than printing nothing", () => {
expect(
healthSentence(
healthFixture({ state: "degraded", reason: "provider_errors" }),
t,
opts,
),
).toBe(
"Oura antwortet gerade nicht zuverlässig. HealthLog versucht es weiter, du musst nichts tun.",
);
});
it("tells an Apple Health user to open the app", () => {
expect(
healthSentence(
healthFixture({
state: "degraded",
reason: "no_new_data",
since: "2026-10-12T12:00:00.000Z",
}),
t,
{ ...opts, provider: "Apple Health", appleHealth: true },
),
).toBe(
"Seit 12.10., 14:00 sind keine neuen Daten gekommen. Öffne HealthLog einmal auf dem iPhone.",
);
});
it.each(locales)(
"has a sentence and a badge word for every state and reason in %s",
(locale) => {
const tl = translator(locale);
const f = makeFormatters(locale, "Europe/Berlin", "AUTO", "AUTO");
for (const state of CONNECTION_HEALTH_STATES) {
const word = healthBadgeLabel(state, tl);
expect(word).not.toContain("settings.");
}
for (const reason of CONNECTION_HEALTH_REASONS) {
for (const since of [null, "2026-10-14T16:40:00.000Z"]) {
const sentence = healthSentence(
healthFixture({
reason,
since,
unavailable: [
{ item: "spo2", cause: "plan" },
{ item: "activity", cause: "permission" },
],
}),
tl,
{ provider: "Oura", locale, fmt: f, now: NOW },
);
expect(sentence, `${locale} ${reason}`).not.toMatch(
/settings\.|\{\w+\}| · | — | – /,
);
expect(sentence.length).toBeGreaterThan(5);
}
}
},
);
});
describe("diagnosticsText", () => {
it("prints the draft's block from health alone", () => {
const text = diagnosticsText(
{
version: "1.42.4",
provider: "Oura",
health: healthFixture({
state: "degraded",
reason: "provider_errors",
since: "2026-10-14T18:40:00.000Z",
lastSuccessAt: "2026-10-14T18:12:00.000Z",
lastDataAt: "2026-10-14T07:55:00.000Z",
unavailable: [{ item: "spo2", cause: "permission" }],
diagnostics: {
ledgerState: "error_transient",
failingLegs: ["sleep"],
failingSince: "2026-10-14T18:40:00.000Z",
lastAttemptAt: "2026-10-15T09:15:00.000Z",
verdict: "failing",
recentCalls: [
{
endpoint: "sleep",
status: "transient",
at: "2026-10-15T09:15:00.000Z",
errorCode: "503",
},
{
endpoint: "readiness",
status: "ok",
at: "2026-10-15T08:15:00.000Z",
errorCode: null,
},
],
},
}),
},
t,
);
expect(text.split("\n")).toEqual([
"HealthLog 1.42.4 Oura",
"Zustand: degraded / provider_errors seit 2026-10-14 18:40 UTC",
"Letzte Synchronisierung: 2026-10-14 18:12 UTC",
"Letzte Daten: 2026-10-14 07:55 UTC",
"Ledger: error_transient, fehlende Teile: sleep",
"Letzte Aufrufe:",
" 2026-10-15 09:15 sleep transient 503",
" 2026-10-15 08:15 readiness ok",
"Nicht verfügbar: spo2 (Freigabe)",
]);
});
it("leaves out what the server does not know instead of inventing it", () => {
const text = diagnosticsText(
{
version: "",
provider: "Apple Health",
health: healthFixture({
lastSuccessAt: null,
lastDataAt: null,
diagnostics: {
ledgerState: null,
failingLegs: [],
failingSince: null,
lastAttemptAt: null,
verdict: "fresh",
recentCalls: [],
},
}),
},
t,
);
expect(text.split("\n")).toEqual([
"HealthLog Apple Health",
"Zustand: ok / data_arriving",
"Letzte Synchronisierung: keine",
"Letzte Daten: keine",
]);
});
});
@@ -1,23 +1,23 @@
/**
* Nightscout parity + correctness.
* Nightscout on the shared integration card.
*
* 1. A `parked` verdict renders the warning banner + reconnect button
* (matching the WHOOP card's parked treatment, byte-for-byte classes).
* 2. A healthy connection renders the shared TestConnectionButton AND the
* connect→data link to /insights/blood-glucose.
* 3. The card reads the consolidated envelope — it was the last card firing
* its own status round-trip — so a disconnect invalidates that one key.
* 1. A paused connection offers "Resume", which opens the address form (there
* is no OAuth round-trip to re-run).
* 2. A healthy connection keeps sync, test, the data link and disconnect in
* the menu, and disconnect asks first.
* 3. The card reads the consolidated envelope, so a disconnect invalidates
* that one key.
* 4. An unapproved private origin is named for what it is, an operator
* action, without echoing the stored error text.
*/
import { describe, expect, it, vi } from "vitest";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { queryKeys } from "@/lib/query-keys";
// Capture the disconnect mutation's onSuccess so the test can assert which
// query keys it invalidates.
const invalidateSpy = vi.fn();
type OnSuccess = () => void;
const capturedDisconnectOnSuccess: { fn: OnSuccess | null } = { fn: null };
const capturedDisconnectOnSuccess: { fn: (() => void) | null } = { fn: null };
vi.mock("@tanstack/react-query", () => ({
useMutation: ({ onSuccess }: { onSuccess?: () => void }) => {
@@ -28,11 +28,30 @@ vi.mock("@tanstack/react-query", () => ({
useQueryClient: () => ({ invalidateQueries: invalidateSpy }),
}));
let lastCardProps: IntegrationCardProps | null = null;
vi.mock("../integration-card", async (importOriginal) => {
const actual = await importOriginal<typeof import("../integration-card")>();
return {
...actual,
IntegrationCard: (props: IntegrationCardProps) => {
lastCardProps = props;
return createElement(actual.IntegrationCard, props);
},
};
});
import { I18nProvider } from "@/lib/i18n/context";
import type { IntegrationCardProps } from "../integration-card";
import { NightscoutCard } from "../nightscout-card";
import type { IntegrationStatusViewModel } from "../shared";
import { healthFixture } from "./health-fixture";
function render(viewModel?: Partial<IntegrationStatusViewModel>) {
/** The envelope carries more than the card declares; the card must ignore it. */
type Envelope = Partial<IntegrationStatusViewModel> & {
lastError?: string | null;
};
function render(viewModel?: Envelope) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<NightscoutCard
@@ -40,9 +59,6 @@ function render(viewModel?: Partial<IntegrationStatusViewModel>) {
viewModel
? ({
integration: "nightscout",
state: "connected",
lastSuccessAt: null,
lastAttemptAt: null,
lastError: null,
...viewModel,
} as IntegrationStatusViewModel)
@@ -53,121 +69,126 @@ function render(viewModel?: Partial<IntegrationStatusViewModel>) {
);
}
describe("NightscoutCard — parked + test + data-link + invalidation", () => {
it("renders the parked banner + reconnect button when the verdict is parked", () => {
function menuIds(): string[] {
return (lastCardProps?.menu ?? []).map((item) => item.id);
}
describe("NightscoutCard on the shared card", () => {
it("offers Resume on a paused connection, and Resume opens the address form", () => {
const html = render({
connected: true,
configured: true,
state: "parked",
lastError: "Nightscout unreachable",
syncHealth: { verdict: "parked", since: null },
health: healthFixture({
state: "action_required",
reason: "paused_after_errors",
since: "2026-10-12T14:00:00.000Z",
action: "resume",
}),
});
expect(html).toContain('data-state="parked"');
expect(html).toContain('data-testid="nightscout-parked-banner"');
expect(html).toContain('data-testid="nightscout-resume-button"');
expect(html).toContain("border-warning/30 bg-warning/10");
expect(html).toMatch(
/data-slot="integration-health-badge"[^>]*data-state="action_required"/,
);
expect(html).toContain('data-action="resume"');
expect(html).toContain("Nightscout is paused after failing since");
expect(lastCardProps?.primary?.onResume).toBeTypeOf("function");
// The form is closed until the button asks for it.
expect(html).not.toContain('id="nightscout-url"');
});
it("renders the test-connection button + data link when connected", () => {
const html = render({
it("keeps sync, test, the data link and disconnect in the menu", () => {
render({
connected: true,
configured: true,
lastSuccessAt: "2026-06-01T00:00:00.000Z",
syncHealth: { verdict: "fresh", since: null },
health: healthFixture(),
metricFreshness: [],
});
expect(html).toContain("Test connection");
expect(html).toContain('data-testid="nightscout-data-link"');
expect(html).toContain('href="/insights/blood-glucose"');
expect(menuIds()).toEqual([
"sync",
"test",
"view-data",
"credentials",
"setup-guide",
"disconnect",
]);
const viewData = lastCardProps?.menu?.find((i) => i.id === "view-data");
expect(viewData?.href).toBe("/insights/blood-glucose");
const disconnect = lastCardProps?.menu?.find((i) => i.id === "disconnect");
expect(disconnect?.destructive).toBe(true);
expect(disconnect?.confirm?.title).toBeTruthy();
});
it("carries no card divider — the card gap does that job", () => {
it("puts the per-metric list in the details, stale rows included", () => {
const html = render({
connected: true,
configured: true,
syncHealth: { verdict: "fresh", since: null },
});
expect(html).not.toContain('data-testid="integration-card-divider"');
});
it("renders the per-metric freshness disclosure with a stale row", () => {
const html = render({
connected: true,
configured: true,
syncHealth: { verdict: "fresh", since: null },
health: healthFixture(),
metricFreshness: [
{
type: "BLOOD_GLUCOSE",
lastSeenAt: "2026-06-01T00:00:00.000Z",
lastSeenAt: new Date(Date.now() - 3 * 86_400_000).toISOString(),
stale: true,
},
],
});
expect(html).toContain('data-slot="metric-freshness"');
expect(html).toContain("quiet");
const details = html.slice(html.indexOf('data-slot="integration-details"'));
expect(details).toContain('data-metric="BLOOD_GLUCOSE"');
expect(details).toContain('data-state="stale"');
});
it("disconnect invalidates the envelope the card actually reads", () => {
render({ connected: true, configured: true, health: healthFixture() });
invalidateSpy.mockClear();
capturedDisconnectOnSuccess.fn = null;
render({
connected: true,
configured: true,
syncHealth: { verdict: "fresh", since: null },
capturedDisconnectOnSuccess.fn?.();
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: queryKeys.integrationsStatus(),
});
const onSuccess = capturedDisconnectOnSuccess.fn as (() => void) | null;
expect(typeof onSuccess).toBe("function");
onSuccess?.();
const invalidatedKeys = invalidateSpy.mock.calls.map((c) => c[0]?.queryKey);
expect(invalidatedKeys).toContainEqual(queryKeys.integrationsStatus());
});
});
describe("NightscoutCard — sync-now action", () => {
it("offers the sync action on a connected instance", () => {
const html = render({
connected: true,
configured: true,
syncHealth: { verdict: "fresh", since: null },
});
expect(html).toContain('data-testid="nightscout-sync"');
expect(html).toContain("Sync now");
});
it("does not offer it before an instance is configured", () => {
expect(
render({
connected: false,
configured: false,
state: "disconnected",
syncHealth: { verdict: "disconnected", since: null },
}),
).not.toContain('data-testid="nightscout-sync"');
it("shows the address form and Connect while not connected", () => {
const html = render({ connected: false, configured: false, health: null });
expect(html).toContain('data-state="not_connected"');
expect(html).toContain('id="nightscout-url"');
expect(html).toContain('data-testid="nightscout-connect"');
expect(html).toContain('data-testid="nightscout-setup-guide"');
});
});
describe("NightscoutCard — operator-owned private access", () => {
it("does not offer an ordinary user a private-host enforcement bypass", () => {
const html = render({
connected: false,
configured: false,
state: "disconnected",
syncHealth: { verdict: "disconnected", since: null },
});
const html = render({ connected: false, configured: false, health: null });
expect(html).not.toContain('id="nightscout-private"');
expect(html).not.toContain("This site runs on a private network");
expect(html).not.toContain("Enable only if your Nightscout instance");
});
it("shows a redacted operator-action state for an unapproved legacy private connection", () => {
it("names the operator action for an unapproved private origin without echoing the error", () => {
const html = render({
connected: true,
configured: true,
state: "error_reauth",
// Still on the wire for one release; the card must not show it.
lastError:
"private_origin_not_approved: http://10.0.0.4:1337/api/v1/entries.json?token=ns-secret",
syncHealth: { verdict: "failing", since: null },
allowPrivateHost: true,
health: healthFixture({
state: "degraded",
reason: "provider_errors",
since: "2026-10-14T18:40:00.000Z",
diagnostics: {
ledgerState: "error_transient",
failingLegs: ["sync"],
failingSince: "2026-10-14T18:40:00.000Z",
lastAttemptAt: "2026-10-15T09:15:00.000Z",
verdict: "failing",
recentCalls: [
{
endpoint: "sync",
status: "persistent",
at: "2026-10-15T09:15:00.000Z",
errorCode: "private_origin_not_approved",
},
],
},
}),
});
expect(html).toContain(
@@ -176,35 +197,27 @@ describe("NightscoutCard — operator-owned private access", () => {
expect(html).toContain(
"Private Nightscout access requires server operator approval.",
);
expect(html).toContain(
"Ask your server operator to approve this exact Nightscout origin.",
);
expect(html).not.toContain("private_origin_not_approved");
// The short code appears in the diagnostics block, the stored message
// nowhere: no address, no token.
expect(html).not.toContain("private_origin_not_approved:");
expect(html).not.toContain("10.0.0.4");
expect(html).not.toContain("ns-secret");
expect(html).not.toContain('data-testid="nightscout-sync"');
expect(html).not.toContain("Test connection");
expect(html).toContain("Disconnect");
// Sync and test cannot help until the operator approves the origin.
expect(menuIds()).not.toContain("sync");
expect(menuIds()).not.toContain("test");
expect(menuIds()).toContain("disconnect");
});
it.each([
["public", false],
["operator-approved private", true],
])("keeps a healthy %s connection usable", (_label, allowPrivateHost) => {
it("keeps a healthy connection usable", () => {
const html = render({
connected: true,
configured: true,
state: "connected",
lastError: null,
syncHealth: { verdict: "fresh", since: null },
// Compatibility/display metadata only; it is not the authority asserted
// by this positive flow.
allowPrivateHost,
health: healthFixture(),
});
expect(html).toContain('data-testid="nightscout-sync"');
expect(html).toContain("Test connection");
expect(html).toContain("Disconnect");
expect(menuIds()).toContain("sync");
expect(menuIds()).toContain("test");
expect(html).not.toContain(
'data-testid="nightscout-private-operator-required"',
);
@@ -1,274 +0,0 @@
/**
* v1.17.0 — interaction-parity check for the shared OAuth integration card
* (Polar / Oura). The new cards must match the existing WHOOP card's
* parked-state + test-connection + connect→data treatment:
*
* 1. A `parked` status renders the warning banner + reconnect button.
* 2. A `connected` status renders the shared TestConnectionButton (its
* "Test connection" affordance) AND the connect→data link.
* 3. The data link resolves to the provider's insight surface.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { Watch } from "lucide-react";
// The card reads the consolidated envelope only — its per-card status fetch is
// gone, so every fixture is a view-model.
vi.mock("@tanstack/react-query", () => ({
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { OAuthProviderCard } from "../oauth-provider-card";
type ViewModel = Parameters<typeof OAuthProviderCard>[0]["viewModel"];
function render({
credentials = false,
viewModel,
}: {
credentials?: boolean;
viewModel?: ViewModel;
} = {}) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<OAuthProviderCard
provider="polar"
statusQueryKey={["polar"]}
i18nPrefix="settings.polar"
icon={Watch}
dataHref="/insights/sleep"
credentials={credentials}
viewModel={viewModel}
callbackUrl="https://app.example/api/polar/callback"
/>
</I18nProvider>,
);
}
describe("OAuthProviderCard — parked + test + data-link parity", () => {
it("renders the parked banner + reconnect button when state is parked", () => {
const html = render({
viewModel: {
connected: true,
configured: true,
available: true,
state: "parked",
lastSuccessAt: null,
lastError: "Polar grant expired",
syncHealth: { verdict: "parked", since: null },
},
});
expect(html).toContain('data-state="parked"');
expect(html).toContain('data-testid="polar-parked-banner"');
expect(html).toContain('data-testid="polar-resume-button"');
expect(html).toContain("Paused: reconnect");
// The parked banner uses the same warning treatment as the WHOOP card.
expect(html).toContain("border-warning/30 bg-warning/10");
});
it("renders the test-connection button + data link when connected", () => {
const html = render({
viewModel: {
connected: true,
configured: true,
available: true,
state: "connected",
lastSuccessAt: "2026-06-01T00:00:00.000Z",
lastError: null,
syncHealth: { verdict: "fresh", since: null },
},
});
// The shared TestConnectionButton surfaces its "Test connection" label.
expect(html).toContain("Test connection");
// connect→data link points at the provider's insight surface.
expect(html).toContain('data-testid="polar-data-link"');
expect(html).toContain('href="/insights/sleep"');
});
it("reads off the passed view-model — the per-card fetch is gone", () => {
// There is no `useQuery` left in this component: the envelope is the only
// source. A per-provider status response carries no verdict, so falling
// back to it would have painted a status the server never resolved.
const html = render({
viewModel: {
connected: true,
configured: true,
available: true,
state: "connected",
lastSuccessAt: "2026-06-01T00:00:00.000Z",
lastError: null,
syncHealth: { verdict: "fresh", since: null },
},
});
expect(html).toContain('data-testid="polar-data-link"');
expect(html).toContain("Test connection");
});
it("does not render the data link or test button when disconnected", () => {
const html = render({
viewModel: {
connected: false,
configured: false,
available: true,
syncHealth: { verdict: "disconnected", since: null },
},
});
expect(html).not.toContain('data-testid="polar-data-link"');
expect(html).not.toContain("Test connection");
// The connect CTA stands in instead.
expect(html).toContain('data-testid="polar-connect"');
});
});
describe("OAuthProviderCard — per-user BYO credentials form (v1.17.1)", () => {
it("renders the credentials form only when the `credentials` prop is set", () => {
const viewModel: ViewModel = {
connected: false,
configured: false,
available: true,
hasOwnCredentials: false,
syncHealth: { verdict: "disconnected", since: null },
};
// Opt-in: the BYO client-id/secret form + save button appear.
const withForm = render({ credentials: true, viewModel });
expect(withForm).toContain('data-testid="polar-credentials"');
expect(withForm).toContain('id="polar-clientid"');
expect(withForm).toContain('id="polar-secret"');
// Default: no credential inputs (env-only behaviour preserved).
const withoutForm = render({ viewModel });
expect(withoutForm).not.toContain('data-testid="polar-credentials"');
expect(withoutForm).not.toContain('id="polar-clientid"');
});
it("shows the saved-placeholder once the user has stored their own pair", () => {
const html = render({
credentials: true,
viewModel: {
connected: true,
configured: true,
available: true,
hasOwnCredentials: true,
state: "connected",
lastSuccessAt: null,
lastError: null,
syncHealth: { verdict: "pending_first_sync", since: null },
},
});
expect(html).toContain("Saved. Enter new to replace");
});
});
describe("OAuthProviderCard — redirect-URI mini-guide (v1.29.x, UX audit H2)", () => {
it("shows the callback URL guide before the user has BYO credentials", () => {
const html = render({
credentials: true,
viewModel: {
connected: false,
configured: false,
available: true,
hasOwnCredentials: false,
syncHealth: { verdict: "disconnected", since: null },
},
});
expect(html).toContain('data-testid="polar-redirect-guide"');
expect(html).toContain('data-testid="polar-redirect-uri"');
expect(html).toContain("https://app.example/api/polar/callback");
});
it("hides the guide once the user has stored their own credentials", () => {
const html = render({
credentials: true,
viewModel: {
connected: true,
configured: true,
available: true,
hasOwnCredentials: true,
state: "connected",
lastSuccessAt: null,
lastError: null,
syncHealth: { verdict: "fresh", since: null },
},
});
expect(html).not.toContain('data-testid="polar-redirect-guide"');
});
});
/**
* v1.32.28 — sync now. The route without the button would be an endpoint with
* no caller; the button is what makes the manual sync a feature rather than an
* API detail. Rendered only once the provider is connected, in the same action
* row as Test connection.
*/
describe("OAuthProviderCard — sync-now action", () => {
it("offers the sync action on a connected provider", () => {
const html = render({
viewModel: {
connected: true,
configured: true,
available: true,
state: "connected",
lastSuccessAt: null,
lastError: null,
syncHealth: { verdict: "fresh", since: null },
},
});
expect(html).toContain('data-testid="polar-sync"');
expect(html).toContain("Sync now");
});
it("does not offer it before the provider is connected", () => {
expect(
render({
viewModel: {
connected: false,
configured: true,
available: true,
syncHealth: { verdict: "disconnected", since: null },
},
}),
).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");
});
});
@@ -1,138 +0,0 @@
/**
* One verdict, one pill mapping.
*
* There used to be three `pillStateFor` implementations — one per card family —
* and they disagreed with each other. The same ledger state painted red on the
* Withings card and orange on the Polar card, on the same page. The server now
* resolves the verdict once; this pins that the client projects it once, and
* that a fourth dialect cannot be minted without someone editing this file.
*/
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { walkSourceFiles } from "@/__tests__/helpers/source-files";
import type { SyncVerdict } from "@/lib/integrations/sync-verdict";
import {
pickStatus,
pillFailurePropsFor,
pillStateForVerdict,
} from "../shared";
describe("pillStateForVerdict — the verdict→pill table", () => {
const table: Array<[SyncVerdict | undefined, string]> = [
["fresh", "connected"],
["stale", "stale"],
["stalled", "stalled"],
["failing", "warning"],
["reauth_required", "error"],
["parked", "parked"],
["pending_first_sync", "pending-setup"],
["disconnected", "disconnected"],
// Still loading: the card renders its neutral default rather than
// guessing at a status it has not been told.
[undefined, "disconnected"],
];
it.each(table)("maps %s to the %s pill", (verdict, expected) => {
expect(pillStateForVerdict(verdict)).toBe(expected);
});
/**
* The decision this release makes and must not silently lose: **red means
* "your action fixes this"**. Reconnecting cannot repair an upstream 503 or
* a cron that stopped running, so neither of those may reach the destructive
* pill.
*/
it("reserves the error pill for the states a reconnect actually fixes", () => {
const red = table
.filter(([, state]) => state === "error")
.map(([verdict]) => verdict);
expect(red).toEqual(["reauth_required"]);
expect(pillStateForVerdict("failing")).not.toBe("error");
expect(pillStateForVerdict("stalled")).not.toBe("error");
});
});
describe("pillFailurePropsFor", () => {
it("projects the active bucket and envelope threshold onto the pill", () => {
const status = pickStatus(
{
threshold: 3,
integrations: [
{
integration: "withings",
state: "error_transient",
lastSuccessAt: null,
lastAttemptAt: "2026-07-28T12:00:00.000Z",
lastError: "upstream unavailable",
consecutiveFailuresByKind: {
transient: 2,
reauth_required: 0,
persistent: 1,
},
},
],
},
"withings",
);
expect(pillFailurePropsFor(status)).toEqual({
failureCount: 2,
failureThreshold: 3,
});
});
it("returns no ratio for a healthy zero-count status", () => {
const status = pickStatus(
{
threshold: 3,
integrations: [
{
integration: "withings",
state: "connected",
lastSuccessAt: "2026-07-28T12:00:00.000Z",
lastAttemptAt: "2026-07-28T12:00:00.000Z",
lastError: null,
consecutiveFailuresByKind: {
transient: 0,
reauth_required: 0,
persistent: 0,
},
},
],
},
"withings",
);
expect(pillFailurePropsFor(status)).toEqual({});
});
});
describe("structural guard — no second pill dialect", () => {
const SRC = join(process.cwd(), "src");
function sourceFiles(): string[] {
return walkSourceFiles(SRC, { floor: 3000 })
.filter((p) => !p.startsWith("generated/"))
.filter((p) => !p.includes("__tests__"))
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"));
}
/**
* The one module allowed to define a status→pill mapping. A tripwire, not a
* proof: it cannot show the mapping is right, only that nobody added a
* second one without editing this file.
*/
const MAPPER_HOME = "components/settings/integrations/shared.tsx";
it("defines the mapping in exactly one module", () => {
const definers = sourceFiles().filter((rel) =>
/function\s+pillStateFor\w*\s*\(/.test(
readFileSync(join(SRC, rel), "utf8"),
),
);
expect(definers).toEqual([MAPPER_HOME]);
});
});
@@ -1,65 +0,0 @@
/**
* 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("");
});
});
@@ -1,51 +0,0 @@
/**
* v1.29.x — UX audit H2: the WHOOP card shows a compact callback-URL guide
* while the account has no BYO credentials saved, and steps out of the way
* once configured. Mirrors the OAuthProviderCard coverage for Polar/Oura.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("@tanstack/react-query", () => ({
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { WhoopCard } from "../whoop-card";
import type { IntegrationStatusViewModel } from "../shared";
function render(
viewModel?: Partial<IntegrationStatusViewModel>,
callbackUrl: string | null = "https://app.example/api/whoop/callback",
) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<WhoopCard
viewModel={viewModel as IntegrationStatusViewModel | undefined}
callbackUrl={callbackUrl}
/>
</I18nProvider>,
);
}
describe("<WhoopCard> redirect-URI mini-guide", () => {
it("shows the callback URL guide before credentials are configured", () => {
const html = render({ connected: false, configured: false });
expect(html).toContain('data-testid="whoop-redirect-guide"');
expect(html).toContain('data-testid="whoop-redirect-uri"');
expect(html).toContain("https://app.example/api/whoop/callback");
});
it("falls back to the fixed path when the server has no redirect URI", () => {
const html = render({ connected: false, configured: false }, null);
expect(html).toContain('data-testid="whoop-redirect-uri"');
expect(html).toContain("/api/whoop/callback");
expect(html).not.toContain("undefined/api/whoop/callback");
});
it("hides the guide once credentials are configured", () => {
const html = render({ connected: false, configured: true });
expect(html).not.toContain('data-testid="whoop-redirect-guide"');
});
});
@@ -1,36 +1,29 @@
"use client";
/**
* Apple Health — the one integration with no ledger row and no cron.
* Apple Health: the one integration with no ledger row and no cron.
*
* The iOS app pushes; nothing here polls. The card used to derive its status
* from the mere presence of `lastSyncedAt`, so any timestamp at all painted the
* green "data received" chip — a pipe dead for three weeks looked exactly like
* one that delivered this morning. It now reads the server's verdict, which
* applies a seven-day threshold to the same timestamp, and renders the shared
* pill instead of a bespoke badge so the flagship integration speaks the same
* status language as its siblings.
* The iOS app pushes; nothing here polls. The card renders the shared
* integration card from the server's `health`, so the flagship integration
* speaks the same status language as its siblings. What only Apple Health
* knows (how the last batch arrived, how far the first backfill reached, when
* each data type last came in) lives in the card's details.
*/
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { HeartPulse, Upload } from "lucide-react";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { SettingsCard } from "@/components/settings/settings-card";
import {
formatRelative,
IntegrationStatusPill,
} from "@/components/settings/integration-status-pill";
import { formatRelative } from "@/components/settings/integration-status-pill";
import { Button } from "@/components/ui/button";
import { apiGet } from "@/lib/api/api-fetch";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { ConnectionHealth } from "@/lib/integrations/connection-health";
import type { HealthKitFreshnessEntry } from "@/lib/integrations/healthkit-type-sync";
import type { SyncHealth } from "@/lib/integrations/sync-verdict";
import { queryKeys } from "@/lib/query-keys";
import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure";
import { IntegrationErrorMessage, pillStateForVerdict } from "./shared";
import { IntegrationCard } from "./integration-card";
import { MetricFreshnessList } from "./metric-freshness-list";
type SyncTrigger = "foreground" | "background" | "push" | "manual";
@@ -40,7 +33,8 @@ interface HealthKitStatus {
lastSyncTrigger?: SyncTrigger | null;
/** When data last arrived without the app being opened. */
lastBackgroundSyncAt?: string | null;
syncHealth?: SyncHealth;
/** The unified connection state; null until the phone has delivered. */
health?: ConnectionHealth | null;
/** Per type: newest sample, plus when it last arrived and how (#1173). */
metricFreshness?: HealthKitFreshnessEntry[];
/** Server-side backfill progress (#778); null when the read failed. */
@@ -188,7 +182,7 @@ export function ArrivalDetail({
data-state="received"
data-trigger={trigger ?? "unknown"}
>
{parts.join(" · ")}
{parts.join(", ")}
</span>
);
}
@@ -279,88 +273,84 @@ export function AppleHealthCard({ enabled }: { enabled: boolean }) {
refetchOnWindowFocus: true,
});
const verdict = status?.syncHealth?.verdict;
const pillState = pillStateForVerdict(verdict);
// A failed status read is an error line, not a status state — the card says
// nothing about the pipe it could not ask about.
const showPill = !isLoading && !isError && !!status;
const health = status?.health ?? null;
// Apple Health has nothing to connect: it counts as connected once the
// phone has delivered anything, which is exactly when the server resolves a
// health for it. A failed read is neither, so the card says it could not
// ask instead of claiming "not connected".
const connected = isError || health !== null;
const importLink = (
<Button
asChild
variant="outline"
size="sm"
className="min-h-11 w-full sm:min-h-9 sm:w-auto"
>
<Link
href="/settings/export#settings-section-import-title"
data-testid="apple-health-import-link"
>
<Upload className="size-3.5" aria-hidden="true" />
{t("settings.appleHealth.importAction")}
</Link>
</Button>
);
return (
<SettingsCard
as="section"
aria-labelledby="apple-health-card-title"
data-testid="apple-health-card"
>
<SettingsCardHeader
icon={HeartPulse}
titleId="apple-health-card-title"
title={t("settings.appleHealth.title")}
description={t("settings.appleHealth.description")}
status={
showPill ? (
<IntegrationStatusPill
testId="apple-health-status"
state={pillState}
lastSyncAt={status?.lastSyncedAt ?? null}
/>
) : null
}
/>
<div className="space-y-4">
{isError && (
<IntegrationErrorMessage
message={t("settings.appleHealth.status.unavailable")}
/>
)}
{verdict === "stale" && (
<p className="text-warning text-sm" data-testid="apple-health-stale">
{t("settings.appleHealth.staleNote")}
</p>
)}
{showPill && <DeliveryDiagnostic status={status} t={t} />}
{showPill && <SyncProgress status={status} t={t} />}
<MetricFreshnessDisclosure
entries={status?.metricFreshness}
idPrefix="apple-health"
title={t("settings.appleHealth.freshness.title")}
detail={(entry) => <ArrivalDetail entry={entry} t={t} />}
/>
<div className="space-y-2">
<h3 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{t("settings.appleHealth.setupTitle")}
</h3>
<ol className="text-muted-foreground list-decimal space-y-2 pl-5 text-sm">
<li>{t("settings.appleHealth.permissionStep")}</li>
<li>{t("settings.appleHealth.backgroundStep")}</li>
</ol>
</div>
<div className="border-border bg-muted/30 flex flex-col gap-3 rounded-lg border p-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-muted-foreground text-xs sm:max-w-xl">
<IntegrationCard
provider="apple-health"
providerName={t("settings.appleHealth.title")}
icon={HeartPulse}
description={t("settings.appleHealth.description")}
loading={enabled && isLoading}
connected={connected}
health={health}
appleHealth
setup={
<div className="space-y-3">
<div className="space-y-2">
<h3 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{t("settings.appleHealth.setupTitle")}
</h3>
<ol className="list-decimal space-y-2 pl-5 text-sm">
<li>{t("settings.appleHealth.permissionStep")}</li>
<li>{t("settings.appleHealth.backgroundStep")}</li>
</ol>
</div>
<p className="text-muted-foreground text-xs">
{t("settings.appleHealth.importNote")}
</p>
<Button
asChild
variant="outline"
size="sm"
className="min-h-11 shrink-0 sm:min-h-9"
>
<Link
href="/settings/export#settings-section-import-title"
data-testid="apple-health-import-link"
>
<Upload className="h-3.5 w-3.5" aria-hidden="true" />
{t("settings.appleHealth.importAction")}
</Link>
</Button>
</div>
</div>
</SettingsCard>
}
connectAction={importLink}
menu={[
{
id: "import",
label: t("settings.appleHealth.importAction"),
icon: Upload,
href: "/settings/export#settings-section-import-title",
},
]}
details={
status && !isError ? (
<>
<DeliveryDiagnostic status={status} t={t} />
<SyncProgress status={status} t={t} />
<MetricFreshnessList
entries={status.metricFreshness}
title={t("settings.appleHealth.freshness.title")}
detail={(entry) => <ArrivalDetail entry={entry} t={t} />}
/>
</>
) : undefined
}
>
{isError ? (
<p role="alert" className="text-destructive text-sm">
{t("settings.appleHealth.status.unavailable")}
</p>
) : null}
</IntegrationCard>
);
}
@@ -17,23 +17,21 @@ import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { AppleHealthCard } from "@/components/settings/integrations/apple-health-card";
import { FitbitCard } from "@/components/settings/integrations/fitbit-card";
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";
import { NightscoutCard } from "@/components/settings/integrations/nightscout-card";
import type { OAuthProviderStatus } from "@/components/settings/integrations/oauth-provider-card";
import { OuraCard } from "@/components/settings/integrations/oura-card";
import { PolarCard } from "@/components/settings/integrations/polar-card";
import { StravaCard } from "@/components/settings/integrations/strava-card";
import {
OAUTH_INTEGRATIONS,
OAuthIntegrationCard,
} from "@/components/settings/integrations/oauth-integration-card";
import {
pickStatus,
useIntegrationStatuses,
type IntegrationKey,
type IntegrationStatusViewModel,
} from "@/components/settings/integrations/shared";
import { WhoopCard } from "@/components/settings/integrations/whoop-card";
import { WithingsCard } from "@/components/settings/integrations/withings-card";
import { Badge } from "@/components/ui/badge";
import { TagChip } from "@/components/ui/tag-chip";
import { useAuth } from "@/hooks/use-auth";
import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls";
import { useTranslations } from "@/lib/i18n/context";
@@ -179,32 +177,6 @@ export function oauthReasonKey(
: `${base}.generic`;
}
/**
* Adapt the consolidated-envelope view-model into the OAuth card's status
* shape. Returns `undefined` when the envelope hasn't loaded so the card
* renders its loading/disconnected default rather than a half-populated state.
*/
function toOAuthStatus(
vm: IntegrationStatusViewModel | undefined,
): OAuthProviderStatus | undefined {
if (!vm) return undefined;
return {
connected: vm.connected ?? false,
configured: vm.configured ?? false,
available: vm.available ?? false,
hasOwnCredentials: vm.hasOwnCredentials,
state: vm.state,
lastSuccessAt: vm.lastSuccessAt,
lastAttemptAt: vm.lastAttemptAt,
lastError: vm.lastError,
legacyLastSyncedAt: vm.legacyLastSyncedAt,
consecutiveFailuresByKind: vm.consecutiveFailuresByKind,
failureThreshold: vm.failureThreshold,
syncHealth: vm.syncHealth,
metricFreshness: vm.metricFreshness,
};
}
/**
* The keys a bespoke card on this panel renders. Every OTHER configured
* envelope entry falls through to `IntegrationFallbackRow` — the panel's unit
@@ -320,21 +292,6 @@ export function ConnectionsPanel({
}
}, [oauthOutcome, router, queryClient, t]);
const withingsViewModel = pickStatus(integrationStatus, "withings");
const whoopViewModel = pickStatus(integrationStatus, "whoop");
const fitbitViewModel = pickStatus(integrationStatus, "fitbit");
// v1.27.0 — Google Health reads off the same consolidated envelope; the
// ledger integration key is the hyphenated `google-health`.
const googleHealthViewModel = pickStatus(integrationStatus, "google-health");
// v1.17.1 — Polar/Oura now read off the same consolidated envelope; the cards
// no longer fire their own /api/<provider>/status round-trip.
const polarViewModel = toOAuthStatus(pickStatus(integrationStatus, "polar"));
const ouraViewModel = toOAuthStatus(pickStatus(integrationStatus, "oura"));
// v1.28.x — Strava reads off the same consolidated envelope.
const stravaViewModel = toOAuthStatus(
pickStatus(integrationStatus, "strava"),
);
const nightscoutViewModel = pickStatus(integrationStatus, "nightscout");
const unclaimedIntegrations = (integrationStatus?.integrations ?? [])
.filter(
(entry) =>
@@ -351,26 +308,41 @@ export function ConnectionsPanel({
{/* Per-provider anchors so deep-links (onboarding "more sources" chips,
`/settings/integrations#oura`) scroll straight to the right card. */}
<div id="withings" className="scroll-mt-28">
<WithingsCard
viewModel={withingsViewModel}
<OAuthIntegrationCard
config={OAUTH_INTEGRATIONS.withings}
viewModel={pickStatus(integrationStatus, "withings")}
callbackUrl={callbackUrls.withings}
/>
</div>
<div id="whoop" className="scroll-mt-28">
<WhoopCard
viewModel={whoopViewModel}
<OAuthIntegrationCard
config={OAUTH_INTEGRATIONS.whoop}
viewModel={pickStatus(integrationStatus, "whoop")}
callbackUrl={callbackUrls.whoop}
/>
</div>
<div id="fitbit" className="scroll-mt-28">
<FitbitCard
viewModel={fitbitViewModel}
<OAuthIntegrationCard
config={OAUTH_INTEGRATIONS.fitbit}
viewModel={pickStatus(integrationStatus, "fitbit")}
callbackUrl={callbackUrls.fitbit}
titleAccessory={
<>
<TagChip>{t("settings.fitbitTag")}</TagChip>
<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={googleHealthViewModel}
viewModel={pickStatus(integrationStatus, "google-health")}
callbackUrl={callbackUrls["google-health"]}
/>
</div>
@@ -378,22 +350,30 @@ export function ConnectionsPanel({
<AppleHealthCard enabled={isAuthenticated} />
</div>
<div id="polar" className="scroll-mt-28">
<PolarCard
viewModel={polarViewModel}
<OAuthIntegrationCard
config={OAUTH_INTEGRATIONS.polar}
viewModel={pickStatus(integrationStatus, "polar")}
callbackUrl={callbackUrls.polar}
/>
</div>
<div id="oura" className="scroll-mt-28">
<OuraCard viewModel={ouraViewModel} callbackUrl={callbackUrls.oura} />
<OAuthIntegrationCard
config={OAUTH_INTEGRATIONS.oura}
viewModel={pickStatus(integrationStatus, "oura")}
callbackUrl={callbackUrls.oura}
/>
</div>
<div id="strava" className="scroll-mt-28">
<StravaCard
viewModel={stravaViewModel}
<OAuthIntegrationCard
config={OAUTH_INTEGRATIONS.strava}
viewModel={pickStatus(integrationStatus, "strava")}
callbackUrl={callbackUrls.strava}
/>
</div>
<div id="nightscout" className="scroll-mt-28">
<NightscoutCard viewModel={nightscoutViewModel} />
<NightscoutCard
viewModel={pickStatus(integrationStatus, "nightscout")}
/>
</div>
{/* Every configured envelope entry no card above claims. Normally this
renders nothing; on an account carrying a dropped-but-still-running
@@ -0,0 +1,175 @@
"use client";
/**
* The bring-your-own OAuth app credentials form, one copy for every provider
* that takes a client id and secret (Withings, WHOOP, Fitbit, Google Health,
* Polar, Oura, Strava). It sits in the not-connected card before "Connect",
* and behind "Change credentials" in the menu once connected.
*
* Config fields, so no password manager is offered them (`autoComplete="off"`
* on both, the secret through `PasswordInput`).
*/
import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { Loader2, Save } from "lucide-react";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { PasswordInput } from "@/components/ui/password-input";
import { apiFetchRaw } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
export function CredentialsForm({
provider,
i18nPrefix,
saved,
secretPlaceholder,
statusQueryKey,
}: {
/** Route segment: PUT `/api/<provider>/credentials`; also the field ids. */
provider: string;
/** i18n base, e.g. `settings.withings`. */
i18nPrefix: string;
/** A pair is stored already: the fields show the saved placeholder. */
saved: boolean;
/**
* The provider ships a secret-specific saved placeholder
* (`…CredentialsSavedPlaceholderSecret`); the others reuse the id's.
*/
secretPlaceholder: boolean;
statusQueryKey: readonly unknown[];
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [clientId, setClientId] = useState("");
const [clientSecret, setClientSecret] = useState("");
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState<{
kind: "success" | "error";
text: string;
} | null>(null);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setMsg(null);
try {
const res = await apiFetchRaw(`/api/${provider}/credentials`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
clientId: clientId.trim(),
clientSecret: clientSecret.trim(),
}),
});
if (res.ok) {
setMsg({ kind: "success", text: t(`${i18nPrefix}CredentialsSaved`) });
setClientId("");
setClientSecret("");
queryClient.invalidateQueries({ queryKey: statusQueryKey });
// The cards read the consolidated envelope; repaint it as well.
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
} else {
let text = t("settings.savingError");
try {
const json = await res.json();
if (json.error) text = json.error;
} catch {
// keep the generic message
}
setMsg({ kind: "error", text });
}
} catch {
setMsg({ kind: "error", text: t("common.networkError") });
}
setSaving(false);
}
return (
<form
onSubmit={handleSubmit}
className="space-y-3"
data-testid={`${provider}-credentials`}
>
<h3 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{t(`${i18nPrefix}Credentials`)}
</h3>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor={`${provider}-clientid`}>
{t(`${i18nPrefix}ClientId`)}
</Label>
<Input
id={`${provider}-clientid`}
value={clientId}
onChange={(e) => setClientId(e.target.value)}
placeholder={
saved
? t(`${i18nPrefix}CredentialsSavedPlaceholder`)
: t(`${i18nPrefix}ClientId`)
}
maxLength={200}
autoComplete="off"
inputMode="text"
spellCheck={false}
autoCapitalize="none"
enterKeyHint="next"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor={`${provider}-secret`}>
{t(`${i18nPrefix}ClientSecret`)}
</Label>
<PasswordInput
id={`${provider}-secret`}
value={clientSecret}
onChange={(e) => setClientSecret(e.target.value)}
placeholder={
!saved
? t(`${i18nPrefix}ClientSecret`)
: secretPlaceholder
? t(`${i18nPrefix}CredentialsSavedPlaceholderSecret`)
: t(`${i18nPrefix}CredentialsSavedPlaceholder`)
}
maxLength={200}
autoComplete="off"
inputMode="text"
spellCheck={false}
autoCapitalize="none"
enterKeyHint="done"
/>
</div>
</div>
{msg && (
<p
role={msg.kind === "error" ? "alert" : "status"}
className={`text-sm ${msg.kind === "success" ? "text-success" : "text-destructive"}`}
>
{msg.text}
</p>
)}
<SettingsCardActions>
<Button
type="submit"
variant="outline"
size="sm"
className="min-h-11 w-full sm:min-h-9 sm:w-auto"
disabled={saving || !clientId.trim() || !clientSecret.trim()}
>
{saving ? (
<Loader2 className="size-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Save className="size-3.5" aria-hidden="true" />
)}
{t(`${i18nPrefix}SaveCredentials`)}
</Button>
</SettingsCardActions>
</form>
);
}
@@ -1,537 +0,0 @@
"use client";
// v1.12.0 — Fitbit (Fitbit & Pixel) card. Mirrors the WHOOP card: a
// BYO-key credentials form first, then an OAuth connect, then the
// sync/test/disconnect action row + parked-resume banner. v1.12.1 — status
// reads off the consolidated /api/integrations/status envelope (the per-card
// /api/fitbit/status round-trip is gone); the pill/error/parked state comes off
// the same view-model like WHOOP.
import { useState } from "react";
import Link from "next/link";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
ArrowRight,
HeartPulse,
Link2,
Loader2,
RefreshCw,
Save,
Unlink,
} from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { PasswordInput } from "@/components/ui/password-input";
import { TagChip } from "@/components/ui/tag-chip";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { IntegrationStatusPill } from "@/components/settings/integration-status-pill";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch";
import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line";
import { useTranslations } from "@/lib/i18n/context";
import {
invalidateKeys,
measurementDependentKeys,
queryKeys,
} from "@/lib/query-keys";
import {
IntegrationErrorMessage,
pillFailurePropsFor,
pillStateFor,
pillTimestampFor,
type IntegrationStatusViewModel,
} from "./shared";
import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure";
import {
readSyncOutcome,
useSyncOutcomeMessage,
type SyncOutcomeState,
} from "./sync-outcome";
import {
CallbackMismatchNotice,
IntegrationCardDescription,
IntegrationRedirectGuide,
} from "./setup-guide-link";
import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls";
import { loadDocument } from "@/lib/navigation/load-document";
export function FitbitCard({
viewModel,
callbackUrl,
}: {
viewModel: IntegrationStatusViewModel | undefined;
/** Server-resolved OAuth callback URL; see `getIntegrationCallbackUrls`. */
callbackUrl: IntegrationCallbackUrls["fitbit"];
}) {
const { t } = useTranslations();
const describeSyncOutcome = useSyncOutcomeMessage();
const [syncing, setSyncing] = useState(false);
const [syncResult, setSyncResult] = useState<SyncOutcomeState | null>(null);
const [clientId, setClientId] = useState("");
const [clientSecret, setClientSecret] = useState("");
const [credsSaving, setCredsSaving] = useState(false);
const [credsMsg, setCredsMsg] = useState<string | null>(null);
const [credsMsgType, setCredsMsgType] = useState<"success" | "error" | null>(
null,
);
const queryClient = useQueryClient();
const status = viewModel;
const disconnect = useMutation({
mutationFn: async () => {
await apiPost("/api/fitbit/disconnect");
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.fitbit() });
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
},
});
// Clear a parked integration via the resume endpoint. The CTA is
// rendered inside the parked banner below; success invalidates both
// the per-card status (so the pill flips back to connected
// immediately) and the cross-integration envelope (so any other view
// picks up the change on its next focus).
const resume = useMutation({
mutationFn: async () => {
return apiPost<{ resumed: boolean; wasParked: boolean }>(
"/api/integrations/fitbit/resume",
);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.fitbit() });
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
},
});
async function handleSync(fullSync = false) {
setSyncing(true);
setSyncResult(null);
try {
const res = await apiFetchRaw("/api/fitbit/sync", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fullSync }),
});
const json = await res.json();
const result = res.ok ? readSyncOutcome(json) : null;
if (result) {
// The tone comes off what the run wrote, not off `res.ok`.
setSyncResult({
outcome: result.outcome,
message: describeSyncOutcome(
result,
t("settings.fitbitSyncResult", { count: result.imported }),
),
});
void invalidateKeys(queryClient, measurementDependentKeys);
queryClient.invalidateQueries({ queryKey: queryKeys.fitbit() });
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
} else {
setSyncResult({
outcome: "failed",
message: json?.error || t("settings.fitbitSyncFailed"),
});
}
} catch {
setSyncResult({
outcome: "failed",
message: t("settings.fitbitSyncFailed"),
});
} finally {
setSyncing(false);
}
}
async function handleSaveCredentials(e: React.FormEvent) {
e.preventDefault();
setCredsSaving(true);
setCredsMsg(null);
setCredsMsgType(null);
try {
const res = await apiFetchRaw("/api/fitbit/credentials", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
clientId: clientId.trim(),
clientSecret: clientSecret.trim(),
}),
});
if (res.ok) {
setCredsMsg(t("settings.fitbitCredentialsSaved"));
setCredsMsgType("success");
setClientId("");
setClientSecret("");
queryClient.invalidateQueries({ queryKey: queryKeys.fitbit() });
} else {
try {
const json = await res.json();
setCredsMsg(json.error || t("settings.savingError"));
} catch {
setCredsMsg(t("settings.savingError"));
}
setCredsMsgType("error");
}
} catch {
setCredsMsg(t("common.networkError"));
setCredsMsgType("error");
}
setCredsSaving(false);
}
// The server resolves the verdict; the card only projects it. `connected`
// rides into that resolution, so a disconnected provider still lands on the
// "Not connected" pill without a second local rule here.
const pillState = pillStateFor(viewModel);
const pillLastSyncAt = pillTimestampFor(viewModel);
const errorMessage =
(pillState === "error" ||
pillState === "parked" ||
pillState === "warning") &&
viewModel?.lastError
? viewModel.lastError
: null;
return (
<SettingsCard data-testid="fitbit-card">
<SettingsCardHeader
icon={HeartPulse}
title={t("settings.fitbit")}
titleAccessory={
<>
<TagChip>{t("settings.fitbitTag")}</TagChip>
<Badge
variant="outline"
data-testid="fitbit-experimental-badge"
className="border-warning/50 text-warning"
>
{t("settings.fitbitExperimentalBadge")}
</Badge>
</>
}
description={
<IntegrationCardDescription
i18nPrefix="settings.fitbit"
provider="fitbit"
/>
}
status={
<IntegrationStatusPill
state={pillState}
lastSyncAt={pillLastSyncAt}
{...pillFailurePropsFor(viewModel)}
/>
}
/>
<div className="space-y-4">
{errorMessage && <IntegrationErrorMessage message={errorMessage} />}
{/* Parked-integration resume CTA. Surfaces only when the row
state is `parked` (>24h of persistent failures). The button
POSTs to /api/integrations/fitbit/resume which calls
`resumeIntegrationFromPark`; on success the per-card status
invalidates and the pill flips back to connected without a
page refresh. */}
{pillState === "parked" && (
<div
data-testid="fitbit-parked-banner"
className="border-warning/30 bg-warning/10 flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm"
>
<span className="text-warning min-w-0 text-xs break-words">
{t("settings.integrationPill.parkedReconnect")}
</span>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => resume.mutate()}
disabled={resume.isPending}
data-testid="fitbit-resume-button"
className="min-h-11"
>
{resume.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Link2 className="h-3.5 w-3.5" />
)}
{t("settings.integrationPill.resumeCta")}
</Button>
</div>
)}
{resume.isError && (
<p
role="alert"
className="text-destructive text-sm"
data-testid="fitbit-resume-error"
>
{t("settings.integrationPill.resumeError")}
</p>
)}
{status?.connected && (
<MetricFreshnessDisclosure
entries={viewModel?.metricFreshness}
idPrefix="fitbit"
/>
)}
{resume.isSuccess && resume.data?.wasParked && (
<p
role="status"
className="text-success text-xs"
data-testid="fitbit-resume-success"
>
{t("settings.integrationPill.resumeSuccess")}
</p>
)}
<div className="space-y-3">
<h3 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{t("settings.fitbitCredentials")}
</h3>
{!status?.configured && (
<IntegrationRedirectGuide
provider="fitbit"
providerLabel={t("settings.fitbit")}
callbackUrl={callbackUrl}
/>
)}
<form onSubmit={handleSaveCredentials} className="space-y-3">
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="fitbit-clientid">
{t("settings.fitbitClientId")}
</Label>
<Input
id="fitbit-clientid"
value={clientId}
onChange={(e) => setClientId(e.target.value)}
placeholder={
status?.configured
? t("settings.fitbitCredentialsSavedPlaceholder")
: t("settings.fitbitClientId")
}
maxLength={200}
autoComplete="off"
inputMode="text"
spellCheck={false}
autoCapitalize="none"
enterKeyHint="next"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="fitbit-secret">
{t("settings.fitbitClientSecret")}
</Label>
<PasswordInput
id="fitbit-secret"
value={clientSecret}
onChange={(e) => setClientSecret(e.target.value)}
placeholder={
status?.configured
? t("settings.fitbitCredentialsSavedPlaceholderSecret")
: t("settings.fitbitClientSecret")
}
maxLength={200}
autoComplete="off"
inputMode="text"
spellCheck={false}
autoCapitalize="none"
enterKeyHint="done"
/>
</div>
</div>
<SettingsCardActions>
<Button
type="submit"
variant="outline"
size="sm"
className="min-h-11 w-full sm:w-auto"
disabled={
credsSaving || !clientId.trim() || !clientSecret.trim()
}
>
{credsSaving ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Save className="h-3.5 w-3.5" />
)}
{t("settings.fitbitSaveCredentials")}
</Button>
</SettingsCardActions>
{credsMsg && (
<p
role="alert"
className={`text-sm ${credsMsgType === "success" ? "text-success" : "text-destructive"}`}
>
{credsMsg}
</p>
)}
</form>
</div>
{status?.connected ? (
<>
<div className="flex flex-wrap items-start gap-2 [&>*]:min-w-[10rem] sm:[&>*]:min-w-0">
<Button
variant="outline"
size="sm"
className="min-h-11"
onClick={() => handleSync(false)}
disabled={syncing}
>
{syncing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
{t("settings.fitbitSync")}
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="min-h-11"
disabled={syncing}
>
{syncing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
{t("settings.fitbitFullSync")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.fitbitFullSyncTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.fitbitFullSyncDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={() => handleSync(true)}>
{t("settings.fitbitSynchronize")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<TestConnectionButton
endpoint="/api/integrations/fitbit/test"
disabled={!status?.connected}
/>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="text-destructive min-h-11"
>
<Unlink className="h-3.5 w-3.5" />
{t("settings.fitbitDisconnect")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.fitbitDisconnectTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.fitbitDisconnectDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => disconnect.mutate()}
>
{t("settings.fitbitDisconnect")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
{status?.backfillCompleted === false && (
<p className="text-muted-foreground text-xs">
{t("settings.fitbitBackfillInProgress")}
</p>
)}
{syncResult && (
<WrittenOutcomeLine
outcome={syncResult.outcome}
message={syncResult.message}
testId="fitbit-sync-result"
/>
)}
{/* connect→data loop: a discreet link to where this provider's
readings now surface — doubles as the "your data is richer"
cue. */}
<Link
href="/insights/sleep"
data-testid="fitbit-data-link"
className="text-primary inline-flex items-center gap-1 text-xs underline-offset-2 hover:underline"
>
{t("settings.fitbitViewData")}
<ArrowRight className="h-3 w-3" />
</Link>
</>
) : status?.configured ? (
<SettingsCardActions>
<Button
size="sm"
className="min-h-11 w-full sm:w-auto"
data-testid="fitbit-connect"
onClick={() => {
loadDocument("/api/fitbit/connect");
}}
>
<Link2 className="h-3.5 w-3.5" />
{t("settings.fitbitConnect")}
</Button>
</SettingsCardActions>
) : (
<p
className="text-muted-foreground text-xs"
data-testid="integration-unavailable"
>
{t("settings.fitbitNoCredentials")}
</p>
)}
</div>
<CallbackMismatchNotice
provider={t("settings.fitbit")}
callbackUrl={callbackUrl}
/>
</SettingsCard>
);
}
@@ -1,72 +1,32 @@
"use client";
// v1.27.0 — Google Health (Fitbit + Pixel Watch + Fitbit Air) card. Reads
// Fitbit and Pixel Watch data through the successor Google Health API — a
// separate, coexisting integration from the classic `fitbit` transport, which
// sunsets Sept 2026. Mirrors the Fitbit card anatomy: a BYO Google-Cloud
// client-id/secret form first, then an OAuth connect, then the
// sync/test/disconnect action row + parked-resume banner. Status reads off the
// consolidated /api/integrations/status envelope (no per-card round-trip).
// Google Health (Fitbit + Pixel Watch + Fitbit Air), read through the
// successor Google Health API: a separate, coexisting integration from the
// classic `fitbit` transport, which sunsets in September 2026.
//
// One thing this card carries that the classic Fitbit card does not: a distinct
// RE-CONSENT CTA. Google expires the refresh token after 7 days while the
// operator's OAuth client stays in "Testing" publishing mode (the CASA-free
// path), so a connected user is periodically pushed back through OAuth. When the
// envelope reports `needsReauth`, the card surfaces a clear "Reconnect" banner
// separate from the connect/disconnect state.
// It renders the shared OAuth integration card. What it adds is its own sync
// choreography: a manual sync may outlive its request, so the card polls the
// authenticated status route until the run settles, shows the run's progress
// while it does, and lists the resources that did not complete in the details.
// Google's 7-day refresh expiry in "Testing" publishing mode is no longer a
// banner of its own: the server reports it as `action_required` /
// `reauth_required`, and the card's one primary button reconnects.
import { useState } from "react";
import Link from "next/link";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
ArrowRight,
Link2,
Loader2,
RefreshCw,
Save,
Unlink,
Watch,
} from "lucide-react";
import { useQueryClient } from "@tanstack/react-query";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { PasswordInput } from "@/components/ui/password-input";
import { TagChip } from "@/components/ui/tag-chip";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { IntegrationStatusPill } from "@/components/settings/integration-status-pill";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch";
import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line";
import { Badge } from "@/components/ui/badge";
import { TagChip } from "@/components/ui/tag-chip";
import { apiFetchRaw } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls";
import {
invalidateKeys,
measurementDependentKeys,
queryKeys,
} from "@/lib/query-keys";
import {
IntegrationErrorMessage,
pillFailurePropsFor,
pillStateFor,
pillTimestampFor,
type IntegrationStatusViewModel,
} from "./shared";
import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure";
import {
completedGoogleHealthResourceCount,
failedGoogleHealthResources,
@@ -74,18 +34,17 @@ import {
readGoogleHealthProgress,
type GoogleHealthProgress,
} from "./google-health-progress-view";
import {
OAUTH_INTEGRATIONS,
OAuthIntegrationCardView,
} from "./oauth-integration-card";
import type { IntegrationStatusViewModel } from "./shared";
import {
readSyncOutcome,
useSyncOutcomeMessage,
type SyncOutcomeResource,
type SyncOutcomeState,
} from "./sync-outcome";
import {
CallbackMismatchNotice,
IntegrationCardDescription,
} from "./setup-guide-link";
import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls";
import { loadDocument } from "@/lib/navigation/load-document";
export function GoogleHealthCard({
viewModel,
@@ -102,17 +61,8 @@ export function GoogleHealthCard({
const [syncProgress, setSyncProgress] = useState<GoogleHealthProgress | null>(
null,
);
const [clientId, setClientId] = useState("");
const [clientSecret, setClientSecret] = useState("");
const [credsSaving, setCredsSaving] = useState(false);
const [credsMsg, setCredsMsg] = useState<string | null>(null);
const [credsMsgType, setCredsMsgType] = useState<"success" | "error" | null>(
null,
);
const queryClient = useQueryClient();
const status = viewModel;
function invalidateCommittedWorkout(resources: SyncOutcomeResource[]) {
const workoutCommitted = resources.some(
(resource) =>
@@ -216,36 +166,6 @@ export function GoogleHealthCard({
}
}
const disconnect = useMutation({
mutationFn: async () => {
await apiPost("/api/google-health/disconnect");
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.googleHealth() });
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
},
});
// Clear a parked integration via the resume endpoint. The CTA is rendered
// inside the parked banner below; success invalidates both the per-card
// status and the cross-integration envelope so any other view picks up the
// change on its next focus.
const resume = useMutation({
mutationFn: async () => {
return apiPost<{ resumed: boolean; wasParked: boolean }>(
"/api/integrations/google-health/resume",
);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.googleHealth() });
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
},
});
async function handleSync(fullSync = false) {
setSyncing(true);
setSyncResult(null);
@@ -335,360 +255,48 @@ export function GoogleHealthCard({
}
}
async function handleSaveCredentials(e: React.FormEvent) {
e.preventDefault();
setCredsSaving(true);
setCredsMsg(null);
setCredsMsgType(null);
try {
const res = await apiFetchRaw("/api/google-health/credentials", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
clientId: clientId.trim(),
clientSecret: clientSecret.trim(),
}),
});
if (res.ok) {
setCredsMsg(t("settings.googleHealthCredentialsSaved"));
setCredsMsgType("success");
setClientId("");
setClientSecret("");
queryClient.invalidateQueries({ queryKey: queryKeys.googleHealth() });
} else {
try {
const json = await res.json();
setCredsMsg(json.error || t("settings.savingError"));
} catch {
setCredsMsg(t("settings.savingError"));
}
setCredsMsgType("error");
}
} catch {
setCredsMsg(t("common.networkError"));
setCredsMsgType("error");
}
setCredsSaving(false);
}
// The server resolves the verdict; the card only projects it. `connected`
// rides into that resolution, so a disconnected provider still lands on the
// "Not connected" pill without a second local rule here.
const pillState = pillStateFor(viewModel);
const pillLastSyncAt = pillTimestampFor(viewModel);
const errorMessage =
(pillState === "error" ||
pillState === "parked" ||
pillState === "warning") &&
viewModel?.lastError
? viewModel.lastError
: null;
// The re-consent banner only makes sense while the connection still exists —
// a disconnected card has no token to renew. `needsReauth` is the 7-day
// Testing-mode expiry (or a revoked grant) surfaced by the status route.
const showReauth = Boolean(status?.connected && status?.needsReauth);
const resourceFailures = failedGoogleHealthResources(syncProgress);
const completedResources = completedGoogleHealthResourceCount(syncProgress);
const inProgress = syncProgress?.state === "in_progress";
return (
<SettingsCard data-testid="google-health-card">
<SettingsCardHeader
icon={Watch}
title={t("settings.googleHealth")}
titleAccessory={
<>
<TagChip>{t("settings.googleHealthTag")}</TagChip>
<Badge
variant="outline"
data-testid="google-health-beta-badge"
className="border-warning/50 text-warning"
>
{t("settings.googleHealthBetaBadge")}
</Badge>
</>
}
description={
<IntegrationCardDescription
i18nPrefix="settings.googleHealth"
provider="google-health"
/>
}
status={
<IntegrationStatusPill
state={pillState}
lastSyncAt={pillLastSyncAt}
{...pillFailurePropsFor(viewModel)}
/>
}
/>
<div className="space-y-4">
{errorMessage && <IntegrationErrorMessage message={errorMessage} />}
{/* Re-consent CTA — distinct from parked/disconnected. Google expires
the refresh token after 7 days in "Testing" publishing mode (the
CASA-free path), so a connected user is periodically pushed back
through OAuth. Re-running connect mints a fresh grant. */}
{showReauth && (
<div
data-testid="google-health-reauth-banner"
className="border-warning/30 bg-warning/10 flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm"
<OAuthIntegrationCardView
config={OAUTH_INTEGRATIONS["google-health"]}
viewModel={viewModel}
callbackUrl={callbackUrl}
titleAccessory={
<>
<TagChip>{t("settings.googleHealthTag")}</TagChip>
<Badge
variant="outline"
data-testid="google-health-beta-badge"
className="border-warning/50 text-warning"
>
<span className="text-warning min-w-0 text-xs break-words">
{t("settings.googleHealthReauthBanner")}
</span>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
loadDocument("/api/google-health/connect");
}}
data-testid="google-health-reconnect-button"
className="min-h-11"
>
<RefreshCw className="h-3.5 w-3.5" />
{t("settings.googleHealthReconnect")}
</Button>
</div>
)}
{/* Parked-integration resume CTA — surfaces only when the row state is
`parked` (>24h of persistent failures). */}
{pillState === "parked" && (
<div
data-testid="google-health-parked-banner"
className="border-warning/30 bg-warning/10 flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm"
>
<span className="text-warning min-w-0 text-xs break-words">
{t("settings.integrationPill.parkedReconnect")}
</span>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => resume.mutate()}
disabled={resume.isPending}
data-testid="google-health-resume-button"
className="min-h-11"
>
{resume.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Link2 className="h-3.5 w-3.5" />
)}
{t("settings.integrationPill.resumeCta")}
</Button>
</div>
)}
{resume.isError && (
<p
role="alert"
className="text-destructive text-sm"
data-testid="google-health-resume-error"
>
{t("settings.integrationPill.resumeError")}
</p>
)}
{status?.connected && (
<MetricFreshnessDisclosure
entries={viewModel?.metricFreshness}
idPrefix="google-health"
/>
)}
{resume.isSuccess && resume.data?.wasParked && (
<p
role="status"
className="text-success text-xs"
data-testid="google-health-resume-success"
>
{t("settings.integrationPill.resumeSuccess")}
</p>
)}
<div className="space-y-3">
<h3 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{t("settings.googleHealthCredentials")}
</h3>
<form onSubmit={handleSaveCredentials} className="space-y-3">
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="google-health-clientid">
{t("settings.googleHealthClientId")}
</Label>
<Input
id="google-health-clientid"
value={clientId}
onChange={(e) => setClientId(e.target.value)}
placeholder={
status?.configured
? t("settings.googleHealthCredentialsSavedPlaceholder")
: t("settings.googleHealthClientId")
}
maxLength={200}
autoComplete="off"
inputMode="text"
spellCheck={false}
autoCapitalize="none"
enterKeyHint="next"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="google-health-secret">
{t("settings.googleHealthClientSecret")}
</Label>
<PasswordInput
id="google-health-secret"
value={clientSecret}
onChange={(e) => setClientSecret(e.target.value)}
placeholder={
status?.configured
? t(
"settings.googleHealthCredentialsSavedPlaceholderSecret",
)
: t("settings.googleHealthClientSecret")
}
maxLength={200}
autoComplete="off"
inputMode="text"
spellCheck={false}
autoCapitalize="none"
enterKeyHint="done"
/>
</div>
</div>
<SettingsCardActions>
<Button
type="submit"
variant="outline"
size="sm"
className="min-h-11 w-full sm:w-auto"
disabled={
credsSaving || !clientId.trim() || !clientSecret.trim()
}
>
{credsSaving ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Save className="h-3.5 w-3.5" />
)}
{t("settings.googleHealthSaveCredentials")}
</Button>
</SettingsCardActions>
{credsMsg && (
<p
role="alert"
className={`text-sm ${credsMsgType === "success" ? "text-success" : "text-destructive"}`}
>
{credsMsg}
</p>
)}
</form>
</div>
{status?.connected ? (
<>
<div className="flex flex-wrap items-start gap-2 [&>*]:min-w-[10rem] sm:[&>*]:min-w-0">
<Button
variant="outline"
size="sm"
className="min-h-11"
onClick={() => {
if (syncProgress?.state === "in_progress") {
void refreshSyncStatus();
} else {
void handleSync(false);
}
}}
disabled={syncing}
>
{syncing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
{syncProgress?.state === "in_progress"
? t("settings.googleHealthSyncCheckStatus")
: t("settings.googleHealthSync")}
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="min-h-11"
disabled={syncing || syncProgress?.state === "in_progress"}
>
{syncing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
{t("settings.googleHealthFullSync")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.googleHealthFullSyncTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.googleHealthFullSyncDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={() => handleSync(true)}>
{t("settings.googleHealthSynchronize")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<TestConnectionButton
endpoint="/api/integrations/google-health/test"
disabled={!status?.connected}
/>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="text-destructive min-h-11"
>
<Unlink className="h-3.5 w-3.5" />
{t("settings.googleHealthDisconnect")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.googleHealthDisconnectTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.googleHealthDisconnectDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => disconnect.mutate()}
>
{t("settings.googleHealthDisconnect")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
{status?.backfillCompleted === false && (
<p className="text-muted-foreground text-xs">
{t("settings.googleHealthBackfillInProgress")}
</p>
)}
{syncProgress?.state === "in_progress" && (
{t("settings.googleHealthBetaBadge")}
</Badge>
</>
}
sync={{
syncing,
result: syncResult,
run: (fullSync) => {
if (!fullSync && syncProgress?.state === "in_progress") {
void refreshSyncStatus();
} else {
void handleSync(fullSync);
}
},
}}
syncLabel={
inProgress
? t("settings.googleHealthSyncCheckStatus")
: t("settings.googleHealthSync")
}
fullSyncDisabled={inProgress}
syncResults={
inProgress || syncResult ? (
<div className="space-y-2">
{inProgress ? (
<p
role="status"
aria-live="polite"
@@ -701,75 +309,40 @@ export function GoogleHealthCard({
total: syncProgress.resources.length,
})}
</p>
)}
{syncResult && (
) : null}
{syncResult ? (
<WrittenOutcomeLine
outcome={syncResult.outcome}
message={syncResult.message}
testId="google-health-sync-result"
/>
)}
{resourceFailures.length > 0 && (
<div
role="alert"
data-testid="google-health-resource-failures"
className="border-warning/30 bg-warning/10 rounded-md border px-3 py-2"
>
<p className="text-foreground text-xs font-medium">
{t("settings.googleHealthResourceFailuresTitle")}
</p>
<ul className="text-muted-foreground mt-1 space-y-1 text-xs">
{resourceFailures.map((resource) => (
<li key={resource.resource}>
<span className="text-foreground font-medium">
{resourceLabel(resource.resource)}
</span>
{": "}
{resourceFailureMessage(resource)}
</li>
))}
</ul>
</div>
)}
{/* connect→data loop: a discreet link to where this provider's
readings now surface — doubles as the "your data is richer"
cue. */}
<Link
href="/insights/steps"
data-testid="google-health-data-link"
className="text-primary inline-flex items-center gap-1 text-xs underline-offset-2 hover:underline"
>
{t("settings.googleHealthViewData")}
<ArrowRight className="h-3 w-3" />
</Link>
</>
) : status?.configured ? (
<SettingsCardActions>
<Button
size="sm"
className="min-h-11 w-full sm:w-auto"
data-testid="googlehealth-connect"
onClick={() => {
loadDocument("/api/google-health/connect");
}}
>
<Link2 className="h-3.5 w-3.5" />
{t("settings.googleHealthConnect")}
</Button>
</SettingsCardActions>
) : (
<p
className="text-muted-foreground text-xs"
data-testid="integration-unavailable"
) : null}
</div>
) : null
}
details={
resourceFailures.length > 0 ? (
<div
data-testid="google-health-resource-failures"
className="space-y-1"
>
{t("settings.googleHealthNoCredentials")}
</p>
)}
</div>
<CallbackMismatchNotice
provider={t("settings.googleHealth")}
callbackUrl={callbackUrl}
/>
</SettingsCard>
<p className="text-sm font-medium">
{t("settings.googleHealthResourceFailuresTitle")}
</p>
<ul className="space-y-1 text-sm">
{resourceFailures.map((resource) => (
<li key={resource.resource}>
<span className="font-medium">
{resourceLabel(resource.resource)}
</span>
{": "}
{resourceFailureMessage(resource)}
</li>
))}
</ul>
</div>
) : undefined
}
/>
);
}
@@ -0,0 +1,208 @@
"use client";
/**
* The requests behind an integration card's menu, shared by every provider
* controller: sync now (optionally the whole history), test the connection,
* and the lines that report what happened. The card renders the lines in its
* body; the menu only starts the request.
*/
import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { CheckCircle2, Loader2 } from "lucide-react";
import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line";
import {
runConnectionTest,
TestConnectionFailure,
type ConnectionTestResult,
} from "@/components/settings/test-connection-button";
import { apiFetchRaw } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import {
invalidateKeys,
measurementDependentKeys,
queryKeys,
} from "@/lib/query-keys";
import {
readSyncOutcome,
useSyncOutcomeMessage,
type SyncOutcomeState,
} from "./sync-outcome";
export interface ProviderSync {
syncing: boolean;
result: SyncOutcomeState | null;
run: (fullSync: boolean) => void;
}
/**
* Sync now, for every provider whose sync route answers the shared written
* outcome. The tone comes off what the run wrote, never off `res.ok`.
*/
export function useStandardSync({
provider,
i18nPrefix,
acceptsFullSync,
fullSyncResult,
statusQueryKey,
}: {
/** Route segment: POST `/api/<provider>/sync`. */
provider: string;
i18nPrefix: string;
/** The route takes `{ fullSync }`; the others take no body. */
acceptsFullSync: boolean;
/** A full sync has its own success sentence (`…FullSyncResult`). */
fullSyncResult?: boolean;
statusQueryKey?: readonly unknown[];
}): ProviderSync {
const { t } = useTranslations();
const describeSyncOutcome = useSyncOutcomeMessage();
const queryClient = useQueryClient();
const [syncing, setSyncing] = useState(false);
const [syncResult, setSyncResult] = useState<SyncOutcomeState | null>(null);
async function run(fullSync: boolean) {
setSyncing(true);
setSyncResult(null);
try {
const res = await apiFetchRaw(
`/api/${provider}/sync`,
acceptsFullSync
? {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fullSync }),
}
: { method: "POST" },
);
const json = await res.json();
const result = res.ok ? readSyncOutcome(json) : null;
if (result) {
setSyncResult({
outcome: result.outcome,
message: describeSyncOutcome(
result,
fullSync && fullSyncResult
? t(`${i18nPrefix}FullSyncResult`, { count: result.imported })
: t(`${i18nPrefix}SyncResult`, { count: result.imported }),
),
});
void invalidateKeys(queryClient, measurementDependentKeys);
if (statusQueryKey) {
queryClient.invalidateQueries({ queryKey: statusQueryKey });
}
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
} else {
setSyncResult({
outcome: "failed",
message: json?.error || t(`${i18nPrefix}SyncFailed`),
});
}
} catch {
setSyncResult({
outcome: "failed",
message: t(`${i18nPrefix}SyncFailed`),
});
} finally {
setSyncing(false);
}
}
return {
syncing,
result: syncResult,
run: (fullSync) => void run(fullSync),
};
}
/** "Test connection" from the menu; the result shows in the card body. */
export function useConnectionTest(endpoint: string) {
const [testing, setTesting] = useState(false);
const [result, setResult] = useState<ConnectionTestResult | null>(null);
async function run() {
setTesting(true);
setResult(null);
try {
setResult(await runConnectionTest(endpoint));
} finally {
setTesting(false);
}
}
return { testing, result, run: () => void run() };
}
/**
* What the last menu action reported: a running sync or test, the sync's
* written outcome, the test's answer. Renders nothing when nothing ran.
*/
export function ActionResultLines({
provider,
sync,
test,
}: {
provider: string;
sync?: Pick<ProviderSync, "syncing" | "result">;
test?: ReturnType<typeof useConnectionTest>;
}) {
const { t } = useTranslations();
function describeError(errorCode: string): string {
const key = `settings.testConnection.errors.${errorCode}`;
const translated = t(key);
return translated === key
? t("settings.testConnection.errors.generic")
: translated;
}
const busy = sync?.syncing || test?.testing;
const syncResult = sync?.result ?? null;
if (!busy && !syncResult && !test?.result) return null;
return (
<div className="space-y-2" data-slot="integration-action-results">
{busy ? (
<p
role="status"
className="text-muted-foreground flex items-center gap-1.5 text-xs"
>
<Loader2
aria-hidden="true"
className="size-3.5 animate-spin motion-reduce:animate-none"
/>
{sync?.syncing
? t("settings.integrationCard.syncing")
: t("settings.testConnection.testing")}
</p>
) : null}
{syncResult ? (
<WrittenOutcomeLine
outcome={syncResult.outcome}
message={syncResult.message}
testId={`${provider}-sync-result`}
/>
) : null}
{test?.result?.kind === "ok" ? (
<p
role="status"
className="text-success flex items-center gap-1.5 text-xs"
data-testid={`${provider}-test-result`}
>
<CheckCircle2 aria-hidden="true" className="size-3.5" />
{t("settings.testConnection.ok", { latency: test.result.latency })}
</p>
) : null}
{test?.result?.kind === "error" ? (
<TestConnectionFailure
message={describeError(test.result.errorCode)}
upstreamStatus={test.result.upstreamStatus}
smtpCode={test.result.smtpCode}
upstreamBody={test.result.upstreamBody}
/>
) : null}
</div>
);
}
@@ -0,0 +1,608 @@
"use client";
/**
* `<IntegrationCard>` — the one card every connection in Settings renders.
*
* A card says three things: whether it works (a badge with one word), what
* that means (one sentence), and what the person can do (at most one button).
* Everything else is either diagnostics, collapsed under "Details for a bug
* report", or rarely needed, in the three-dot menu.
*
* The card renders the server's `health` and nothing else. It does not read a
* ledger state, a verdict, a token expiry or a failure counter, and it never
* decides a state of its own: the badge, the sentence and the button are each
* a projection of one `ConnectionHealth`. Per-provider controllers (OAuth,
* Nightscout, Apple Health) own the requests and hand the card their menu
* entries, their primary handlers and anything that belongs in the details.
*
* Anatomy, top to bottom (design standards §1, §12, §13):
* header `SettingsCardHeader`, the badge in its status slot
* sentence `text-sm` foreground, then one muted meta line
* body transient lines a controller adds (a sync result, a form)
* details collapsed, always present on a connected card
* actions `SettingsCardActions`, the last element: menu left, at most
* one primary right
*/
import { useState, type ReactNode } from "react";
import {
Check,
ChevronDown,
Copy,
ExternalLink,
Link2,
Loader2,
MoreHorizontal,
Play,
type LucideIcon,
} from "lucide-react";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { SettingsCard } from "@/components/settings/settings-card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ui/confirm-button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Skeleton } from "@/components/ui/skeleton";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type {
ConnectionHealth,
ConnectionHealthState,
} from "@/lib/integrations/connection-health";
import { cn } from "@/lib/utils";
import {
diagnosticsText,
formatHealthInstant,
healthBadgeLabel,
healthSentence,
unavailableItemLabel,
} from "./integration-health-copy";
/** The app version the build stamped; empty in a dev build. */
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "";
/**
* The three calm states take the status token on a light wash. "Action
* needed" is the solid destructive badge: red text on a red wash falls below
* AA in both themes, and this is the one state that should stand out anyway.
*/
const BADGE_TONE: Record<ConnectionHealthState, string> = {
ok: "border-success/30 bg-success/15 text-success",
limited: "border-info/30 bg-info/15 text-info",
degraded: "border-warning/30 bg-warning/15 text-warning",
action_required: "",
};
/** The status badge: a dot and one word. The word is the accessible name. */
export function HealthBadge({ state }: { state: ConnectionHealthState }) {
const { t } = useTranslations();
return (
<Badge
data-slot="integration-health-badge"
data-state={state}
variant={state === "action_required" ? "destructive" : "outline"}
className={cn("gap-1.5", BADGE_TONE[state])}
>
<span aria-hidden="true" className="size-1.5 rounded-full bg-current" />
{healthBadgeLabel(state, t)}
</Badge>
);
}
export interface IntegrationMenuAction {
/** Stable id; becomes `data-action` and the `<provider>-menu-<id>` testid. */
id: string;
label: string;
icon: LucideIcon;
onSelect?: () => void;
/** Navigates instead of acting. External links open in a new tab. */
href?: string;
external?: boolean;
disabled?: boolean;
destructive?: boolean;
/** Ask first; the action runs from the dialog's confirm button. */
confirm?: { title: string; body: string; confirmLabel: string };
}
export interface IntegrationPrimaryHandlers {
onReconnect?: () => void;
onResume?: () => void;
pending?: boolean;
disabled?: boolean;
}
export interface IntegrationCardProps {
/** Connection key; drives `data-integration` and the testids. */
provider: string;
/** Provider name used inside sentences and the diagnostics. */
providerName: string;
icon: LucideIcon;
title?: ReactNode;
titleAccessory?: ReactNode;
/** One meta sentence with the setup-guide link; shown while not connected. */
description?: ReactNode;
/** The status has not loaded yet. */
loading?: boolean;
connected: boolean;
/** The server-resolved health; null when the server has none to give. */
health: ConnectionHealth | null;
/** Server fact: older history is still loading in the background. */
historyLoading?: boolean;
/** Apple Health's "open HealthLog on your iPhone" sentence variant. */
appleHealth?: boolean;
primary?: IntegrationPrimaryHandlers;
menu?: IntegrationMenuAction[];
/** Provider extras for the details section (lists, delivery facts). */
details?: ReactNode;
/** Extra redacted lines for the copied diagnostics. */
diagnosticsExtra?: readonly string[];
/** Lines below the sentence: results, errors, an opened form. */
children?: ReactNode;
/** Not connected: the forms and guides that come before connecting. */
setup?: ReactNode;
/** Not connected: the one primary button, usually "Connect". */
connectAction?: ReactNode;
/** Overrides the `<provider>-card` testid (the read-only fallback row). */
testId?: string;
}
export function IntegrationCard({
provider,
providerName,
icon,
title,
titleAccessory,
description,
loading = false,
connected,
health,
historyLoading,
appleHealth,
primary,
menu = [],
details,
diagnosticsExtra,
children,
setup,
connectAction,
testId,
}: IntegrationCardProps) {
const { t, locale } = useTranslations();
const fmt = useFormatters();
const [detailsOpen, setDetailsOpen] = useState(false);
const detailsId = `${provider}-integration-details`;
const cardState = loading
? "loading"
: !connected
? "not_connected"
: (health?.state ?? "unknown");
const header = (
<SettingsCardHeader
icon={icon}
title={title ?? providerName}
titleAccessory={titleAccessory}
description={!loading && !connected ? description : undefined}
status={
connected && !loading && health ? (
<HealthBadge state={health.state} />
) : null
}
/>
);
const shell = (body: ReactNode) => (
<SettingsCard
data-testid={testId ?? `${provider}-card`}
data-slot="integration-card"
data-integration={provider}
data-state={cardState}
>
{header}
{body}
</SettingsCard>
);
if (loading) {
// A loading state paints the card and its header (§13), so the page does
// not move when the status lands.
return shell(
<div className="space-y-2" aria-busy="true">
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-3 w-1/3" />
</div>,
);
}
if (!connected) {
return shell(
<>
<p className="text-sm" data-slot="integration-sentence">
{t("settings.integrationCard.notConnected")}
</p>
{setup}
{children}
{connectAction ? (
<SettingsCardActions data-slot="integration-actions">
{connectAction}
</SettingsCardActions>
) : null}
</>,
);
}
const sentence = health
? healthSentence(health, t, {
provider: providerName,
historyLoading,
appleHealth,
locale,
fmt,
})
: null;
const lastData = health
? health.lastDataAt
? t("settings.integrationCard.lastData", {
when: formatHealthInstant(health.lastDataAt, t, fmt),
})
: t("settings.integrationCard.lastDataNone")
: null;
const action = health?.action ?? null;
const primaryButton =
action === "reconnect" && primary?.onReconnect ? (
<PrimaryButton
action="reconnect"
icon={Link2}
label={t("settings.integrationCard.reconnect")}
onClick={primary.onReconnect}
pending={primary.pending}
disabled={primary.disabled}
provider={provider}
/>
) : action === "resume" && primary?.onResume ? (
<PrimaryButton
action="resume"
icon={Play}
label={t("settings.integrationCard.resume")}
onClick={primary.onResume}
pending={primary.pending}
disabled={primary.disabled}
provider={provider}
/>
) : null;
return shell(
<>
{sentence || lastData ? (
<div className="space-y-1">
{sentence ? (
<p className="text-sm" data-slot="integration-sentence">
{sentence}
</p>
) : null}
{lastData ? (
<p
className="text-muted-foreground text-xs"
data-slot="integration-last-data"
>
{lastData}
</p>
) : null}
</div>
) : null}
{children}
{health ? (
<DetailsPanel
id={detailsId}
open={detailsOpen}
text={diagnosticsText(
{
version: APP_VERSION,
provider: providerName,
health,
extra: diagnosticsExtra,
},
t,
)}
unavailable={health.unavailable.map((u) =>
unavailableItemLabel(u.item, t),
)}
>
{details}
</DetailsPanel>
) : details ? (
<div className="space-y-3">{details}</div>
) : null}
{health || menu.length > 0 || primaryButton ? (
<SettingsCardActions
data-slot="integration-actions"
className="justify-between"
>
{health ? (
<button
type="button"
aria-expanded={detailsOpen}
aria-controls={detailsId}
onClick={() => setDetailsOpen((open) => !open)}
data-slot="integration-details-toggle"
data-testid={`${provider}-details-toggle`}
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 -ml-1 flex min-h-11 items-center gap-2 rounded-md px-1 text-sm outline-none focus-visible:ring-[3px] sm:min-h-9"
>
<ChevronDown
aria-hidden="true"
className={cn(
"size-3.5 shrink-0 transition-transform motion-reduce:transition-none",
detailsOpen && "rotate-180",
)}
/>
{t("settings.integrationCard.details.title")}
</button>
) : null}
<div className="ml-auto flex items-center gap-2">
{menu.length > 0 ? (
<IntegrationMenu
provider={provider}
providerName={providerName}
items={menu}
/>
) : null}
{primaryButton}
</div>
</SettingsCardActions>
) : null}
</>,
);
}
function PrimaryButton({
action,
icon: Icon,
label,
onClick,
pending,
disabled,
provider,
}: {
action: "reconnect" | "resume";
icon: LucideIcon;
label: string;
onClick: () => void;
pending?: boolean;
disabled?: boolean;
provider: string;
}) {
return (
<Button
type="button"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={onClick}
disabled={disabled || pending}
aria-busy={pending || undefined}
data-slot="integration-primary-action"
data-action={action}
data-testid={`${provider}-${action}-button`}
>
{pending ? (
<Loader2 className="size-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Icon className="size-3.5" aria-hidden="true" />
)}
{label}
</Button>
);
}
/**
* The rare actions. A destructive or heavy entry asks first through the same
* confirmation the card used before; the dialog is controlled here because a
* menu item closes its menu, and with it any dialog nested inside.
*/
function IntegrationMenu({
provider,
providerName,
items,
}: {
provider: string;
providerName: string;
items: IntegrationMenuAction[];
}) {
const { t } = useTranslations();
const [confirming, setConfirming] = useState<IntegrationMenuAction | null>(
null,
);
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
size="icon"
className="min-h-11 min-w-11 sm:size-9 sm:min-h-9 sm:min-w-9"
aria-label={t("settings.integrationCard.menuLabel", {
provider: providerName,
})}
data-slot="integration-menu-trigger"
data-testid={`${provider}-menu`}
>
<MoreHorizontal className="size-4" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="w-64 max-w-[calc(100vw-2rem)]"
data-slot="integration-menu"
>
{items.map((item) => {
const Icon = item.icon;
const common = {
"data-slot": "integration-menu-item",
"data-action": item.id,
"data-testid": `${provider}-menu-${item.id}`,
disabled: item.disabled,
variant: item.destructive
? ("destructive" as const)
: ("default" as const),
};
if (item.href) {
return (
<DropdownMenuItem key={item.id} asChild {...common}>
<a
href={item.href}
{...(item.external
? { target: "_blank", rel: "noopener noreferrer" }
: {})}
>
<Icon aria-hidden="true" />
{item.label}
{item.external ? (
<ExternalLink
aria-hidden="true"
className="ml-auto size-3"
/>
) : null}
</a>
</DropdownMenuItem>
);
}
return (
<DropdownMenuItem
key={item.id}
{...common}
onSelect={() => {
if (item.confirm) setConfirming(item);
else item.onSelect?.();
}}
>
<Icon aria-hidden="true" />
{item.label}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
{confirming?.confirm ? (
<ConfirmDialog
open
onOpenChange={(open) => {
if (!open) setConfirming(null);
}}
title={confirming.confirm.title}
body={confirming.confirm.body}
confirmLabel={confirming.confirm.confirmLabel}
destructive={confirming.destructive}
slot={`${provider}-${confirming.id}`}
onConfirm={() => {
const run = confirming.onSelect;
setConfirming(null);
run?.();
}}
/>
) : null}
</>
);
}
/**
* "Details for a bug report": collapsed by default and present in every state,
* so a report always looks the same. Its toggle sits at the start of the
* action row and the panel opens above that row, which stays the last thing in
* the card. The monospace block is exactly the text the copy button places on
* the clipboard. The panel is always in the markup and `hidden` while closed,
* so what a report would contain is inspectable without opening it.
*/
function DetailsPanel({
id,
open,
text,
unavailable,
children,
}: {
id: string;
open: boolean;
text: string;
unavailable: string[];
children?: ReactNode;
}) {
const { t } = useTranslations();
const [copy, setCopy] = useState<"idle" | "copied" | "failed">("idle");
async function handleCopy() {
try {
await navigator.clipboard.writeText(text);
setCopy("copied");
window.setTimeout(() => setCopy("idle"), 2000);
} catch {
setCopy("failed");
}
}
return (
<div
id={id}
hidden={!open}
className="space-y-3"
data-slot="integration-details"
data-state={open ? "open" : "closed"}
>
{unavailable.length > 0 ? (
<p className="text-sm" data-slot="integration-unavailable-list">
{t("settings.integrationCard.details.unavailableList", {
list: unavailable.join(", "),
})}
</p>
) : null}
{children}
<pre
className="bg-muted/40 text-foreground overflow-x-auto rounded-lg border p-3 font-mono text-xs leading-relaxed whitespace-pre"
data-slot="integration-diagnostics"
>
{text}
</pre>
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={handleCopy}
data-slot="integration-diagnostics-copy"
>
{copy === "copied" ? (
<Check className="size-3.5" aria-hidden="true" />
) : (
<Copy className="size-3.5" aria-hidden="true" />
)}
{t("common.copy")}
</Button>
<span
role="status"
aria-live="polite"
data-slot="integration-diagnostics-copy-status"
data-state={copy}
className={cn(
"text-xs",
copy === "failed" ? "text-destructive" : "text-success",
)}
>
{copy === "copied"
? t("common.copied")
: copy === "failed"
? t("settings.integrationCard.details.copyFailed")
: ""}
</span>
</div>
</div>
);
}
@@ -20,21 +20,10 @@
import { Plug } from "lucide-react";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import {
formatRelative,
IntegrationStatusPill,
} from "@/components/settings/integration-status-pill";
import { useTranslations } from "@/lib/i18n/context";
import {
pillFailurePropsFor,
pillStateFor,
pillTimestampFor,
type IntegrationKey,
type IntegrationStatusViewModel,
} from "./shared";
import { IntegrationCard } from "./integration-card";
import type { IntegrationKey, IntegrationStatusViewModel } from "./shared";
/**
* Display names, mirroring the server-side `INTEGRATION_LABELS`. Only the
@@ -52,50 +41,28 @@ export const INTEGRATION_DISPLAY_NAMES: Record<IntegrationKey, string> = {
strava: "Strava",
};
/**
* The same card every integration renders, read-only: the server's health,
* its sentence and its details, and no menu, because a dropped integration
* does not get its management surface back.
*/
export function IntegrationFallbackRow({
status,
now,
}: {
status: IntegrationStatusViewModel;
/** Override "now" for deterministic testing. */
now?: Date;
}) {
const { t } = useTranslations();
const pillState = pillStateFor(status);
const reference = now ?? new Date();
const lastAttemptAt =
status.lastAttemptAt ?? status.syncHealth?.since ?? null;
const meta = lastAttemptAt
? t("settings.integrationFallback.lastAttempt", {
relative: formatRelative(
Math.max(0, reference.getTime() - new Date(lastAttemptAt).getTime()),
t,
),
})
: t("settings.integrationFallback.noAttempt");
const name =
INTEGRATION_DISPLAY_NAMES[status.integration] ?? status.integration;
return (
<SettingsCard
data-testid="integration-fallback-row"
data-integration={status.integration}
>
<SettingsCardHeader
icon={Plug}
title={
INTEGRATION_DISPLAY_NAMES[status.integration] ?? status.integration
}
description={t("settings.integrationFallback.description")}
status={
<IntegrationStatusPill
state={pillState}
lastSyncAt={pillTimestampFor(status)}
{...pillFailurePropsFor(status)}
now={now}
/>
}
/>
<p className="text-muted-foreground text-xs">{meta}</p>
</SettingsCard>
<IntegrationCard
provider={status.integration}
providerName={name}
icon={Plug}
description={t("settings.integrationFallback.description")}
connected={(status.connected ?? status.configured) === true}
health={status.health ?? null}
testId="integration-fallback-row"
/>
);
}
@@ -0,0 +1,358 @@
/**
* The words an integration card says about a connection's health.
*
* The server resolves one `ConnectionHealth` per connection and the card only
* renders it, so everything here is a projection: a badge word per state, one
* sentence per reason, a relative instant, and the plain-text diagnostics a
* person can paste into a bug report. Nothing in this module decides a state.
*
* Pure (a translator and the locale formatters are passed in) so every
* sentence and the copied diagnostics are unit-testable without a renderer.
*/
import type {
ConnectionHealth,
ConnectionHealthState,
UnavailableItem,
} from "@/lib/integrations/connection-health";
import { pluralKey } from "@/lib/i18n/plural";
import type { Locale } from "@/lib/i18n/config";
type Translate = (
key: string,
params?: Record<string, string | number>,
) => string;
interface InstantFormatters {
dateShortSmart: (value: Date) => string;
time: (value: Date) => string;
}
const DAY_MS = 24 * 60 * 60 * 1000;
/** The one word in the status badge. */
export function healthBadgeLabel(
state: ConnectionHealthState,
t: Translate,
): string {
switch (state) {
case "ok":
return t("settings.integrationCard.state.ok");
case "limited":
return t("settings.integrationCard.state.limited");
case "degraded":
return t("settings.integrationCard.state.degraded");
case "action_required":
return t("settings.integrationCard.state.actionRequired");
}
}
/**
* An instant as a person reads it: "today, 07:12", "yesterday, 18:40", or the
* date with its time. The day comparison goes through the same zone-aware
* formatter that prints the date, so the boundary and the printed day can
* never disagree.
*/
export function formatHealthInstant(
iso: string,
t: Translate,
fmt: InstantFormatters,
now: Date = new Date(),
): string {
const at = new Date(iso);
if (Number.isNaN(at.getTime())) return "";
const day = fmt.dateShortSmart(at);
const time = fmt.time(at);
if (day === fmt.dateShortSmart(now)) {
return t("settings.integrationCard.when.today", { time });
}
if (day === fmt.dateShortSmart(new Date(now.getTime() - DAY_MS))) {
return t("settings.integrationCard.when.yesterday", { time });
}
return t("settings.integrationCard.when.date", { date: day, time });
}
/**
* A readable name for a data type the provider withholds. Written as literal
* keys so the i18n coverage guards see each one; an unknown key degrades to a
* readable form of itself rather than leaking `snake_case`.
*/
export function unavailableItemLabel(item: string, t: Translate): string {
switch (item) {
case "spo2":
return t("settings.integrations.withings.plan.items.spo2");
case "vascularAge":
return t("settings.integrations.withings.plan.items.vascularAge");
case "pulseWaveVelocity":
return t("settings.integrations.withings.plan.items.pulseWaveVelocity");
case "vo2Max":
return t("settings.integrations.withings.plan.items.vo2Max");
case "visceralFat":
return t("settings.integrations.withings.plan.items.visceralFat");
case "skinTemperature":
return t("settings.integrations.withings.plan.items.skinTemperature");
case "ecg":
return t("settings.integrations.withings.plan.items.ecg");
case "sleepScore":
return t("settings.integrations.withings.plan.items.sleepScore");
case "sleepHrv":
return t("settings.integrations.withings.plan.items.sleepHrv");
case "sleepHeartRate":
return t("settings.integrations.withings.plan.items.sleepHeartRate");
case "sleepRespiratoryRate":
return t(
"settings.integrations.withings.plan.items.sleepRespiratoryRate",
);
case "sleepSpo2":
return t("settings.integrations.withings.plan.items.sleepSpo2");
case "bodyTemperature":
return t("settings.integrations.withings.plan.items.bodyTemperature");
case "bodyFat":
return t("settings.integrations.withings.plan.items.bodyFat");
case "fatMass":
return t("settings.integrations.withings.plan.items.fatMass");
case "fatFreeMass":
return t("settings.integrations.withings.plan.items.fatFreeMass");
case "muscleMass":
return t("settings.integrations.withings.plan.items.muscleMass");
case "bodyWater":
return t("settings.integrations.withings.plan.items.bodyWater");
case "boneMass":
return t("settings.integrations.withings.plan.items.boneMass");
case "heartRate":
return t("settings.integrations.withings.plan.items.heartRate");
case "bloodPressure":
return t("settings.integrations.withings.plan.items.bloodPressure");
case "weight":
return t("settings.integrations.withings.plan.items.weight");
case "vo2max":
return t("settings.integrations.oura.scope.items.vo2max");
case "cardio_age":
return t("settings.integrations.oura.scope.items.cardio_age");
case "resilience":
return t("settings.integrations.oura.scope.items.resilience");
case "activity":
return t("settings.integrationCard.items.activity");
case "sleep":
return t("settings.integrationCard.items.sleep");
default: {
const words = item
.replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/[_-]+/g, " ")
.toLowerCase();
return words;
}
}
}
/**
* At most three names in a sentence. Up to three types are all named; more
* than three name the first two and count the rest ("SpO2, vascular age and
* 3 more"), and the full list lives in the details.
*/
export function namedItemList(
items: readonly UnavailableItem[],
t: Translate,
locale: Locale,
): string {
const labels = items.map((u) => unavailableItemLabel(u.item, t));
const parts =
labels.length <= 3
? labels
: [
...labels.slice(0, 2),
t(
pluralKey(
"settings.integrationCard.moreValues",
labels.length - 2,
locale,
),
{ count: labels.length - 2 },
),
];
try {
return new Intl.ListFormat(locale, {
style: "long",
type: "conjunction",
}).format(parts);
} catch {
return parts.join(", ");
}
}
export interface HealthSentenceOptions {
/** Provider name as the person knows it ("Oura", "Withings"). */
provider: string;
/** Older history is still loading (a server fact, e.g. backfill pending). */
historyLoading?: boolean;
/** Apple Health: data only moves when the iPhone app runs. */
appleHealth?: boolean;
locale: Locale;
fmt: InstantFormatters;
now?: Date;
}
/** The one sentence a card says under its header. */
export function healthSentence(
health: ConnectionHealth,
t: Translate,
options: HealthSentenceOptions,
): string {
const { provider, locale, fmt } = options;
const since = health.since
? formatHealthInstant(health.since, t, fmt, options.now)
: null;
const ofCause = (cause: UnavailableItem["cause"]) =>
namedItemList(
health.unavailable.filter((u) => u.cause === cause),
t,
locale,
);
switch (health.reason) {
case "data_arriving":
return options.historyLoading
? t("settings.integrationCard.reason.dataArrivingHistory")
: t("settings.integrationCard.reason.dataArriving");
case "first_sync_pending":
return t("settings.integrationCard.reason.firstSyncPending");
case "plan_limited":
return t("settings.integrationCard.reason.planLimited", {
provider,
list: ofCause("plan"),
});
case "permission_limited":
return t("settings.integrationCard.reason.permissionLimited", {
list: ofCause("permission"),
});
case "provider_errors":
return since
? t("settings.integrationCard.reason.providerErrors", {
provider,
since,
})
: t("settings.integrationCard.reason.providerErrorsNoSince", {
provider,
});
case "sync_stalled":
return since
? t("settings.integrationCard.reason.syncStalled", { since })
: t("settings.integrationCard.reason.syncStalledNoSince");
case "no_new_data":
if (options.appleHealth) {
return since
? t("settings.integrationCard.reason.noNewDataApple", { since })
: t("settings.integrationCard.reason.noNewDataAppleNoSince");
}
return since
? t("settings.integrationCard.reason.noNewData", { since })
: t("settings.integrationCard.reason.noNewDataNoSince");
case "unexpected_silence":
return since
? t("settings.integrationCard.reason.unexpectedSilence", {
provider,
since,
})
: t("settings.integrationCard.reason.unexpectedSilenceNoSince", {
provider,
});
case "reauth_required":
return t("settings.integrationCard.reason.reauthRequired", { provider });
case "paused_after_errors":
return since
? t("settings.integrationCard.reason.pausedAfterErrors", {
provider,
since,
})
: t("settings.integrationCard.reason.pausedAfterErrorsNoSince", {
provider,
});
}
}
/** `2026-10-14T18:40:12.000Z` → `2026-10-14 18:40 UTC`. */
function utc(iso: string | null): string | null {
if (!iso) return null;
const at = new Date(iso);
if (Number.isNaN(at.getTime())) return null;
return `${at.toISOString().slice(0, 16).replace("T", " ")} UTC`;
}
export interface DiagnosticsInput {
/** The running app version; empty when the build did not stamp one. */
version: string;
provider: string;
health: ConnectionHealth;
/** Extra lines the card adds, already redacted (a failure streak count). */
extra?: readonly string[];
}
/**
* The plain-text diagnostics, exactly as the details block shows them and the
* copy button places them on the clipboard.
*
* Built only from `health`, whose diagnostics the server already reduced to
* leg names, outcomes, instants and short error codes. No measured value, no
* token, no account id and no provider error message can reach this text,
* because none of them is on the input.
*/
export function diagnosticsText(input: DiagnosticsInput, t: Translate): string {
const { health } = input;
const none = t("settings.integrationCard.details.none");
const lines: string[] = [];
lines.push(
`${["HealthLog", input.version].filter(Boolean).join(" ")} ${input.provider}`,
);
const since = utc(health.since);
lines.push(
`${t("settings.integrationCard.details.state")}: ${health.state} / ${health.reason}${
since ? ` ${t("settings.integrationCard.details.since")} ${since}` : ""
}`,
);
lines.push(
`${t("settings.integrationCard.details.lastSync")}: ${utc(health.lastSuccessAt) ?? none}`,
);
lines.push(
`${t("settings.integrationCard.details.lastData")}: ${utc(health.lastDataAt) ?? none}`,
);
const d = health.diagnostics;
if (d.ledgerState || d.failingLegs.length > 0) {
const legs =
d.failingLegs.length > 0
? `, ${t("settings.integrationCard.details.failingParts")}: ${d.failingLegs.join(", ")}`
: "";
lines.push(`Ledger: ${d.ledgerState ?? none}${legs}`);
}
for (const line of input.extra ?? []) lines.push(line);
if (d.recentCalls.length > 0) {
lines.push(`${t("settings.integrationCard.details.recentCalls")}:`);
const width = Math.max(...d.recentCalls.map((c) => c.endpoint.length));
for (const call of d.recentCalls) {
const at = (utc(call.at) ?? "").replace(" UTC", "");
const row = [
at,
call.endpoint.padEnd(width),
call.status.padEnd(15),
call.errorCode ?? "",
]
.join(" ")
.trimEnd();
lines.push(` ${row}`);
}
}
if (health.unavailable.length > 0) {
const items = health.unavailable
.map(
(u) =>
`${u.item} (${
u.cause === "plan"
? t("settings.integrationCard.details.causePlan")
: t("settings.integrationCard.details.causePermission")
})`,
)
.join(", ");
lines.push(
`${t("settings.integrationCard.details.unavailable")}: ${items}`,
);
}
return lines.join("\n");
}
@@ -10,18 +10,17 @@
* an upstream that quietly stopped returning one data type. The integration
* pill cannot express it, because the integration itself is fine.
*
* Collapsed by default: a Withings or Google Health user carries 15-25 types
* and an always-open list would bury the card's actions. The collapsed summary
* still names how many types have gone quiet, so the signal never hides behind
* a click.
* It lives in the card's collapsed details: a Withings or Google Health user
* carries 15-25 types and an always-open list would bury the card. The summary
* line above the rows still names how many types have gone quiet.
*
* Honest absence is structural: the server only reports `(source, type)` pairs
* that have rows, so a type a provider never delivered simply is not here. No
* "expected but missing" row is invented.
*/
import { useState, type ReactNode } from "react";
import { AlertTriangle, ChevronDown } from "lucide-react";
import type { ReactNode } from "react";
import { AlertTriangle } from "lucide-react";
import { formatRelative } from "@/components/settings/integration-status-pill";
import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/components/measurements/measurement-list-meta";
@@ -42,18 +41,15 @@ function humanise(type: string): string {
return words.charAt(0).toUpperCase() + words.slice(1);
}
export function MetricFreshnessDisclosure<
export function MetricFreshnessList<
Entry extends MetricFreshnessEntry = MetricFreshnessEntry,
>({
entries,
idPrefix,
now,
title,
detail,
}: {
entries: Entry[] | undefined;
/** Namespaces the disclosure's ids so several cards can coexist. */
idPrefix: string;
/** Override "now" for deterministic testing. */
now?: Date;
/** Replaces the shared "received data" heading. */
@@ -65,7 +61,6 @@ export function MetricFreshnessDisclosure<
detail?: (entry: Entry) => ReactNode;
}) {
const { t } = useTranslations();
const [open, setOpen] = useState(false);
if (!entries?.length) return null;
@@ -92,93 +87,76 @@ export function MetricFreshnessDisclosure<
const newest = entries.reduce((max, entry) =>
entry.lastSeenAt > max.lastSeenAt ? entry : max,
);
const panelId = `${idPrefix}-metric-freshness-panel`;
return (
<div data-slot="metric-freshness" className="space-y-2">
<button
type="button"
onClick={() => setOpen((value) => !value)}
aria-expanded={open}
aria-controls={panelId}
data-slot="metric-freshness-toggle"
className="flex w-full items-center gap-2 text-left"
>
<ChevronDown
aria-hidden="true"
className={cn(
"text-muted-foreground size-3.5 shrink-0 transition-transform",
open && "rotate-180",
)}
/>
<p className="flex min-w-0 flex-wrap items-baseline gap-x-2">
<span className="text-sm font-medium">
{title ?? t("settings.integrationFreshness.title")}
</span>
<span className="text-muted-foreground min-w-0 truncate text-xs">
<span className="text-muted-foreground text-xs">
{t("settings.integrationFreshness.summary", {
count: entries.length,
relative: relative(newest.lastSeenAt),
})}
{staleCount > 0 ? (
<span className="text-warning">
{" · "}
{", "}
{t("settings.integrationFreshness.quiet", { count: staleCount })}
</span>
) : null}
</span>
</button>
</p>
{open && (
<ul id={panelId} className={detail ? "space-y-2.5" : "space-y-1"}>
{sorted.map((entry) => {
const extra = detail?.(entry);
return (
<li
key={entry.type}
data-slot="metric-freshness-row"
data-metric={entry.type}
data-state={entry.stale ? "stale" : "fresh"}
className={cn(
"text-xs",
entry.stale ? "text-warning" : "text-muted-foreground",
)}
>
<div className="flex items-center justify-between gap-3">
<span className="flex min-w-0 items-center gap-1.5">
{entry.stale ? (
<AlertTriangle
aria-hidden="true"
className="size-3 shrink-0"
/>
) : null}
{/* With a detail line under it, the type name is the
<ul className={detail ? "space-y-2.5" : "space-y-1"}>
{sorted.map((entry) => {
const extra = detail?.(entry);
return (
<li
key={entry.type}
data-slot="metric-freshness-row"
data-metric={entry.type}
data-state={entry.stale ? "stale" : "fresh"}
className={cn(
"text-xs",
entry.stale ? "text-warning" : "text-muted-foreground",
)}
>
<div className="flex items-center justify-between gap-3">
<span className="flex min-w-0 items-center gap-1.5">
{entry.stale ? (
<AlertTriangle
aria-hidden="true"
className="size-3 shrink-0"
/>
) : null}
{/* With a detail line under it, the type name is the
row's content and the line its meta. */}
<span
className={cn(
"truncate",
extra && !entry.stale && "text-foreground",
)}
>
{label(entry.type)}
</span>
</span>
<time
dateTime={entry.lastSeenAt}
className="shrink-0 tabular-nums"
<span
className={cn(
"truncate",
extra && !entry.stale && "text-foreground",
)}
>
{relative(entry.lastSeenAt)}
</time>
{label(entry.type)}
</span>
</span>
<time
dateTime={entry.lastSeenAt}
className="shrink-0 tabular-nums"
>
{relative(entry.lastSeenAt)}
</time>
</div>
{extra ? (
<div data-slot="metric-freshness-detail" className="mt-0.5">
{extra}
</div>
{extra ? (
<div data-slot="metric-freshness-detail" className="mt-0.5">
{extra}
</div>
) : null}
</li>
);
})}
</ul>
)}
) : null}
</li>
);
})}
</ul>
</div>
);
}
@@ -1,50 +1,36 @@
"use client";
// v1.17.0 — Nightscout CGM integration card. Unlike WHOOP / Fitbit (OAuth,
// BYO-key), Nightscout is a URL + token the self-hoster pastes once: the user
// runs their own instance and HealthLog pulls continuous glucose off it. The
// card reads the consolidated `/api/integrations/status` envelope like every
// sibling — it was the last card firing its own status round-trip, and the
// last place a fourth status dialect could hide. Warm copy, mobile-first, the
// private-network access is operator-owned through an exact-origin server
// allowlist; ordinary users cannot weaken the outbound-request policy.
// Nightscout CGM. Unlike the OAuth providers, Nightscout is a URL and an
// optional token the self-hoster pastes once: they run their own instance and
// HealthLog pulls continuous glucose from it. The card reads the consolidated
// `/api/integrations/status` envelope like every sibling and renders the
// shared integration card. Private-network access is operator-owned through an
// exact-origin server allowlist; an ordinary user cannot weaken the
// outbound-request policy, and the card says so when that is what stands in
// the way.
import { useRef, useState } from "react";
import Link from "next/link";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
ArrowRight,
BookOpen,
Droplet,
KeyRound,
Link2,
Loader2,
Plug,
RefreshCw,
Save,
Unlink,
} from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { PasswordInput } from "@/components/ui/password-input";
import { TagChip } from "@/components/ui/tag-chip";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { IntegrationStatusPill } from "@/components/settings/integration-status-pill";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch";
import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line";
import { useTranslations } from "@/lib/i18n/context";
import {
invalidateKeys,
@@ -52,20 +38,21 @@ import {
queryKeys,
} from "@/lib/query-keys";
import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure";
import {
readSyncOutcome,
useSyncOutcomeMessage,
type SyncOutcomeState,
} from "./sync-outcome";
ActionResultLines,
useConnectionTest,
useStandardSync,
} from "./integration-actions";
import {
IntegrationErrorMessage,
pillFailurePropsFor,
pillStateForVerdict,
pillTimestampFor,
type IntegrationStatusViewModel,
} from "./shared";
import { IntegrationCardDescription } from "./setup-guide-link";
IntegrationCard,
type IntegrationMenuAction,
} from "./integration-card";
import { MetricFreshnessList } from "./metric-freshness-list";
import {
IntegrationCardDescription,
integrationDocsHref,
} from "./setup-guide-link";
import { failureStreakFor, type IntegrationStatusViewModel } from "./shared";
export function NightscoutCard({
viewModel,
@@ -73,19 +60,17 @@ export function NightscoutCard({
viewModel?: IntegrationStatusViewModel;
}) {
const { t } = useTranslations();
const describeSyncOutcome = useSyncOutcomeMessage();
const queryClient = useQueryClient();
const [url, setUrl] = useState("");
const [token, setToken] = useState("");
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
const [syncing, setSyncing] = useState(false);
const [syncResult, setSyncResult] = useState<SyncOutcomeState | null>(null);
const status = viewModel;
const connected = status?.connected ?? false;
const sync = useStandardSync({
provider: "nightscout",
i18nPrefix: "settings.nightscout",
acceptsFullSync: false,
});
const test = useConnectionTest("/api/nightscout/test");
const [editing, setEditing] = useState(false);
const formRef = useRef<HTMLFormElement | null>(null);
const disconnect = useMutation({
@@ -93,114 +78,50 @@ export function NightscoutCard({
await apiPost("/api/nightscout/disconnect");
},
onSuccess: () => {
setUrl("");
setToken("");
// The card reads the consolidated envelope, so that is the one key a
// disconnect has to invalidate for the pill to flip back.
// disconnect has to invalidate for the card to flip back.
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
},
});
async function handleConnect(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setMsg(null);
setMsgType(null);
try {
const res = await apiFetchRaw("/api/nightscout/connect", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
url: url.trim(),
token: token.trim(),
}),
});
if (res.ok) {
setMsg(t("settings.nightscoutConnected"));
setMsgType("success");
setToken("");
void invalidateKeys(queryClient, measurementDependentKeys);
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
} else {
let detail = t("settings.nightscoutConnectFailed");
try {
const json = await res.json();
if (json.error) detail = json.error;
} catch {
// keep the generic message
}
setMsg(detail);
setMsgType("error");
}
} catch {
setMsg(t("common.networkError"));
setMsgType("error");
}
setSaving(false);
// A pause or a lapsed token is resolved by submitting the address again:
// there is no OAuth round-trip to re-run, so the button opens the form and
// puts the cursor in the URL field.
function openForm() {
setEditing(true);
requestAnimationFrame(() => {
formRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
formRef.current
?.querySelector<HTMLInputElement>("#nightscout-url")
?.focus();
});
}
// Sync now. Same shape as the WHOOP card's incremental arm, minus the
// full-history dialog — Nightscout has no full-sync arm to call.
async function handleSync() {
setSyncing(true);
setSyncResult(null);
try {
const res = await apiFetchRaw("/api/nightscout/sync", { method: "POST" });
const json = await res.json();
const result = res.ok ? readSyncOutcome(json) : null;
if (result) {
// The tone comes off what the run wrote, not off `res.ok`.
setSyncResult({
outcome: result.outcome,
message: describeSyncOutcome(
result,
t("settings.nightscoutSyncResult", { count: result.imported }),
),
});
void invalidateKeys(queryClient, measurementDependentKeys);
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
} else {
setSyncResult({
outcome: "failed",
message: json?.error || t("settings.nightscoutSyncFailed"),
});
}
} catch {
setSyncResult({
outcome: "failed",
message: t("settings.nightscoutSyncFailed"),
});
} finally {
setSyncing(false);
}
}
const pillState = pillStateForVerdict(status?.syncHealth?.verdict);
// The newest recorded attempt names the one failure the person cannot fix
// from here: the instance sits on a private network the operator has not
// approved. Read from the health diagnostics' short error code, never from
// a stored message.
const operatorApprovalRequired =
status?.lastError?.startsWith("private_origin_not_approved") ?? false;
// `warning` joins the set now that a transient failure paints amber rather
// than red — the detail belongs on this line, not in a colour that tells the
// user to reconnect against something reconnecting cannot fix.
const errorMessage =
(pillState === "error" ||
pillState === "parked" ||
pillState === "warning") &&
status?.lastError &&
!operatorApprovalRequired
? status.lastError
: null;
status?.health?.diagnostics.recentCalls[0]?.errorCode ===
"private_origin_not_approved";
return (
<SettingsCard data-testid="nightscout-card">
<SettingsCardHeader
const form = (
<NightscoutForm
formRef={formRef}
connected={connected}
configured={status?.configured ?? false}
hasToken={status?.hasToken ?? false}
/>
);
if (!connected) {
return (
<IntegrationCard
provider="nightscout"
providerName={t("settings.nightscout")}
icon={Droplet}
title={t("settings.nightscout")}
titleAccessory={<TagChip>{t("settings.nightscoutTag")}</TagChip>}
description={
<IntegrationCardDescription
@@ -208,231 +129,247 @@ export function NightscoutCard({
provider="nightscout"
/>
}
status={
<IntegrationStatusPill
state={pillState}
lastSyncAt={pillTimestampFor(status)}
{...pillFailurePropsFor(status)}
/>
}
loading={viewModel === undefined}
connected={false}
health={null}
setup={form}
/>
);
}
<div className="space-y-4">
{errorMessage && <IntegrationErrorMessage message={errorMessage} />}
{operatorApprovalRequired && (
<div
data-testid="nightscout-private-operator-required"
className="border-warning/30 bg-warning/10 space-y-1 rounded-md border px-3 py-2"
>
<p className="text-warning text-sm font-medium">
{t("settings.nightscoutPrivateOperatorTitle")}
</p>
<p className="text-muted-foreground text-xs">
{t("settings.nightscoutPrivateOperatorDescription")}
</p>
</div>
)}
const menu: IntegrationMenuAction[] = [
...(operatorApprovalRequired
? []
: [
{
id: "sync",
label: t("settings.nightscoutSync"),
icon: RefreshCw,
disabled: sync.syncing,
onSelect: () => sync.run(false),
},
{
id: "test",
label: t("settings.testConnection.test"),
icon: Plug,
disabled: test.testing,
onSelect: test.run,
},
]),
{
id: "view-data",
label: t("settings.nightscoutViewData"),
icon: ArrowRight,
href: "/insights/blood-glucose",
},
{
id: "credentials",
label: t("settings.integrationCard.menu.address"),
icon: KeyRound,
onSelect: () => setEditing((open) => !open),
},
{
id: "setup-guide",
label: t("settings.integrationSetupGuide"),
icon: BookOpen,
href: integrationDocsHref("nightscout"),
external: true,
},
{
id: "disconnect",
label: t("settings.nightscoutDisconnect"),
icon: Unlink,
destructive: true,
onSelect: () => disconnect.mutate(),
confirm: {
title: t("settings.nightscoutDisconnectTitle"),
body: t("settings.nightscoutDisconnectDescription"),
confirmLabel: t("settings.nightscoutDisconnect"),
},
},
];
{/* Parked-integration resume CTA. Surfaces only when the row state
is `parked` (>24h of persistent failures). Nightscout has no OAuth
redirect — the user re-validates by re-submitting the connect form,
so "reconnect" scrolls the form into view + focuses the URL field.
Markup matches the WHOOP card byte for byte. */}
{pillState === "parked" && (
<div
data-testid="nightscout-parked-banner"
className="border-warning/30 bg-warning/10 flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm"
>
<span className="text-warning min-w-0 text-xs break-words">
{t("settings.integrationPill.parkedReconnect")}
</span>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
formRef.current?.scrollIntoView({
behavior: "smooth",
block: "center",
});
formRef.current
?.querySelector<HTMLInputElement>("#nightscout-url")
?.focus();
}}
data-testid="nightscout-resume-button"
className="min-h-11"
>
<Link2 className="h-3.5 w-3.5" />
{t("settings.integrationPill.resumeCta")}
</Button>
</div>
)}
const streak = failureStreakFor(status);
{status?.connected && (
<MetricFreshnessDisclosure
entries={status.metricFreshness}
idPrefix="nightscout"
/>
)}
return (
<IntegrationCard
provider="nightscout"
providerName={t("settings.nightscout")}
icon={Droplet}
titleAccessory={<TagChip>{t("settings.nightscoutTag")}</TagChip>}
connected
health={status?.health ?? null}
primary={{ onReconnect: openForm, onResume: openForm }}
menu={menu}
diagnosticsExtra={
streak
? [
t("settings.integrationCard.details.failureStreak", {
count: streak.count,
threshold: streak.threshold,
}),
]
: undefined
}
details={
status?.metricFreshness?.length ? (
<MetricFreshnessList entries={status.metricFreshness} />
) : undefined
}
>
{operatorApprovalRequired ? (
// The one fact the generic sentence cannot carry: the instance sits
// on a private network the operator has not approved, which the
// person cannot fix from here. Said in the body, not as a banner.
<p
className="text-sm"
data-testid="nightscout-private-operator-required"
>
{t("settings.nightscoutPrivateOperatorTitle")}{" "}
{t("settings.nightscoutPrivateOperatorDescription")}
</p>
) : null}
<ActionResultLines provider="nightscout" sync={sync} test={test} />
{editing ? form : null}
</IntegrationCard>
);
}
<form ref={formRef} onSubmit={handleConnect} className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="nightscout-url">
{t("settings.nightscoutUrl")}
</Label>
<Input
id="nightscout-url"
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder={
status?.configured
? t("settings.nightscoutUrlSavedPlaceholder")
: "https://your-site.up.railway.app"
}
maxLength={2048}
autoComplete="off"
inputMode="url"
spellCheck={false}
autoCapitalize="none"
enterKeyHint="next"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="nightscout-token">
{t("settings.nightscoutToken")}
</Label>
<PasswordInput
id="nightscout-token"
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder={
status?.hasToken
? t("settings.nightscoutTokenSavedPlaceholder")
: t("settings.nightscoutTokenOptional")
}
maxLength={512}
autoComplete="off"
spellCheck={false}
autoCapitalize="none"
enterKeyHint="done"
/>
<p className="text-muted-foreground text-xs">
{t("settings.nightscoutTokenHelp")}
</p>
</div>
function NightscoutForm({
formRef,
connected,
configured,
hasToken,
}: {
formRef: React.RefObject<HTMLFormElement | null>;
connected: boolean;
configured: boolean;
hasToken: boolean;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [url, setUrl] = useState("");
const [token, setToken] = useState("");
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState<{
kind: "success" | "error";
text: string;
} | null>(null);
<SettingsCardActions>
<Button
type="submit"
variant="outline"
size="sm"
className="min-h-11 w-full sm:w-auto"
disabled={saving || !url.trim()}
>
{saving ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : status?.connected ? (
<Save className="h-3.5 w-3.5" />
) : (
<Link2 className="h-3.5 w-3.5" />
)}
{status?.connected
? t("settings.nightscoutUpdate")
: t("settings.nightscoutConnect")}
</Button>
</SettingsCardActions>
async function handleConnect(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setMsg(null);
try {
const res = await apiFetchRaw("/api/nightscout/connect", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: url.trim(), token: token.trim() }),
});
if (res.ok) {
setMsg({ kind: "success", text: t("settings.nightscoutConnected") });
setToken("");
void invalidateKeys(queryClient, measurementDependentKeys);
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
} else {
let text = t("settings.nightscoutConnectFailed");
try {
const json = await res.json();
if (json.error) text = json.error;
} catch {
// keep the generic message
}
setMsg({ kind: "error", text });
}
} catch {
setMsg({ kind: "error", text: t("common.networkError") });
}
setSaving(false);
}
{msg && (
<p
role="alert"
className={`text-sm ${msgType === "success" ? "text-success" : "text-destructive"}`}
>
{msg}
</p>
return (
<form
ref={formRef}
onSubmit={handleConnect}
className="space-y-3"
data-testid="nightscout-form"
>
<div className="space-y-1.5">
<Label htmlFor="nightscout-url">{t("settings.nightscoutUrl")}</Label>
<Input
id="nightscout-url"
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder={
configured
? t("settings.nightscoutUrlSavedPlaceholder")
: "https://your-site.up.railway.app"
}
maxLength={2048}
autoComplete="off"
inputMode="url"
spellCheck={false}
autoCapitalize="none"
enterKeyHint="next"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="nightscout-token">
{t("settings.nightscoutToken")}
</Label>
<PasswordInput
id="nightscout-token"
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder={
hasToken
? t("settings.nightscoutTokenSavedPlaceholder")
: t("settings.nightscoutTokenOptional")
}
maxLength={512}
autoComplete="off"
spellCheck={false}
autoCapitalize="none"
enterKeyHint="done"
/>
<p className="text-muted-foreground text-xs">
{t("settings.nightscoutTokenHelp")}
</p>
</div>
{msg && (
<p
role={msg.kind === "error" ? "alert" : "status"}
className={`text-sm ${msg.kind === "success" ? "text-success" : "text-destructive"}`}
>
{msg.text}
</p>
)}
<SettingsCardActions>
<Button
type="submit"
size="sm"
variant={connected ? "outline" : "default"}
className="min-h-11 w-full sm:min-h-9 sm:w-auto"
disabled={saving || !url.trim()}
data-slot={connected ? undefined : "integration-primary-action"}
data-action={connected ? undefined : "connect"}
data-testid="nightscout-connect"
>
{saving ? (
<Loader2 className="size-3.5 animate-spin motion-reduce:animate-none" />
) : connected ? (
<Save className="size-3.5" aria-hidden="true" />
) : (
<Link2 className="size-3.5" aria-hidden="true" />
)}
</form>
{status?.connected && (
<>
<div className="flex flex-wrap items-start gap-2 [&>*]:min-w-[10rem] sm:[&>*]:min-w-0">
{!operatorApprovalRequired && (
<>
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11"
onClick={handleSync}
disabled={syncing}
data-testid="nightscout-sync"
>
{syncing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
{t("settings.nightscoutSync")}
</Button>
<TestConnectionButton
endpoint="/api/nightscout/test"
disabled={!status?.connected}
/>
</>
)}
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="text-destructive min-h-11"
>
<Unlink className="h-3.5 w-3.5" />
{t("settings.nightscoutDisconnect")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.nightscoutDisconnectTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.nightscoutDisconnectDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => disconnect.mutate()}
>
{t("settings.nightscoutDisconnect")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
{syncResult && (
<WrittenOutcomeLine
outcome={syncResult.outcome}
message={syncResult.message}
testId="nightscout-sync-result"
/>
)}
{/* connect→data loop: a discreet link to where the glucose
readings now surface — doubles as the "your data is richer"
cue. */}
<Link
href="/insights/blood-glucose"
data-testid="nightscout-data-link"
className="text-primary inline-flex items-center gap-1 text-xs underline-offset-2 hover:underline"
>
{t("settings.nightscoutViewData")}
<ArrowRight className="h-3 w-3" />
</Link>
</>
)}
</div>
</SettingsCard>
{connected
? t("settings.nightscoutUpdate")
: t("settings.nightscoutConnect")}
</Button>
</SettingsCardActions>
</form>
);
}
@@ -0,0 +1,539 @@
"use client";
/**
* The controller behind every OAuth integration card: Withings, WHOOP,
* Fitbit, Google Health, Polar, Oura and Strava.
*
* It owns the requests (connect, resume, sync, test, disconnect, the BYO
* credentials form) and hands `<IntegrationCard>` what the person can do. The
* card decides nothing about health: it renders the server's `health` field.
* What used to be per-provider banners (the Withings plan note, the Oura scope
* note, the Withings activity-scope reconnect, the Google Health re-consent,
* the paused pill with its resume button) is now the sentence plus the one
* primary button that `health.action` names.
*/
import { useState, type ReactNode } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
Activity,
ArrowRight,
BookOpen,
CircleDot,
ExternalLink,
HeartPulse,
History,
KeyRound,
Link2,
Plug,
RefreshCw,
Unlink,
Watch,
type LucideIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { apiPost } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls";
import { loadDocument } from "@/lib/navigation/load-document";
import { WITHINGS_API_PLANS_URL } from "@/lib/withings/plan-features";
import {
invalidateKeys,
measurementDependentKeys,
queryKeys,
} from "@/lib/query-keys";
import { CredentialsForm } from "./credentials-form";
import {
ActionResultLines,
useConnectionTest,
useStandardSync,
type ProviderSync,
} from "./integration-actions";
import {
IntegrationCard,
type IntegrationMenuAction,
} from "./integration-card";
import { MetricFreshnessList } from "./metric-freshness-list";
import {
CallbackMismatchNotice,
IntegrationCardDescription,
IntegrationRedirectGuide,
integrationDocsHref,
type IntegrationDocsProvider,
} from "./setup-guide-link";
import { failureStreakFor, type IntegrationStatusViewModel } from "./shared";
export type OAuthIntegrationKey =
| "withings"
| "whoop"
| "fitbit"
| "google-health"
| "polar"
| "oura"
| "strava";
export interface OAuthIntegrationConfig {
/** Connection key; also the `/api/<key>/…` route segment. */
provider: OAuthIntegrationKey;
/** i18n base, e.g. `settings.withings`. */
i18nPrefix: string;
icon: LucideIcon;
docs: IntegrationDocsProvider;
/** Where this provider's data surfaces. */
dataHref: string;
/** The sync route takes `{ fullSync }` and the menu offers the full reload. */
fullSync: boolean;
/** A full sync reports with its own sentence (`…FullSyncResult`). */
fullSyncResult?: boolean;
/**
* `required`: the person must store their own client id/secret before
* connecting (`configured`). `optional`: a shared app may exist on the
* server (`available`), and the person's own pair is an alternative.
*/
credentials: "required" | "optional";
/** The provider ships a secret-specific saved placeholder. */
secretPlaceholder: boolean;
/** Show the callback-URL guide before credentials are stored. */
redirectGuide: boolean;
/**
* POST endpoint that clears a pause. Null: the grant itself has to be
* re-issued, so "Resume" re-runs connect, as it always did for these.
*/
resumeEndpoint: string | null;
testEndpoint: string;
statusQueryKey: () => readonly unknown[];
}
export const OAUTH_INTEGRATIONS: Record<
OAuthIntegrationKey,
OAuthIntegrationConfig
> = {
withings: {
provider: "withings",
i18nPrefix: "settings.withings",
icon: Link2,
docs: "withings",
dataHref: "/measurements",
fullSync: true,
fullSyncResult: true,
credentials: "required",
secretPlaceholder: true,
redirectGuide: true,
resumeEndpoint: "/api/integrations/withings/resume",
testEndpoint: "/api/integrations/withings/test",
statusQueryKey: queryKeys.withings,
},
whoop: {
provider: "whoop",
i18nPrefix: "settings.whoop",
icon: Activity,
docs: "whoop",
dataHref: "/insights/recovery",
fullSync: true,
credentials: "required",
secretPlaceholder: true,
redirectGuide: true,
resumeEndpoint: "/api/integrations/whoop/resume",
testEndpoint: "/api/integrations/whoop/test",
statusQueryKey: queryKeys.whoop,
},
fitbit: {
provider: "fitbit",
i18nPrefix: "settings.fitbit",
icon: HeartPulse,
docs: "fitbit",
dataHref: "/insights/sleep",
fullSync: true,
credentials: "required",
secretPlaceholder: true,
redirectGuide: true,
resumeEndpoint: "/api/integrations/fitbit/resume",
testEndpoint: "/api/integrations/fitbit/test",
statusQueryKey: queryKeys.fitbit,
},
"google-health": {
provider: "google-health",
i18nPrefix: "settings.googleHealth",
icon: Watch,
docs: "google-health",
dataHref: "/insights/steps",
fullSync: true,
credentials: "required",
secretPlaceholder: true,
redirectGuide: false,
resumeEndpoint: "/api/integrations/google-health/resume",
testEndpoint: "/api/integrations/google-health/test",
statusQueryKey: queryKeys.googleHealth,
},
polar: {
provider: "polar",
i18nPrefix: "settings.polar",
icon: Watch,
docs: "polar",
dataHref: "/insights/sleep",
fullSync: false,
credentials: "optional",
secretPlaceholder: false,
redirectGuide: true,
resumeEndpoint: null,
testEndpoint: "/api/polar/test",
statusQueryKey: queryKeys.polar,
},
oura: {
provider: "oura",
i18nPrefix: "settings.oura",
icon: CircleDot,
docs: "oura",
dataHref: "/insights/sleep",
fullSync: false,
credentials: "optional",
secretPlaceholder: false,
redirectGuide: true,
resumeEndpoint: null,
testEndpoint: "/api/oura/test",
statusQueryKey: queryKeys.oura,
},
strava: {
provider: "strava",
i18nPrefix: "settings.strava",
icon: Activity,
docs: "strava",
dataHref: "/insights/workouts",
fullSync: false,
credentials: "optional",
secretPlaceholder: false,
redirectGuide: true,
resumeEndpoint: null,
testEndpoint: "/api/strava/test",
statusQueryKey: queryKeys.strava,
},
};
/** The standard controller: a provider whose sync answers in one response. */
export function OAuthIntegrationCard(props: {
config: OAuthIntegrationConfig;
viewModel: IntegrationStatusViewModel | undefined;
callbackUrl: IntegrationCallbackUrls[OAuthIntegrationKey];
icon?: LucideIcon;
titleAccessory?: ReactNode;
}) {
const { config } = props;
const sync = useStandardSync({
provider: config.provider,
i18nPrefix: config.i18nPrefix,
acceptsFullSync: config.fullSync,
fullSyncResult: config.fullSyncResult,
statusQueryKey: config.statusQueryKey(),
});
return <OAuthIntegrationCardView {...props} sync={sync} />;
}
/**
* The view half, so a provider with its own sync choreography (Google Health
* polls for completion) can hand in its own `sync` and keep everything else.
*/
export function OAuthIntegrationCardView({
config,
viewModel,
callbackUrl,
icon,
titleAccessory,
sync,
syncLabel,
syncDisabled,
fullSyncDisabled,
syncResults,
details,
}: {
config: OAuthIntegrationConfig;
viewModel: IntegrationStatusViewModel | undefined;
callbackUrl: IntegrationCallbackUrls[OAuthIntegrationKey];
icon?: LucideIcon;
titleAccessory?: ReactNode;
sync: ProviderSync;
/** Overrides the sync item's label (Google Health's "check status"). */
syncLabel?: string;
syncDisabled?: boolean;
fullSyncDisabled?: boolean;
/** Replaces the standard sync result line. */
syncResults?: ReactNode;
/** Provider extras for the details section. */
details?: ReactNode;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const { provider, i18nPrefix } = config;
const statusQueryKey = config.statusQueryKey();
const test = useConnectionTest(config.testEndpoint);
const [editingCredentials, setEditingCredentials] = useState(false);
const providerName = t(i18nPrefix);
const connected = viewModel?.connected ?? false;
const configured =
config.credentials === "required"
? (viewModel?.configured ?? false)
: (viewModel?.available ?? false);
const ownCredentials =
config.credentials === "required"
? (viewModel?.configured ?? false)
: (viewModel?.hasOwnCredentials ?? false);
function invalidateStatus() {
queryClient.invalidateQueries({ queryKey: statusQueryKey });
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
}
function connect() {
void invalidateKeys(queryClient, measurementDependentKeys);
loadDocument(`/api/${provider}/connect`);
}
const disconnect = useMutation({
mutationFn: async () => {
await apiPost(`/api/${provider}/disconnect`);
},
onSuccess: invalidateStatus,
});
const resume = useMutation({
mutationFn: async () => {
if (!config.resumeEndpoint) return null;
return apiPost<{ resumed: boolean; wasParked: boolean }>(
config.resumeEndpoint,
);
},
onSuccess: invalidateStatus,
});
const credentialsForm = (
<CredentialsForm
provider={provider}
i18nPrefix={i18nPrefix}
saved={ownCredentials}
secretPlaceholder={config.secretPlaceholder}
statusQueryKey={statusQueryKey}
/>
);
const description = (
<IntegrationCardDescription
i18nPrefix={i18nPrefix}
provider={config.docs}
/>
);
if (!connected) {
return (
<IntegrationCard
provider={provider}
providerName={providerName}
icon={icon ?? config.icon}
titleAccessory={titleAccessory}
description={description}
loading={viewModel === undefined}
connected={false}
health={null}
setup={
<div className="space-y-3">
{config.redirectGuide && !ownCredentials ? (
<IntegrationRedirectGuide
provider={config.docs}
providerLabel={providerName}
callbackUrl={callbackUrl}
/>
) : null}
{credentialsForm}
{!configured ? (
<p
className="text-muted-foreground text-xs"
data-testid={
config.credentials === "required"
? "integration-unavailable"
: `${provider}-unavailable`
}
>
{config.credentials === "required"
? t(`${i18nPrefix}NoCredentials`)
: t(`${i18nPrefix}Unavailable`)}
</p>
) : null}
<CallbackMismatchNotice
provider={providerName}
callbackUrl={callbackUrl}
/>
</div>
}
connectAction={
<Button
type="button"
size="sm"
className="min-h-11 w-full sm:min-h-9 sm:w-auto"
disabled={!configured}
onClick={connect}
data-slot="integration-primary-action"
data-action="connect"
data-testid={`${provider}-connect`}
>
<Link2 className="size-3.5" aria-hidden="true" />
{t(`${i18nPrefix}Connect`)}
</Button>
}
/>
);
}
const busy = sync.syncing;
const menu: IntegrationMenuAction[] = [
{
id: "sync",
label: syncLabel ?? t(`${i18nPrefix}Sync`),
icon: RefreshCw,
disabled: busy || syncDisabled,
onSelect: () => sync.run(false),
},
...(config.fullSync
? [
{
id: "full-sync",
label: t(`${i18nPrefix}FullSync`),
icon: History,
disabled: busy || fullSyncDisabled,
onSelect: () => sync.run(true),
confirm: {
title: t(`${i18nPrefix}FullSyncTitle`),
body: t(`${i18nPrefix}FullSyncDescription`),
confirmLabel: t(`${i18nPrefix}Synchronize`),
},
} satisfies IntegrationMenuAction,
]
: []),
{
id: "test",
label: t("settings.testConnection.test"),
icon: Plug,
disabled: test.testing,
onSelect: test.run,
},
{
id: "view-data",
label: t(`${i18nPrefix}ViewData`),
icon: ArrowRight,
href: config.dataHref,
},
{
id: "credentials",
label: t("settings.integrationCard.menu.credentials"),
icon: KeyRound,
onSelect: () => setEditingCredentials((open) => !open),
},
{
id: "setup-guide",
label: t("settings.integrationSetupGuide"),
icon: BookOpen,
href: integrationDocsHref(config.docs),
external: true,
},
{
id: "disconnect",
label: t(`${i18nPrefix}Disconnect`),
icon: Unlink,
destructive: true,
onSelect: () => disconnect.mutate(),
confirm: {
title: t(`${i18nPrefix}DisconnectTitle`),
body: t(`${i18nPrefix}DisconnectDescription`),
confirmLabel: t(`${i18nPrefix}Disconnect`),
},
},
];
const streak = failureStreakFor(viewModel);
// Withings' paid API plan withholds some readings; the page that explains
// the plans stays one tap away in the details.
const planLimited =
provider === "withings" &&
(viewModel?.health?.unavailable.some((u) => u.cause === "plan") ?? false);
return (
<IntegrationCard
provider={provider}
providerName={providerName}
icon={icon ?? config.icon}
titleAccessory={titleAccessory}
connected
health={viewModel?.health ?? null}
historyLoading={viewModel?.backfillCompleted === false}
primary={{
onReconnect: connect,
onResume: config.resumeEndpoint ? () => resume.mutate() : connect,
pending: resume.isPending,
disabled: config.credentials === "optional" && !configured,
}}
menu={menu}
diagnosticsExtra={
streak
? [
t("settings.integrationCard.details.failureStreak", {
count: streak.count,
threshold: streak.threshold,
}),
]
: undefined
}
details={
details || planLimited || viewModel?.metricFreshness?.length ? (
<>
{planLimited ? (
<a
href={WITHINGS_API_PLANS_URL}
target="_blank"
rel="noopener noreferrer"
data-testid="withings-plan-link"
className="text-primary inline-flex min-h-11 items-center gap-1 text-sm underline-offset-2 hover:underline sm:min-h-0"
>
{t("settings.integrations.withings.plan.link")}
<ExternalLink className="size-3" aria-hidden="true" />
</a>
) : null}
{details}
<MetricFreshnessList entries={viewModel?.metricFreshness} />
</>
) : undefined
}
>
{syncResults ?? (
<ActionResultLines provider={provider} sync={sync} test={test} />
)}
{syncResults ? (
<ActionResultLines provider={provider} test={test} />
) : null}
{resume.isError ? (
<p
role="alert"
className="text-destructive text-sm"
data-testid={`${provider}-resume-error`}
>
{t("settings.integrationPill.resumeError")}
</p>
) : null}
{resume.isSuccess && resume.data?.wasParked ? (
<p
role="status"
className="text-success text-xs"
data-testid={`${provider}-resume-success`}
>
{t("settings.integrationPill.resumeSuccess")}
</p>
) : null}
{editingCredentials ? credentialsForm : null}
<CallbackMismatchNotice
provider={providerName}
callbackUrl={callbackUrl}
/>
</IntegrationCard>
);
}
@@ -1,551 +0,0 @@
"use client";
// v1.17.0 (F4) — shared OAuth integration card for Polar / Oura.
// v1.17.1 — these are now per-user BYO-key integrations (like WHOOP / Fitbit):
// the card renders an optional "your OAuth app credentials" form (driven by the
// `credentials` prop) above the connect button. Credentials resolve DB-first
// then env on the server, so a user who pastes their own client id/secret uses
// their own app while existing env-configured deploys keep working unchanged.
// Mirrors the Nightscout card's self-contained status pattern.
import { useState } from "react";
import Link from "next/link";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
ArrowRight,
Link2,
Loader2,
RefreshCw,
Save,
Unlink,
type LucideIcon,
} from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { PasswordInput } from "@/components/ui/password-input";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { IntegrationStatusPill } from "@/components/settings/integration-status-pill";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch";
import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line";
import { useTranslations } from "@/lib/i18n/context";
import type {
MetricFreshnessEntry,
SyncHealth,
} from "@/lib/integrations/sync-verdict";
import {
invalidateKeys,
measurementDependentKeys,
queryKeys,
} from "@/lib/query-keys";
import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure";
import { OuraScopeNote } from "./oura-scope-note";
import {
readSyncOutcome,
useSyncOutcomeMessage,
type SyncOutcomeState,
} from "./sync-outcome";
import {
IntegrationErrorMessage,
pillFailurePropsFor,
pillStateForVerdict,
type IntegrationState,
} from "./shared";
import {
CallbackMismatchNotice,
IntegrationCardDescription,
IntegrationRedirectGuide,
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 {
connected: boolean;
configured: boolean;
available: boolean;
/** Whether the user has stored their own BYO client id/secret pair. */
hasOwnCredentials?: boolean;
/**
* The raw ledger state, carried for shape parity with the envelope. The
* card paints from `syncHealth.verdict`, never from this — which is why
* v1.38.19's `unknown` member needs no arm here.
*/
state?: IntegrationState;
lastSuccessAt?: string | null;
lastAttemptAt?: string | null;
lastError?: string | null;
legacyLastSyncedAt?: string | null;
consecutiveFailuresByKind?: {
transient: number;
reauth_required: number;
persistent: number;
} | null;
failureThreshold?: number;
/** 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 {
/** Lower-case provider key, used for routes + query keys + testids. */
provider: "polar" | "oura" | "strava";
/** The provider's own query key, invalidated after connect / sync / save. */
statusQueryKey: readonly unknown[];
/** i18n key prefix (e.g. `settings.polar`). */
i18nPrefix: string;
/** Distinct card glyph, kept in the same size/treatment as the other cards. */
icon: LucideIcon;
/** Where this provider's synced data surfaces (e.g. `/insights/sleep`). */
dataHref: string;
/** When set, render a per-user BYO OAuth-credentials form above the connect
* button. The endpoint is the PUT target (e.g. `/api/polar/credentials`). */
credentials?: boolean;
/**
* The card's status, sourced off the consolidated
* `/api/integrations/status` envelope. The card used to fall back to its own
* `/api/<provider>/status` round-trip when this was absent; that fallback is
* gone. Every mount passes a view-model, the per-provider response carries no
* `syncHealth` so the fallback would have painted a wrong verdict, and during
* envelope load it fired three redundant requests for a caller that does not
* exist.
*/
viewModel?: OAuthProviderStatus;
/** Server-resolved OAuth callback URL; see `getIntegrationCallbackUrls`. */
callbackUrl: IntegrationCallbackUrls[OAuthProviderCardProps["provider"]];
}
export function OAuthProviderCard({
provider,
statusQueryKey,
i18nPrefix,
icon,
dataHref,
credentials = false,
viewModel,
callbackUrl,
}: OAuthProviderCardProps) {
const { t } = useTranslations();
const describeSyncOutcome = useSyncOutcomeMessage();
const queryClient = useQueryClient();
const [msg, setMsg] = useState<string | null>(null);
const [syncing, setSyncing] = useState(false);
const [syncResult, setSyncResult] = useState<SyncOutcomeState | null>(null);
const [clientId, setClientId] = useState("");
const [clientSecret, setClientSecret] = useState("");
const [credsSaving, setCredsSaving] = useState(false);
const [credsMsg, setCredsMsg] = useState<string | null>(null);
const [credsMsgType, setCredsMsgType] = useState<"success" | "error" | null>(
null,
);
const status = viewModel;
// Sync now. Same shape as the WHOOP card's incremental arm, minus the
// full-history dialog — none of these providers has a full-sync arm to call.
async function handleSync() {
setSyncing(true);
setSyncResult(null);
try {
const res = await apiFetchRaw(`/api/${provider}/sync`, {
method: "POST",
});
const json = await res.json();
const result = res.ok ? readSyncOutcome(json) : null;
if (result) {
// The tone comes off what the run wrote, not off `res.ok`.
setSyncResult({
outcome: result.outcome,
message: describeSyncOutcome(
result,
t(`${i18nPrefix}SyncResult`, { count: result.imported }),
),
});
void invalidateKeys(queryClient, measurementDependentKeys);
queryClient.invalidateQueries({ queryKey: statusQueryKey });
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
} else {
setSyncResult({
outcome: "failed",
message: json?.error || t(`${i18nPrefix}SyncFailed`),
});
}
} catch {
setSyncResult({
outcome: "failed",
message: t(`${i18nPrefix}SyncFailed`),
});
} finally {
setSyncing(false);
}
}
async function handleSaveCredentials(e: React.FormEvent) {
e.preventDefault();
setCredsSaving(true);
setCredsMsg(null);
setCredsMsgType(null);
try {
const res = await apiFetchRaw(`/api/${provider}/credentials`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
clientId: clientId.trim(),
clientSecret: clientSecret.trim(),
}),
});
if (res.ok) {
setCredsMsg(t(`${i18nPrefix}CredentialsSaved`));
setCredsMsgType("success");
setClientId("");
setClientSecret("");
queryClient.invalidateQueries({ queryKey: statusQueryKey });
// The card may read off the consolidated envelope — invalidate it too
// so the saved-credentials state repaints regardless of the source.
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
} else {
try {
const json = await res.json();
setCredsMsg(json.error || t("settings.savingError"));
} catch {
setCredsMsg(t("settings.savingError"));
}
setCredsMsgType("error");
}
} catch {
setCredsMsg(t("common.networkError"));
setCredsMsgType("error");
}
setCredsSaving(false);
}
const disconnect = useMutation({
mutationFn: async () => {
await apiPost(`/api/${provider}/disconnect`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: statusQueryKey });
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
},
});
const pillState = pillStateForVerdict(status?.syncHealth?.verdict);
// The pill says what; this line says why. `warning` joins the set now that a
// transient failure no longer paints red — the detail moved here from a
// colour that told the user to reconnect against something reconnecting
// cannot fix.
const errorMessage =
(pillState === "error" ||
pillState === "parked" ||
pillState === "warning") &&
status?.lastError
? status.lastError
: null;
const serverUnavailable = status && !status.available;
function handleConnect() {
setMsg(null);
void invalidateKeys(queryClient, measurementDependentKeys);
loadDocument(`/api/${provider}/connect`);
}
return (
<SettingsCard data-testid={`${provider}-card`}>
<SettingsCardHeader
icon={icon}
title={t(`${i18nPrefix}`)}
description={
<IntegrationCardDescription
i18nPrefix={i18nPrefix}
provider={provider as IntegrationDocsProvider}
/>
}
status={
<IntegrationStatusPill
state={pillState}
lastSyncAt={
pillState === "stale" || pillState === "stalled"
? (status?.syncHealth?.since ?? null)
: (status?.legacyLastSyncedAt ?? status?.lastSuccessAt ?? null)
}
{...pillFailurePropsFor(status)}
/>
}
/>
<div className="space-y-4">
{errorMessage && (
<div data-testid={`${provider}-error`}>
<IntegrationErrorMessage message={errorMessage} />
</div>
)}
{/* Parked-integration resume CTA. Surfaces only when the row state
is `parked` (>24h of persistent failures). For an env-based OAuth
provider there are no stored BYO credentials to re-validate — the
grant itself has to be re-issued, so "reconnect" re-initiates the
existing connect flow (`/api/<provider>/connect`); a successful
callback clears the park. Markup matches the WHOOP card byte for
byte. */}
{pillState === "parked" && (
<div
data-testid={`${provider}-parked-banner`}
className="border-warning/30 bg-warning/10 flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm"
>
<span className="text-warning min-w-0 text-xs break-words">
{t("settings.integrationPill.parkedReconnect")}
</span>
<Button
type="button"
size="sm"
variant="outline"
onClick={handleConnect}
disabled={serverUnavailable}
data-testid={`${provider}-resume-button`}
className="min-h-11"
>
<Link2 className="h-3.5 w-3.5" />
{t("settings.integrationPill.resumeCta")}
</Button>
</div>
)}
{provider === "oura" && status?.connected && (
<OuraScopeNote
collections={status.scopeReconnect}
onReconnect={handleConnect}
disabled={serverUnavailable}
/>
)}
{status?.connected && (
<MetricFreshnessDisclosure
entries={status.metricFreshness}
idPrefix={provider}
/>
)}
{credentials && (
<div className="space-y-3" data-testid={`${provider}-credentials`}>
<h3 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{t(`${i18nPrefix}Credentials`)}
</h3>
{!status?.hasOwnCredentials && (
<IntegrationRedirectGuide
provider={provider}
providerLabel={t(i18nPrefix)}
callbackUrl={callbackUrl}
/>
)}
<form onSubmit={handleSaveCredentials} className="space-y-3">
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor={`${provider}-clientid`}>
{t(`${i18nPrefix}ClientId`)}
</Label>
<Input
id={`${provider}-clientid`}
value={clientId}
onChange={(e) => setClientId(e.target.value)}
placeholder={
status?.hasOwnCredentials
? t(`${i18nPrefix}CredentialsSavedPlaceholder`)
: t(`${i18nPrefix}ClientId`)
}
maxLength={200}
autoComplete="off"
inputMode="text"
spellCheck={false}
autoCapitalize="none"
enterKeyHint="next"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor={`${provider}-secret`}>
{t(`${i18nPrefix}ClientSecret`)}
</Label>
<PasswordInput
id={`${provider}-secret`}
value={clientSecret}
onChange={(e) => setClientSecret(e.target.value)}
placeholder={
status?.hasOwnCredentials
? t(`${i18nPrefix}CredentialsSavedPlaceholder`)
: t(`${i18nPrefix}ClientSecret`)
}
maxLength={200}
autoComplete="off"
inputMode="text"
spellCheck={false}
autoCapitalize="none"
enterKeyHint="done"
/>
</div>
</div>
<SettingsCardActions>
<Button
type="submit"
variant="outline"
size="sm"
className="min-h-11 w-full sm:w-auto"
disabled={
credsSaving || !clientId.trim() || !clientSecret.trim()
}
>
{credsSaving ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Save className="h-3.5 w-3.5" />
)}
{t(`${i18nPrefix}SaveCredentials`)}
</Button>
</SettingsCardActions>
{credsMsg && (
<p
role="alert"
className={`text-sm ${credsMsgType === "success" ? "text-success" : "text-destructive"}`}
>
{credsMsg}
</p>
)}
</form>
</div>
)}
{serverUnavailable && (
<p
className="text-muted-foreground text-xs"
data-testid={`${provider}-unavailable`}
>
{t(`${i18nPrefix}Unavailable`)}
</p>
)}
{status?.connected ? (
<>
<div className="flex flex-wrap items-start gap-2 [&>*]:min-w-[10rem] sm:[&>*]:min-w-0">
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11"
onClick={handleSync}
disabled={syncing}
data-testid={`${provider}-sync`}
>
{syncing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
{t(`${i18nPrefix}Sync`)}
</Button>
<TestConnectionButton
endpoint={`/api/${provider}/test`}
disabled={!status?.connected}
/>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="text-destructive min-h-11"
data-testid={`${provider}-disconnect`}
>
<Unlink className="h-3.5 w-3.5" />
{t(`${i18nPrefix}Disconnect`)}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t(`${i18nPrefix}DisconnectTitle`)}
</AlertDialogTitle>
<AlertDialogDescription>
{t(`${i18nPrefix}DisconnectDescription`)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => disconnect.mutate()}
>
{t(`${i18nPrefix}Disconnect`)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
{syncResult && (
<WrittenOutcomeLine
outcome={syncResult.outcome}
message={syncResult.message}
testId={`${provider}-sync-result`}
/>
)}
{/* connect→data loop: a discreet link to where this provider's
readings now surface — doubles as the "your data is richer"
cue. */}
<Link
href={dataHref}
data-testid={`${provider}-data-link`}
className="text-primary inline-flex items-center gap-1 text-xs underline-offset-2 hover:underline"
>
{t(`${i18nPrefix}ViewData`)}
<ArrowRight className="h-3 w-3" />
</Link>
</>
) : (
<SettingsCardActions>
<Button
type="button"
size="sm"
className="min-h-11 w-full sm:w-auto"
disabled={serverUnavailable}
onClick={handleConnect}
data-testid={`${provider}-connect`}
>
<Link2 className="h-3.5 w-3.5" />
{t(`${i18nPrefix}Connect`)}
</Button>
</SettingsCardActions>
)}
{msg && (
<p role="alert" className="text-destructive text-sm">
{msg}
</p>
)}
</div>
<CallbackMismatchNotice
provider={t(i18nPrefix)}
callbackUrl={callbackUrl}
/>
</SettingsCard>
);
}
@@ -1,35 +0,0 @@
"use client";
// v1.17.0 (F4) — Oura Cloud integration card. Thin wrapper over the shared
// OAuth card.
// v1.17.1 — per-user BYO OAuth credentials (DB-first then env).
import { CircleDot } from "lucide-react";
import {
OAuthProviderCard,
type OAuthProviderStatus,
} from "@/components/settings/integrations/oauth-provider-card";
import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls";
import { queryKeys } from "@/lib/query-keys";
export function OuraCard({
viewModel,
callbackUrl,
}: {
viewModel?: OAuthProviderStatus;
callbackUrl: IntegrationCallbackUrls["oura"];
}) {
return (
<OAuthProviderCard
provider="oura"
statusQueryKey={queryKeys.oura()}
i18nPrefix="settings.oura"
icon={CircleDot}
dataHref="/insights/sleep"
credentials
viewModel={viewModel}
callbackUrl={callbackUrl}
/>
);
}
@@ -1,60 +0,0 @@
"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>
);
}
@@ -1,35 +0,0 @@
"use client";
// v1.17.0 (F4) — Polar AccessLink integration card. Thin wrapper over the
// shared OAuth card.
// v1.17.1 — per-user BYO OAuth credentials (DB-first then env).
import { Watch } from "lucide-react";
import {
OAuthProviderCard,
type OAuthProviderStatus,
} from "@/components/settings/integrations/oauth-provider-card";
import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls";
import { queryKeys } from "@/lib/query-keys";
export function PolarCard({
viewModel,
callbackUrl,
}: {
viewModel?: OAuthProviderStatus;
callbackUrl: IntegrationCallbackUrls["polar"];
}) {
return (
<OAuthProviderCard
provider="polar"
statusQueryKey={queryKeys.polar()}
i18nPrefix="settings.polar"
icon={Watch}
dataHref="/insights/sleep"
credentials
viewModel={viewModel}
callbackUrl={callbackUrl}
/>
);
}
+34 -161
View File
@@ -8,23 +8,12 @@
*/
import { useQuery } from "@tanstack/react-query";
import { AlertCircle } from "lucide-react";
import type { IntegrationPillState } from "@/components/settings/integration-status-pill";
import { apiGet } from "@/lib/api/api-fetch";
import type {
MetricFreshnessEntry,
SyncHealth,
SyncVerdict,
} from "@/lib/integrations/sync-verdict";
import type { ConnectionHealth } from "@/lib/integrations/connection-health";
import type { MetricFreshnessEntry } 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
// IntegrationStatusPill now owns state + last-sync presentation, and
// the actionable error message is shown inline above the action row.
export type IntegrationKey =
| "withings"
| "whoop"
@@ -38,23 +27,20 @@ export type IntegrationKey =
// Nightscout folds onto the envelope so the card stops fetching its own
// status and inherits the shared verdict.
| "nightscout";
/** Mirrors `IntegrationState` in `src/lib/integrations/status.ts`. */
export type IntegrationState =
| "connected"
| "error_transient"
| "error_reauth"
| "disconnected"
| "parked"
// v1.38.19 — no ledger row: the ledger has no history for this provider.
// Read like `disconnected`; `syncHealth.verdict` owns liveness.
| "unknown";
/**
* One envelope entry as the cards read it. Since v1.42.4 the cards render
* `health` alone; the older health-shaped fields (ledger `state`, `lastError`,
* `syncHealth`, token expiry, plan and scope lists, `needsReauth`) stay on the
* wire for one transition release and are deliberately not declared here, so
* the compiler refuses a card that starts reading them again.
*/
export interface IntegrationStatusViewModel {
integration: IntegrationKey;
state: IntegrationState;
lastSuccessAt: string | null;
lastAttemptAt: string | null;
lastError: string | null;
/**
* The one server-resolved state (ok / limited / degraded / action_required),
* null while the provider is not connected.
*/
health?: ConnectionHealth | null;
consecutiveFailuresByKind?: {
transient: number;
reauth_required: number;
@@ -64,38 +50,17 @@ export interface IntegrationStatusViewModel {
failureThreshold?: number;
configured?: boolean;
connected?: boolean;
connectedAt?: string | null;
legacyLastSyncedAt?: string | null;
tokenExpiresAt?: string | null;
tokenExpired?: boolean | null;
enabled?: boolean;
// 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.
/** WHOOP / Fitbit / Google Health: older history still loading. */
backfillCompleted?: boolean | null;
// v1.27.0 — Google Health surfaces `needsReauth` when the refresh token has
// lapsed (the 7-day "Testing"-mode expiry, or a revoked grant). The card
// paints a distinct re-consent CTA off this flag, separate from the parked
// state. Server-populated on the `/api/integrations/status` envelope.
needsReauth?: boolean;
// Polar / Oura OAuth card: usable-credentials + BYO-key flags. `available`
// greys out the connect button when no credentials resolve; `hasOwnCredentials`
// drives the saved-placeholder UI.
available?: boolean;
hasOwnCredentials?: boolean;
// Nightscout: a stored token, and the private-network opt-in.
hasToken?: boolean;
allowPrivateHost?: boolean;
/**
* The server-resolved liveness verdict. Present on every envelope entry;
* `undefined` only while the envelope is still loading.
* Polar / Oura / Strava: usable credentials resolve (BYO or the shared
* app). Greys out "Connect" when false.
*/
syncHealth?: SyncHealth;
available?: boolean;
/** Polar / Oura / Strava: the person stored their own client id/secret. */
hasOwnCredentials?: boolean;
/** Nightscout: a stored token. */
hasToken?: boolean;
/** Per-metric last-seen timestamps with the server-computed stale flag. */
metricFreshness?: MetricFreshnessEntry[];
}
@@ -106,10 +71,8 @@ export interface IntegrationStatusEnvelope {
}
/**
* Shared status fetch for the Settings → Integrations card. Returns
* the per-integration view-model AND the global threshold so the
* "{n}/{threshold} consecutive failures" string in the UI is single-
* sourced from the server.
* Shared status fetch for the Settings → Integrations cards. Returns the
* per-integration view-model and the global failure threshold.
*/
export function useIntegrationStatuses(enabled: boolean) {
return useQuery({
@@ -135,116 +98,26 @@ export function pickStatus(
}
/**
* The one verdict→pill mapping in the tree.
*
* There used to be three of these, one per card family, and they disagreed:
* the same ledger state painted red on one card and orange on the next, on the
* same page. The server now resolves the verdict once and this projects it once
* — a fourth dialect cannot be minted without the structural guard noticing.
*
* `failing` deliberately lands on the warning tone, not the destructive one:
* **red is reserved for "your action fixes this"** (reconnect, resume). Telling
* a user to reconnect against a 503 sends them clicking at something they
* cannot repair; the streak detail survives in the inline error line and the
* "{n}/{threshold} consecutive failures" string the envelope single-sources.
* The current failure streak paired with the server-owned alert threshold, for
* the card's copied diagnostics. The card itself shows no counter: whether a
* streak matters is already in `health`. The alert ladder uses the same
* maximum, so the ratio cannot disagree with when a notification goes out.
*/
export function pillStateForVerdict(
verdict: SyncVerdict | undefined,
): IntegrationPillState {
switch (verdict) {
case "fresh":
return "connected";
case "stale":
return "stale";
case "stalled":
return "stalled";
case "failing":
return "warning";
case "reauth_required":
return "error";
case "parked":
return "parked";
case "pending_first_sync":
return "pending-setup";
case "disconnected":
case undefined:
return "disconnected";
}
}
/** Project a view-model onto its pill state. Undefined = still loading. */
export function pillStateFor(
status: IntegrationStatusViewModel | undefined,
): IntegrationPillState {
return pillStateForVerdict(status?.syncHealth?.verdict);
}
export interface IntegrationPillFailureProps {
failureCount?: number;
failureThreshold?: number;
}
/**
* Pair the largest per-kind streak with the server-owned alert threshold. The
* alert ladder uses the same maximum, so the ratio cannot disagree with when a
* notification becomes eligible.
*/
export function pillFailurePropsFor(
export function failureStreakFor(
status:
| Pick<
IntegrationStatusViewModel,
"consecutiveFailuresByKind" | "failureThreshold"
>
| undefined,
): IntegrationPillFailureProps {
): { count: number; threshold: number } | null {
const buckets = status?.consecutiveFailuresByKind;
const failureThreshold = status?.failureThreshold;
if (!buckets || failureThreshold === undefined) return {};
const failureCount = Math.max(
const threshold = status?.failureThreshold;
if (!buckets || threshold === undefined) return null;
const count = Math.max(
buckets.transient,
buckets.reauth_required,
buckets.persistent,
);
return failureCount > 0 ? { failureCount, failureThreshold } : {};
}
/**
* The timestamp the pill shows inline. `connected` reads the last success (a
* connection row's own `lastSyncedAt` wins when it is newer, which is what
* repaints a fresh "just now" straight after a manual sync); the aging and
* failing states read the instant that triggered the verdict.
*
* `failing` must NOT fall through to the `legacyLastSyncedAt` arm below. That
* field is a connection row's own clock, kept fresh by the very sync that is
* erroring, so a provider that had delivered nothing for a fortnight painted a
* "just now" beside its warning.
*/
export function pillTimestampFor(
status: IntegrationStatusViewModel | undefined,
): string | null {
const verdict = status?.syncHealth?.verdict;
if (verdict === "stale" || verdict === "stalled" || verdict === "failing") {
return status?.syncHealth?.since ?? null;
}
return status?.legacyLastSyncedAt ?? status?.lastSuccessAt ?? null;
}
/**
* Inline actionable error message that surfaces under the pill when a
* sync attempt failed. The pill conveys "something is wrong"; this
* line tells the user *what* is wrong so they can act on it. Keeping
* it deliberately small (one icon + one line) so it doesn't recreate
* the v1.4.18 redundant banner the maintainer removed.
*/
export function IntegrationErrorMessage({ message }: { message: string }) {
return (
<p
data-testid="integration-error-message"
className="text-destructive flex items-start gap-1.5 text-sm"
>
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span className="min-w-0 break-words">{message}</span>
</p>
);
return count > 0 ? { count, threshold } : null;
}
@@ -1,36 +0,0 @@
"use client";
// v1.28.x — Strava integration card. Thin wrapper over the shared OAuth card
// (per-user BYO OAuth credentials, DB-first then env). Strava is a workout
// source: connecting it syncs your activities into the workout surface, where
// the cross-source picker collapses any twin already captured by a wearable.
import { Activity } from "lucide-react";
import {
OAuthProviderCard,
type OAuthProviderStatus,
} from "@/components/settings/integrations/oauth-provider-card";
import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls";
import { queryKeys } from "@/lib/query-keys";
export function StravaCard({
viewModel,
callbackUrl,
}: {
viewModel?: OAuthProviderStatus;
callbackUrl: IntegrationCallbackUrls["strava"];
}) {
return (
<OAuthProviderCard
provider="strava"
statusQueryKey={queryKeys.strava()}
i18nPrefix="settings.strava"
icon={Activity}
dataHref="/insights/workouts"
credentials
viewModel={viewModel}
callbackUrl={callbackUrl}
/>
);
}
@@ -1,519 +0,0 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
Activity,
ArrowRight,
Link2,
Loader2,
RefreshCw,
Save,
Unlink,
} from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { PasswordInput } from "@/components/ui/password-input";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { IntegrationStatusPill } from "@/components/settings/integration-status-pill";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch";
import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line";
import { useTranslations } from "@/lib/i18n/context";
import {
invalidateKeys,
measurementDependentKeys,
queryKeys,
} from "@/lib/query-keys";
import {
IntegrationErrorMessage,
pillFailurePropsFor,
pillStateFor,
pillTimestampFor,
type IntegrationStatusViewModel,
} from "./shared";
import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure";
import {
readSyncOutcome,
useSyncOutcomeMessage,
type SyncOutcomeState,
} from "./sync-outcome";
import {
CallbackMismatchNotice,
IntegrationCardDescription,
IntegrationRedirectGuide,
} from "./setup-guide-link";
import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls";
import { loadDocument } from "@/lib/navigation/load-document";
export function WhoopCard({
viewModel,
callbackUrl,
}: {
viewModel: IntegrationStatusViewModel | undefined;
/** Server-resolved OAuth callback URL; see `getIntegrationCallbackUrls`. */
callbackUrl: IntegrationCallbackUrls["whoop"];
}) {
const { t } = useTranslations();
const describeSyncOutcome = useSyncOutcomeMessage();
const [syncing, setSyncing] = useState(false);
const [syncResult, setSyncResult] = useState<SyncOutcomeState | null>(null);
const [clientId, setClientId] = useState("");
const [clientSecret, setClientSecret] = useState("");
const [credsSaving, setCredsSaving] = useState(false);
const [credsMsg, setCredsMsg] = useState<string | null>(null);
const [credsMsgType, setCredsMsgType] = useState<"success" | "error" | null>(
null,
);
const queryClient = useQueryClient();
// v1.12.1 — read off the consolidated /api/integrations/status
// envelope; the per-card /api/whoop/status round-trip is gone.
const status = viewModel;
const disconnect = useMutation({
mutationFn: async () => {
await apiPost("/api/whoop/disconnect");
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.whoop() });
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
},
});
// Clear a parked integration via the resume endpoint. The CTA is
// rendered inside the parked banner below; success invalidates both
// the per-card status (so the pill flips back to connected
// immediately) and the cross-integration envelope (so any other view
// picks up the change on its next focus).
const resume = useMutation({
mutationFn: async () => {
return apiPost<{ resumed: boolean; wasParked: boolean }>(
"/api/integrations/whoop/resume",
);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.whoop() });
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
},
});
async function handleSync(fullSync = false) {
setSyncing(true);
setSyncResult(null);
try {
const res = await apiFetchRaw("/api/whoop/sync", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fullSync }),
});
const json = await res.json();
const result = res.ok ? readSyncOutcome(json) : null;
if (result) {
// The tone comes off what the run wrote, not off `res.ok`.
setSyncResult({
outcome: result.outcome,
message: describeSyncOutcome(
result,
t("settings.whoopSyncResult", { count: result.imported }),
),
});
void invalidateKeys(queryClient, measurementDependentKeys);
queryClient.invalidateQueries({ queryKey: queryKeys.whoop() });
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
} else {
setSyncResult({
outcome: "failed",
message: json?.error || t("settings.whoopSyncFailed"),
});
}
} catch {
setSyncResult({
outcome: "failed",
message: t("settings.whoopSyncFailed"),
});
} finally {
setSyncing(false);
}
}
async function handleSaveCredentials(e: React.FormEvent) {
e.preventDefault();
setCredsSaving(true);
setCredsMsg(null);
setCredsMsgType(null);
try {
const res = await apiFetchRaw("/api/whoop/credentials", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
clientId: clientId.trim(),
clientSecret: clientSecret.trim(),
}),
});
if (res.ok) {
setCredsMsg(t("settings.whoopCredentialsSaved"));
setCredsMsgType("success");
setClientId("");
setClientSecret("");
queryClient.invalidateQueries({ queryKey: queryKeys.whoop() });
} else {
try {
const json = await res.json();
setCredsMsg(json.error || t("settings.savingError"));
} catch {
setCredsMsg(t("settings.savingError"));
}
setCredsMsgType("error");
}
} catch {
setCredsMsg(t("common.networkError"));
setCredsMsgType("error");
}
setCredsSaving(false);
}
// The server resolves the verdict; the card only projects it. `connected`
// rides into that resolution, so a disconnected provider still lands on the
// "Not connected" pill without a second local rule here.
const pillState = pillStateFor(viewModel);
const pillLastSyncAt = pillTimestampFor(viewModel);
const errorMessage =
(pillState === "error" ||
pillState === "parked" ||
pillState === "warning") &&
viewModel?.lastError
? viewModel.lastError
: null;
return (
<SettingsCard data-testid="whoop-card">
<SettingsCardHeader
icon={Activity}
title={t("settings.whoop")}
description={
<IntegrationCardDescription
i18nPrefix="settings.whoop"
provider="whoop"
/>
}
status={
<IntegrationStatusPill
state={pillState}
lastSyncAt={pillLastSyncAt}
{...pillFailurePropsFor(viewModel)}
/>
}
/>
<div className="space-y-4">
{errorMessage && <IntegrationErrorMessage message={errorMessage} />}
{/* Parked-integration resume CTA. Surfaces only when the row
state is `parked` (>24h of persistent failures). The button
POSTs to /api/integrations/whoop/resume which calls
`resumeIntegrationFromPark`; on success the per-card status
invalidates and the pill flips back to connected without a
page refresh. Wider tap target than the inline action row so
it stays reachable on a Pixel 5 viewport. */}
{pillState === "parked" && (
<div
data-testid="whoop-parked-banner"
className="border-warning/30 bg-warning/10 flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm"
>
<span className="text-warning min-w-0 text-xs break-words">
{t("settings.integrationPill.parkedReconnect")}
</span>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => resume.mutate()}
disabled={resume.isPending}
data-testid="whoop-resume-button"
className="min-h-11"
>
{resume.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Link2 className="h-3.5 w-3.5" />
)}
{t("settings.integrationPill.resumeCta")}
</Button>
</div>
)}
{resume.isError && (
<p
role="alert"
className="text-destructive text-sm"
data-testid="whoop-resume-error"
>
{t("settings.integrationPill.resumeError")}
</p>
)}
{status?.connected && (
<MetricFreshnessDisclosure
entries={viewModel?.metricFreshness}
idPrefix="whoop"
/>
)}
{resume.isSuccess && resume.data?.wasParked && (
<p
role="status"
className="text-success text-xs"
data-testid="whoop-resume-success"
>
{t("settings.integrationPill.resumeSuccess")}
</p>
)}
<div className="space-y-3">
<h3 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{t("settings.whoopCredentials")}
</h3>
{!status?.configured && (
<IntegrationRedirectGuide
provider="whoop"
providerLabel={t("settings.whoop")}
callbackUrl={callbackUrl}
/>
)}
<form onSubmit={handleSaveCredentials} className="space-y-3">
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="whoop-clientid">
{t("settings.whoopClientId")}
</Label>
<Input
id="whoop-clientid"
value={clientId}
onChange={(e) => setClientId(e.target.value)}
placeholder={
status?.configured
? t("settings.whoopCredentialsSavedPlaceholder")
: t("settings.whoopClientId")
}
maxLength={200}
autoComplete="off"
inputMode="text"
spellCheck={false}
autoCapitalize="none"
enterKeyHint="next"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="whoop-secret">
{t("settings.whoopClientSecret")}
</Label>
<PasswordInput
id="whoop-secret"
value={clientSecret}
onChange={(e) => setClientSecret(e.target.value)}
placeholder={
status?.configured
? t("settings.whoopCredentialsSavedPlaceholderSecret")
: t("settings.whoopClientSecret")
}
maxLength={200}
autoComplete="off"
inputMode="text"
spellCheck={false}
autoCapitalize="none"
enterKeyHint="done"
/>
</div>
</div>
<SettingsCardActions>
<Button
type="submit"
variant="outline"
size="sm"
className="min-h-11 w-full sm:w-auto"
disabled={
credsSaving || !clientId.trim() || !clientSecret.trim()
}
>
{credsSaving ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Save className="h-3.5 w-3.5" />
)}
{t("settings.whoopSaveCredentials")}
</Button>
</SettingsCardActions>
{credsMsg && (
<p
role="alert"
className={`text-sm ${credsMsgType === "success" ? "text-success" : "text-destructive"}`}
>
{credsMsg}
</p>
)}
</form>
</div>
{status?.connected ? (
<>
<div className="flex flex-wrap items-start gap-2 [&>*]:min-w-[10rem] sm:[&>*]:min-w-0">
<Button
variant="outline"
size="sm"
className="min-h-11"
onClick={() => handleSync(false)}
disabled={syncing}
>
{syncing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
{t("settings.whoopSync")}
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="min-h-11"
disabled={syncing}
>
{syncing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
{t("settings.whoopFullSync")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.whoopFullSyncTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.whoopFullSyncDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={() => handleSync(true)}>
{t("settings.whoopSynchronize")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<TestConnectionButton
endpoint="/api/integrations/whoop/test"
disabled={!status?.connected}
/>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="text-destructive min-h-11"
>
<Unlink className="h-3.5 w-3.5" />
{t("settings.whoopDisconnect")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.whoopDisconnectTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.whoopDisconnectDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => disconnect.mutate()}
>
{t("settings.whoopDisconnect")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
{status?.backfillCompleted === false && (
<p className="text-muted-foreground text-xs">
{t("settings.whoopBackfillInProgress")}
</p>
)}
{syncResult && (
<WrittenOutcomeLine
outcome={syncResult.outcome}
message={syncResult.message}
testId="whoop-sync-result"
/>
)}
{/* connect→data loop: a discreet link to where this provider's
readings now surface — doubles as the "your data is richer"
cue. */}
<Link
href="/insights/recovery"
data-testid="whoop-data-link"
className="text-primary inline-flex items-center gap-1 text-xs underline-offset-2 hover:underline"
>
{t("settings.whoopViewData")}
<ArrowRight className="h-3 w-3" />
</Link>
</>
) : status?.configured ? (
<SettingsCardActions>
<Button
size="sm"
className="min-h-11 w-full sm:w-auto"
data-testid="whoop-connect"
onClick={() => {
loadDocument("/api/whoop/connect");
}}
>
<Link2 className="h-3.5 w-3.5" />
{t("settings.whoopConnect")}
</Button>
</SettingsCardActions>
) : (
<p
className="text-muted-foreground text-xs"
data-testid="integration-unavailable"
>
{t("settings.whoopNoCredentials")}
</p>
)}
</div>
<CallbackMismatchNotice
provider={t("settings.whoop")}
callbackUrl={callbackUrl}
/>
</SettingsCard>
);
}
@@ -1,573 +0,0 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
ArrowRight,
Link2,
Loader2,
RefreshCw,
Save,
Unlink,
} from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { PasswordInput } from "@/components/ui/password-input";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { IntegrationStatusPill } from "@/components/settings/integration-status-pill";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch";
import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line";
import { useTranslations } from "@/lib/i18n/context";
import {
invalidateKeys,
measurementDependentKeys,
queryKeys,
} from "@/lib/query-keys";
import {
IntegrationErrorMessage,
pillStateFor,
pillFailurePropsFor,
pillTimestampFor,
type IntegrationStatusViewModel,
} from "./shared";
import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure";
import { WithingsPlanNote } from "./withings-plan-note";
import {
readSyncOutcome,
useSyncOutcomeMessage,
type SyncOutcomeState,
} from "./sync-outcome";
import {
CallbackMismatchNotice,
IntegrationCardDescription,
IntegrationRedirectGuide,
} from "./setup-guide-link";
import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls";
import { loadDocument } from "@/lib/navigation/load-document";
export function WithingsCard({
viewModel,
callbackUrl,
}: {
viewModel: IntegrationStatusViewModel | undefined;
/** Server-resolved OAuth callback URL; see `getIntegrationCallbackUrls`. */
callbackUrl: IntegrationCallbackUrls["withings"];
}) {
const { t } = useTranslations();
const describeSyncOutcome = useSyncOutcomeMessage();
const [syncing, setSyncing] = useState(false);
const [syncResult, setSyncResult] = useState<SyncOutcomeState | null>(null);
const [clientId, setClientId] = useState("");
const [clientSecret, setClientSecret] = useState("");
const [credsSaving, setCredsSaving] = useState(false);
const [credsMsg, setCredsMsg] = useState<string | null>(null);
const [credsMsgType, setCredsMsgType] = useState<"success" | "error" | null>(
null,
);
const queryClient = useQueryClient();
// v1.12.1 — the Withings status is read off the consolidated
// /api/integrations/status envelope (the `viewModel` prop). The
// per-card /api/withings/status round-trip is gone; the envelope
// carries every field this card renders (connected / configured /
// last-sync / activity scope).
const status = viewModel;
const disconnect = useMutation({
mutationFn: async () => {
await apiPost("/api/withings/disconnect");
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.withings() });
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
},
});
// v1.4.43 W14 — clear a parked integration via the resume endpoint.
// The CTA is rendered inside the parked banner below; success
// invalidates both the per-card status (so the pill flips back to
// connected immediately) and the cross-integration envelope (so any
// other view picks up the change on its next focus).
const resume = useMutation({
mutationFn: async () => {
return apiPost<{ resumed: boolean; wasParked: boolean }>(
"/api/integrations/withings/resume",
);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.withings() });
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
},
});
async function handleSync(fullSync = false) {
setSyncing(true);
setSyncResult(null);
try {
const res = await apiFetchRaw("/api/withings/sync", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fullSync }),
});
const json = await res.json();
const result = res.ok ? readSyncOutcome(json) : null;
if (result) {
// The tone comes off what the run wrote, not off `res.ok`.
setSyncResult({
outcome: result.outcome,
message: describeSyncOutcome(
result,
fullSync
? t("settings.withingsFullSyncResult", { count: result.imported })
: t("settings.withingsSyncResult", { count: result.imported }),
),
});
void invalidateKeys(queryClient, measurementDependentKeys);
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
} else {
setSyncResult({
outcome: "failed",
message: json?.error || t("settings.withingsSyncFailed"),
});
}
} catch {
setSyncResult({
outcome: "failed",
message: t("settings.withingsSyncFailed"),
});
} finally {
setSyncing(false);
}
}
async function handleSaveCredentials(e: React.FormEvent) {
e.preventDefault();
setCredsSaving(true);
setCredsMsg(null);
setCredsMsgType(null);
try {
const res = await apiFetchRaw("/api/withings/credentials", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
clientId: clientId.trim(),
clientSecret: clientSecret.trim(),
}),
});
if (res.ok) {
setCredsMsg(t("settings.withingsCredentialsSaved"));
setCredsMsgType("success");
setClientId("");
setClientSecret("");
queryClient.invalidateQueries({ queryKey: queryKeys.withings() });
} else {
try {
const json = await res.json();
setCredsMsg(json.error || t("settings.savingError"));
} catch {
setCredsMsg(t("settings.savingError"));
}
setCredsMsgType("error");
}
} catch {
setCredsMsg(t("common.networkError"));
setCredsMsgType("error");
}
setCredsSaving(false);
}
// The pill state derives from the cross-integration status
// envelope. `legacyLastSyncedAt` is the connection row's own
// last-sync timestamp (carried in the same envelope) so the pill
// still paints a fresh "X min ago" right after a manual sync once
// the envelope refetches. When the envelope hasn't answered yet we
// fall back to "disconnected" so the card never renders status-less.
// The server resolves the verdict; the card only projects it. `connected`
// rides into that resolution, so a disconnected provider still lands on the
// "Not connected" pill without a second local rule here.
const pillState = pillStateFor(viewModel);
const pillLastSyncAt = pillTimestampFor(viewModel);
// v1.4.43 W14 — `parked` and `error` both want the underlying error
// message surfaced under the pill (the pill says "what" — the
// message says "why"). Other states leave the inline line off.
const errorMessage =
(pillState === "error" ||
pillState === "parked" ||
pillState === "warning") &&
viewModel?.lastError
? viewModel.lastError
: null;
return (
<SettingsCard data-testid="withings-card">
<SettingsCardHeader
icon={Link2}
title={t("settings.withings")}
description={
<IntegrationCardDescription
i18nPrefix="settings.withings"
provider="withings"
/>
}
status={
<IntegrationStatusPill
state={pillState}
lastSyncAt={pillLastSyncAt}
{...pillFailurePropsFor(viewModel)}
/>
}
/>
<div className="space-y-4">
{errorMessage && <IntegrationErrorMessage message={errorMessage} />}
{/* v1.4.25 W5d — Withings activity-scope reconnect banner.
Only paints when (a) the user has an active Withings
connection (so the credentials are already saved + a token
exists) AND (b) the persisted scope is missing
`user.activity`. Reconnecting takes the user through the
standard /api/withings/connect → Withings → callback flow;
the callback persists the upgraded scope and the banner
vanishes on the next status poll. */}
{status?.connected && status?.hasActivityScope === false && (
<a
href="/api/withings/connect"
data-testid="withings-reconnect-banner"
className="border-warning/30 bg-warning/10 text-foreground hover:bg-warning/20 block rounded-md border px-3 py-2 text-sm transition-colors"
>
<span className="font-medium">
{t("settings.integrations.withings.reconnect.banner.title")}
</span>
<span className="text-muted-foreground block text-xs">
{t("settings.integrations.withings.reconnect.banner.body")}
</span>
<span className="text-primary mt-1 inline-block text-xs font-medium">
{t("settings.integrations.withings.reconnect.banner.action")}
{" →"}
</span>
</a>
)}
{/* v1.4.43 W14 — parked-integration resume CTA. Surfaces only
when the row state is `parked` (>24h of persistent
failures). The button POSTs to /api/integrations/withings/
resume which calls `resumeIntegrationFromPark`; on success
the per-card status invalidates and the pill flips back to
connected without a page refresh. The button is the
primary action the user can take from this card — wider
tap target than the inline action row so it's reachable
on a Pixel 5 viewport. */}
{pillState === "parked" && (
<div
data-testid="withings-parked-banner"
className="border-warning/30 bg-warning/10 flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm"
>
<span className="text-warning min-w-0 text-xs break-words">
{t("settings.integrationPill.parkedReconnect")}
</span>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => resume.mutate()}
disabled={resume.isPending}
data-testid="withings-resume-button"
className="min-h-11"
>
{resume.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Link2 className="h-3.5 w-3.5" />
)}
{t("settings.integrationPill.resumeCta")}
</Button>
</div>
)}
{resume.isError && (
<p
role="alert"
className="text-destructive text-sm"
data-testid="withings-resume-error"
>
{t("settings.integrationPill.resumeError")}
</p>
)}
{status?.connected && (
<WithingsPlanNote features={status.planUnavailable} />
)}
{status?.connected && (
<MetricFreshnessDisclosure
entries={viewModel?.metricFreshness}
idPrefix="withings"
/>
)}
{resume.isSuccess && resume.data?.wasParked && (
<p
role="status"
className="text-success text-xs"
data-testid="withings-resume-success"
>
{t("settings.integrationPill.resumeSuccess")}
</p>
)}
<div className="space-y-3">
<h3 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{t("settings.withingsCredentials")}
</h3>
{!status?.configured && (
<IntegrationRedirectGuide
provider="withings"
providerLabel={t("settings.withings")}
callbackUrl={callbackUrl}
/>
)}
<form onSubmit={handleSaveCredentials} className="space-y-3">
{/* v1.4.27 MB7 / CF-53 — the credentials grid drops from
a 3-column row (client-id / secret / save) to a
2-column row of inputs at `sm:`, with the Save button
lifted out into its own row below. The previous
"invisible Label" hack to align the button with the
input baselines fell apart on Galaxy Fold; lifting
the button into a dedicated `flex justify-end` row
gives it consistent placement on every viewport and
lets the input pair span the full width. */}
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="w-clientid">
{t("settings.withingsClientId")}
</Label>
<Input
id="w-clientid"
value={clientId}
onChange={(e) => setClientId(e.target.value)}
placeholder={
status?.configured
? t("settings.withingsCredentialsSavedPlaceholder")
: t("settings.withingsClientId")
}
maxLength={200}
autoComplete="off"
inputMode="text"
spellCheck={false}
autoCapitalize="none"
enterKeyHint="next"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="w-secret">
{t("settings.withingsClientSecret")}
</Label>
<PasswordInput
id="w-secret"
value={clientSecret}
onChange={(e) => setClientSecret(e.target.value)}
placeholder={
status?.configured
? t("settings.withingsCredentialsSavedPlaceholderSecret")
: t("settings.withingsClientSecret")
}
maxLength={200}
autoComplete="off"
inputMode="text"
spellCheck={false}
autoCapitalize="none"
enterKeyHint="done"
/>
</div>
</div>
<SettingsCardActions>
<Button
type="submit"
variant="outline"
size="sm"
className="min-h-11 w-full sm:w-auto"
disabled={
credsSaving || !clientId.trim() || !clientSecret.trim()
}
>
{credsSaving ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Save className="h-3.5 w-3.5" />
)}
{t("settings.withingsSaveCredentials")}
</Button>
</SettingsCardActions>
{credsMsg && (
<p
role="alert"
className={`text-sm ${credsMsgType === "success" ? "text-success" : "text-destructive"}`}
>
{credsMsg}
</p>
)}
</form>
</div>
{status?.connected ? (
<>
{/* v1.4.27 MB7 / CF-57 — the action row already wraps via
`flex-wrap`, but on Pixel 5 the four AlertDialog +
test-connection triggers each took a fractional slot
that read jagged. Force each button to a `min-w-[10rem]`
on `<sm` so they stack two per row at most and fill
their column cleanly. Tablet+ keeps the inline row. */}
<div className="flex flex-wrap items-start gap-2 [&>*]:min-w-[10rem] sm:[&>*]:min-w-0">
<Button
variant="outline"
size="sm"
className="min-h-11"
onClick={() => handleSync(false)}
disabled={syncing}
>
{syncing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
{t("settings.withingsSync")}
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="min-h-11"
disabled={syncing}
>
{syncing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
{t("settings.withingsFullSync")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.withingsFullSyncTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.withingsFullSyncDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={() => handleSync(true)}>
{t("settings.withingsSynchronize")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<TestConnectionButton
endpoint="/api/integrations/withings/test"
disabled={!status?.connected}
/>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="text-destructive min-h-11"
>
<Unlink className="h-3.5 w-3.5" />
{t("settings.withingsDisconnect")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.withingsDisconnectTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.withingsDisconnectDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => disconnect.mutate()}
>
{t("settings.withingsDisconnect")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
{syncResult && (
<WrittenOutcomeLine
outcome={syncResult.outcome}
message={syncResult.message}
testId="withings-sync-result"
/>
)}
{/* connect→data loop: a discreet link to where this provider's
readings now surface — doubles as the "your data is richer"
cue. */}
<Link
href="/measurements"
data-testid="withings-data-link"
className="text-primary inline-flex items-center gap-1 text-xs underline-offset-2 hover:underline"
>
{t("settings.withingsViewData")}
<ArrowRight className="h-3 w-3" />
</Link>
</>
) : status?.configured ? (
<SettingsCardActions>
<Button
size="sm"
className="min-h-11 w-full sm:w-auto"
data-testid="withings-connect"
onClick={() => {
loadDocument("/api/withings/connect");
}}
>
<Link2 className="h-3.5 w-3.5" />
{t("settings.withingsConnect")}
</Button>
</SettingsCardActions>
) : (
<p
className="text-muted-foreground text-xs"
data-testid="integration-unavailable"
>
{t("settings.withingsNoCredentials")}
</p>
)}
</div>
<CallbackMismatchNotice
provider={t("settings.withings")}
callbackUrl={callbackUrl}
/>
</SettingsCard>
);
}
@@ -1,84 +0,0 @@
"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>
);
}
@@ -1,28 +1,27 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Plug } from "lucide-react";
import { Droplet, ExternalLink, Plug } from "lucide-react";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { IntegrationStatusPill } from "@/components/settings/integration-status-pill";
import { SettingsCard } from "@/components/settings/settings-card";
import { IntegrationCard } from "@/components/settings/integrations/integration-card";
import { OAUTH_INTEGRATIONS } from "@/components/settings/integrations/oauth-integration-card";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { SectionHeading } from "@/components/ui/section-heading";
import { apiGet } from "@/lib/api/api-fetch";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import type { ConnectionHealth } from "@/lib/integrations/connection-health";
import { queryKeys } from "@/lib/query-keys";
import { assertRecordSettingsResponseForRecord } from "@/lib/record-settings";
type ManagedIntegrationState =
"connected" | "error_transient" | "error_reauth" | "disconnected" | "parked";
type ManagedIntegrationKey = keyof typeof INTEGRATION_DISPLAY_NAMES;
interface ManagedIntegrationStatusResponse {
recordId: string;
integrations: Array<{
integration: keyof typeof INTEGRATION_DISPLAY_NAMES;
state: ManagedIntegrationState;
lastSuccessAt: string | null;
lastAttemptAt: string | null;
integration: ManagedIntegrationKey;
/** The server-resolved state; null while the provider is not connected. */
health: ConnectionHealth | null;
}>;
}
@@ -37,25 +36,24 @@ const INTEGRATION_DISPLAY_NAMES = {
strava: "Strava",
} as const;
function recordStatusPillState(state: ManagedIntegrationState) {
switch (state) {
case "connected":
return "connected" as const;
case "error_transient":
return "warning" as const;
case "error_reauth":
return "error" as const;
case "parked":
return "parked" as const;
case "disconnected":
return "disconnected" as const;
}
/** Where the guardian boundary is explained, connected services included. */
const SHARING_DOCS_HREF =
"https://docs.healthlog.dev/self-hosting/account-sharing#records-with-no-login";
function iconFor(key: ManagedIntegrationKey) {
if (key === "nightscout") return Droplet;
return OAUTH_INTEGRATIONS[key]?.icon ?? Plug;
}
/**
* The managed-record integration view is deliberately a status projection.
* It reads the active record's id from the resolved account-access payload and
* has no connection, credential, sync, or provider-control affordance.
* The managed record's connections, on the same card every integration in
* Settings renders, from the same server-resolved `health`.
*
* Read-only by construction: a guardian cannot connect, reconnect, resume,
* sync, test or disconnect a managed record's provider, because an OAuth flow
* cannot safely carry a record selector. So the cards carry no menu and no
* button, and one sentence above them says why, with the page that explains
* the boundary.
*/
export function ManagedIntegrationStatus() {
const { t } = useTranslations();
@@ -78,60 +76,58 @@ export function ManagedIntegrationStatus() {
return <QueryErrorCard onRetry={() => void statusQuery.refetch()} />;
}
if (statusQuery.isLoading || !statusQuery.data) {
return (
<SettingsCard
aria-busy="true"
aria-live="polite"
data-record-id={recordId ?? undefined}
data-record-settings-family="integrations"
>
<SettingsCardHeader
icon={Plug}
title={t("settings.sections.integrations.title")}
description={t("settings.sharedRecord.integrationStatusDescription")}
/>
<p className="text-sm">
{t("settings.sharedRecord.integrationStatusDetail")}
</p>
</SettingsCard>
const loading = statusQuery.isLoading || !statusQuery.data;
const entries =
statusQuery.data?.integrations ??
(Object.keys(INTEGRATION_DISPLAY_NAMES) as ManagedIntegrationKey[]).map(
(integration) => ({ integration, health: null }),
);
}
return (
<SettingsCard
aria-labelledby="managed-integration-status-title"
<section
className="space-y-4"
aria-labelledby={loading ? undefined : "managed-integration-status-title"}
aria-busy={loading || undefined}
aria-live={loading ? "polite" : undefined}
data-record-id={recordId ?? undefined}
data-record-settings-family="integrations"
>
<SettingsCardHeader
<SectionHeading
icon={Plug}
id="managed-integration-status-title"
title={t("settings.sections.integrations.title")}
titleId="managed-integration-status-title"
description={t("settings.sharedRecord.integrationStatusDescription")}
subtitle={t("settings.sharedRecord.integrationStatusDescription")}
/>
<p className="text-sm" data-slot="managed-integration-read-only">
{t("settings.sharedRecord.integrationStatusDetail")}{" "}
<a
href={SHARING_DOCS_HREF}
target="_blank"
rel="noopener noreferrer"
className="text-primary inline-flex items-center gap-1 underline-offset-2 hover:underline"
>
{t("settings.sharedRecord.integrationStatusDocs")}
<ExternalLink className="size-3" aria-hidden="true" />
</a>
</p>
<ul
className="space-y-3"
className="space-y-4"
aria-label={activeRecord?.displayName ?? undefined}
>
{statusQuery.data.integrations.map((integration) => (
<li
key={integration.integration}
className="flex items-center justify-between gap-3"
>
<span className="text-sm font-medium">
{INTEGRATION_DISPLAY_NAMES[integration.integration]}
</span>
<IntegrationStatusPill
state={recordStatusPillState(integration.state)}
lastSyncAt={
integration.lastSuccessAt ?? integration.lastAttemptAt
}
testId={`managed-integration-${integration.integration}`}
{entries.map((entry) => (
<li key={entry.integration}>
<IntegrationCard
provider={entry.integration}
providerName={INTEGRATION_DISPLAY_NAMES[entry.integration]}
icon={iconFor(entry.integration)}
loading={loading}
connected={entry.health !== null}
health={entry.health}
testId={`managed-integration-${entry.integration}`}
/>
</li>
))}
</ul>
</SettingsCard>
</section>
);
}
@@ -47,7 +47,8 @@ const openRow = (overrides: Partial<WatchRow> = {}): WatchRow => ({
});
describe("classifySilence", () => {
const oura = SOURCE_PROFILES.oura;
// A wearable whose cloud receives data on its own: the 24 h window.
const wearable = SOURCE_PROFILES.fitbit;
const withings = SOURCE_PROFILES.withings;
it("never flags an on-demand source for silence, however long and however regular", () => {
@@ -67,7 +68,7 @@ describe("classifySilence", () => {
it("is clear while a continuous source delivers inside its window", () => {
expect(
classifySilence({
profile: oura,
profile: wearable,
lastDataAt: ago(20),
deliveredDays: null,
currentSilentSince: null,
@@ -79,7 +80,7 @@ describe("classifySilence", () => {
it("asks for the history before flagging (the cheap hourly pass reads it lazily)", () => {
expect(
classifySilence({
profile: oura,
profile: wearable,
lastDataAt: ago(30),
deliveredDays: null,
currentSilentSince: null,
@@ -91,7 +92,7 @@ describe("classifySilence", () => {
it("flags a silent source that delivered on most of the previous 14 days", () => {
expect(
classifySilence({
profile: oura,
profile: wearable,
lastDataAt: ago(30),
deliveredDays: NORMAL_DELIVERY_MIN_DAYS,
currentSilentSince: null,
@@ -103,7 +104,7 @@ describe("classifySilence", () => {
it("does not flag a source its owner wears only now and then", () => {
expect(
classifySilence({
profile: oura,
profile: wearable,
lastDataAt: ago(30),
deliveredDays: NORMAL_DELIVERY_MIN_DAYS - 1,
currentSilentSince: null,
@@ -115,7 +116,7 @@ describe("classifySilence", () => {
it("keeps a standing flag on the hourly pass without re-reading history", () => {
expect(
classifySilence({
profile: oura,
profile: wearable,
lastDataAt: ago(50),
deliveredDays: null,
currentSilentSince: ago(50),
@@ -127,7 +128,7 @@ describe("classifySilence", () => {
it("drops a standing flag on the daily re-check once the history no longer says it delivers", () => {
expect(
classifySilence({
profile: oura,
profile: wearable,
lastDataAt: ago(24 * 6),
deliveredDays: 8,
currentSilentSince: ago(24 * 6),
@@ -136,7 +137,55 @@ describe("classifySilence", () => {
).toEqual({ kind: "not_expected" });
});
it("waits 48 h for Oura and WHOOP, which upload only when their phone app is opened", () => {
for (const key of ["oura", "whoop"] as const) {
const profile = SOURCE_PROFILES[key];
expect(profile.kind).toBe("continuous");
// A day and a half without opening the app is ordinary.
expect(
classifySilence({
profile,
lastDataAt: ago(36),
deliveredDays: 14,
currentSilentSince: null,
now: NOW,
}),
).toEqual({ kind: "clear" });
// Past two days it is not.
expect(
classifySilence({
profile,
lastDataAt: ago(49),
deliveredDays: 14,
currentSilentSince: null,
now: NOW,
}),
).toEqual({ kind: "flag", silentSince: ago(49) });
}
});
it("keeps the 24 h window for wearables whose cloud receives data on its own", () => {
for (const key of [
"fitbit",
"google-health",
"polar",
"nightscout",
] as const) {
expect(SOURCE_PROFILES[key]).toEqual({
kind: "continuous",
silenceWindowMs: 24 * HOUR,
});
}
});
it("never alerts on silence for scales, cuffs and workout logs", () => {
for (const key of ["withings", "strava"] as const) {
expect(SOURCE_PROFILES[key].kind).toBe("on_demand");
}
});
it("gives Apple Health a wider window than a wearable", () => {
expect(SOURCE_PROFILES["apple-health"].silenceWindowMs).toBe(48 * HOUR);
const apple = SOURCE_PROFILES["apple-health"];
expect(
classifySilence({
+18 -9
View File
@@ -50,20 +50,29 @@ export const INCIDENT_AFTER_MS = DAY_MS;
/**
* Per connection. Withings is mostly scales and cuffs, Strava is workouts:
* both are on-demand. Everything worn or attached around the clock is
* continuous.
* both are on-demand and never alert on silence, only on errors. Everything
* worn or attached around the clock is continuous.
*
* Apple Health gets 48 h instead of 24 h. iOS defers background delivery when
* the phone is low on storage, in low-power mode or simply not unlocked, and a
* day of that is ordinary; the history gate already limits the check to
* phones that deliver almost daily, and the longer window keeps an ordinary
* quiet day from paging anyone.
* The window is how long a continuous source may stay quiet before silence
* counts, and it follows how each source actually delivers:
*
* - 24 h for wearables and CGMs whose cloud receives data on its own
* (Fitbit, Google Health, Polar, Nightscout).
* - 48 h for Oura and WHOOP. Their rings and straps upload only when the
* phone app is opened, so a day without opening it is ordinary and must
* not page anyone.
* - 48 h for Apple Health. iOS defers background delivery when the phone is
* low on storage, in low-power mode or simply not unlocked, and a day of
* that is ordinary too.
*
* The history gate already limits the check to sources that deliver almost
* daily; the window only decides how long a gap is ordinary.
*/
export const SOURCE_PROFILES: Record<ConnectionKey, SourceProfile> = {
withings: { kind: "on_demand", silenceWindowMs: DAY_MS },
strava: { kind: "on_demand", silenceWindowMs: DAY_MS },
whoop: { kind: "continuous", silenceWindowMs: DAY_MS },
oura: { kind: "continuous", silenceWindowMs: DAY_MS },
whoop: { kind: "continuous", silenceWindowMs: 2 * DAY_MS },
oura: { kind: "continuous", silenceWindowMs: 2 * DAY_MS },
fitbit: { kind: "continuous", silenceWindowMs: DAY_MS },
"google-health": { kind: "continuous", silenceWindowMs: DAY_MS },
polar: { kind: "continuous", silenceWindowMs: DAY_MS },
+1 -1
View File
@@ -105,7 +105,7 @@ const syncHealth = z
// Mirrors `ConnectionHealth` in `src/lib/integrations/connection-health.ts`:
// the one server-resolved state per connection every client renders.
const connectionHealth = z
export const connectionHealth = z
.object({
state: z
.enum(CONNECTION_HEALTH_STATES)
+8 -2
View File
@@ -25,6 +25,7 @@ import { z } from "zod/v4";
import { METRIC_BOUNDS } from "@/lib/analytics/effective-range";
import { MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS } from "@/lib/record-settings";
import { connectionHealth } from "./integrations";
import {
dataEnvelope,
errorEnvelope,
@@ -96,6 +97,11 @@ const recordSettingsIntegrationsResponse = z
]),
lastSuccessAt: z.iso.datetime({ offset: true }).nullable(),
lastAttemptAt: z.iso.datetime({ offset: true }).nullable(),
health: connectionHealth
.nullable()
.describe(
"The same server-resolved connection state the owner's own envelope carries, null while the provider is not connected. The web renders this; `state` stays for one transition release.",
),
}),
)
.describe(
@@ -105,7 +111,7 @@ const recordSettingsIntegrationsResponse = z
.meta({
id: "RecordSettingsIntegrationsResponse",
description:
"Whether the managed record's integrations are connected, and nothing else. Deliberately no control and no credential projection — OAuth and provider state cannot safely carry a record selector, so a Guardian can see that a pipe exists but cannot connect, disconnect, probe or resume it from here. A provider with no real credential reads `disconnected` even when its ledger row says otherwise, because a synthesised healthy ledger is not proof of a connection.",
"Whether the managed record's integrations are connected and how each is doing (`health`), and nothing else. Deliberately no control and no credential projection — OAuth and provider state cannot safely carry a record selector, so a Guardian can see that a pipe exists but cannot connect, disconnect, probe or resume it from here. A provider with no real credential reads `disconnected` even when its ledger row says otherwise, because a synthesised healthy ledger is not proof of a connection.",
});
// ── The six family DTOs ──────────────────────────────────────────────
@@ -353,7 +359,7 @@ export const recordSettingsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Account sharing"],
summary: "Read the managed record's integration connection states",
description:
"Disclosure only: whether each of the record's integrations is connected and when it last succeeded. There is deliberately no control arm and no credential projection anywhere under this path — a Guardian cannot connect, disconnect, probe or resume a managed record's provider, because an OAuth flow cannot safely carry a record selector. Cookie session or Bearer, while acting on a managed record the caller guards.",
"Disclosure only: whether each of the record's integrations is connected, when it last succeeded, and its server-resolved health. There is deliberately no control arm and no credential projection anywhere under this path — a Guardian cannot connect, disconnect, probe or resume a managed record's provider, because an OAuth flow cannot safely carry a record selector. Cookie session or Bearer, while acting on a managed record the caller guards.",
responses: {
...recordRefusal(),
"200": {