From 3bae93256d78f9be4ec3bc827ac167b05d35b4d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 20 Oct 2026 13:53:51 +0200 Subject: [PATCH] chore(settings): retire the unused IntegrationStatusPill Three status pills paint a connection state: HealthBadge on the shared integration card, the notification channel badge, and IntegrationStatusPill. The first two describe different state models and stay. The third had no production caller since the integration cards moved to HealthBadge; it goes with its test and the nine strings only it used. formatRelative, which two cards still import, moves to its own module. --- messages/de.json | 11 +- messages/en.json | 11 +- messages/es.json | 11 +- messages/fr.json | 11 +- messages/it.json | 11 +- messages/ko.json | 11 +- messages/nl.json | 11 +- messages/pl.json | 11 +- .../success-affordance-guard.test.ts | 4 - .../integration-status-pill.test.tsx | 286 ------------------ src/components/settings/_card-header.tsx | 3 +- .../settings/integration-status-pill.tsx | 242 --------------- .../integrations/apple-health-card.tsx | 2 +- .../settings/integrations/format-relative.ts | 21 ++ .../integrations/metric-freshness-list.tsx | 2 +- 15 files changed, 32 insertions(+), 616 deletions(-) delete mode 100644 src/components/settings/__tests__/integration-status-pill.test.tsx delete mode 100644 src/components/settings/integration-status-pill.tsx create mode 100644 src/components/settings/integrations/format-relative.ts diff --git a/messages/de.json b/messages/de.json index a583ed16d..bc7ac86ec 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6674,21 +6674,12 @@ } }, "integrationPill": { - "connected": "Verbunden", - "errorReconnect": "Fehler: neu verbinden", - "warningServerError": "Sync fehlgeschlagen", - "failureCount": "{count}/{threshold}", - "parkedReconnect": "Pausiert: neu verbinden", - "notConnected": "Nicht verbunden", "justNow": "gerade eben", "minutesAgo": "vor {count} min", "hoursAgo": "vor {count} Std.", "daysAgo": "vor {count} d", "resumeSuccess": "Integration wieder aktiv", - "resumeError": "Wieder verbinden fehlgeschlagen", - "stale": "Verbunden, keine neuen Daten", - "stalled": "Sync gestoppt", - "pendingSetup": "Warte auf erste Daten" + "resumeError": "Wieder verbinden fehlgeschlagen" }, "integrationFreshness": { "title": "Empfangene Daten", diff --git a/messages/en.json b/messages/en.json index 35ae9c711..780787051 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6674,21 +6674,12 @@ } }, "integrationPill": { - "connected": "Connected", - "errorReconnect": "Error: reconnect", - "warningServerError": "Sync failing", - "failureCount": "{count}/{threshold}", - "parkedReconnect": "Paused: reconnect", - "notConnected": "Not connected", "justNow": "just now", "minutesAgo": "{count} min ago", "hoursAgo": "{count} h ago", "daysAgo": "{count} d ago", "resumeSuccess": "Integration resumed", - "resumeError": "Reconnect failed", - "stale": "Connected, no recent data", - "stalled": "Sync stopped", - "pendingSetup": "Waiting for first data" + "resumeError": "Reconnect failed" }, "integrationFreshness": { "title": "Data received", diff --git a/messages/es.json b/messages/es.json index 2cfd320aa..8782a47a7 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6674,21 +6674,12 @@ } }, "integrationPill": { - "connected": "Conectado", - "errorReconnect": "Error: reconectar", - "warningServerError": "Sincronización con errores", - "failureCount": "{count}/{threshold}", - "parkedReconnect": "Pausado: reconectar", - "notConnected": "No conectado", "justNow": "ahora mismo", "minutesAgo": "hace {count} min", "hoursAgo": "hace {count} h", "daysAgo": "hace {count} d", "resumeSuccess": "Integración reanudada", - "resumeError": "Reconexión fallida", - "stale": "Conectado, sin datos recientes", - "stalled": "Sincronización detenida", - "pendingSetup": "Esperando los primeros datos" + "resumeError": "Reconexión fallida" }, "integrationFreshness": { "title": "Datos recibidos", diff --git a/messages/fr.json b/messages/fr.json index 1370a2b4e..f2db6c1a6 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6674,21 +6674,12 @@ } }, "integrationPill": { - "connected": "Connecté", - "errorReconnect": "Erreur : reconnecter", - "warningServerError": "Échec de synchronisation", - "failureCount": "{count}/{threshold}", - "parkedReconnect": "En pause : reconnecter", - "notConnected": "Non connecté", "justNow": "à l’instant", "minutesAgo": "il y a {count} min", "hoursAgo": "il y a {count} h", "daysAgo": "il y a {count} j", "resumeSuccess": "Intégration reprise", - "resumeError": "Échec de la reconnexion", - "stale": "Connecté, aucune donnée récente", - "stalled": "Synchronisation arrêtée", - "pendingSetup": "En attente des premières données" + "resumeError": "Échec de la reconnexion" }, "integrationFreshness": { "title": "Données reçues", diff --git a/messages/it.json b/messages/it.json index 6d1583fda..ea19c378e 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6674,21 +6674,12 @@ } }, "integrationPill": { - "connected": "Connesso", - "errorReconnect": "Errore, riconnetti", - "warningServerError": "Sincronizzazione in errore", - "failureCount": "{count}/{threshold}", - "parkedReconnect": "In pausa, riconnetti", - "notConnected": "Non connesso", "justNow": "proprio ora", "minutesAgo": "{count} min fa", "hoursAgo": "{count} h fa", "daysAgo": "{count} g fa", "resumeSuccess": "Integrazione ripresa", - "resumeError": "Riconnessione fallita", - "stale": "Connesso, nessun dato recente", - "stalled": "Sincronizzazione ferma", - "pendingSetup": "In attesa dei primi dati" + "resumeError": "Riconnessione fallita" }, "integrationFreshness": { "title": "Dati ricevuti", diff --git a/messages/ko.json b/messages/ko.json index c98d10cf8..3a753a8f9 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -6674,21 +6674,12 @@ } }, "integrationPill": { - "connected": "연결됨", - "errorReconnect": "오류: 다시 연결", - "warningServerError": "동기화 실패 중", - "failureCount": "{count}/{threshold}", - "parkedReconnect": "일시중지: 다시 연결", - "notConnected": "연결 안 됨", "justNow": "방금", "minutesAgo": "{count}분 전", "hoursAgo": "{count}시간 전", "daysAgo": "{count}일 전", "resumeSuccess": "연동을 다시 시작했어요", - "resumeError": "다시 연결하지 못했어요", - "stale": "연결됨, 최근 데이터 없음", - "stalled": "동기화 멈춤", - "pendingSetup": "첫 데이터 기다리는 중" + "resumeError": "다시 연결하지 못했어요" }, "integrationFreshness": { "title": "받은 데이터", diff --git a/messages/nl.json b/messages/nl.json index c51305972..3b80f1854 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -6674,21 +6674,12 @@ } }, "integrationPill": { - "connected": "Gekoppeld", - "errorReconnect": "Fout: opnieuw koppelen", - "warningServerError": "Synchronisatie mislukt", - "failureCount": "{count}/{threshold}", - "parkedReconnect": "Gepauzeerd: opnieuw koppelen", - "notConnected": "Niet gekoppeld", "justNow": "zojuist", "minutesAgo": "{count} min geleden", "hoursAgo": "{count} u geleden", "daysAgo": "{count} d geleden", "resumeSuccess": "Koppeling hervat", - "resumeError": "Opnieuw koppelen mislukt", - "stale": "Gekoppeld, geen recente gegevens", - "stalled": "Synchronisatie gestopt", - "pendingSetup": "Wachten op eerste gegevens" + "resumeError": "Opnieuw koppelen mislukt" }, "integrationFreshness": { "title": "Ontvangen gegevens", diff --git a/messages/pl.json b/messages/pl.json index 06a06f600..78b02dc65 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6674,21 +6674,12 @@ } }, "integrationPill": { - "connected": "Połączono", - "errorReconnect": "Błąd: połącz ponownie", - "warningServerError": "Synchronizacja nie działa", - "failureCount": "{count}/{threshold}", - "parkedReconnect": "Wstrzymane: połącz ponownie", - "notConnected": "Nie połączono", "justNow": "przed chwilą", "minutesAgo": "{count} min temu", "hoursAgo": "{count} godz. temu", "daysAgo": "{count} dni temu", "resumeSuccess": "Integracja wznowiona", - "resumeError": "Ponowne połączenie nieudane", - "stale": "Połączono, brak nowych danych", - "stalled": "Synchronizacja zatrzymana", - "pendingSetup": "Czekam na pierwsze dane" + "resumeError": "Ponowne połączenie nieudane" }, "integrationFreshness": { "title": "Odebrane dane", diff --git a/src/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts index 0677418be..a41563ed7 100644 --- a/src/__tests__/success-affordance-guard.test.ts +++ b/src/__tests__/success-affordance-guard.test.ts @@ -353,10 +353,6 @@ const PINNED_AFFORDANCES: Record< "src/components/settings/insights-pill-order-section.tsx": { "toast.success": 1, }, - "src/components/settings/integration-status-pill.tsx": { - CheckCircle2: 2, - "text-success": 1, - }, "src/components/settings/integrations/connections-panel.tsx": { "toast.success": 2, }, diff --git a/src/components/settings/__tests__/integration-status-pill.test.tsx b/src/components/settings/__tests__/integration-status-pill.test.tsx deleted file mode 100644 index d1663c12d..000000000 --- a/src/components/settings/__tests__/integration-status-pill.test.tsx +++ /dev/null @@ -1,286 +0,0 @@ -/** - * v1.4.19 — IntegrationStatusPill - * - * The single tag rendered top-right of every integration card. The - * pill is the ONLY place a connection status surfaces — the maintainer was - * staring at three- to four-fold redundancy across Withings + Mood Log - * cards in v1.4.18 and called it out. This component is reusable so - * v1.4.20 can drop the same pill on the Apple Health card. - * - * The four states this file locks in (one assertion each): - * 1. connected → "Connected · 12 min ago" pattern - * 2. error → "Error — reconnect" pattern - * 3. error (without ts) → bare error label, no "ago" suffix - * 4. disconnected → "Not connected" pattern (no relative ts) - * - * Plus locale parity: switching to `de` renders the German strings. - * Plus a "no last-sync timestamp" guard for connected state. - */ -import { describe, expect, it } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; - -import { I18nProvider } from "@/lib/i18n/context"; -import { IntegrationStatusPill } from "../integration-status-pill"; -import { primeLocales } from "@/__tests__/helpers/prime-locales"; - -primeLocales("de"); - -function render(node: React.ReactNode, locale: "en" | "de" = "en") { - return renderToStaticMarkup( - {node}, - ); -} - -describe("IntegrationStatusPill", () => { - it("renders the connected state with relative time when lastSyncAt is recent", () => { - const twelveMinAgo = new Date(Date.now() - 12 * 60 * 1000); - const html = render( - , - ); - expect(html).toContain("Connected"); - // 12 min in EN locale - expect(html).toMatch(/12\s+min(\.|utes)?\s+ago/); - // Renders within a single pill marker so cards can target it. - expect(html).toContain('data-testid="integration-status-pill"'); - expect(html).toContain('data-state="connected"'); - }); - - it("renders the connected state without relative time when lastSyncAt is null", () => { - const html = render( - , - ); - expect(html).toContain("Connected"); - // Must not render the relative-time separator " · " when the - // timestamp is missing, otherwise the pill reads "Connected · ". - expect(html).not.toMatch(/Connected.*\s·\s\s/); - }); - - it("renders the error state with the reconnect-cta phrasing", () => { - const html = render( - , - ); - expect(html).toContain("Error"); - expect(html).toContain("reconnect"); - expect(html).toContain('data-state="error"'); - }); - - it("renders the disconnected state with the 'not connected' label", () => { - const html = render( - , - ); - expect(html).toContain("Not connected"); - expect(html).toContain('data-state="disconnected"'); - }); - - it("renders German strings when the active locale is 'de'", () => { - const html = render( - , - "de", - ); - expect(html).toContain("Nicht verbunden"); - }); - - it("uses an abbreviated relative-time form for very recent syncs (< 1 min)", () => { - const fortySecondsAgo = new Date(Date.now() - 40 * 1000); - const html = render( - , - ); - // Sub-minute syncs collapse to "just now" so the pill stays - // narrow on Pixel 5. - expect(html).toMatch(/just\s+now/i); - }); - - // v1.4.43 — parked-state copy. - // Distinct from `error` (red reconnect pill) because the user can - // resume the integration without redoing the OAuth dance; distinct - // from `warning` because the persistent streak survived the alert - // ladder AND the 24h grace window — manual intervention required. - it("renders the parked state with manual-reconnect phrasing (EN)", () => { - const html = render( - , - ); - expect(html).toContain("Paused"); - expect(html).toContain("Paused: reconnect"); - expect(html).toContain('data-state="parked"'); - }); - - it("renders the parked state with manual-reconnect phrasing (DE)", () => { - const html = render( - , - "de", - ); - expect(html).toContain("Pausiert"); - expect(html).toContain("Pausiert: neu verbinden"); - }); - - // The three states the server verdict added. `stale` and `stalled` both - // carry an inline timestamp, because "since when" is the whole point of - // them; `pending-setup` has nothing to timestamp yet. - it("renders the stale state in the warning tone with its timestamp", () => { - const html = render( - , - ); - expect(html).toContain('data-state="stale"'); - expect(html).toContain("no recent data"); - expect(html).toMatch(/9\s+d\s+ago/); - expect(html).toContain("bg-warning/15"); - }); - - it("renders the stalled state as a stopped sync, not an error", () => { - const html = render( - , - ); - expect(html).toContain('data-state="stalled"'); - expect(html).toContain("Sync stopped"); - expect(html).toMatch(/28\s+d\s+ago/); - // Warning tone, never destructive: the user cannot fix a cron that - // stopped by clicking reconnect. - expect(html).toContain("bg-warning/15"); - expect(html).not.toContain('data-variant="destructive"'); - }); - - it("renders the pending-setup state with no timestamp", () => { - const html = render( - , - ); - expect(html).toContain('data-state="pending-setup"'); - expect(html).toContain("Waiting for first data"); - expect(html).not.toContain("bg-warning/15"); - }); - - /** - * Red is reserved for "your action fixes this". A transient upstream failure - * is not that: telling the user to reconnect against a 503 sends them - * clicking at something they cannot repair. - */ - it("keeps the destructive variant for reauth alone", () => { - expect( - render(), - ).toContain('data-variant="destructive"'); - for (const state of [ - "warning", - "stale", - "stalled", - "parked", - "pending-setup", - "connected", - ] as const) { - expect( - render(), - state, - ).not.toContain('data-variant="destructive"'); - } - }); - - it("lets a card keep its own established testid", () => { - const html = render( - , - ); - expect(html).toContain('data-testid="apple-health-status"'); - }); -}); - -describe("IntegrationStatusPill — a failing provider states its age", () => { - it("renders the failing state with how long it has been failing", () => { - const seventeenDaysAgo = new Date(Date.now() - 17 * 24 * 60 * 60 * 1000); - const html = render( - , - ); - // The label must not open with a claim of connection: the pipe has - // delivered nothing for a fortnight. - expect(html).not.toContain("Connected"); - expect(html).toContain("Sync failing"); - // And the age has to be on the pill. Without it, hour one and day - // seventeen render identically, which is how a dead sync stayed quiet. - expect(html).toMatch(/17\s*d\s+ago/); - expect(html).toContain('data-state="warning"'); - }); - - it("renders the localized failure count against the server threshold", () => { - const html = render( - , - ); - - expect(html).toContain("2/3"); - }); - - it("omits a partial failure ratio", () => { - const html = render( - , - ); - - expect(html).not.toContain("2/"); - }); - - it("keeps the German label free of a connection claim too", () => { - const html = render( - , - "de", - ); - expect(html).not.toContain("Verbunden"); - expect(html).toContain("Sync fehlgeschlagen"); - }); -}); - -describe("IntegrationStatusPill — accessible name", () => { - /** - * A generic `aria-label` used to sit on the badge. Being a label, it - * REPLACED the accessible name with itself, so a screen reader announced the - * same words for a healthy connection as for a broken one — erasing the one - * distinction the pill exists to draw. - */ - it.each([ - ["connected", "Connected"], - ["error", "Error"], - ["parked", "Paused"], - ] as const)("lets the %s label be the accessible name", (state, label) => { - const html = render( - , - ); - expect(html).not.toContain("aria-label="); - expect(html).toContain(label); - }); -}); diff --git a/src/components/settings/_card-header.tsx b/src/components/settings/_card-header.tsx index 16f53a465..d0382bd69 100644 --- a/src/components/settings/_card-header.tsx +++ b/src/components/settings/_card-header.tsx @@ -61,8 +61,7 @@ export interface SettingsCardHeaderProps { * anchors it relies on are pinned in `src/lib/command-palette/ * settings-cards.ts`. */ anchor?: string; - /** Optional right-aligned status surface — typically an - * `` or a wrapper around badges. */ + /** Optional right-aligned status surface — typically a badge. */ status?: React.ReactNode; /** Optional extra classes applied to the outer wrapper. */ className?: string; diff --git a/src/components/settings/integration-status-pill.tsx b/src/components/settings/integration-status-pill.tsx deleted file mode 100644 index 50e87119e..000000000 --- a/src/components/settings/integration-status-pill.tsx +++ /dev/null @@ -1,242 +0,0 @@ -"use client"; - -import { - CheckCircle2, - AlertTriangle, - CircleSlash, - Clock, - Info, - PauseCircle, -} from "lucide-react"; - -import { Badge } from "@/components/ui/badge"; -import { useTranslations } from "@/lib/i18n/context"; -import { cn } from "@/lib/utils"; - -/** - * v1.4.19 — single status pill rendered top-right of every - * integration card (Withings, Mood Log, Apple Health…). It replaces - * the three- to four-fold redundant status surface the maintainer spotted in - * v1.4.18: - * - * - top-right "Connected" badge - * - middle container with "Connected / last successful / last attempt" - * - bottom-of-card "letzter Sync" line - * - * Now there is exactly ONE place a sync status can appear on a card - * and it follows the pattern the maintainer liked from the Withings header: a - * Dracula-tokenized chip with the relative time inline. - * - * The component is locale-aware (EN + DE) and mobile-safe: on a - * Pixel-5 viewport (393 CSS px) the pill stays on the same line as - * the card title because it whitespace-nowraps and tucks the icon + - * abbreviated time form into a 12 char string at most. - */ - -/** - * Pill states — a thin projection of the server's sync-health verdict. - * - * - connected → happy path; relative "last sync" suffix is shown. - * - stale → connected, but no new data for a while; suffix shown. - * - stalled → the sync stopped even attempting; suffix shown. - * - warning → attempting and failing (a 5xx, a rate limit, an upstream - * contract mismatch). Not the user's to fix. - * - error → reauth-required; "Error — reconnect" copy. - * - parked → >24h of persistent failures put the integration to - * sleep; "Paused — reconnect manually" copy + resume CTA. - * - pending-setup → configured but nothing has arrived yet. - * - disconnected → user clicked Disconnect; "Not connected" copy. - * - * **Red is reserved for "your action fixes this".** A transient upstream - * failure carries the warning tone, not the destructive one: telling a user to - * reconnect cannot help against a 503, and ten fruitless reconnect clicks are - * the cost of saying otherwise. - */ -export type IntegrationPillState = - | "connected" - | "stale" - | "stalled" - | "warning" - | "error" - | "parked" - | "pending-setup" - | "disconnected"; - -interface IntegrationStatusPillProps { - state: IntegrationPillState; - /** When connected, the last-successful-sync timestamp drives the - * relative "12 min ago" suffix. Pass `null` to suppress the - * suffix (used when the integration was just configured but has - * never synced yet). */ - lastSyncAt: Date | string | null; - /** Current consecutive-failure count from the active server bucket. */ - failureCount?: number; - /** Server-owned alert threshold paired with `failureCount`. */ - failureThreshold?: number; - /** Override "now" for deterministic testing. Defaults to `new Date()`. */ - now?: Date; - /** Override the testid so a card can keep its own established e2e hook. */ - testId?: string; - className?: string; -} - -function toDate(value: Date | string): Date { - return value instanceof Date ? value : new Date(value); -} - -/** - * Translate a millisecond delta into a short, locale-aware relative - * string. We keep the buckets small and fixed (just-now / minutes / - * hours / days) because the pill must fit on a 360 px wide card. - */ -export function formatRelative( - deltaMs: number, - t: (key: string, params?: Record) => string, -): string { - if (deltaMs < 60_000) return t("settings.integrationPill.justNow"); - const minutes = Math.floor(deltaMs / 60_000); - if (minutes < 60) { - return t("settings.integrationPill.minutesAgo", { count: minutes }); - } - const hours = Math.floor(minutes / 60); - if (hours < 24) { - return t("settings.integrationPill.hoursAgo", { count: hours }); - } - const days = Math.floor(hours / 24); - return t("settings.integrationPill.daysAgo", { count: days }); -} - -/** The states that paint the semantic warning chip rather than a variant. */ -const WARNING_STATES = new Set([ - "stale", - "stalled", - "warning", - "parked", -]); - -/** - * The states whose label carries an inline relative timestamp. - * - * `warning` is in the set because leaving it out was the whole lie: the pill - * said "sync issue" identically on hour one and on day seventeen, and the age - * — which the mapper computes and passes in — was dropped on the floor. How - * long it has been broken is the only part of that sentence a person can act - * on. - */ -const RELATIVE_STATES = new Set([ - "connected", - "stale", - "stalled", - "warning", -]); - -export function IntegrationStatusPill({ - state, - lastSyncAt, - failureCount, - failureThreshold, - now, - testId, - className, -}: IntegrationStatusPillProps) { - const { t } = useTranslations(); - - // The chip classes apply to `connected` and to every warning-tone state — - // `error` falls back to `variant="destructive"`, `disconnected` and - // `pending-setup` to `variant="outline"`. The warning tone is an AA-safe - // amber that clears contrast in light mode; the distinct labels + icons - // carry which flavour of "not right" this is. - const connectedChipClass = "border-success/30 bg-success/15 text-success"; - const warningChipClass = "border-warning/30 bg-warning/15 text-warning"; - - let label: string; - let icon: React.ReactNode; - - switch (state) { - case "connected": - label = t("settings.integrationPill.connected"); - icon =