mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
Merge branch 'v1424/cards' into release/v1.42.4
This commit is contained in:
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
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/,
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
+18
-12
@@ -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");
|
||||
}
|
||||
+56
-78
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in new issue
Block a user