From ce74fd52a22b8ab9cc4c37fd4526ba0e629d8ad4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 25 Jul 2026 09:41:37 +0200 Subject: [PATCH] feat(settings): render the sync verdict and per-metric freshness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three `pillStateFor` implementations disagreed with each other, so the same ledger state painted red on one card and amber on the next, on one page. All three are gone; the client projects the server verdict once. Red now means "your action fixes this" and nothing else. A transient upstream failure carries the warning tone: telling a user to reconnect against a 503 sends them clicking at something they cannot repair, and the detail belongs in the inline error line anyway. Three new pill states carry the rest — no recent data, sync stopped, waiting for first data. Per-metric freshness has been computed on every status call since v1.27.30 and rendered nowhere. Every connected card now opens a disclosure listing each data type the connection has delivered and how long ago, with a quiet one flagged. Collapsed by default so a 25-type card keeps its shape, but the collapsed summary already names the count, or the signal hides behind a click. A type a provider never delivered simply is not listed. The panel iterates the envelope and renders a plain status row for every configured entry no card claims. That is the structural half: a connection can no longer be running, failing and absent from the page at once. Apple Health drops its bespoke badge for the shared pill, gains the divider and body inset every sibling carries, and says something when a week has passed with nothing arriving. Nightscout reads the envelope instead of its own status round-trip. The OAuth card uses the shared error primitive, all nine cards carry the divider testid, and the four original connectors gain the "view your data" link the newer ones already had. --- messages/de.json | 29 +++- messages/en.json | 29 +++- messages/es.json | 29 +++- messages/fr.json | 29 +++- messages/it.json | 29 +++- messages/pl.json | 29 +++- .../settings/integration-status-pill.tsx | 89 +++++++--- .../integrations/apple-health-card.tsx | 157 ++++++++++-------- .../integrations/connections-panel.tsx | 49 +++++- .../settings/integrations/fitbit-card.tsx | 38 ++++- .../integrations/google-health-card.tsx | 47 +++++- .../integrations/integration-fallback-row.tsx | 100 +++++++++++ .../metric-freshness-disclosure.tsx | 154 +++++++++++++++++ .../settings/integrations/nightscout-card.tsx | 108 +++++------- .../integrations/oauth-provider-card.tsx | 101 ++++++----- .../settings/integrations/oura-card.tsx | 9 +- .../settings/integrations/polar-card.tsx | 9 +- .../settings/integrations/shared.tsx | 96 +++++++---- .../settings/integrations/strava-card.tsx | 9 +- .../settings/integrations/whoop-card.tsx | 40 ++++- .../settings/integrations/withings-card.tsx | 48 +++++- src/lib/query-keys/integrations.ts | 7 +- 22 files changed, 887 insertions(+), 348 deletions(-) create mode 100644 src/components/settings/integrations/integration-fallback-row.tsx create mode 100644 src/components/settings/integrations/metric-freshness-disclosure.tsx diff --git a/messages/de.json b/messages/de.json index b7b6373a8..7bde66b09 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5279,7 +5279,7 @@ "integrationPill": { "connected": "Verbunden", "errorReconnect": "Fehler — neu verbinden", - "warningServerError": "Verbunden, aber Serverfehler", + "warningServerError": "Verbunden, Sync-Problem", "parkedReconnect": "Pausiert — manuell wieder verbinden", "notConnected": "Nicht verbunden", "justNow": "gerade eben", @@ -5289,8 +5289,26 @@ "ariaLabel": "Integrationsstatus", "resumeCta": "Wieder verbinden", "resumeSuccess": "Integration wieder aktiv", - "resumeError": "Wieder verbinden fehlgeschlagen" + "resumeError": "Wieder verbinden fehlgeschlagen", + "stale": "Verbunden, keine neuen Daten", + "stalled": "Sync gestoppt", + "pendingSetup": "Warte auf erste Daten" }, + "integrationFreshness": { + "title": "Empfangene Daten", + "summary": "{count} Datentypen · neuestes {relative}", + "quiet": "{count} still", + "workouts": "Workouts" + }, + "integrationFallback": { + "description": "Für dieses Konto eingerichtet, hat aber keine eigene Einstellungskarte. Diese Zeile sorgt dafür, dass eine Verbindung nicht unbemerkt ausfallen kann.", + "lastAttempt": "Letzter Versuch {relative}", + "noAttempt": "Kein Sync-Versuch aufgezeichnet" + }, + "withingsViewData": "Messwerte ansehen", + "whoopViewData": "Erholung ansehen", + "fitbitViewData": "Schlaf ansehen", + "googleHealthViewData": "Schritte ansehen", "apiTokens": "API-Tokens", "apiTokensDescription": "API-Tokens für die externe Medikamenten-Einnahme prüfen und widerrufen. Tokens werden pro Medikament über dessen API-Endpoint-Schalter ausgestellt.", "tokenMintMovedDescription": "Tokens werden pro Medikament ausgestellt. Öffne ein Medikament und aktiviere dessen API-Endpoint, um ein Token zu erhalten, das nur für dieses Medikament gilt.", @@ -5850,17 +5868,14 @@ "title": "Apple Health", "description": "Die Live-Synchronisierung erfolgt über die HealthLog-iOS-App – nicht über eine Web- oder OAuth-Verbindung.", "status": { - "checking": "Apple-Health-Daten werden geprüft…", - "dataReceived": "Apple-Health-Daten empfangen", - "setup": "In der iOS-App einrichten", "unavailable": "Apple-Health-Status nicht verfügbar" }, - "lastDataLabel": "Letzte Apple-Health-Daten:", "setupTitle": "Live-Synchronisierung aktivieren", "permissionStep": "Öffne in der HealthLog-iOS-App Einstellungen → Apple Health und erlaube die Gesundheitskategorien, die du teilen möchtest.", "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" + "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." }, "garminInfo": { "title": "Garmin", diff --git a/messages/en.json b/messages/en.json index eb739414e..e6b4d44d8 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5269,7 +5269,7 @@ "integrationPill": { "connected": "Connected", "errorReconnect": "Error — reconnect", - "warningServerError": "Connected, server error", + "warningServerError": "Connected, sync issue", "parkedReconnect": "Paused — reconnect manually", "notConnected": "Not connected", "justNow": "just now", @@ -5279,8 +5279,26 @@ "ariaLabel": "Integration status", "resumeCta": "Reconnect", "resumeSuccess": "Integration resumed", - "resumeError": "Reconnect failed" + "resumeError": "Reconnect failed", + "stale": "Connected, no recent data", + "stalled": "Sync stopped", + "pendingSetup": "Waiting for first data" }, + "integrationFreshness": { + "title": "Data received", + "summary": "{count} data types · newest {relative}", + "quiet": "{count} quiet", + "workouts": "Workouts" + }, + "integrationFallback": { + "description": "Configured on this account without a settings card of its own. This row exists so a connection cannot stop working unnoticed.", + "lastAttempt": "Last attempt {relative}", + "noAttempt": "No sync attempt recorded" + }, + "withingsViewData": "View your measurements", + "whoopViewData": "View your recovery", + "fitbitViewData": "View your sleep", + "googleHealthViewData": "View your steps", "apiTokens": "API Tokens", "apiTokensDescription": "Review and revoke API tokens for external medication intake. Tokens are issued per medication from the medication's API endpoint toggle.", "tokenMintMovedDescription": "Tokens are issued per medication. Open a medication and enable its API endpoint to get a token scoped to that medication alone.", @@ -5840,17 +5858,14 @@ "title": "Apple Health", "description": "Live sync comes from the HealthLog iOS app. It is not a web or OAuth connection.", "status": { - "checking": "Checking Apple Health data…", - "dataReceived": "Apple Health data received", - "setup": "Set up in iOS app", "unavailable": "Apple Health status unavailable" }, - "lastDataLabel": "Last Apple Health data:", "setupTitle": "Turn on live sync", "permissionStep": "In the HealthLog iOS app, open Settings → Apple Health and allow the health categories you want to share.", "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" + "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." }, "garminInfo": { "title": "Garmin", diff --git a/messages/es.json b/messages/es.json index 5799a20cf..75b5da539 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5281,7 +5281,7 @@ "integrationPill": { "connected": "Conectado", "errorReconnect": "Error — reconectar", - "warningServerError": "Conectado, error del servidor", + "warningServerError": "Conectado, problema de sincronización", "parkedReconnect": "Pausado — reconectar manualmente", "notConnected": "No conectado", "justNow": "ahora mismo", @@ -5291,8 +5291,26 @@ "ariaLabel": "Estado de la integración", "resumeCta": "Reconectar", "resumeSuccess": "Integración reanudada", - "resumeError": "Reconexión fallida" + "resumeError": "Reconexión fallida", + "stale": "Conectado, sin datos recientes", + "stalled": "Sincronización detenida", + "pendingSetup": "Esperando los primeros datos" }, + "integrationFreshness": { + "title": "Datos recibidos", + "summary": "{count} tipos de datos · más reciente {relative}", + "quiet": "{count} en silencio", + "workouts": "Entrenamientos" + }, + "integrationFallback": { + "description": "Configurado en esta cuenta pero sin tarjeta de ajustes propia. Esta fila existe para que una conexión no deje de funcionar sin que nadie lo note.", + "lastAttempt": "Último intento {relative}", + "noAttempt": "Ningún intento de sincronización registrado" + }, + "withingsViewData": "Ver tus mediciones", + "whoopViewData": "Ver tu recuperación", + "fitbitViewData": "Ver tu sueño", + "googleHealthViewData": "Ver tus pasos", "apiTokens": "Tokens de API", "apiTokensDescription": "Revisa y revoca tokens de API para el registro externo de tomas de medicación. Los tokens se emiten por medicamento desde su interruptor de endpoint de API.", "tokenMintMovedDescription": "Los tokens se emiten por medicamento. Abre un medicamento y activa su endpoint de API para obtener un token limitado únicamente a ese medicamento.", @@ -5852,17 +5870,14 @@ "title": "Apple Health", "description": "La sincronización en vivo se realiza desde la app HealthLog para iOS, no mediante una conexión web ni OAuth.", "status": { - "checking": "Comprobando datos de Apple Health…", - "dataReceived": "Datos de Apple Health recibidos", - "setup": "Configurar en la app de iOS", "unavailable": "Estado de Apple Health no disponible" }, - "lastDataLabel": "Últimos datos de Apple Health:", "setupTitle": "Activar la sincronización en vivo", "permissionStep": "En la app HealthLog para iOS, abre Ajustes → Apple Health y permite las categorías de salud que quieras compartir.", "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" + "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." }, "garminInfo": { "title": "Garmin", diff --git a/messages/fr.json b/messages/fr.json index e2c6159af..a0a638b3d 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5283,7 +5283,7 @@ "integrationPill": { "connected": "Connecté", "errorReconnect": "Erreur — reconnecter", - "warningServerError": "Connecté, erreur serveur", + "warningServerError": "Connecté, problème de synchronisation", "parkedReconnect": "En pause — reconnecter manuellement", "notConnected": "Non connecté", "justNow": "à l’instant", @@ -5293,8 +5293,26 @@ "ariaLabel": "Statut de l’intégration", "resumeCta": "Reconnecter", "resumeSuccess": "Intégration reprise", - "resumeError": "Échec de la reconnexion" + "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" }, + "integrationFreshness": { + "title": "Données reçues", + "summary": "{count} types de données · plus récent {relative}", + "quiet": "{count} silencieux", + "workouts": "Séances" + }, + "integrationFallback": { + "description": "Configuré sur ce compte sans carte de réglages dédiée. Cette ligne existe pour qu'une connexion ne puisse pas cesser de fonctionner sans que personne ne le voie.", + "lastAttempt": "Dernière tentative {relative}", + "noAttempt": "Aucune tentative de synchronisation enregistrée" + }, + "withingsViewData": "Voir vos mesures", + "whoopViewData": "Voir votre récupération", + "fitbitViewData": "Voir votre sommeil", + "googleHealthViewData": "Voir vos pas", "apiTokens": "Jetons API", "apiTokensDescription": "Consultez et révoquez les jetons API pour l’enregistrement externe des prises de médicaments. Les jetons sont émis par médicament depuis son interrupteur de point de terminaison API.", "tokenMintMovedDescription": "Les jetons sont émis par médicament. Ouvrez un médicament et activez son point de terminaison API pour obtenir un jeton limité à ce seul médicament.", @@ -5854,17 +5872,14 @@ "title": "Apple Health", "description": "La synchronisation en direct passe par l’app HealthLog pour iOS, et non par une connexion web ou OAuth.", "status": { - "checking": "Vérification des données Apple Health…", - "dataReceived": "Données Apple Health reçues", - "setup": "Configurer dans l’app iOS", "unavailable": "Statut Apple Health indisponible" }, - "lastDataLabel": "Dernières données Apple Health :", "setupTitle": "Activer la synchronisation en direct", "permissionStep": "Dans l’app HealthLog pour iOS, ouvre Réglages → Apple Health et autorise les catégories de santé que tu souhaites partager.", "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" + "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." }, "garminInfo": { "title": "Garmin", diff --git a/messages/it.json b/messages/it.json index ada7a6ab9..114b1fc07 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5273,7 +5273,7 @@ "integrationPill": { "connected": "Connesso", "errorReconnect": "Errore — riconnetti", - "warningServerError": "Connesso, errore del server", + "warningServerError": "Connesso, problema di sincronizzazione", "parkedReconnect": "In pausa — riconnettere manualmente", "notConnected": "Non connesso", "justNow": "proprio ora", @@ -5283,8 +5283,26 @@ "ariaLabel": "Stato dell’integrazione", "resumeCta": "Riconnetti", "resumeSuccess": "Integrazione ripresa", - "resumeError": "Riconnessione fallita" + "resumeError": "Riconnessione fallita", + "stale": "Connesso, nessun dato recente", + "stalled": "Sincronizzazione ferma", + "pendingSetup": "In attesa dei primi dati" }, + "integrationFreshness": { + "title": "Dati ricevuti", + "summary": "{count} tipi di dati · più recente {relative}", + "quiet": "{count} in silenzio", + "workouts": "Allenamenti" + }, + "integrationFallback": { + "description": "Configurato su questo account ma senza una scheda di impostazioni propria. Questa riga esiste perché una connessione non possa smettere di funzionare senza che nessuno se ne accorga.", + "lastAttempt": "Ultimo tentativo {relative}", + "noAttempt": "Nessun tentativo di sincronizzazione registrato" + }, + "withingsViewData": "Vedi le tue misurazioni", + "whoopViewData": "Vedi il tuo recupero", + "fitbitViewData": "Vedi il tuo sonno", + "googleHealthViewData": "Vedi i tuoi passi", "apiTokens": "Token API", "apiTokensDescription": "Rivedi e revoca i token API per la registrazione esterna delle assunzioni di farmaci. I token vengono emessi per singolo farmaco dal relativo interruttore dell’endpoint API.", "tokenMintMovedDescription": "I token vengono emessi per singolo farmaco. Apri un farmaco e attiva il suo endpoint API per ottenere un token valido solo per quel farmaco.", @@ -5844,17 +5862,14 @@ "title": "Apple Health", "description": "La sincronizzazione in tempo reale avviene tramite l’app HealthLog per iOS, non tramite una connessione web o OAuth.", "status": { - "checking": "Controllo dei dati Apple Health…", - "dataReceived": "Dati Apple Health ricevuti", - "setup": "Configura nell’app iOS", "unavailable": "Stato di Apple Health non disponibile" }, - "lastDataLabel": "Ultimi dati Apple Health:", "setupTitle": "Attiva la sincronizzazione in tempo reale", "permissionStep": "Nell’app HealthLog per iOS, apri Impostazioni → Apple Health e autorizza le categorie di salute che vuoi condividere.", "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" + "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." }, "garminInfo": { "title": "Garmin", diff --git a/messages/pl.json b/messages/pl.json index 309fb99b2..85bcb9a2f 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5267,7 +5267,7 @@ "integrationPill": { "connected": "Połączono", "errorReconnect": "Błąd — połącz ponownie", - "warningServerError": "Połączono, błąd serwera", + "warningServerError": "Połączono, problem z synchronizacją", "parkedReconnect": "Wstrzymane — połącz ręcznie", "notConnected": "Nie połączono", "justNow": "przed chwilą", @@ -5277,8 +5277,26 @@ "ariaLabel": "Status integracji", "resumeCta": "Połącz ponownie", "resumeSuccess": "Integracja wznowiona", - "resumeError": "Ponowne połączenie nieudane" + "resumeError": "Ponowne połączenie nieudane", + "stale": "Połączono, brak nowych danych", + "stalled": "Synchronizacja zatrzymana", + "pendingSetup": "Czekam na pierwsze dane" }, + "integrationFreshness": { + "title": "Odebrane dane", + "summary": "{count} typów danych · najnowsze {relative}", + "quiet": "{count} bez danych", + "workouts": "Treningi" + }, + "integrationFallback": { + "description": "Skonfigurowane na tym koncie, ale bez własnej karty ustawień. Ten wiersz istnieje po to, by przerwane połączenie nie pozostało niezauważone.", + "lastAttempt": "Ostatnia próba {relative}", + "noAttempt": "Brak zapisanej próby synchronizacji" + }, + "withingsViewData": "Zobacz swoje pomiary", + "whoopViewData": "Zobacz swoją regenerację", + "fitbitViewData": "Zobacz swój sen", + "googleHealthViewData": "Zobacz swoje kroki", "apiTokens": "Tokeny API", "apiTokensDescription": "Przeglądaj i unieważniaj tokeny API do zewnętrznego rejestrowania przyjęć leków. Tokeny są wydawane dla pojedynczego leku z jego przełącznika punktu końcowego API.", "tokenMintMovedDescription": "Tokeny są wydawane dla pojedynczego leku. Otwórz lek i włącz jego punkt końcowy API, aby otrzymać token obejmujący wyłącznie ten lek.", @@ -5838,17 +5856,14 @@ "title": "Apple Health", "description": "Synchronizacja na żywo odbywa się przez aplikację HealthLog na iOS, a nie przez połączenie internetowe ani OAuth.", "status": { - "checking": "Sprawdzanie danych Apple Health…", - "dataReceived": "Odebrano dane Apple Health", - "setup": "Skonfiguruj w aplikacji iOS", "unavailable": "Stan Apple Health jest niedostępny" }, - "lastDataLabel": "Najnowsze dane Apple Health:", "setupTitle": "Włącz synchronizację na żywo", "permissionStep": "W aplikacji HealthLog na iOS otwórz Ustawienia → Apple Health i zezwól na udostępnianie wybranych kategorii zdrowotnych.", "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" + "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." }, "garminInfo": { "title": "Garmin", diff --git a/src/components/settings/integration-status-pill.tsx b/src/components/settings/integration-status-pill.tsx index b65c4adb9..35d8ef14d 100644 --- a/src/components/settings/integration-status-pill.tsx +++ b/src/components/settings/integration-status-pill.tsx @@ -4,6 +4,8 @@ import { CheckCircle2, AlertTriangle, CircleSlash, + Clock, + Info, PauseCircle, } from "lucide-react"; @@ -32,20 +34,33 @@ import { cn } from "@/lib/utils"; */ /** - * Pill states. + * Pill states — a thin projection of the server's sync-health verdict. * * - connected → happy path; relative "last sync" suffix is shown. - * - warning → connected but the upstream just returned a - * contract-mismatch error (v1.4.43 W4). - * - error → reauth-required / persistent transient error; - * "Error — reconnect" copy. - * - parked → v1.4.43 W14: >24h of persistent failures put the - * integration to sleep; "Pausiert — manuell wieder - * verbinden" copy + reconnect CTA. + * - 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" | "warning" | "error" | "parked" | "disconnected"; + | "connected" + | "stale" + | "stalled" + | "warning" + | "error" + | "parked" + | "pending-setup" + | "disconnected"; interface IntegrationStatusPillProps { state: IntegrationPillState; @@ -56,6 +71,8 @@ interface IntegrationStatusPillProps { lastSyncAt: Date | string | null; /** 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; } @@ -68,7 +85,7 @@ function toDate(value: Date | string): Date { * string. We keep the buckets small and fixed (just-now / minutes / * hours / days) because the pill must fit on a 360 px wide card. */ -function formatRelative( +export function formatRelative( deltaMs: number, t: (key: string, params?: Record) => string, ): string { @@ -85,26 +102,37 @@ function formatRelative( 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. */ +const RELATIVE_STATES = new Set([ + "connected", + "stale", + "stalled", +]); + export function IntegrationStatusPill({ state, lastSyncAt, now, + testId, className, }: IntegrationStatusPillProps) { const { t } = useTranslations(); - // chipClass only applies to the `connected`, `warning`, and `parked` - // branches — `error` falls back to `variant="destructive"` and - // `disconnected` to `variant="outline"`. The warning and parked chips - // both carry the semantic `warning` tone (an AA-safe amber that clears - // contrast in light mode); their distinct labels + icons carry the - // meaning — "connected, but the upstream returned a contract-level error - // a reauth won't fix" vs "tried to keep going for 24h but the mismatch - // persists, manual intervention required" — distinct from the red - // reconnect pill (`error`). + // 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"; - const parkedChipClass = "border-warning/30 bg-warning/15 text-warning"; let label: string; let icon: React.ReactNode; @@ -114,6 +142,14 @@ export function IntegrationStatusPill({ label = t("settings.integrationPill.connected"); icon =