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 =