mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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.
This commit is contained in:
1 parent
cc190c4710
commit
3bae93256d
15 files changed
+32
-616
No files matched your search
+1
-10
@@ -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",
|
||||
|
||||
+1
-10
@@ -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",
|
||||
|
||||
+1
-10
@@ -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",
|
||||
|
||||
+1
-10
@@ -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",
|
||||
|
||||
+1
-10
@@ -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",
|
||||
|
||||
+1
-10
@@ -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": "받은 데이터",
|
||||
|
||||
+1
-10
@@ -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",
|
||||
|
||||
+1
-10
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
<IntegrationStatusPill
|
||||
state="connected"
|
||||
lastSyncAt={twelveMinAgo}
|
||||
now={new Date()}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<IntegrationStatusPill state="connected" lastSyncAt={null} />,
|
||||
);
|
||||
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(
|
||||
<IntegrationStatusPill state="error" lastSyncAt={null} />,
|
||||
);
|
||||
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(
|
||||
<IntegrationStatusPill state="disconnected" lastSyncAt={null} />,
|
||||
);
|
||||
expect(html).toContain("Not connected");
|
||||
expect(html).toContain('data-state="disconnected"');
|
||||
});
|
||||
|
||||
it("renders German strings when the active locale is 'de'", () => {
|
||||
const html = render(
|
||||
<IntegrationStatusPill state="disconnected" lastSyncAt={null} />,
|
||||
"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(
|
||||
<IntegrationStatusPill
|
||||
state="connected"
|
||||
lastSyncAt={fortySecondsAgo}
|
||||
now={new Date()}
|
||||
/>,
|
||||
);
|
||||
// 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(
|
||||
<IntegrationStatusPill state="parked" lastSyncAt={null} />,
|
||||
);
|
||||
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(
|
||||
<IntegrationStatusPill state="parked" lastSyncAt={null} />,
|
||||
"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(
|
||||
<IntegrationStatusPill
|
||||
state="stale"
|
||||
lastSyncAt={new Date(Date.now() - 9 * 24 * 60 * 60 * 1000)}
|
||||
now={new Date()}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<IntegrationStatusPill
|
||||
state="stalled"
|
||||
lastSyncAt={new Date(Date.now() - 28 * 24 * 60 * 60 * 1000)}
|
||||
now={new Date()}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<IntegrationStatusPill state="pending-setup" lastSyncAt={null} />,
|
||||
);
|
||||
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(<IntegrationStatusPill state="error" lastSyncAt={null} />),
|
||||
).toContain('data-variant="destructive"');
|
||||
for (const state of [
|
||||
"warning",
|
||||
"stale",
|
||||
"stalled",
|
||||
"parked",
|
||||
"pending-setup",
|
||||
"connected",
|
||||
] as const) {
|
||||
expect(
|
||||
render(<IntegrationStatusPill state={state} lastSyncAt={null} />),
|
||||
state,
|
||||
).not.toContain('data-variant="destructive"');
|
||||
}
|
||||
});
|
||||
|
||||
it("lets a card keep its own established testid", () => {
|
||||
const html = render(
|
||||
<IntegrationStatusPill
|
||||
state="connected"
|
||||
lastSyncAt={null}
|
||||
testId="apple-health-status"
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<IntegrationStatusPill
|
||||
state="warning"
|
||||
lastSyncAt={seventeenDaysAgo}
|
||||
now={new Date()}
|
||||
/>,
|
||||
);
|
||||
// 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(
|
||||
<IntegrationStatusPill
|
||||
state="warning"
|
||||
lastSyncAt={new Date()}
|
||||
now={new Date()}
|
||||
failureCount={2}
|
||||
failureThreshold={3}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain("2/3");
|
||||
});
|
||||
|
||||
it("omits a partial failure ratio", () => {
|
||||
const html = render(
|
||||
<IntegrationStatusPill
|
||||
state="warning"
|
||||
lastSyncAt={new Date()}
|
||||
now={new Date()}
|
||||
failureCount={2}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).not.toContain("2/");
|
||||
});
|
||||
|
||||
it("keeps the German label free of a connection claim too", () => {
|
||||
const html = render(
|
||||
<IntegrationStatusPill
|
||||
state="warning"
|
||||
lastSyncAt={new Date(Date.now() - 3 * 24 * 60 * 60 * 1000)}
|
||||
now={new Date()}
|
||||
/>,
|
||||
"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(
|
||||
<IntegrationStatusPill
|
||||
state={state}
|
||||
lastSyncAt={new Date(Date.now() - 12 * 60 * 1000)}
|
||||
now={new Date()}
|
||||
/>,
|
||||
);
|
||||
expect(html).not.toContain("aria-label=");
|
||||
expect(html).toContain(label);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
* `<IntegrationStatusPill>` 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;
|
||||
|
||||
@@ -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 | number>) => 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<IntegrationPillState>([
|
||||
"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<IntegrationPillState>([
|
||||
"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 = <CheckCircle2 aria-hidden="true" className="h-3 w-3" />;
|
||||
break;
|
||||
case "stale":
|
||||
label = t("settings.integrationPill.stale");
|
||||
icon = <AlertTriangle aria-hidden="true" className="h-3 w-3" />;
|
||||
break;
|
||||
case "stalled":
|
||||
label = t("settings.integrationPill.stalled");
|
||||
icon = <Clock aria-hidden="true" className="h-3 w-3" />;
|
||||
break;
|
||||
case "warning":
|
||||
label = t("settings.integrationPill.warningServerError");
|
||||
icon = <AlertTriangle aria-hidden="true" className="h-3 w-3" />;
|
||||
break;
|
||||
case "error":
|
||||
label = t("settings.integrationPill.errorReconnect");
|
||||
icon = <AlertTriangle aria-hidden="true" className="h-3 w-3" />;
|
||||
break;
|
||||
case "parked":
|
||||
label = t("settings.integrationPill.parkedReconnect");
|
||||
icon = <PauseCircle aria-hidden="true" className="h-3 w-3" />;
|
||||
break;
|
||||
case "pending-setup":
|
||||
label = t("settings.integrationPill.pendingSetup");
|
||||
icon = <Info aria-hidden="true" className="h-3 w-3" />;
|
||||
break;
|
||||
case "disconnected":
|
||||
label = t("settings.integrationPill.notConnected");
|
||||
icon = <CircleSlash aria-hidden="true" className="h-3 w-3" />;
|
||||
break;
|
||||
}
|
||||
|
||||
const reference = now ?? new Date();
|
||||
const relative =
|
||||
RELATIVE_STATES.has(state) && lastSyncAt
|
||||
? formatRelative(
|
||||
Math.max(0, reference.getTime() - toDate(lastSyncAt).getTime()),
|
||||
t,
|
||||
)
|
||||
: null;
|
||||
const failureRatio =
|
||||
failureCount !== undefined &&
|
||||
failureCount > 0 &&
|
||||
failureThreshold !== undefined
|
||||
? t("settings.integrationPill.failureCount", {
|
||||
count: failureCount,
|
||||
threshold: failureThreshold,
|
||||
})
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Badge
|
||||
data-testid={testId ?? "integration-status-pill"}
|
||||
data-state={state}
|
||||
variant={
|
||||
state === "connected" || WARNING_STATES.has(state)
|
||||
? undefined
|
||||
: state === "error"
|
||||
? "destructive"
|
||||
: "outline"
|
||||
}
|
||||
// No `aria-label` here. One used to sit on the badge and, being a
|
||||
// generic string, it REPLACED the accessible name with itself — a screen
|
||||
// reader announced the same words for "connected · 4 minutes ago" as for
|
||||
// "error", which is the one distinction the pill exists to make. The
|
||||
// visible label is the accessible name; the relative time stays
|
||||
// `aria-hidden` because it is decoration on top of a state that already
|
||||
// reads.
|
||||
className={cn(
|
||||
"max-w-full whitespace-nowrap",
|
||||
state === "connected" && connectedChipClass,
|
||||
WARNING_STATES.has(state) && warningChipClass,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
<span className="truncate">
|
||||
{label}
|
||||
{failureRatio ? ` · ${failureRatio}` : null}
|
||||
{relative ? <span aria-hidden="true"> · {relative}</span> : null}
|
||||
</span>
|
||||
{relative ? <span className="sr-only">{relative}</span> : null}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import Link from "next/link";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { HeartPulse, Upload } from "lucide-react";
|
||||
|
||||
import { formatRelative } from "@/components/settings/integration-status-pill";
|
||||
import { formatRelative } from "./format-relative";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* "just now" / "12 min ago" / "3 h ago" / "2 days ago" for a sync or arrival
|
||||
* age in milliseconds. Used by the Apple Health card and the per-metric
|
||||
* freshness list; it outlived the status pill it was written for.
|
||||
*/
|
||||
export function formatRelative(
|
||||
deltaMs: number,
|
||||
t: (key: string, params?: Record<string, string | number>) => 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 });
|
||||
}
|
||||
@@ -22,7 +22,7 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
|
||||
import { formatRelative } from "@/components/settings/integration-status-pill";
|
||||
import { formatRelative } from "./format-relative";
|
||||
import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/components/measurements/measurement-list-meta";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { MetricFreshnessEntry } from "@/lib/integrations/sync-verdict";
|
||||
|
||||
Reference in new issue
Block a user