mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +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
+17
-9
@@ -22032,10 +22032,11 @@ 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."
|
||||
description: "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:
|
||||
"200":
|
||||
description: Connection state per provider.
|
||||
@@ -79496,11 +79497,18 @@ components:
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
- type: "null"
|
||||
health:
|
||||
anyOf:
|
||||
- $ref: "#/components/schemas/ConnectionHealth"
|
||||
- type: "null"
|
||||
description: 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.
|
||||
required:
|
||||
- integration
|
||||
- state
|
||||
- lastSuccessAt
|
||||
- lastAttemptAt
|
||||
- health
|
||||
additionalProperties: false
|
||||
description: All eight providers, always, in the fixed order withings, whoop, fitbit, nightscout, polar, oura,
|
||||
google-health, strava. Note the order differs from the owner's own `/api/integrations/status` envelope.
|
||||
@@ -79508,11 +79516,11 @@ components:
|
||||
- recordId
|
||||
- integrations
|
||||
additionalProperties: false
|
||||
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.
|
||||
description: 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.
|
||||
GetRecordSettingsFamilyEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import AxeBuilder from "@axe-core/playwright";
|
||||
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
import {
|
||||
INTEGRATION_CARD_STATES,
|
||||
mockIntegrationCards,
|
||||
} from "./setup/integration-cards-fixture";
|
||||
|
||||
/**
|
||||
* Settings → Integrations: the shared integration card, one card per state.
|
||||
*
|
||||
* Every provider is mocked into a different `health` (see the fixture), and
|
||||
* the page is checked at desktop width and at 390 px. Assertions read data
|
||||
* attributes only: the card's `data-state`, the badge's `data-state`, the one
|
||||
* primary button's `data-action`. The wording is pinned by the component
|
||||
* tests against the bundle itself.
|
||||
*
|
||||
* Runs in the desktop project and sizes the page itself, so one run covers
|
||||
* both widths without a second project driving the same account.
|
||||
*/
|
||||
const VIEWPORTS = [
|
||||
{ name: "desktop", width: 1280, height: 900 },
|
||||
{ name: "390 px", width: 390, height: 844 },
|
||||
] as const;
|
||||
|
||||
test.describe("Settings → Integrations: one card per health state", () => {
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
|
||||
test.beforeEach(({}, testInfo) => {
|
||||
test.skip(
|
||||
testInfo.project.name !== "chromium-desktop",
|
||||
"sizes the viewport itself",
|
||||
);
|
||||
});
|
||||
|
||||
for (const viewport of VIEWPORTS) {
|
||||
test(`renders each state from health alone at ${viewport.name}`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize(viewport);
|
||||
await mockIntegrationCards(page);
|
||||
await page.goto("/settings/integrations");
|
||||
|
||||
for (const [provider, expected] of Object.entries(
|
||||
INTEGRATION_CARD_STATES,
|
||||
)) {
|
||||
const card = page.locator(
|
||||
`[data-slot="integration-card"][data-integration="${provider}"]`,
|
||||
);
|
||||
await expect(card, provider).toHaveAttribute(
|
||||
"data-state",
|
||||
expected.state,
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
|
||||
const badge = card.locator('[data-slot="integration-health-badge"]');
|
||||
if (expected.state === "not_connected") {
|
||||
await expect(badge, provider).toHaveCount(0);
|
||||
} else {
|
||||
await expect(badge, provider).toHaveAttribute(
|
||||
"data-state",
|
||||
expected.state,
|
||||
);
|
||||
// The word is the accessible name, not decoration.
|
||||
await expect(badge, provider).not.toHaveText("");
|
||||
}
|
||||
|
||||
// At most one primary, and exactly the one the state names.
|
||||
const primary = card.locator(
|
||||
'[data-slot="integration-primary-action"]',
|
||||
);
|
||||
if (expected.primary === null) {
|
||||
await expect(primary, provider).toHaveCount(0);
|
||||
} else {
|
||||
await expect(primary, provider).toHaveCount(1);
|
||||
await expect(primary, provider).toHaveAttribute(
|
||||
"data-action",
|
||||
expected.primary,
|
||||
);
|
||||
if (viewport.width < 640) {
|
||||
const box = await primary.boundingBox();
|
||||
expect(
|
||||
box?.height ?? 0,
|
||||
`${provider} tap target`,
|
||||
).toBeGreaterThanOrEqual(44);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing on the page states a status the old way.
|
||||
await expect(
|
||||
page.locator('[data-testid="integration-status-pill"]'),
|
||||
).toHaveCount(0);
|
||||
|
||||
const dims = await page.evaluate(() => ({
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
innerWidth: window.innerWidth,
|
||||
}));
|
||||
expect(
|
||||
dims.scrollWidth,
|
||||
`scrollWidth=${dims.scrollWidth}, innerWidth=${dims.innerWidth}`,
|
||||
).toBeLessThanOrEqual(dims.innerWidth + 1);
|
||||
});
|
||||
}
|
||||
|
||||
test("the menu works from the keyboard and keeps its confirmations", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockIntegrationCards(page);
|
||||
await page.goto("/settings/integrations");
|
||||
|
||||
const card = page.locator(
|
||||
'[data-slot="integration-card"][data-integration="withings"]',
|
||||
);
|
||||
const trigger = card.locator('[data-slot="integration-menu-trigger"]');
|
||||
await expect(trigger).toBeVisible({ timeout: 15_000 });
|
||||
await trigger.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
const menu = page.locator('[data-slot="integration-menu"]');
|
||||
await expect(menu).toBeVisible();
|
||||
await expect(
|
||||
menu.locator('[data-slot="integration-menu-item"]'),
|
||||
).toHaveCount(7);
|
||||
|
||||
// Disconnect asks first; dismissing the dialog does nothing.
|
||||
await menu.locator('[data-action="disconnect"]').focus();
|
||||
await page.keyboard.press("Enter");
|
||||
const dialog = page.getByRole("alertdialog");
|
||||
await expect(dialog).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await expect(card).toHaveAttribute("data-state", "limited");
|
||||
|
||||
// Escape closes the menu and returns focus to its trigger.
|
||||
await trigger.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(menu).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(menu).toHaveCount(0);
|
||||
await expect(trigger).toBeFocused();
|
||||
});
|
||||
|
||||
test("details open on request and Copy puts exactly the shown text on the clipboard", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await mockIntegrationCards(page);
|
||||
await page.goto("/settings/integrations");
|
||||
|
||||
const card = page.locator(
|
||||
'[data-slot="integration-card"][data-integration="polar"]',
|
||||
);
|
||||
const toggle = card.locator('[data-slot="integration-details-toggle"]');
|
||||
const panel = card.locator('[data-slot="integration-details"]');
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "false", {
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(panel).toBeHidden();
|
||||
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "true");
|
||||
await expect(panel).toBeVisible();
|
||||
|
||||
const shown = await panel
|
||||
.locator('[data-slot="integration-diagnostics"]')
|
||||
.innerText();
|
||||
expect(shown).toContain("degraded / provider_errors");
|
||||
expect(shown).toContain("503");
|
||||
|
||||
await panel.locator('[data-slot="integration-diagnostics-copy"]').click();
|
||||
const status = panel.locator(
|
||||
'[data-slot="integration-diagnostics-copy-status"]',
|
||||
);
|
||||
await expect(status).toHaveAttribute("data-state", "copied");
|
||||
await expect(status).toHaveAttribute("role", "status");
|
||||
const copied = await page.evaluate(() => navigator.clipboard.readText());
|
||||
expect(copied.trim()).toBe(shown.trim());
|
||||
});
|
||||
|
||||
for (const theme of ["light", "dark"] as const) {
|
||||
test(`every state reads accessibly in the ${theme} theme`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.emulateMedia({ colorScheme: theme, reducedMotion: "reduce" });
|
||||
await page.addInitScript((explicit: string) => {
|
||||
window.localStorage.setItem("healthlog-theme", explicit);
|
||||
}, theme);
|
||||
await mockIntegrationCards(page);
|
||||
await page.goto("/settings/integrations");
|
||||
const panel = page.locator('[data-slot="connections-panel"]');
|
||||
await expect(
|
||||
panel.locator(
|
||||
'[data-slot="integration-card"][data-integration="whoop"]',
|
||||
),
|
||||
).toHaveAttribute("data-state", "action_required", { timeout: 15_000 });
|
||||
// Scan with one details panel open, so the diagnostics block and its
|
||||
// copy button are part of what is checked.
|
||||
await panel
|
||||
.locator(
|
||||
'[data-integration="polar"] [data-slot="integration-details-toggle"]',
|
||||
)
|
||||
.click();
|
||||
|
||||
const results = await new AxeBuilder({ page })
|
||||
.include('[data-slot="connections-panel"]')
|
||||
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
|
||||
.analyze();
|
||||
const serious = results.violations
|
||||
.filter((v) => v.impact === "serious" || v.impact === "critical")
|
||||
.map(
|
||||
(v) =>
|
||||
`${v.id}: ${v.nodes.map((n) => n.target.join(" ")).join(", ")}`,
|
||||
);
|
||||
expect(serious).toEqual([]);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -3,22 +3,16 @@ import { expect, test } from "./setup/test";
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
/**
|
||||
* v1.4.19 phase A5 — `/settings/integrations` on a Pixel-5 viewport
|
||||
* (393 CSS px) must:
|
||||
* `/settings/integrations` on a Pixel-5 viewport (393 CSS px) must:
|
||||
*
|
||||
* 1. Not produce horizontal page overflow. The new
|
||||
* IntegrationStatusPill is `whitespace-nowrap` so the chip
|
||||
* itself can grow as wide as its longest possible label
|
||||
* ("Verbunden · vor 12 min" / "Error — reconnect"); the parent
|
||||
* header is a `flex-wrap` so the pill drops to its own line if
|
||||
* the title and pill together exceed the card width — this spec
|
||||
* verifies neither path leaks into a horizontal scrollbar on the
|
||||
* <html> element.
|
||||
* 1. Not produce horizontal page overflow. The badge in each card header is
|
||||
* `whitespace-nowrap`; the header row wraps so the badge drops under the
|
||||
* title when both do not fit. Neither path may leak into a horizontal
|
||||
* scrollbar on the <html> element.
|
||||
*
|
||||
* 2. Render exactly one pill per integration card. Belt-and-braces
|
||||
* guard for the card consolidation — the Vitest spec covers this
|
||||
* on the SSR markup level, but the Pixel-5 layout is what matters
|
||||
* in practice.
|
||||
* 2. Render exactly one status badge per connected card, and none on a card
|
||||
* that is not connected. The Vitest spec covers this on the SSR markup;
|
||||
* the Pixel-5 layout is what matters in practice.
|
||||
*
|
||||
* Mobile-only spec — desktop project skips it.
|
||||
*/
|
||||
@@ -29,7 +23,7 @@ test.describe("/settings/integrations Pixel-5 layout", () => {
|
||||
test.skip(testInfo.project.name !== "chromium-mobile", "mobile-only spec");
|
||||
});
|
||||
|
||||
test("Pixel-5 viewport: no horizontal page scroll, one pill per card", async ({
|
||||
test("Pixel-5 viewport: no horizontal page scroll, one badge per connected card", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Stub the three endpoints the Integrations section reads — both
|
||||
@@ -66,42 +60,56 @@ test.describe("/settings/integrations Pixel-5 layout", () => {
|
||||
integrations: [
|
||||
{
|
||||
integration: "withings",
|
||||
state: "connected",
|
||||
lastSuccessAt: recent,
|
||||
lastAttemptAt: recent,
|
||||
lastError: null,
|
||||
consecutiveFailures: 0,
|
||||
// v1.12.1 — the cards read connection state from this
|
||||
// consolidated envelope (the per-provider routes were
|
||||
// dropped from the section). The Withings pill keys on
|
||||
// `connected`; a card-less pill keys on `configured`.
|
||||
configured: true,
|
||||
connected: true,
|
||||
connectedAt: "2026-04-01T12:00:00Z",
|
||||
legacyLastSyncedAt: recent,
|
||||
tokenExpiresAt: "2026-12-01T12:00:00Z",
|
||||
tokenExpired: false,
|
||||
scope: "user.info,user.metrics,user.activity",
|
||||
hasActivityScope: true,
|
||||
syncHealth: { verdict: "fresh", since: null },
|
||||
health: {
|
||||
state: "ok",
|
||||
reason: "data_arriving",
|
||||
since: null,
|
||||
action: null,
|
||||
unavailable: [],
|
||||
lastSuccessAt: recent,
|
||||
lastDataAt: recent,
|
||||
diagnostics: {
|
||||
ledgerState: "connected",
|
||||
failingLegs: [],
|
||||
failingSince: null,
|
||||
lastAttemptAt: recent,
|
||||
verdict: "fresh",
|
||||
recentCalls: [],
|
||||
},
|
||||
},
|
||||
metricFreshness: [
|
||||
{ type: "WEIGHT", lastSeenAt: recent, stale: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
// Configured, card-less, and no longer attempting: the case
|
||||
// the fallback row exists for. Synthetic since v1.32.33 —
|
||||
// every shipped provider has a card again, and the row must
|
||||
// stay proven before the next one is dropped.
|
||||
// Configured, card-less, and no longer syncing: the case the
|
||||
// fallback row exists for. Synthetic since v1.32.33 — every
|
||||
// shipped provider has a card again, and the row must stay
|
||||
// proven before the next one is dropped.
|
||||
integration: "example-provider",
|
||||
state: "connected",
|
||||
lastSuccessAt: recent,
|
||||
lastAttemptAt: recent,
|
||||
lastError: null,
|
||||
consecutiveFailures: 0,
|
||||
configured: true,
|
||||
legacyLastSyncedAt: monthAgo,
|
||||
syncHealth: { verdict: "stalled", since: monthAgo },
|
||||
connected: true,
|
||||
health: {
|
||||
state: "degraded",
|
||||
reason: "sync_stalled",
|
||||
since: monthAgo,
|
||||
action: null,
|
||||
unavailable: [],
|
||||
lastSuccessAt: monthAgo,
|
||||
lastDataAt: monthAgo,
|
||||
diagnostics: {
|
||||
ledgerState: "connected",
|
||||
failingLegs: [],
|
||||
failingSince: null,
|
||||
lastAttemptAt: monthAgo,
|
||||
verdict: "stalled",
|
||||
recentCalls: [],
|
||||
},
|
||||
},
|
||||
metricFreshness: [],
|
||||
},
|
||||
// v1.28.x — Strava OAuth card reads off this consolidated
|
||||
@@ -109,30 +117,23 @@ test.describe("/settings/integrations Pixel-5 layout", () => {
|
||||
// never resolves and the integrations page hangs.
|
||||
{
|
||||
integration: "strava",
|
||||
state: "disconnected",
|
||||
lastSuccessAt: null,
|
||||
lastAttemptAt: null,
|
||||
lastError: null,
|
||||
connected: false,
|
||||
configured: false,
|
||||
available: false,
|
||||
hasOwnCredentials: false,
|
||||
syncHealth: { verdict: "disconnected", since: null },
|
||||
health: null,
|
||||
metricFreshness: [],
|
||||
},
|
||||
// Nightscout reads the envelope too since the card dropped its
|
||||
// own status round-trip; without an entry its pill never resolves.
|
||||
// own status round-trip; without an entry its card never settles.
|
||||
{
|
||||
integration: "nightscout",
|
||||
state: "disconnected",
|
||||
lastSuccessAt: null,
|
||||
lastAttemptAt: null,
|
||||
lastError: null,
|
||||
connected: false,
|
||||
configured: false,
|
||||
hasToken: false,
|
||||
allowPrivateHost: false,
|
||||
syncHealth: { verdict: "disconnected", since: null },
|
||||
health: null,
|
||||
metricFreshness: [],
|
||||
},
|
||||
],
|
||||
@@ -142,6 +143,19 @@ test.describe("/settings/integrations Pixel-5 layout", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
// Apple Health has delivered nothing on this account: not connected, so
|
||||
// no badge. Mocked so a seeded HealthKit row cannot add one.
|
||||
await page.route("**/api/integrations/healthkit", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: { entries: [], lastSyncedAt: null, health: null },
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await page.route("**/api/withings/status", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
@@ -164,42 +178,45 @@ test.describe("/settings/integrations Pixel-5 layout", () => {
|
||||
});
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Wait for the pills to mount (queries resolve after first paint).
|
||||
// One pill per card: Withings, WHOOP, Fitbit, Google Health, Polar,
|
||||
// Oura, Strava, Nightscout — plus the fallback row for the configured,
|
||||
// card-less entry. (The Garmin info note is not an OAuth card and
|
||||
// carries no pill; the Apple Health card's pill keeps its own testid.)
|
||||
const pills = page.locator('[data-testid="integration-status-pill"]');
|
||||
await expect(pills).toHaveCount(9, { timeout: 10_000 });
|
||||
// Wait for the cards to settle (queries resolve after first paint).
|
||||
// Withings and the card-less fallback are connected: one badge each.
|
||||
// Every provider without an envelope entry stays in its loading state,
|
||||
// and the not-connected ones carry no badge at all.
|
||||
const badges = page.locator('[data-slot="integration-health-badge"]');
|
||||
await expect(badges).toHaveCount(2, { timeout: 10_000 });
|
||||
|
||||
// The acceptance case: a configured integration with no card of its own,
|
||||
// which has stopped even attempting, is visible on the page.
|
||||
// which has stopped syncing, is visible on the page, read-only.
|
||||
const fallback = page.locator(
|
||||
'[data-testid="integration-fallback-row"][data-integration="example-provider"]',
|
||||
);
|
||||
await expect(fallback).toHaveCount(1);
|
||||
await expect(
|
||||
fallback.locator('[data-testid="integration-status-pill"]'),
|
||||
).toHaveAttribute("data-state", "stalled");
|
||||
fallback.locator('[data-slot="integration-health-badge"]'),
|
||||
).toHaveAttribute("data-state", "degraded");
|
||||
await expect(
|
||||
fallback.locator('[data-slot="integration-menu-trigger"]'),
|
||||
).toHaveCount(0);
|
||||
|
||||
// Card anatomy is uniform because no integration card paints a rule under
|
||||
// its header any more. The `<hr>` existed only in this folder — nine cards
|
||||
// carrying a separator no other Settings card has — and the card's own gap
|
||||
// does the separating now.
|
||||
// its header any more.
|
||||
await expect(
|
||||
page.locator('[data-testid="integration-card-divider"]'),
|
||||
).toHaveCount(0);
|
||||
|
||||
// Withings is connected in this fixture; WHOOP is a BYO-keys card that
|
||||
// stays dormant until an operator adds credentials, so assert the one
|
||||
// connected pill.
|
||||
await expect(
|
||||
page.locator(
|
||||
'[data-testid="integration-status-pill"][data-state="connected"]',
|
||||
'[data-slot="integration-card"][data-integration="withings"]',
|
||||
),
|
||||
).toHaveCount(1);
|
||||
).toHaveAttribute("data-state", "ok");
|
||||
await expect(
|
||||
page.locator('[data-slot="integration-card"][data-integration="strava"]'),
|
||||
).toHaveAttribute("data-state", "not_connected");
|
||||
|
||||
// The redundant v1.4.18 banner must be gone.
|
||||
// The retired status surfaces stay gone.
|
||||
await expect(
|
||||
page.locator('[data-testid="integration-status-pill"]'),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
page.locator('[data-testid="integration-status-banner"]'),
|
||||
).toHaveCount(0);
|
||||
@@ -215,12 +232,11 @@ test.describe("/settings/integrations Pixel-5 layout", () => {
|
||||
`scrollWidth=${dims.scrollWidth}, innerWidth=${dims.innerWidth}`,
|
||||
).toBeLessThanOrEqual(dims.innerWidth + 1);
|
||||
|
||||
// Pill text remains legible at this viewport — assert non-empty
|
||||
// bounding rect so we know it's painted, not display:none-d by
|
||||
// some surprising container query.
|
||||
// Badge text remains legible at this viewport: painted, not hidden by a
|
||||
// surprising container query.
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const box = await pills.nth(i).boundingBox();
|
||||
expect(box, `pill ${i} has no bounding box`).not.toBeNull();
|
||||
const box = await badges.nth(i).boundingBox();
|
||||
expect(box, `badge ${i} has no bounding box`).not.toBeNull();
|
||||
expect(box!.width).toBeGreaterThan(0);
|
||||
expect(box!.height).toBeGreaterThan(0);
|
||||
}
|
||||
@@ -241,7 +257,8 @@ test.describe("Apple Health guidance", () => {
|
||||
data: {
|
||||
entries: [],
|
||||
lastSyncedAt: null,
|
||||
syncHealth: { verdict: "pending_first_sync", since: null },
|
||||
// Nothing delivered yet: no health, so the card is not connected.
|
||||
health: null,
|
||||
metricFreshness: [],
|
||||
// #778 — nothing accepted yet, so the card must show no
|
||||
// progress section for this pending-first-sync account.
|
||||
@@ -268,10 +285,10 @@ test.describe("Apple Health guidance", () => {
|
||||
// A raw key means the bundle lookup failed, which is the failure this
|
||||
// assertion can actually see from a browser.
|
||||
await expect(description).not.toContainText("settings.appleHealth");
|
||||
await expect(page.getByTestId("apple-health-status")).toHaveAttribute(
|
||||
"data-state",
|
||||
"pending-setup",
|
||||
);
|
||||
await expect(card).toHaveAttribute("data-state", "not_connected");
|
||||
await expect(
|
||||
card.locator('[data-slot="integration-health-badge"]'),
|
||||
).toHaveCount(0);
|
||||
|
||||
const importLink = page.getByTestId("apple-health-import-link");
|
||||
await expect(importLink).toHaveAttribute(
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Settings → Integrations with one card per health state, all mocked.
|
||||
*
|
||||
* Each provider carries the `health` the server would publish for one
|
||||
* situation, so a single page shows every state the shared card can be in:
|
||||
*
|
||||
* withings limited / plan_limited (no button)
|
||||
* oura limited / permission_limited (one Reconnect)
|
||||
* fitbit ok / data_arriving (older history loading)
|
||||
* polar degraded / provider_errors (no button)
|
||||
* whoop action_required / paused (one Resume)
|
||||
* google-health action_required / reauth (one Reconnect)
|
||||
* strava not connected (Connect)
|
||||
* nightscout not connected (address form)
|
||||
* apple-health degraded / no_new_data
|
||||
*
|
||||
* Timestamps are relative to now, so the sentences read the same on any day.
|
||||
*/
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
|
||||
function ago(hours: number): string {
|
||||
return new Date(Date.now() - hours * HOUR).toISOString();
|
||||
}
|
||||
|
||||
function health(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
state: "ok",
|
||||
reason: "data_arriving",
|
||||
since: null,
|
||||
action: null,
|
||||
unavailable: [],
|
||||
lastSuccessAt: ago(1),
|
||||
lastDataAt: ago(2),
|
||||
diagnostics: {
|
||||
ledgerState: "connected",
|
||||
failingLegs: [],
|
||||
failingSince: null,
|
||||
lastAttemptAt: ago(1),
|
||||
verdict: "fresh",
|
||||
recentCalls: [
|
||||
{ endpoint: "measures", status: "ok", at: ago(1), errorCode: null },
|
||||
],
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function connected(integration: string, h: Record<string, unknown>) {
|
||||
return {
|
||||
integration,
|
||||
lastError: null,
|
||||
connected: true,
|
||||
configured: true,
|
||||
available: true,
|
||||
hasOwnCredentials: true,
|
||||
health: h,
|
||||
metricFreshness: [
|
||||
{ type: "WEIGHT", lastSeenAt: ago(2), stale: false },
|
||||
{ type: "PULSE", lastSeenAt: ago(80), stale: true },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function disconnected(integration: string) {
|
||||
return {
|
||||
integration,
|
||||
lastError: null,
|
||||
connected: false,
|
||||
configured: false,
|
||||
available: true,
|
||||
hasOwnCredentials: false,
|
||||
health: null,
|
||||
metricFreshness: [],
|
||||
};
|
||||
}
|
||||
|
||||
export const INTEGRATION_CARD_STATES = {
|
||||
withings: { state: "limited", primary: null },
|
||||
oura: { state: "limited", primary: "reconnect" },
|
||||
fitbit: { state: "ok", primary: null },
|
||||
polar: { state: "degraded", primary: null },
|
||||
whoop: { state: "action_required", primary: "resume" },
|
||||
"google-health": { state: "action_required", primary: "reconnect" },
|
||||
strava: { state: "not_connected", primary: "connect" },
|
||||
nightscout: { state: "not_connected", primary: "connect" },
|
||||
"apple-health": { state: "degraded", primary: null },
|
||||
} as const;
|
||||
|
||||
export function integrationCardsEnvelope() {
|
||||
return {
|
||||
threshold: 3,
|
||||
metricFreshnessDegraded: false,
|
||||
integrations: [
|
||||
connected(
|
||||
"withings",
|
||||
health({
|
||||
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" },
|
||||
],
|
||||
}),
|
||||
),
|
||||
{
|
||||
...connected("whoop", {
|
||||
...health({
|
||||
state: "action_required",
|
||||
reason: "paused_after_errors",
|
||||
since: ago(50),
|
||||
action: "resume",
|
||||
lastDataAt: ago(52),
|
||||
}),
|
||||
diagnostics: {
|
||||
ledgerState: "parked",
|
||||
failingLegs: ["recovery", "sleep"],
|
||||
failingSince: ago(50),
|
||||
lastAttemptAt: ago(3),
|
||||
verdict: "parked",
|
||||
recentCalls: [
|
||||
{
|
||||
endpoint: "recovery",
|
||||
status: "persistent",
|
||||
at: ago(3),
|
||||
errorCode: "500",
|
||||
},
|
||||
{
|
||||
endpoint: "sleep",
|
||||
status: "persistent",
|
||||
at: ago(3),
|
||||
errorCode: "500",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
consecutiveFailuresByKind: {
|
||||
transient: 0,
|
||||
reauth_required: 0,
|
||||
persistent: 4,
|
||||
},
|
||||
},
|
||||
{
|
||||
...connected("fitbit", health()),
|
||||
backfillCompleted: false,
|
||||
},
|
||||
connected(
|
||||
"google-health",
|
||||
health({
|
||||
state: "action_required",
|
||||
reason: "reauth_required",
|
||||
action: "reconnect",
|
||||
since: ago(30),
|
||||
lastDataAt: ago(31),
|
||||
}),
|
||||
),
|
||||
connected(
|
||||
"polar",
|
||||
health({
|
||||
state: "degraded",
|
||||
reason: "provider_errors",
|
||||
since: ago(9),
|
||||
lastDataAt: ago(20),
|
||||
diagnostics: {
|
||||
ledgerState: "error_transient",
|
||||
failingLegs: ["sleep"],
|
||||
failingSince: ago(9),
|
||||
lastAttemptAt: ago(1),
|
||||
verdict: "failing",
|
||||
recentCalls: [
|
||||
{
|
||||
endpoint: "sleep",
|
||||
status: "transient",
|
||||
at: ago(1),
|
||||
errorCode: "503",
|
||||
},
|
||||
{
|
||||
endpoint: "sleep",
|
||||
status: "transient",
|
||||
at: ago(2),
|
||||
errorCode: "503",
|
||||
},
|
||||
{
|
||||
endpoint: "activity",
|
||||
status: "ok",
|
||||
at: ago(2),
|
||||
errorCode: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
),
|
||||
connected(
|
||||
"oura",
|
||||
health({
|
||||
state: "limited",
|
||||
reason: "permission_limited",
|
||||
action: "reconnect",
|
||||
unavailable: [
|
||||
{ item: "spo2", cause: "permission" },
|
||||
{ item: "vo2max", cause: "permission" },
|
||||
],
|
||||
}),
|
||||
),
|
||||
disconnected("strava"),
|
||||
{ ...disconnected("nightscout"), hasToken: false },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function appleHealthStatus() {
|
||||
return {
|
||||
entries: [],
|
||||
lastSyncedAt: ago(60),
|
||||
lastSyncTrigger: "foreground",
|
||||
lastBackgroundSyncAt: null,
|
||||
metricFreshness: [],
|
||||
syncProgress: { recordsAccepted: 48211, oldestMeasuredAt: ago(24 * 900) },
|
||||
health: health({
|
||||
state: "degraded",
|
||||
reason: "no_new_data",
|
||||
since: ago(60),
|
||||
lastDataAt: ago(60),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Route every read the Integrations page makes onto the fixture. */
|
||||
export async function mockIntegrationCards(page: Page): Promise<void> {
|
||||
await page.route("**/api/integrations/status", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ data: integrationCardsEnvelope(), error: null }),
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/integrations/healthkit", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ data: appleHealthStatus(), error: null }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -628,8 +628,21 @@ test.describe.serial("scoped sharing browser journeys", () => {
|
||||
);
|
||||
await expect(integrations).toBeVisible();
|
||||
await expect(integrations).toHaveAttribute("data-record-id", accountId);
|
||||
// Read-only: the shared integration cards render, but nothing on them
|
||||
// acts on a provider. The only buttons are each card's own "details for a
|
||||
// bug report" toggle and its copy button.
|
||||
await expect(
|
||||
integrations.locator("button, input, select, textarea"),
|
||||
integrations.locator('[data-slot="integration-card"]'),
|
||||
).toHaveCount(8);
|
||||
await expect(
|
||||
integrations.locator(
|
||||
'[data-slot="integration-primary-action"], [data-slot="integration-menu-trigger"], input, select, textarea',
|
||||
),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
integrations.locator(
|
||||
'button:not([data-slot="integration-details-toggle"]):not([data-slot="integration-diagnostics-copy"])',
|
||||
),
|
||||
).toHaveCount(0);
|
||||
|
||||
await page.goBack();
|
||||
|
||||
+70
-23
@@ -5315,7 +5315,8 @@
|
||||
"unavailableTitle": "In diesem geteilten Datensatz nicht verfügbar",
|
||||
"unavailableDescription": "Dieser Einstellungsbereich ist beim Anzeigen des Datensatzes einer anderen Person bewusst nicht verfügbar.",
|
||||
"integrationStatusDescription": "Verbindungsstatus für diesen verwalteten Datensatz.",
|
||||
"integrationStatusDetail": "Verbindungssteuerungen sind nicht verfügbar."
|
||||
"integrationStatusDetail": "Ein betreutes Profil zeigt seine verbundenen Dienste nur als Status. Verbinden, neu verbinden, synchronisieren und trennen ist für Betreuende nicht möglich.",
|
||||
"integrationStatusDocs": "So funktioniert das Teilen"
|
||||
},
|
||||
"sections": {
|
||||
"account": {
|
||||
@@ -6391,7 +6392,6 @@
|
||||
"whoopDisconnectDescription": "Die Verbindung zu WHOOP wird getrennt. Bereits synchronisierte Daten bleiben erhalten.",
|
||||
"whoopConnect": "Mit WHOOP verbinden",
|
||||
"whoopNoCredentials": "Bitte gib oben deine API-Zugangsdaten ein, um WHOOP zu verbinden.",
|
||||
"whoopBackfillInProgress": "Dein WHOOP-Verlauf wird im Hintergrund importiert…",
|
||||
"whoopOauthConnected": "WHOOP verbunden. Die erste Synchronisierung startet im Hintergrund.",
|
||||
"whoopOauthFailed": "WHOOP-Verbindung fehlgeschlagen",
|
||||
"whoopOauthError": {
|
||||
@@ -6427,7 +6427,6 @@
|
||||
"fitbitDisconnectDescription": "Die Verbindung zu Fitbit wird getrennt. Zuvor synchronisierte Daten bleiben erhalten.",
|
||||
"fitbitConnect": "Mit Fitbit verbinden",
|
||||
"fitbitNoCredentials": "Bitte gib oben deine API-Zugangsdaten ein, um Fitbit zu verbinden.",
|
||||
"fitbitBackfillInProgress": "Dein Fitbit-Verlauf wird im Hintergrund importiert…",
|
||||
"fitbitOauthConnected": "Fitbit verbunden. Die erste Synchronisierung startet im Hintergrund.",
|
||||
"fitbitOauthFailed": "Fitbit-Verbindung fehlgeschlagen",
|
||||
"googleHealth": "Google Health",
|
||||
@@ -6472,9 +6471,6 @@
|
||||
"googleHealthDisconnectDescription": "Die Verbindung zu Google Health wird getrennt. Bereits synchronisierte Daten bleiben erhalten.",
|
||||
"googleHealthConnect": "Mit Google verbinden",
|
||||
"googleHealthNoCredentials": "Bitte gib oben deine API-Zugangsdaten ein, um Google Health zu verbinden.",
|
||||
"googleHealthBackfillInProgress": "Dein Google-Health-Verlauf wird im Hintergrund importiert…",
|
||||
"googleHealthReauthBanner": "Google hat die Verbindung ablaufen lassen. Das passiert regelmäßig, solange deine OAuth-App im Testmodus bleibt. Verbinde neu, um die Synchronisierung fortzusetzen.",
|
||||
"googleHealthReconnect": "Neu verbinden",
|
||||
"googleHealthOauthConnected": "Google Health verbunden. Die erste Synchronisierung startet im Hintergrund.",
|
||||
"googleHealthOauthFailed": "Verbindung zu Google Health fehlgeschlagen",
|
||||
"googleHealthOauthError": {
|
||||
@@ -6499,16 +6495,7 @@
|
||||
},
|
||||
"integrations": {
|
||||
"withings": {
|
||||
"reconnect": {
|
||||
"banner": {
|
||||
"title": "Aktivitäts-Sync verfügbar",
|
||||
"body": "Deine Withings-Verbindung wurde autorisiert, bevor der Aktivitäts-Sync hinzugefügt wurde. Verbinde einmal neu, um Schritte, aktive Energie, Geh- und Laufdistanz sowie Stockwerke aus deinen Withings-Geräten zu übernehmen.",
|
||||
"action": "Withings neu verbinden"
|
||||
}
|
||||
},
|
||||
"plan": {
|
||||
"title": "Einige Werte brauchen den kostenpflichtigen API-Plan von Withings",
|
||||
"body": "Withings bietet {list} jetzt nur noch im kostenpflichtigen API-Plan an, und deine Withings-Entwickler-App nutzt den kostenlosen Plan. Gewicht, Blutdruck, Körperzusammensetzung, Herzfrequenz und Schlaf werden weiter synchronisiert.",
|
||||
"link": "Mehr zu den API-Plänen von Withings",
|
||||
"items": {
|
||||
"spo2": "SpO2",
|
||||
@@ -6538,10 +6525,7 @@
|
||||
},
|
||||
"oura": {
|
||||
"scope": {
|
||||
"body": "Verbinde Oura einmal neu, um {list} freizugeben. Alles andere wird weiter synchronisiert.",
|
||||
"action": "Oura neu verbinden",
|
||||
"items": {
|
||||
"spo2": "SpO2",
|
||||
"vo2max": "VO2max",
|
||||
"cardio_age": "kardiovaskuläres Alter",
|
||||
"resilience": "Resilienz"
|
||||
@@ -6560,7 +6544,6 @@
|
||||
"minutesAgo": "vor {count} min",
|
||||
"hoursAgo": "vor {count} Std.",
|
||||
"daysAgo": "vor {count} d",
|
||||
"resumeCta": "Wieder verbinden",
|
||||
"resumeSuccess": "Integration wieder aktiv",
|
||||
"resumeError": "Wieder verbinden fehlgeschlagen",
|
||||
"stale": "Verbunden, keine neuen Daten",
|
||||
@@ -6574,9 +6557,7 @@
|
||||
"workouts": "Workouts"
|
||||
},
|
||||
"integrationFallback": {
|
||||
"description": "Für dieses Konto eingerichtet, hat aber keine eigene Einstellungskarte.",
|
||||
"lastAttempt": "Letzter Versuch {relative}",
|
||||
"noAttempt": "Kein Sync-Versuch aufgezeichnet"
|
||||
"description": "Für dieses Konto eingerichtet, hat aber keine eigene Einstellungskarte."
|
||||
},
|
||||
"withingsViewData": "Messwerte ansehen",
|
||||
"whoopViewData": "Erholung ansehen",
|
||||
@@ -7124,7 +7105,6 @@
|
||||
"backgroundStep": "Lass die Hintergrundaktualisierung aktiviert. Die App synchronisiert neue Daten automatisch im Vordergrund und wenn iOS Hintergrundzeit gewährt.",
|
||||
"importNote": "Du brauchst ältere Daten oder kannst die iOS-App nicht nutzen? Der Apple-Health-XML/ZIP-Upload ist ein einmaliger Ersatzweg und aktiviert keine Live-Synchronisierung.",
|
||||
"importAction": "Einmaligen Import öffnen",
|
||||
"staleNote": "Seit über einer Woche sind keine Apple-Health-Daten angekommen. Öffne die HealthLog-App auf deinem iPhone und prüfe die Apple-Health-Berechtigungen.",
|
||||
"progress": {
|
||||
"title": "Sync-Fortschritt",
|
||||
"receivedLabel": "Empfangene Datensätze",
|
||||
@@ -7217,6 +7197,73 @@
|
||||
"displayCard": {
|
||||
"title": "Darstellung",
|
||||
"description": "Sprache, Einheiten und Formate, gespeichert, sobald du sie änderst."
|
||||
},
|
||||
"integrationCard": {
|
||||
"state": {
|
||||
"ok": "Läuft",
|
||||
"limited": "Eingeschränkt",
|
||||
"degraded": "Verzögert",
|
||||
"actionRequired": "Aktion nötig"
|
||||
},
|
||||
"reason": {
|
||||
"dataArriving": "Daten kommen an.",
|
||||
"dataArrivingHistory": "Daten kommen an. Der ältere Verlauf wird im Hintergrund geladen.",
|
||||
"firstSyncPending": "Verbunden. Die erste Synchronisierung läuft in Kürze.",
|
||||
"planLimited": "Daten kommen an. Dein {provider}-Tarif enthält {list} nicht.",
|
||||
"permissionLimited": "Daten kommen an. Für {list} fehlt die Freigabe.",
|
||||
"providerErrors": "{provider} antwortet seit {since} nicht zuverlässig. HealthLog versucht es weiter, du musst nichts tun.",
|
||||
"providerErrorsNoSince": "{provider} antwortet gerade nicht zuverlässig. HealthLog versucht es weiter, du musst nichts tun.",
|
||||
"syncStalled": "Seit {since} hat HealthLog nicht mehr synchronisiert. Das liegt am Server, nicht an deinem Gerät.",
|
||||
"syncStalledNoSince": "HealthLog synchronisiert gerade nicht. Das liegt am Server, nicht an deinem Gerät.",
|
||||
"noNewData": "Seit {since} sind keine neuen Daten gekommen.",
|
||||
"noNewDataNoSince": "Seit einer Weile sind keine neuen Daten gekommen.",
|
||||
"noNewDataApple": "Seit {since} sind keine neuen Daten gekommen. Öffne HealthLog einmal auf dem iPhone.",
|
||||
"noNewDataAppleNoSince": "Seit einer Weile sind keine neuen Daten gekommen. Öffne HealthLog einmal auf dem iPhone.",
|
||||
"unexpectedSilence": "Seit {since} kommen keine Daten, obwohl {provider} sonst täglich liefert.",
|
||||
"unexpectedSilenceNoSince": "Es kommen keine Daten, obwohl {provider} sonst täglich liefert.",
|
||||
"reauthRequired": "Die Verbindung zu {provider} ist abgelaufen. Verbinde neu, damit wieder Daten kommen.",
|
||||
"pausedAfterErrors": "{provider} ist pausiert, weil es seit {since} nur Fehler gab.",
|
||||
"pausedAfterErrorsNoSince": "{provider} ist pausiert, weil es zuletzt nur Fehler gab."
|
||||
},
|
||||
"when": {
|
||||
"today": "heute, {time}",
|
||||
"yesterday": "gestern, {time}",
|
||||
"date": "{date}, {time}"
|
||||
},
|
||||
"items": {
|
||||
"activity": "Aktivität",
|
||||
"sleep": "Schlaf"
|
||||
},
|
||||
"moreValuesOne": "{count} weiteren Wert",
|
||||
"moreValuesFew": "{count} weitere Werte",
|
||||
"moreValuesOther": "{count} weitere Werte",
|
||||
"notConnected": "Nicht verbunden.",
|
||||
"lastData": "Letzte Daten: {when}",
|
||||
"lastDataNone": "Letzte Daten: noch keine",
|
||||
"reconnect": "Neu verbinden",
|
||||
"resume": "Fortsetzen",
|
||||
"menuLabel": "Weitere Aktionen für {provider}",
|
||||
"menu": {
|
||||
"credentials": "Zugangsdaten ändern",
|
||||
"address": "Adresse und Token ändern"
|
||||
},
|
||||
"syncing": "Synchronisierung läuft…",
|
||||
"details": {
|
||||
"title": "Details für eine Fehlermeldung",
|
||||
"none": "keine",
|
||||
"state": "Zustand",
|
||||
"since": "seit",
|
||||
"lastSync": "Letzte Synchronisierung",
|
||||
"lastData": "Letzte Daten",
|
||||
"failingParts": "fehlende Teile",
|
||||
"recentCalls": "Letzte Aufrufe",
|
||||
"unavailable": "Nicht verfügbar",
|
||||
"unavailableList": "Nicht verfügbar: {list}",
|
||||
"causePlan": "Tarif",
|
||||
"causePermission": "Freigabe",
|
||||
"failureStreak": "Fehlerserie: {count} von {threshold}",
|
||||
"copyFailed": "Kopieren hat nicht geklappt. Markiere den Text und kopiere ihn von Hand."
|
||||
}
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
|
||||
+70
-23
@@ -5315,7 +5315,8 @@
|
||||
"unavailableTitle": "Unavailable in this shared record",
|
||||
"unavailableDescription": "This Settings area is intentionally unavailable while viewing another person's record.",
|
||||
"integrationStatusDescription": "Connection status for this managed record.",
|
||||
"integrationStatusDetail": "Connection controls are unavailable."
|
||||
"integrationStatusDetail": "A managed profile shows its connected services as a status only. Connecting, reconnecting, syncing and disconnecting are not available to guardians.",
|
||||
"integrationStatusDocs": "How sharing works"
|
||||
},
|
||||
"sections": {
|
||||
"account": {
|
||||
@@ -6391,7 +6392,6 @@
|
||||
"whoopDisconnectDescription": "The connection to WHOOP will be disconnected. Previously synced data will be preserved.",
|
||||
"whoopConnect": "Connect with WHOOP",
|
||||
"whoopNoCredentials": "Please enter your API credentials above to connect WHOOP.",
|
||||
"whoopBackfillInProgress": "Importing your WHOOP history in the background…",
|
||||
"whoopOauthConnected": "WHOOP connected. The first sync starts in the background.",
|
||||
"whoopOauthFailed": "WHOOP connection failed",
|
||||
"whoopOauthError": {
|
||||
@@ -6427,7 +6427,6 @@
|
||||
"fitbitDisconnectDescription": "The connection to Fitbit will be disconnected. Previously synced data will be preserved.",
|
||||
"fitbitConnect": "Connect with Fitbit",
|
||||
"fitbitNoCredentials": "Please enter your API credentials above to connect Fitbit.",
|
||||
"fitbitBackfillInProgress": "Importing your Fitbit history in the background…",
|
||||
"fitbitOauthConnected": "Fitbit connected. The first sync starts in the background.",
|
||||
"fitbitOauthFailed": "Fitbit connection failed",
|
||||
"googleHealth": "Google Health",
|
||||
@@ -6472,9 +6471,6 @@
|
||||
"googleHealthDisconnectDescription": "The connection to Google Health will be disconnected. Previously synced data will be preserved.",
|
||||
"googleHealthConnect": "Connect with Google",
|
||||
"googleHealthNoCredentials": "Please enter your API credentials above to connect Google Health.",
|
||||
"googleHealthBackfillInProgress": "Importing your Google Health history in the background…",
|
||||
"googleHealthReauthBanner": "Google has expired the connection. This happens periodically while your OAuth app stays in Testing mode. Reconnect to resume syncing.",
|
||||
"googleHealthReconnect": "Reconnect",
|
||||
"googleHealthOauthConnected": "Google Health connected. The first sync starts in the background.",
|
||||
"googleHealthOauthFailed": "Google Health connection failed",
|
||||
"googleHealthOauthError": {
|
||||
@@ -6499,16 +6495,7 @@
|
||||
},
|
||||
"integrations": {
|
||||
"withings": {
|
||||
"reconnect": {
|
||||
"banner": {
|
||||
"title": "Activity sync available",
|
||||
"body": "Your Withings connection was authorised before activity sync was added. Reconnect once to enable steps, active energy, walking + running distance, and floors-climbed ingest from your Withings devices.",
|
||||
"action": "Reconnect Withings"
|
||||
}
|
||||
},
|
||||
"plan": {
|
||||
"title": "Some readings need Withings' paid API plan",
|
||||
"body": "Withings now offers {list} only in its paid API plan, and your Withings developer app is on the free plan. Weight, blood pressure, body composition, heart rate and sleep keep syncing.",
|
||||
"link": "About the Withings API plans",
|
||||
"items": {
|
||||
"spo2": "SpO2",
|
||||
@@ -6538,10 +6525,7 @@
|
||||
},
|
||||
"oura": {
|
||||
"scope": {
|
||||
"body": "Reconnect Oura once to allow {list}. Everything else keeps syncing.",
|
||||
"action": "Reconnect Oura",
|
||||
"items": {
|
||||
"spo2": "SpO2",
|
||||
"vo2max": "VO2 max",
|
||||
"cardio_age": "cardiovascular age",
|
||||
"resilience": "resilience"
|
||||
@@ -6560,7 +6544,6 @@
|
||||
"minutesAgo": "{count} min ago",
|
||||
"hoursAgo": "{count} h ago",
|
||||
"daysAgo": "{count} d ago",
|
||||
"resumeCta": "Reconnect",
|
||||
"resumeSuccess": "Integration resumed",
|
||||
"resumeError": "Reconnect failed",
|
||||
"stale": "Connected, no recent data",
|
||||
@@ -6574,9 +6557,7 @@
|
||||
"workouts": "Workouts"
|
||||
},
|
||||
"integrationFallback": {
|
||||
"description": "Configured on this account without a settings card of its own.",
|
||||
"lastAttempt": "Last attempt {relative}",
|
||||
"noAttempt": "No sync attempt recorded"
|
||||
"description": "Configured on this account without a settings card of its own."
|
||||
},
|
||||
"withingsViewData": "View your measurements",
|
||||
"whoopViewData": "View your recovery",
|
||||
@@ -7124,7 +7105,6 @@
|
||||
"backgroundStep": "Keep Background App Refresh enabled. The app syncs new data automatically in the foreground and when iOS grants background time.",
|
||||
"importNote": "Need older history or cannot use the iOS app? The Apple Health XML/ZIP upload is a one-shot fallback and does not enable live sync.",
|
||||
"importAction": "Open one-shot import",
|
||||
"staleNote": "No Apple Health data has arrived for over a week. Open the HealthLog app on your iPhone and check the Apple Health permissions.",
|
||||
"progress": {
|
||||
"title": "Sync progress",
|
||||
"receivedLabel": "Records received",
|
||||
@@ -7217,6 +7197,73 @@
|
||||
"displayCard": {
|
||||
"title": "Display",
|
||||
"description": "Language, units and formats, saved as soon as you change them."
|
||||
},
|
||||
"integrationCard": {
|
||||
"state": {
|
||||
"ok": "Working",
|
||||
"limited": "Limited",
|
||||
"degraded": "Delayed",
|
||||
"actionRequired": "Action needed"
|
||||
},
|
||||
"reason": {
|
||||
"dataArriving": "Data is arriving.",
|
||||
"dataArrivingHistory": "Data is arriving. Older history is loading in the background.",
|
||||
"firstSyncPending": "Connected. The first sync runs shortly.",
|
||||
"planLimited": "Data is arriving. Your {provider} plan does not include {list}.",
|
||||
"permissionLimited": "Data is arriving. Permission is missing for {list}.",
|
||||
"providerErrors": "{provider} has not answered reliably since {since}. HealthLog keeps trying; nothing to do on your side.",
|
||||
"providerErrorsNoSince": "{provider} is not answering reliably right now. HealthLog keeps trying; nothing to do on your side.",
|
||||
"syncStalled": "HealthLog has not synced since {since}. That is on the server, not your device.",
|
||||
"syncStalledNoSince": "HealthLog is not syncing right now. That is on the server, not your device.",
|
||||
"noNewData": "No new data since {since}.",
|
||||
"noNewDataNoSince": "No new data for a while.",
|
||||
"noNewDataApple": "No new data since {since}. Open HealthLog on your iPhone once.",
|
||||
"noNewDataAppleNoSince": "No new data for a while. Open HealthLog on your iPhone once.",
|
||||
"unexpectedSilence": "Nothing has arrived since {since}, although {provider} usually delivers daily.",
|
||||
"unexpectedSilenceNoSince": "Nothing is arriving, although {provider} usually delivers daily.",
|
||||
"reauthRequired": "The {provider} connection has expired. Reconnect to keep data coming.",
|
||||
"pausedAfterErrors": "{provider} is paused after failing since {since}.",
|
||||
"pausedAfterErrorsNoSince": "{provider} is paused after repeated failures."
|
||||
},
|
||||
"when": {
|
||||
"today": "today, {time}",
|
||||
"yesterday": "yesterday, {time}",
|
||||
"date": "{date}, {time}"
|
||||
},
|
||||
"items": {
|
||||
"activity": "activity",
|
||||
"sleep": "sleep"
|
||||
},
|
||||
"moreValuesOne": "{count} more",
|
||||
"moreValuesFew": "{count} more",
|
||||
"moreValuesOther": "{count} more",
|
||||
"notConnected": "Not connected.",
|
||||
"lastData": "Last data: {when}",
|
||||
"lastDataNone": "Last data: none yet",
|
||||
"reconnect": "Reconnect",
|
||||
"resume": "Resume",
|
||||
"menuLabel": "More actions for {provider}",
|
||||
"menu": {
|
||||
"credentials": "Change credentials",
|
||||
"address": "Change address and token"
|
||||
},
|
||||
"syncing": "Syncing…",
|
||||
"details": {
|
||||
"title": "Details for a bug report",
|
||||
"none": "none",
|
||||
"state": "State",
|
||||
"since": "since",
|
||||
"lastSync": "Last sync",
|
||||
"lastData": "Last data",
|
||||
"failingParts": "failing parts",
|
||||
"recentCalls": "Recent calls",
|
||||
"unavailable": "Not available",
|
||||
"unavailableList": "Not available: {list}",
|
||||
"causePlan": "plan",
|
||||
"causePermission": "permission",
|
||||
"failureStreak": "Failure streak: {count} of {threshold}",
|
||||
"copyFailed": "Could not copy. Select the text and copy it by hand."
|
||||
}
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
|
||||
+70
-23
@@ -5315,7 +5315,8 @@
|
||||
"unavailableTitle": "No disponible en este registro compartido",
|
||||
"unavailableDescription": "Esta sección de Ajustes no está disponible intencionadamente mientras consultas el registro de otra persona.",
|
||||
"integrationStatusDescription": "Estado de conexión de este registro gestionado.",
|
||||
"integrationStatusDetail": "Los controles de conexión no están disponibles."
|
||||
"integrationStatusDetail": "Un perfil gestionado muestra sus servicios conectados solo como estado. Conectar, volver a conectar, sincronizar y desconectar no está disponible para quienes lo gestionan.",
|
||||
"integrationStatusDocs": "Cómo funciona compartir"
|
||||
},
|
||||
"sections": {
|
||||
"account": {
|
||||
@@ -6391,7 +6392,6 @@
|
||||
"whoopDisconnectDescription": "Se desconectará la conexión con WHOOP. Los datos ya sincronizados se conservarán.",
|
||||
"whoopConnect": "Conectar con WHOOP",
|
||||
"whoopNoCredentials": "Introduce tus credenciales de API arriba para conectar WHOOP.",
|
||||
"whoopBackfillInProgress": "Importando tu historial de WHOOP en segundo plano…",
|
||||
"whoopOauthConnected": "WHOOP conectado: la primera sincronización empieza en segundo plano.",
|
||||
"whoopOauthFailed": "Error al conectar con WHOOP",
|
||||
"whoopOauthError": {
|
||||
@@ -6427,7 +6427,6 @@
|
||||
"fitbitDisconnectDescription": "Se desconectará la conexión con Fitbit. Los datos sincronizados previamente se conservarán.",
|
||||
"fitbitConnect": "Conectar con Fitbit",
|
||||
"fitbitNoCredentials": "Introduce tus credenciales de API arriba para conectar Fitbit.",
|
||||
"fitbitBackfillInProgress": "Importando tu historial de Fitbit en segundo plano…",
|
||||
"fitbitOauthConnected": "Fitbit conectado: la primera sincronización empieza en segundo plano.",
|
||||
"fitbitOauthFailed": "Error al conectar con Fitbit",
|
||||
"googleHealth": "Google Health",
|
||||
@@ -6472,9 +6471,6 @@
|
||||
"googleHealthDisconnectDescription": "Se desconectará la conexión con Google Health. Los datos ya sincronizados se conservarán.",
|
||||
"googleHealthConnect": "Conectar con Google",
|
||||
"googleHealthNoCredentials": "Introduce tus credenciales de API arriba para conectar Google Health.",
|
||||
"googleHealthBackfillInProgress": "Importando tu historial de Google Health en segundo plano…",
|
||||
"googleHealthReauthBanner": "Google ha caducado la conexión: ocurre de forma periódica mientras tu app de OAuth permanece en modo de prueba. Vuelve a conectar para reanudar la sincronización.",
|
||||
"googleHealthReconnect": "Volver a conectar",
|
||||
"googleHealthOauthConnected": "Google Health conectado: la primera sincronización empieza en segundo plano.",
|
||||
"googleHealthOauthFailed": "Error al conectar con Google Health",
|
||||
"googleHealthOauthError": {
|
||||
@@ -6499,16 +6495,7 @@
|
||||
},
|
||||
"integrations": {
|
||||
"withings": {
|
||||
"reconnect": {
|
||||
"banner": {
|
||||
"title": "Sincronización de actividad disponible",
|
||||
"body": "Tu conexión con Withings se autorizó antes de añadir la sincronización de actividad. Vuelve a conectar una vez para habilitar la importación de pasos, energía activa, distancia caminando y corriendo, y pisos subidos desde tus dispositivos Withings.",
|
||||
"action": "Reconectar Withings"
|
||||
}
|
||||
},
|
||||
"plan": {
|
||||
"title": "Algunos valores requieren el plan de API de pago de Withings",
|
||||
"body": "Withings ahora ofrece {list} solo en su plan de API de pago, y tu app de desarrollador de Withings usa el plan gratuito. El peso, la presión arterial, la composición corporal, la frecuencia cardíaca y el sueño se siguen sincronizando.",
|
||||
"link": "Sobre los planes de API de Withings",
|
||||
"items": {
|
||||
"spo2": "SpO2",
|
||||
@@ -6538,10 +6525,7 @@
|
||||
},
|
||||
"oura": {
|
||||
"scope": {
|
||||
"body": "Vuelve a conectar Oura una vez para permitir {list}. Todo lo demás se sigue sincronizando.",
|
||||
"action": "Volver a conectar Oura",
|
||||
"items": {
|
||||
"spo2": "SpO2",
|
||||
"vo2max": "VO2 máx.",
|
||||
"cardio_age": "edad cardiovascular",
|
||||
"resilience": "resiliencia"
|
||||
@@ -6560,7 +6544,6 @@
|
||||
"minutesAgo": "hace {count} min",
|
||||
"hoursAgo": "hace {count} h",
|
||||
"daysAgo": "hace {count} d",
|
||||
"resumeCta": "Reconectar",
|
||||
"resumeSuccess": "Integración reanudada",
|
||||
"resumeError": "Reconexión fallida",
|
||||
"stale": "Conectado, sin datos recientes",
|
||||
@@ -6574,9 +6557,7 @@
|
||||
"workouts": "Entrenamientos"
|
||||
},
|
||||
"integrationFallback": {
|
||||
"description": "Configurado en esta cuenta pero sin tarjeta de ajustes propia.",
|
||||
"lastAttempt": "Último intento {relative}",
|
||||
"noAttempt": "Ningún intento de sincronización registrado"
|
||||
"description": "Configurado en esta cuenta pero sin tarjeta de ajustes propia."
|
||||
},
|
||||
"withingsViewData": "Ver tus mediciones",
|
||||
"whoopViewData": "Ver tu recuperación",
|
||||
@@ -7124,7 +7105,6 @@
|
||||
"backgroundStep": "Mantén activada la actualización en segundo plano. La app sincroniza datos nuevos automáticamente en primer plano y cuando iOS le concede tiempo en segundo plano.",
|
||||
"importNote": "¿Necesitas un historial anterior o no puedes usar la app de iOS? La carga XML/ZIP de Apple Health es una alternativa puntual y no activa la sincronización en vivo.",
|
||||
"importAction": "Abrir importación puntual",
|
||||
"staleNote": "Hace más de una semana que no llegan datos de Apple Health. Abre la app HealthLog en tu iPhone y revisa los permisos de Apple Health.",
|
||||
"progress": {
|
||||
"title": "Progreso de sincronización",
|
||||
"receivedLabel": "Registros recibidos",
|
||||
@@ -7217,6 +7197,73 @@
|
||||
"displayCard": {
|
||||
"title": "Visualización",
|
||||
"description": "Idioma, unidades y formatos; se guardan en cuanto los cambias."
|
||||
},
|
||||
"integrationCard": {
|
||||
"state": {
|
||||
"ok": "Funciona",
|
||||
"limited": "Limitada",
|
||||
"degraded": "Con retraso",
|
||||
"actionRequired": "Requiere acción"
|
||||
},
|
||||
"reason": {
|
||||
"dataArriving": "Están llegando datos.",
|
||||
"dataArrivingHistory": "Están llegando datos. El historial más antiguo se carga en segundo plano.",
|
||||
"firstSyncPending": "Conectado. La primera sincronización se hará en breve.",
|
||||
"planLimited": "Están llegando datos. Tu plan de {provider} no incluye {list}.",
|
||||
"permissionLimited": "Están llegando datos. Falta el permiso para {list}.",
|
||||
"providerErrors": "{provider} no responde de forma fiable desde {since}. HealthLog sigue intentándolo; no tienes que hacer nada.",
|
||||
"providerErrorsNoSince": "{provider} no responde de forma fiable en este momento. HealthLog sigue intentándolo; no tienes que hacer nada.",
|
||||
"syncStalled": "HealthLog no sincroniza desde {since}. Es cosa del servidor, no de tu dispositivo.",
|
||||
"syncStalledNoSince": "HealthLog no está sincronizando en este momento. Es cosa del servidor, no de tu dispositivo.",
|
||||
"noNewData": "No han llegado datos nuevos desde {since}.",
|
||||
"noNewDataNoSince": "Hace tiempo que no llegan datos nuevos.",
|
||||
"noNewDataApple": "No han llegado datos nuevos desde {since}. Abre HealthLog una vez en el iPhone.",
|
||||
"noNewDataAppleNoSince": "Hace tiempo que no llegan datos nuevos. Abre HealthLog una vez en el iPhone.",
|
||||
"unexpectedSilence": "No llega nada desde {since}, aunque {provider} suele enviar datos a diario.",
|
||||
"unexpectedSilenceNoSince": "No llega nada, aunque {provider} suele enviar datos a diario.",
|
||||
"reauthRequired": "La conexión con {provider} ha caducado. Vuelve a conectar para seguir recibiendo datos.",
|
||||
"pausedAfterErrors": "{provider} está en pausa porque desde {since} solo ha habido errores.",
|
||||
"pausedAfterErrorsNoSince": "{provider} está en pausa porque últimamente solo ha habido errores."
|
||||
},
|
||||
"when": {
|
||||
"today": "hoy, {time}",
|
||||
"yesterday": "ayer, {time}",
|
||||
"date": "{date}, {time}"
|
||||
},
|
||||
"items": {
|
||||
"activity": "actividad",
|
||||
"sleep": "sueño"
|
||||
},
|
||||
"moreValuesOne": "{count} valor más",
|
||||
"moreValuesFew": "{count} valores más",
|
||||
"moreValuesOther": "{count} valores más",
|
||||
"notConnected": "No conectado.",
|
||||
"lastData": "Últimos datos: {when}",
|
||||
"lastDataNone": "Últimos datos: todavía ninguno",
|
||||
"reconnect": "Volver a conectar",
|
||||
"resume": "Reanudar",
|
||||
"menuLabel": "Más acciones para {provider}",
|
||||
"menu": {
|
||||
"credentials": "Cambiar credenciales",
|
||||
"address": "Cambiar dirección y token"
|
||||
},
|
||||
"syncing": "Sincronizando…",
|
||||
"details": {
|
||||
"title": "Detalles para un informe de error",
|
||||
"none": "ninguno",
|
||||
"state": "Estado",
|
||||
"since": "desde",
|
||||
"lastSync": "Última sincronización",
|
||||
"lastData": "Últimos datos",
|
||||
"failingParts": "partes con fallos",
|
||||
"recentCalls": "Últimas llamadas",
|
||||
"unavailable": "No disponible",
|
||||
"unavailableList": "No disponible: {list}",
|
||||
"causePlan": "plan",
|
||||
"causePermission": "permiso",
|
||||
"failureStreak": "Racha de fallos: {count} de {threshold}",
|
||||
"copyFailed": "No se pudo copiar. Selecciona el texto y cópialo a mano."
|
||||
}
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
|
||||
+70
-23
@@ -5315,7 +5315,8 @@
|
||||
"unavailableTitle": "Indisponible dans ce dossier partagé",
|
||||
"unavailableDescription": "Cette section des réglages est volontairement indisponible lorsque vous consultez le dossier d’une autre personne.",
|
||||
"integrationStatusDescription": "État de connexion pour ce dossier géré.",
|
||||
"integrationStatusDetail": "Les commandes de connexion ne sont pas disponibles."
|
||||
"integrationStatusDetail": "Un profil géré affiche ses services connectés uniquement sous forme d’état. Les personnes qui le gèrent ne peuvent ni connecter, ni reconnecter, ni synchroniser, ni déconnecter.",
|
||||
"integrationStatusDocs": "Comment fonctionne le partage"
|
||||
},
|
||||
"sections": {
|
||||
"account": {
|
||||
@@ -6391,7 +6392,6 @@
|
||||
"whoopDisconnectDescription": "La connexion à WHOOP sera déconnectée. Les données déjà synchronisées seront conservées.",
|
||||
"whoopConnect": "Se connecter avec WHOOP",
|
||||
"whoopNoCredentials": "Veuillez saisir vos identifiants API ci-dessus pour connecter WHOOP.",
|
||||
"whoopBackfillInProgress": "Importation de votre historique WHOOP en arrière-plan…",
|
||||
"whoopOauthConnected": "WHOOP connecté. La première synchronisation démarre en arrière-plan.",
|
||||
"whoopOauthFailed": "Échec de la connexion à WHOOP",
|
||||
"whoopOauthError": {
|
||||
@@ -6427,7 +6427,6 @@
|
||||
"fitbitDisconnectDescription": "La connexion à Fitbit sera déconnectée. Les données déjà synchronisées seront conservées.",
|
||||
"fitbitConnect": "Se connecter à Fitbit",
|
||||
"fitbitNoCredentials": "Veuillez saisir vos identifiants d'API ci-dessus pour connecter Fitbit.",
|
||||
"fitbitBackfillInProgress": "Importation de votre historique Fitbit en arrière-plan…",
|
||||
"fitbitOauthConnected": "Fitbit connecté. La première synchronisation démarre en arrière-plan.",
|
||||
"fitbitOauthFailed": "Échec de la connexion à Fitbit",
|
||||
"googleHealth": "Google Health",
|
||||
@@ -6472,9 +6471,6 @@
|
||||
"googleHealthDisconnectDescription": "La connexion à Google Health sera déconnectée. Les données déjà synchronisées seront conservées.",
|
||||
"googleHealthConnect": "Se connecter avec Google",
|
||||
"googleHealthNoCredentials": "Saisis tes identifiants d'API ci-dessus pour connecter Google Health.",
|
||||
"googleHealthBackfillInProgress": "Importation de ton historique Google Health en arrière-plan…",
|
||||
"googleHealthReauthBanner": "Google a expiré la connexion. Cela arrive régulièrement tant que ton application OAuth reste en mode Test. Reconnecte-toi pour reprendre la synchronisation.",
|
||||
"googleHealthReconnect": "Se reconnecter",
|
||||
"googleHealthOauthConnected": "Google Health connecté. La première synchronisation démarre en arrière-plan.",
|
||||
"googleHealthOauthFailed": "Échec de la connexion à Google Health",
|
||||
"googleHealthOauthError": {
|
||||
@@ -6499,16 +6495,7 @@
|
||||
},
|
||||
"integrations": {
|
||||
"withings": {
|
||||
"reconnect": {
|
||||
"banner": {
|
||||
"title": "Synchronisation d’activité disponible",
|
||||
"body": "Votre connexion Withings a été autorisée avant l’ajout de la synchronisation d’activité. Reconnectez-vous une fois pour activer l’import des pas, de l’énergie active, de la distance à pied et en course, et des étages gravis depuis vos appareils Withings.",
|
||||
"action": "Reconnecter Withings"
|
||||
}
|
||||
},
|
||||
"plan": {
|
||||
"title": "Certaines mesures nécessitent l’offre API payante de Withings",
|
||||
"body": "Withings ne propose désormais {list} que dans son offre API payante, et votre application développeur Withings utilise l’offre gratuite. Le poids, la tension artérielle, la composition corporelle, la fréquence cardiaque et le sommeil continuent d’être synchronisés.",
|
||||
"link": "En savoir plus sur les offres API de Withings",
|
||||
"items": {
|
||||
"spo2": "la SpO2",
|
||||
@@ -6538,10 +6525,7 @@
|
||||
},
|
||||
"oura": {
|
||||
"scope": {
|
||||
"body": "Reconnectez Oura une fois pour autoriser {list}. Tout le reste continue d’être synchronisé.",
|
||||
"action": "Reconnecter Oura",
|
||||
"items": {
|
||||
"spo2": "la SpO2",
|
||||
"vo2max": "la VO2 max",
|
||||
"cardio_age": "l’âge cardiovasculaire",
|
||||
"resilience": "la résilience"
|
||||
@@ -6560,7 +6544,6 @@
|
||||
"minutesAgo": "il y a {count} min",
|
||||
"hoursAgo": "il y a {count} h",
|
||||
"daysAgo": "il y a {count} j",
|
||||
"resumeCta": "Reconnecter",
|
||||
"resumeSuccess": "Intégration reprise",
|
||||
"resumeError": "Échec de la reconnexion",
|
||||
"stale": "Connecté, aucune donnée récente",
|
||||
@@ -6574,9 +6557,7 @@
|
||||
"workouts": "Séances"
|
||||
},
|
||||
"integrationFallback": {
|
||||
"description": "Configuré sur ce compte sans carte de réglages dédiée.",
|
||||
"lastAttempt": "Dernière tentative {relative}",
|
||||
"noAttempt": "Aucune tentative de synchronisation enregistrée"
|
||||
"description": "Configuré sur ce compte sans carte de réglages dédiée."
|
||||
},
|
||||
"withingsViewData": "Voir vos mesures",
|
||||
"whoopViewData": "Voir votre récupération",
|
||||
@@ -7124,7 +7105,6 @@
|
||||
"backgroundStep": "Laisse l’actualisation en arrière-plan activée. L’app synchronise automatiquement les nouvelles données au premier plan et lorsque iOS lui accorde du temps en arrière-plan.",
|
||||
"importNote": "Besoin d’un ancien historique ou impossible d’utiliser l’app iOS ? L’envoi XML/ZIP Apple Health est une solution ponctuelle et n’active pas la synchronisation en direct.",
|
||||
"importAction": "Ouvrir l’import ponctuel",
|
||||
"staleNote": "Aucune donnée Apple Health n'est arrivée depuis plus d'une semaine. Ouvrez l'app HealthLog sur votre iPhone et vérifiez les autorisations Apple Health.",
|
||||
"progress": {
|
||||
"title": "Progression de la synchronisation",
|
||||
"receivedLabel": "Enregistrements reçus",
|
||||
@@ -7217,6 +7197,73 @@
|
||||
"displayCard": {
|
||||
"title": "Affichage",
|
||||
"description": "Langue, unités et formats, enregistrés dès que vous les modifiez."
|
||||
},
|
||||
"integrationCard": {
|
||||
"state": {
|
||||
"ok": "Fonctionne",
|
||||
"limited": "Limitée",
|
||||
"degraded": "Retardée",
|
||||
"actionRequired": "Action requise"
|
||||
},
|
||||
"reason": {
|
||||
"dataArriving": "Les données arrivent.",
|
||||
"dataArrivingHistory": "Les données arrivent. L’historique plus ancien se charge en arrière-plan.",
|
||||
"firstSyncPending": "Connecté. La première synchronisation aura lieu sous peu.",
|
||||
"planLimited": "Les données arrivent. Votre forfait {provider} n’inclut pas {list}.",
|
||||
"permissionLimited": "Les données arrivent. L’autorisation manque pour {list}.",
|
||||
"providerErrors": "{provider} ne répond plus de façon fiable depuis {since}. HealthLog continue d’essayer, vous n’avez rien à faire.",
|
||||
"providerErrorsNoSince": "{provider} ne répond pas de façon fiable en ce moment. HealthLog continue d’essayer, vous n’avez rien à faire.",
|
||||
"syncStalled": "HealthLog n’a plus synchronisé depuis {since}. Le problème vient du serveur, pas de votre appareil.",
|
||||
"syncStalledNoSince": "HealthLog ne synchronise pas en ce moment. Le problème vient du serveur, pas de votre appareil.",
|
||||
"noNewData": "Aucune nouvelle donnée depuis {since}.",
|
||||
"noNewDataNoSince": "Aucune nouvelle donnée depuis un moment.",
|
||||
"noNewDataApple": "Aucune nouvelle donnée depuis {since}. Ouvrez HealthLog une fois sur votre iPhone.",
|
||||
"noNewDataAppleNoSince": "Aucune nouvelle donnée depuis un moment. Ouvrez HealthLog une fois sur votre iPhone.",
|
||||
"unexpectedSilence": "Rien n’est arrivé depuis {since}, alors que {provider} envoie d’habitude des données chaque jour.",
|
||||
"unexpectedSilenceNoSince": "Rien n’arrive, alors que {provider} envoie d’habitude des données chaque jour.",
|
||||
"reauthRequired": "La connexion à {provider} a expiré. Reconnectez-vous pour continuer à recevoir des données.",
|
||||
"pausedAfterErrors": "{provider} est en pause, car il n’y a eu que des erreurs depuis {since}.",
|
||||
"pausedAfterErrorsNoSince": "{provider} est en pause, car il n’y a eu que des erreurs récemment."
|
||||
},
|
||||
"when": {
|
||||
"today": "aujourd’hui, {time}",
|
||||
"yesterday": "hier, {time}",
|
||||
"date": "{date}, {time}"
|
||||
},
|
||||
"items": {
|
||||
"activity": "l’activité",
|
||||
"sleep": "le sommeil"
|
||||
},
|
||||
"moreValuesOne": "{count} autre valeur",
|
||||
"moreValuesFew": "{count} autres valeurs",
|
||||
"moreValuesOther": "{count} autres valeurs",
|
||||
"notConnected": "Non connecté.",
|
||||
"lastData": "Dernières données : {when}",
|
||||
"lastDataNone": "Dernières données : aucune pour l’instant",
|
||||
"reconnect": "Reconnecter",
|
||||
"resume": "Reprendre",
|
||||
"menuLabel": "Plus d’actions pour {provider}",
|
||||
"menu": {
|
||||
"credentials": "Modifier les identifiants",
|
||||
"address": "Modifier l’adresse et le jeton"
|
||||
},
|
||||
"syncing": "Synchronisation en cours…",
|
||||
"details": {
|
||||
"title": "Détails pour un rapport de bug",
|
||||
"none": "aucune",
|
||||
"state": "État",
|
||||
"since": "depuis",
|
||||
"lastSync": "Dernière synchronisation",
|
||||
"lastData": "Dernières données",
|
||||
"failingParts": "parties en échec",
|
||||
"recentCalls": "Derniers appels",
|
||||
"unavailable": "Non disponible",
|
||||
"unavailableList": "Non disponible : {list}",
|
||||
"causePlan": "forfait",
|
||||
"causePermission": "autorisation",
|
||||
"failureStreak": "Série d’échecs : {count} sur {threshold}",
|
||||
"copyFailed": "La copie a échoué. Sélectionnez le texte et copiez-le à la main."
|
||||
}
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
|
||||
+70
-23
@@ -5315,7 +5315,8 @@
|
||||
"unavailableTitle": "Non disponibile in questo record condiviso",
|
||||
"unavailableDescription": "Questa area delle Impostazioni non è disponibile intenzionalmente mentre visualizzi il record di un’altra persona.",
|
||||
"integrationStatusDescription": "Stato di connessione per questo record gestito.",
|
||||
"integrationStatusDetail": "I controlli di connessione non sono disponibili."
|
||||
"integrationStatusDetail": "Un profilo gestito mostra i servizi collegati solo come stato. Chi lo gestisce non può collegare, ricollegare, sincronizzare o scollegare.",
|
||||
"integrationStatusDocs": "Come funziona la condivisione"
|
||||
},
|
||||
"sections": {
|
||||
"account": {
|
||||
@@ -6391,7 +6392,6 @@
|
||||
"whoopDisconnectDescription": "La connessione a WHOOP verrà disconnessa. I dati già sincronizzati saranno conservati.",
|
||||
"whoopConnect": "Connetti con WHOOP",
|
||||
"whoopNoCredentials": "Inserisci le tue credenziali API qui sopra per connettere WHOOP.",
|
||||
"whoopBackfillInProgress": "Importazione dello storico WHOOP in background…",
|
||||
"whoopOauthConnected": "WHOOP collegato: la prima sincronizzazione parte in background.",
|
||||
"whoopOauthFailed": "Connessione a WHOOP non riuscita",
|
||||
"whoopOauthError": {
|
||||
@@ -6427,7 +6427,6 @@
|
||||
"fitbitDisconnectDescription": "La connessione a Fitbit verrà disconnessa. I dati già sincronizzati saranno conservati.",
|
||||
"fitbitConnect": "Connetti con Fitbit",
|
||||
"fitbitNoCredentials": "Inserisci le tue credenziali API qui sopra per connettere Fitbit.",
|
||||
"fitbitBackfillInProgress": "Importazione dello storico Fitbit in background…",
|
||||
"fitbitOauthConnected": "Fitbit collegato: la prima sincronizzazione parte in background.",
|
||||
"fitbitOauthFailed": "Connessione a Fitbit non riuscita",
|
||||
"googleHealth": "Google Health",
|
||||
@@ -6472,9 +6471,6 @@
|
||||
"googleHealthDisconnectDescription": "La connessione a Google Health verrà disconnessa. I dati già sincronizzati saranno conservati.",
|
||||
"googleHealthConnect": "Connetti con Google",
|
||||
"googleHealthNoCredentials": "Inserisci le tue credenziali API qui sopra per connettere Google Health.",
|
||||
"googleHealthBackfillInProgress": "Importazione della cronologia di Google Health in background…",
|
||||
"googleHealthReauthBanner": "Google ha fatto scadere la connessione. Accade periodicamente finché la tua app OAuth resta in modalità Test. Riconnettiti per riprendere la sincronizzazione.",
|
||||
"googleHealthReconnect": "Riconnetti",
|
||||
"googleHealthOauthConnected": "Google Health connesso: la prima sincronizzazione parte in background.",
|
||||
"googleHealthOauthFailed": "Connessione a Google Health non riuscita",
|
||||
"googleHealthOauthError": {
|
||||
@@ -6499,16 +6495,7 @@
|
||||
},
|
||||
"integrations": {
|
||||
"withings": {
|
||||
"reconnect": {
|
||||
"banner": {
|
||||
"title": "Sincronizzazione attività disponibile",
|
||||
"body": "La tua connessione Withings è stata autorizzata prima dell’aggiunta della sincronizzazione attività. Riconnettiti una volta per abilitare l’importazione di passi, energia attiva, distanza a piedi e di corsa, e piani saliti dai tuoi dispositivi Withings.",
|
||||
"action": "Riconnetti Withings"
|
||||
}
|
||||
},
|
||||
"plan": {
|
||||
"title": "Alcuni valori richiedono il piano API a pagamento di Withings",
|
||||
"body": "Withings ora offre {list} solo nel suo piano API a pagamento, e la tua app sviluppatore Withings usa il piano gratuito. Peso, pressione arteriosa, composizione corporea, frequenza cardiaca e sonno continuano a sincronizzarsi.",
|
||||
"link": "Informazioni sui piani API di Withings",
|
||||
"items": {
|
||||
"spo2": "SpO2",
|
||||
@@ -6538,10 +6525,7 @@
|
||||
},
|
||||
"oura": {
|
||||
"scope": {
|
||||
"body": "Riconnetti Oura una volta per consentire {list}. Tutto il resto continua a sincronizzarsi.",
|
||||
"action": "Riconnetti Oura",
|
||||
"items": {
|
||||
"spo2": "SpO2",
|
||||
"vo2max": "VO2 max",
|
||||
"cardio_age": "età cardiovascolare",
|
||||
"resilience": "resilienza"
|
||||
@@ -6560,7 +6544,6 @@
|
||||
"minutesAgo": "{count} min fa",
|
||||
"hoursAgo": "{count} h fa",
|
||||
"daysAgo": "{count} g fa",
|
||||
"resumeCta": "Riconnetti",
|
||||
"resumeSuccess": "Integrazione ripresa",
|
||||
"resumeError": "Riconnessione fallita",
|
||||
"stale": "Connesso, nessun dato recente",
|
||||
@@ -6574,9 +6557,7 @@
|
||||
"workouts": "Allenamenti"
|
||||
},
|
||||
"integrationFallback": {
|
||||
"description": "Configurato su questo account ma senza una scheda di impostazioni propria.",
|
||||
"lastAttempt": "Ultimo tentativo {relative}",
|
||||
"noAttempt": "Nessun tentativo di sincronizzazione registrato"
|
||||
"description": "Configurato su questo account ma senza una scheda di impostazioni propria."
|
||||
},
|
||||
"withingsViewData": "Vedi le tue misurazioni",
|
||||
"whoopViewData": "Vedi il tuo recupero",
|
||||
@@ -7124,7 +7105,6 @@
|
||||
"backgroundStep": "Mantieni attivo l’aggiornamento in background. L’app sincronizza automaticamente i nuovi dati in primo piano e quando iOS concede tempo in background.",
|
||||
"importNote": "Ti serve lo storico precedente o non puoi usare l’app iOS? Il caricamento XML/ZIP di Apple Health è un’alternativa una tantum e non attiva la sincronizzazione in tempo reale.",
|
||||
"importAction": "Apri importazione una tantum",
|
||||
"staleNote": "Da più di una settimana non arrivano dati da Apple Health. Apri l'app HealthLog sull'iPhone e controlla i permessi di Apple Health.",
|
||||
"progress": {
|
||||
"title": "Avanzamento della sincronizzazione",
|
||||
"receivedLabel": "Record ricevuti",
|
||||
@@ -7217,6 +7197,73 @@
|
||||
"displayCard": {
|
||||
"title": "Visualizzazione",
|
||||
"description": "Lingua, unità e formati, salvati appena li modifichi."
|
||||
},
|
||||
"integrationCard": {
|
||||
"state": {
|
||||
"ok": "Funziona",
|
||||
"limited": "Limitata",
|
||||
"degraded": "In ritardo",
|
||||
"actionRequired": "Azione richiesta"
|
||||
},
|
||||
"reason": {
|
||||
"dataArriving": "I dati stanno arrivando.",
|
||||
"dataArrivingHistory": "I dati stanno arrivando. Lo storico meno recente si carica in background.",
|
||||
"firstSyncPending": "Connesso. La prima sincronizzazione partirà a breve.",
|
||||
"planLimited": "I dati stanno arrivando. Il tuo piano {provider} non include {list}.",
|
||||
"permissionLimited": "I dati stanno arrivando. Manca l’autorizzazione per {list}.",
|
||||
"providerErrors": "{provider} non risponde in modo affidabile da {since}. HealthLog continua a riprovare, non devi fare nulla.",
|
||||
"providerErrorsNoSince": "{provider} al momento non risponde in modo affidabile. HealthLog continua a riprovare, non devi fare nulla.",
|
||||
"syncStalled": "HealthLog non sincronizza più da {since}. Dipende dal server, non dal tuo dispositivo.",
|
||||
"syncStalledNoSince": "HealthLog al momento non sincronizza. Dipende dal server, non dal tuo dispositivo.",
|
||||
"noNewData": "Nessun dato nuovo da {since}.",
|
||||
"noNewDataNoSince": "Nessun dato nuovo da un po’ di tempo.",
|
||||
"noNewDataApple": "Nessun dato nuovo da {since}. Apri HealthLog una volta sull’iPhone.",
|
||||
"noNewDataAppleNoSince": "Nessun dato nuovo da un po’ di tempo. Apri HealthLog una volta sull’iPhone.",
|
||||
"unexpectedSilence": "Non arriva nulla da {since}, anche se di solito {provider} invia dati ogni giorno.",
|
||||
"unexpectedSilenceNoSince": "Non arriva nulla, anche se di solito {provider} invia dati ogni giorno.",
|
||||
"reauthRequired": "La connessione a {provider} è scaduta. Riconnetti per continuare a ricevere dati.",
|
||||
"pausedAfterErrors": "{provider} è in pausa perché da {since} ci sono stati solo errori.",
|
||||
"pausedAfterErrorsNoSince": "{provider} è in pausa perché ultimamente ci sono stati solo errori."
|
||||
},
|
||||
"when": {
|
||||
"today": "oggi, {time}",
|
||||
"yesterday": "ieri, {time}",
|
||||
"date": "{date}, {time}"
|
||||
},
|
||||
"items": {
|
||||
"activity": "attività",
|
||||
"sleep": "sonno"
|
||||
},
|
||||
"moreValuesOne": "{count} altro valore",
|
||||
"moreValuesFew": "altri {count} valori",
|
||||
"moreValuesOther": "altri {count} valori",
|
||||
"notConnected": "Non connesso.",
|
||||
"lastData": "Ultimi dati: {when}",
|
||||
"lastDataNone": "Ultimi dati: ancora nessuno",
|
||||
"reconnect": "Riconnetti",
|
||||
"resume": "Riprendi",
|
||||
"menuLabel": "Altre azioni per {provider}",
|
||||
"menu": {
|
||||
"credentials": "Modifica credenziali",
|
||||
"address": "Modifica indirizzo e token"
|
||||
},
|
||||
"syncing": "Sincronizzazione in corso…",
|
||||
"details": {
|
||||
"title": "Dettagli per una segnalazione di errore",
|
||||
"none": "nessuno",
|
||||
"state": "Stato",
|
||||
"since": "da",
|
||||
"lastSync": "Ultima sincronizzazione",
|
||||
"lastData": "Ultimi dati",
|
||||
"failingParts": "parti con errori",
|
||||
"recentCalls": "Ultime chiamate",
|
||||
"unavailable": "Non disponibile",
|
||||
"unavailableList": "Non disponibile: {list}",
|
||||
"causePlan": "piano",
|
||||
"causePermission": "autorizzazione",
|
||||
"failureStreak": "Serie di errori: {count} su {threshold}",
|
||||
"copyFailed": "Copia non riuscita. Seleziona il testo e copialo a mano."
|
||||
}
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
|
||||
+70
-23
@@ -5315,7 +5315,8 @@
|
||||
"unavailableTitle": "이 공유 기록에서는 사용할 수 없어요",
|
||||
"unavailableDescription": "다른 사람의 기록을 보는 동안에는 이 설정 영역을 사용할 수 없어요.",
|
||||
"integrationStatusDescription": "이 관리 기록의 연결 상태예요.",
|
||||
"integrationStatusDetail": "연결 설정은 사용할 수 없어요."
|
||||
"integrationStatusDetail": "관리 프로필은 연결된 서비스를 상태로만 보여 줘요. 보호자는 연결, 다시 연결, 동기화, 연결 해제를 할 수 없어요.",
|
||||
"integrationStatusDocs": "공유 방식 알아보기"
|
||||
},
|
||||
"sections": {
|
||||
"account": {
|
||||
@@ -6391,7 +6392,6 @@
|
||||
"whoopDisconnectDescription": "WHOOP 연결이 해제돼요. 이미 동기화한 데이터는 그대로 남아요.",
|
||||
"whoopConnect": "WHOOP으로 연결",
|
||||
"whoopNoCredentials": "WHOOP을 연결하려면 위에 API 자격 증명을 입력해 주세요.",
|
||||
"whoopBackfillInProgress": "WHOOP 기록을 백그라운드에서 가져오는 중이에요…",
|
||||
"whoopOauthConnected": "WHOOP을 연결했어요. 첫 동기화가 백그라운드에서 시작돼요.",
|
||||
"whoopOauthFailed": "WHOOP 연결에 실패했어요",
|
||||
"whoopOauthError": {
|
||||
@@ -6427,7 +6427,6 @@
|
||||
"fitbitDisconnectDescription": "Fitbit 연결이 해제돼요. 이미 동기화한 데이터는 그대로 남아요.",
|
||||
"fitbitConnect": "Fitbit으로 연결",
|
||||
"fitbitNoCredentials": "Fitbit을 연결하려면 위에 API 자격 증명을 입력해 주세요.",
|
||||
"fitbitBackfillInProgress": "Fitbit 기록을 백그라운드에서 가져오는 중이에요…",
|
||||
"fitbitOauthConnected": "Fitbit을 연결했어요. 첫 동기화가 백그라운드에서 시작돼요.",
|
||||
"fitbitOauthFailed": "Fitbit 연결에 실패했어요",
|
||||
"googleHealth": "Google Health",
|
||||
@@ -6472,9 +6471,6 @@
|
||||
"googleHealthDisconnectDescription": "Google Health 연결이 해제돼요. 이미 동기화한 데이터는 그대로 남아요.",
|
||||
"googleHealthConnect": "Google로 연결",
|
||||
"googleHealthNoCredentials": "Google Health를 연결하려면 위에 API 자격 증명을 입력해 주세요.",
|
||||
"googleHealthBackfillInProgress": "Google Health 기록을 백그라운드에서 가져오는 중이에요…",
|
||||
"googleHealthReauthBanner": "Google이 연결을 만료시켰어요. OAuth 앱이 테스트 모드로 있는 동안 주기적으로 생기는 일이에요. 다시 연결하면 동기화가 이어져요.",
|
||||
"googleHealthReconnect": "다시 연결",
|
||||
"googleHealthOauthConnected": "Google Health를 연결했어요. 첫 동기화가 백그라운드에서 시작돼요.",
|
||||
"googleHealthOauthFailed": "Google Health 연결에 실패했어요",
|
||||
"googleHealthOauthError": {
|
||||
@@ -6499,16 +6495,7 @@
|
||||
},
|
||||
"integrations": {
|
||||
"withings": {
|
||||
"reconnect": {
|
||||
"banner": {
|
||||
"title": "활동 동기화를 쓸 수 있어요",
|
||||
"body": "활동 동기화가 추가되기 전에 Withings 연결을 승인했어요. 한 번만 다시 연결하면 Withings 기기에서 걸음 수, 활동 에너지, 걷기·달리기 거리, 오른 층수를 가져올 수 있어요.",
|
||||
"action": "Withings 다시 연결"
|
||||
}
|
||||
},
|
||||
"plan": {
|
||||
"title": "일부 측정값은 Withings 유료 API 요금제가 필요해요",
|
||||
"body": "Withings는 이제 {list} 데이터를 유료 API 요금제에서만 제공하고, 사용 중인 Withings 개발자 앱은 무료 요금제예요. 체중, 혈압, 체성분, 심박수, 수면은 계속 동기화돼요.",
|
||||
"link": "Withings API 요금제 알아보기",
|
||||
"items": {
|
||||
"spo2": "SpO2",
|
||||
@@ -6538,10 +6525,7 @@
|
||||
},
|
||||
"oura": {
|
||||
"scope": {
|
||||
"body": "{list} 데이터를 허용하려면 Oura를 한 번 다시 연결하세요. 나머지는 계속 동기화돼요.",
|
||||
"action": "Oura 다시 연결",
|
||||
"items": {
|
||||
"spo2": "SpO2",
|
||||
"vo2max": "VO2 max",
|
||||
"cardio_age": "심혈관 나이",
|
||||
"resilience": "회복탄력성"
|
||||
@@ -6560,7 +6544,6 @@
|
||||
"minutesAgo": "{count}분 전",
|
||||
"hoursAgo": "{count}시간 전",
|
||||
"daysAgo": "{count}일 전",
|
||||
"resumeCta": "다시 연결",
|
||||
"resumeSuccess": "연동을 다시 시작했어요",
|
||||
"resumeError": "다시 연결하지 못했어요",
|
||||
"stale": "연결됨, 최근 데이터 없음",
|
||||
@@ -6574,9 +6557,7 @@
|
||||
"workouts": "운동"
|
||||
},
|
||||
"integrationFallback": {
|
||||
"description": "이 계정에 설정돼 있지만 전용 설정 카드는 없어요.",
|
||||
"lastAttempt": "마지막 시도 {relative}",
|
||||
"noAttempt": "동기화 시도 기록이 없어요"
|
||||
"description": "이 계정에 설정돼 있지만 전용 설정 카드는 없어요."
|
||||
},
|
||||
"withingsViewData": "측정값 보기",
|
||||
"whoopViewData": "회복도 보기",
|
||||
@@ -7124,7 +7105,6 @@
|
||||
"backgroundStep": "백그라운드 앱 새로고침을 켠 채로 두세요. 앱은 화면에 떠 있을 때와 iOS가 백그라운드 시간을 줄 때 새 데이터를 자동으로 동기화해요.",
|
||||
"importNote": "예전 기록이 필요하거나 iOS 앱을 쓸 수 없나요? Apple Health XML/ZIP 업로드는 한 번만 쓰는 대안이라 실시간 동기화는 켜지지 않아요.",
|
||||
"importAction": "일회성 불러오기 열기",
|
||||
"staleNote": "일주일 넘게 Apple Health 데이터가 들어오지 않았어요. iPhone에서 HealthLog 앱을 열고 Apple Health 권한을 확인해 주세요.",
|
||||
"progress": {
|
||||
"title": "동기화 진행 상황",
|
||||
"receivedLabel": "받은 기록 수",
|
||||
@@ -7217,6 +7197,73 @@
|
||||
"displayCard": {
|
||||
"title": "표시",
|
||||
"description": "언어, 단위, 형식은 바꾸는 즉시 저장돼요."
|
||||
},
|
||||
"integrationCard": {
|
||||
"state": {
|
||||
"ok": "작동 중",
|
||||
"limited": "제한됨",
|
||||
"degraded": "지연됨",
|
||||
"actionRequired": "조치 필요"
|
||||
},
|
||||
"reason": {
|
||||
"dataArriving": "데이터가 들어오고 있어요.",
|
||||
"dataArrivingHistory": "데이터가 들어오고 있어요. 이전 기록은 백그라운드에서 불러오는 중이에요.",
|
||||
"firstSyncPending": "연결됐어요. 첫 동기화가 곧 시작돼요.",
|
||||
"planLimited": "데이터가 들어오고 있어요. {provider} 요금제에는 {list} 항목이 포함되지 않아요.",
|
||||
"permissionLimited": "데이터가 들어오고 있어요. {list} 항목에 대한 권한이 없어요.",
|
||||
"providerErrors": "{since}부터 {provider}의 응답이 불안정해요. HealthLog가 계속 다시 시도하니 따로 하실 일은 없어요.",
|
||||
"providerErrorsNoSince": "지금 {provider}의 응답이 불안정해요. HealthLog가 계속 다시 시도하니 따로 하실 일은 없어요.",
|
||||
"syncStalled": "{since}부터 HealthLog가 동기화하지 못했어요. 기기가 아니라 서버 쪽 문제예요.",
|
||||
"syncStalledNoSince": "지금 HealthLog가 동기화하지 못하고 있어요. 기기가 아니라 서버 쪽 문제예요.",
|
||||
"noNewData": "{since}부터 새 데이터가 들어오지 않았어요.",
|
||||
"noNewDataNoSince": "한동안 새 데이터가 들어오지 않았어요.",
|
||||
"noNewDataApple": "{since}부터 새 데이터가 들어오지 않았어요. iPhone에서 HealthLog를 한 번 열어 주세요.",
|
||||
"noNewDataAppleNoSince": "한동안 새 데이터가 들어오지 않았어요. iPhone에서 HealthLog를 한 번 열어 주세요.",
|
||||
"unexpectedSilence": "{provider}는 보통 매일 데이터를 보내는데 {since}부터 아무것도 들어오지 않았어요.",
|
||||
"unexpectedSilenceNoSince": "{provider}는 보통 매일 데이터를 보내는데 지금은 아무것도 들어오지 않아요.",
|
||||
"reauthRequired": "{provider} 연결이 만료됐어요. 데이터를 계속 받으려면 다시 연결해 주세요.",
|
||||
"pausedAfterErrors": "{since}부터 오류만 발생해서 {provider}를 일시 중지했어요.",
|
||||
"pausedAfterErrorsNoSince": "최근 오류만 발생해서 {provider}를 일시 중지했어요."
|
||||
},
|
||||
"when": {
|
||||
"today": "오늘 {time}",
|
||||
"yesterday": "어제 {time}",
|
||||
"date": "{date} {time}"
|
||||
},
|
||||
"items": {
|
||||
"activity": "활동",
|
||||
"sleep": "수면"
|
||||
},
|
||||
"moreValuesOne": "그 외 {count}개",
|
||||
"moreValuesFew": "그 외 {count}개",
|
||||
"moreValuesOther": "그 외 {count}개",
|
||||
"notConnected": "연결되지 않음.",
|
||||
"lastData": "마지막 데이터: {when}",
|
||||
"lastDataNone": "마지막 데이터: 아직 없음",
|
||||
"reconnect": "다시 연결",
|
||||
"resume": "재개",
|
||||
"menuLabel": "{provider} 추가 작업",
|
||||
"menu": {
|
||||
"credentials": "인증 정보 변경",
|
||||
"address": "주소와 토큰 변경"
|
||||
},
|
||||
"syncing": "동기화 중…",
|
||||
"details": {
|
||||
"title": "오류 신고용 세부 정보",
|
||||
"none": "없음",
|
||||
"state": "상태",
|
||||
"since": "시작",
|
||||
"lastSync": "마지막 동기화",
|
||||
"lastData": "마지막 데이터",
|
||||
"failingParts": "실패한 부분",
|
||||
"recentCalls": "최근 호출",
|
||||
"unavailable": "사용 불가",
|
||||
"unavailableList": "사용 불가: {list}",
|
||||
"causePlan": "요금제",
|
||||
"causePermission": "권한",
|
||||
"failureStreak": "연속 실패: {threshold}회 중 {count}회",
|
||||
"copyFailed": "복사하지 못했어요. 텍스트를 선택해서 직접 복사해 주세요."
|
||||
}
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
|
||||
+70
-23
@@ -5315,7 +5315,8 @@
|
||||
"unavailableTitle": "Niedostępne w tym udostępnionym rekordzie",
|
||||
"unavailableDescription": "Ten obszar Ustawień jest celowo niedostępny podczas przeglądania rekordu innej osoby.",
|
||||
"integrationStatusDescription": "Stan połączenia dla tego zarządzanego rekordu.",
|
||||
"integrationStatusDetail": "Elementy sterujące połączeniem są niedostępne."
|
||||
"integrationStatusDetail": "Profil zarządzany pokazuje połączone usługi tylko jako stan. Opiekunowie nie mogą łączyć, ponownie łączyć, synchronizować ani rozłączać.",
|
||||
"integrationStatusDocs": "Jak działa udostępnianie"
|
||||
},
|
||||
"sections": {
|
||||
"account": {
|
||||
@@ -6391,7 +6392,6 @@
|
||||
"whoopDisconnectDescription": "Połączenie z WHOOP zostanie zakończone. Wcześniej zsynchronizowane dane zostaną zachowane.",
|
||||
"whoopConnect": "Połącz z WHOOP",
|
||||
"whoopNoCredentials": "Wprowadź powyżej swoje dane API, aby połączyć WHOOP.",
|
||||
"whoopBackfillInProgress": "Importowanie historii WHOOP w tle…",
|
||||
"whoopOauthConnected": "WHOOP połączony. Pierwsza synchronizacja startuje w tle.",
|
||||
"whoopOauthFailed": "Połączenie z WHOOP nie powiodło się",
|
||||
"whoopOauthError": {
|
||||
@@ -6427,7 +6427,6 @@
|
||||
"fitbitDisconnectDescription": "Połączenie z Fitbit zostanie rozłączone. Wcześniej zsynchronizowane dane zostaną zachowane.",
|
||||
"fitbitConnect": "Połącz z Fitbit",
|
||||
"fitbitNoCredentials": "Wprowadź powyżej swoje dane uwierzytelniające API, aby połączyć Fitbit.",
|
||||
"fitbitBackfillInProgress": "Importowanie historii Fitbit w tle…",
|
||||
"fitbitOauthConnected": "Fitbit połączony. Pierwsza synchronizacja startuje w tle.",
|
||||
"fitbitOauthFailed": "Połączenie z Fitbit nie powiodło się",
|
||||
"googleHealth": "Google Health",
|
||||
@@ -6472,9 +6471,6 @@
|
||||
"googleHealthDisconnectDescription": "Połączenie z Google Health zostanie rozłączone. Wcześniej zsynchronizowane dane zostaną zachowane.",
|
||||
"googleHealthConnect": "Połącz z Google",
|
||||
"googleHealthNoCredentials": "Wprowadź powyżej swoje dane API, aby połączyć Google Health.",
|
||||
"googleHealthBackfillInProgress": "Importowanie historii Google Health w tle…",
|
||||
"googleHealthReauthBanner": "Google unieważnił połączenie. Dzieje się to okresowo, dopóki Twoja aplikacja OAuth pozostaje w trybie testowym. Połącz ponownie, aby wznowić synchronizację.",
|
||||
"googleHealthReconnect": "Połącz ponownie",
|
||||
"googleHealthOauthConnected": "Google Health połączony. Pierwsza synchronizacja startuje w tle.",
|
||||
"googleHealthOauthFailed": "Połączenie z Google Health nie powiodło się",
|
||||
"googleHealthOauthError": {
|
||||
@@ -6499,16 +6495,7 @@
|
||||
},
|
||||
"integrations": {
|
||||
"withings": {
|
||||
"reconnect": {
|
||||
"banner": {
|
||||
"title": "Dostępna synchronizacja aktywności",
|
||||
"body": "Twoje połączenie z Withings zostało autoryzowane przed dodaniem synchronizacji aktywności. Połącz ponownie jeden raz, aby włączyć import kroków, energii aktywnej, dystansu marszu i biegu oraz pokonanych pięter z urządzeń Withings.",
|
||||
"action": "Połącz ponownie Withings"
|
||||
}
|
||||
},
|
||||
"plan": {
|
||||
"title": "Niektóre pomiary wymagają płatnego planu API Withings",
|
||||
"body": "Withings udostępnia teraz te dane tylko w płatnym planie API: {list}. Twoja aplikacja deweloperska Withings korzysta z bezpłatnego planu. Waga, ciśnienie krwi, skład ciała, tętno i sen są nadal synchronizowane.",
|
||||
"link": "Więcej o planach API Withings",
|
||||
"items": {
|
||||
"spo2": "SpO2",
|
||||
@@ -6538,10 +6525,7 @@
|
||||
},
|
||||
"oura": {
|
||||
"scope": {
|
||||
"body": "Połącz ponownie Oura jeden raz, aby zezwolić na te dane: {list}. Wszystko inne jest nadal synchronizowane.",
|
||||
"action": "Połącz ponownie Oura",
|
||||
"items": {
|
||||
"spo2": "SpO2",
|
||||
"vo2max": "VO2 max",
|
||||
"cardio_age": "wiek sercowo-naczyniowy",
|
||||
"resilience": "odporność"
|
||||
@@ -6560,7 +6544,6 @@
|
||||
"minutesAgo": "{count} min temu",
|
||||
"hoursAgo": "{count} godz. temu",
|
||||
"daysAgo": "{count} dni temu",
|
||||
"resumeCta": "Połącz ponownie",
|
||||
"resumeSuccess": "Integracja wznowiona",
|
||||
"resumeError": "Ponowne połączenie nieudane",
|
||||
"stale": "Połączono, brak nowych danych",
|
||||
@@ -6574,9 +6557,7 @@
|
||||
"workouts": "Treningi"
|
||||
},
|
||||
"integrationFallback": {
|
||||
"description": "Skonfigurowane na tym koncie, ale bez własnej karty ustawień.",
|
||||
"lastAttempt": "Ostatnia próba {relative}",
|
||||
"noAttempt": "Brak zapisanej próby synchronizacji"
|
||||
"description": "Skonfigurowane na tym koncie, ale bez własnej karty ustawień."
|
||||
},
|
||||
"withingsViewData": "Zobacz swoje pomiary",
|
||||
"whoopViewData": "Zobacz swoją regenerację",
|
||||
@@ -7124,7 +7105,6 @@
|
||||
"backgroundStep": "Pozostaw włączone odświeżanie aplikacji w tle. Aplikacja synchronizuje nowe dane automatycznie na pierwszym planie oraz gdy iOS przydzieli jej czas w tle.",
|
||||
"importNote": "Potrzebujesz starszej historii lub nie możesz użyć aplikacji iOS? Przesłanie pliku XML/ZIP z Apple Health jest jednorazową alternatywą i nie włącza synchronizacji na żywo.",
|
||||
"importAction": "Otwórz jednorazowy import",
|
||||
"staleNote": "Od ponad tygodnia nie napłynęły żadne dane z Apple Health. Otwórz aplikację HealthLog na iPhonie i sprawdź uprawnienia Apple Health.",
|
||||
"progress": {
|
||||
"title": "Postęp synchronizacji",
|
||||
"receivedLabel": "Odebrane rekordy",
|
||||
@@ -7217,6 +7197,73 @@
|
||||
"displayCard": {
|
||||
"title": "Wyświetlanie",
|
||||
"description": "Język, jednostki i formaty zapisywane od razu po zmianie."
|
||||
},
|
||||
"integrationCard": {
|
||||
"state": {
|
||||
"ok": "Działa",
|
||||
"limited": "Ograniczone",
|
||||
"degraded": "Opóźnione",
|
||||
"actionRequired": "Wymaga działania"
|
||||
},
|
||||
"reason": {
|
||||
"dataArriving": "Dane napływają.",
|
||||
"dataArrivingHistory": "Dane napływają. Starsza historia wczytuje się w tle.",
|
||||
"firstSyncPending": "Połączono. Pierwsza synchronizacja ruszy za chwilę.",
|
||||
"planLimited": "Dane napływają. Twój plan {provider} nie obejmuje tych danych: {list}.",
|
||||
"permissionLimited": "Dane napływają. Brakuje uprawnień do tych danych: {list}.",
|
||||
"providerErrors": "{provider} od {since} nie odpowiada niezawodnie. HealthLog próbuje dalej, nie musisz nic robić.",
|
||||
"providerErrorsNoSince": "{provider} nie odpowiada teraz niezawodnie. HealthLog próbuje dalej, nie musisz nic robić.",
|
||||
"syncStalled": "HealthLog nie synchronizuje od {since}. To problem po stronie serwera, nie twojego urządzenia.",
|
||||
"syncStalledNoSince": "HealthLog teraz nie synchronizuje. To problem po stronie serwera, nie twojego urządzenia.",
|
||||
"noNewData": "Od {since} nie napłynęły nowe dane.",
|
||||
"noNewDataNoSince": "Od dłuższego czasu nie napłynęły nowe dane.",
|
||||
"noNewDataApple": "Od {since} nie napłynęły nowe dane. Otwórz raz HealthLog na iPhonie.",
|
||||
"noNewDataAppleNoSince": "Od dłuższego czasu nie napłynęły nowe dane. Otwórz raz HealthLog na iPhonie.",
|
||||
"unexpectedSilence": "Od {since} nic nie napływa, chociaż {provider} zwykle dostarcza dane codziennie.",
|
||||
"unexpectedSilenceNoSince": "Nic nie napływa, chociaż {provider} zwykle dostarcza dane codziennie.",
|
||||
"reauthRequired": "Połączenie z {provider} wygasło. Połącz ponownie, aby dane znów napływały.",
|
||||
"pausedAfterErrors": "{provider} jest wstrzymany, bo od {since} występowały same błędy.",
|
||||
"pausedAfterErrorsNoSince": "{provider} jest wstrzymany, bo ostatnio występowały same błędy."
|
||||
},
|
||||
"when": {
|
||||
"today": "dziś, {time}",
|
||||
"yesterday": "wczoraj, {time}",
|
||||
"date": "{date}, {time}"
|
||||
},
|
||||
"items": {
|
||||
"activity": "aktywność",
|
||||
"sleep": "sen"
|
||||
},
|
||||
"moreValuesOne": "{count} inna wartość",
|
||||
"moreValuesFew": "{count} inne wartości",
|
||||
"moreValuesOther": "{count} innych wartości",
|
||||
"notConnected": "Nie połączono.",
|
||||
"lastData": "Ostatnie dane: {when}",
|
||||
"lastDataNone": "Ostatnie dane: jeszcze brak",
|
||||
"reconnect": "Połącz ponownie",
|
||||
"resume": "Wznów",
|
||||
"menuLabel": "Więcej działań dla {provider}",
|
||||
"menu": {
|
||||
"credentials": "Zmień dane dostępowe",
|
||||
"address": "Zmień adres i token"
|
||||
},
|
||||
"syncing": "Trwa synchronizacja…",
|
||||
"details": {
|
||||
"title": "Szczegóły do zgłoszenia błędu",
|
||||
"none": "brak",
|
||||
"state": "Stan",
|
||||
"since": "od",
|
||||
"lastSync": "Ostatnia synchronizacja",
|
||||
"lastData": "Ostatnie dane",
|
||||
"failingParts": "części z błędami",
|
||||
"recentCalls": "Ostatnie wywołania",
|
||||
"unavailable": "Niedostępne",
|
||||
"unavailableList": "Niedostępne: {list}",
|
||||
"causePlan": "plan",
|
||||
"causePermission": "uprawnienie",
|
||||
"failureStreak": "Seria błędów: {count} z {threshold}",
|
||||
"copyFailed": "Nie udało się skopiować. Zaznacz tekst i skopiuj go ręcznie."
|
||||
}
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -96,12 +96,13 @@ beforeEach(async () => {
|
||||
|
||||
describe("integration watchdog", () => {
|
||||
it("flags a normally-daily wearable gone silent, alerts once, then sends one recovery note", async () => {
|
||||
// Fourteen consecutive days of data before the window, newest 30 h ago.
|
||||
// Thirteen consecutive days of data before the window, newest 54 h ago:
|
||||
// past Oura's 48 h window (it uploads only when its phone app is opened).
|
||||
await seedDailyReadings(
|
||||
USER,
|
||||
"OURA",
|
||||
Array.from({ length: 13 }, (_, i) => i + 2),
|
||||
30,
|
||||
Array.from({ length: 13 }, (_, i) => i + 3),
|
||||
54,
|
||||
);
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(NOW);
|
||||
@@ -170,7 +171,7 @@ describe("integration watchdog", () => {
|
||||
});
|
||||
|
||||
it("leaves a wearable alone when its owner wears it only now and then", async () => {
|
||||
await seedDailyReadings(USER, "OURA", [3, 7, 11], 30);
|
||||
await seedDailyReadings(USER, "OURA", [3, 7, 11], 54);
|
||||
await recordSyncSuccess(USER, "oura");
|
||||
const summary = await runIntegrationWatchdog({ mode: "hourly", now: NOW });
|
||||
expect(summary.silencesFlagged).toBe(0);
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* A guardian reads the managed record's connections through the same
|
||||
* server-resolved `health` the record's owner would see, and only the
|
||||
* record's: a connection on the guardian's own account must not appear on
|
||||
* the record, and the record's connection must answer from the record's rows.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables, switchSessionTo } from "./setup";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => {
|
||||
cookieJar.set(name, value);
|
||||
},
|
||||
delete: (name: string) => {
|
||||
cookieJar.delete(name);
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
interface ManagedIntegration {
|
||||
integration: string;
|
||||
state: string;
|
||||
health: { state: string; reason: string } | null;
|
||||
}
|
||||
|
||||
async function readIntegrations(): Promise<{
|
||||
recordId: string;
|
||||
integrations: ManagedIntegration[];
|
||||
}> {
|
||||
const { GET } = await import("@/app/api/record-settings/integrations/route");
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(200);
|
||||
return (await res.json()).data;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
});
|
||||
|
||||
describe("GET /api/record-settings/integrations", () => {
|
||||
it("publishes the record's own health per provider, never the guardian's", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const guardian = await prisma.user.create({
|
||||
data: {
|
||||
username: "health-guardian",
|
||||
email: "health-guardian@example.test",
|
||||
role: "USER",
|
||||
timezone: "Europe/Berlin",
|
||||
locale: "en",
|
||||
onboardingCompletedAt: new Date(),
|
||||
// The guardian's own Polar: must not surface on the record.
|
||||
polarAccessTokenEncrypted: "not-a-real-token",
|
||||
},
|
||||
});
|
||||
const { createManagedProfile } =
|
||||
await import("@/lib/managed-profiles/create");
|
||||
const { profile } = await createManagedProfile({
|
||||
creatorId: guardian.id,
|
||||
displayName: "Managed record",
|
||||
dateOfBirth: null,
|
||||
locale: "en",
|
||||
timezone: "UTC",
|
||||
gender: null,
|
||||
});
|
||||
// The record's own Oura, synced once.
|
||||
await prisma.user.update({
|
||||
where: { id: profile.id },
|
||||
data: { ouraAccessTokenEncrypted: "not-a-real-token" },
|
||||
});
|
||||
const { recordSyncSuccess } = await import("@/lib/integrations/status");
|
||||
await recordSyncSuccess(profile.id, "oura");
|
||||
|
||||
const session = await prisma.session.create({
|
||||
data: {
|
||||
userId: guardian.id,
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
},
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
await switchSessionTo(session.id, profile.id);
|
||||
|
||||
const data = await readIntegrations();
|
||||
expect(data.recordId).toBe(profile.id);
|
||||
expect(data.integrations).toHaveLength(8);
|
||||
|
||||
const byKey = new Map(data.integrations.map((i) => [i.integration, i]));
|
||||
const oura = byKey.get("oura");
|
||||
expect(oura?.health).not.toBeNull();
|
||||
expect(oura?.health?.state).toBe("ok");
|
||||
// Health and the legacy state agree on "a pipe exists".
|
||||
expect(oura?.state).toBe("connected");
|
||||
|
||||
// The guardian's Polar is the guardian's, not the record's.
|
||||
expect(byKey.get("polar")?.health).toBeNull();
|
||||
for (const key of [
|
||||
"withings",
|
||||
"whoop",
|
||||
"fitbit",
|
||||
"nightscout",
|
||||
"google-health",
|
||||
"strava",
|
||||
]) {
|
||||
expect(byKey.get(key)?.health, key).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user