feat(settings): render the sync verdict and per-metric freshness

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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-25 09:41:37 +02:00
1 parent de47bfa5ba
commit ce74fd52a2
22 files changed
+887 -348

No files matched your search

+22 -7
View File
@@ -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",
+22 -7
View File
@@ -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",
+22 -7
View File
@@ -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",
+22 -7
View File
@@ -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",
+22 -7
View File
@@ -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",
+22 -7
View File
@@ -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",
@@ -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 | number>) => 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<IntegrationPillState>([
"stale",
"stalled",
"warning",
"parked",
]);
/** The states whose label carries an inline relative timestamp. */
const RELATIVE_STATES = new Set<IntegrationPillState>([
"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 = <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" />;
@@ -126,6 +162,10 @@ export function IntegrationStatusPill({
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" />;
@@ -134,7 +174,7 @@ export function IntegrationStatusPill({
const reference = now ?? new Date();
const relative =
state === "connected" && lastSyncAt
RELATIVE_STATES.has(state) && lastSyncAt
? formatRelative(
Math.max(0, reference.getTime() - toDate(lastSyncAt).getTime()),
t,
@@ -143,10 +183,10 @@ export function IntegrationStatusPill({
return (
<Badge
data-testid="integration-status-pill"
data-testid={testId ?? "integration-status-pill"}
data-state={state}
variant={
state === "connected" || state === "warning" || state === "parked"
state === "connected" || WARNING_STATES.has(state)
? undefined
: state === "error"
? "destructive"
@@ -156,8 +196,7 @@ export function IntegrationStatusPill({
className={cn(
"max-w-full whitespace-nowrap",
state === "connected" && connectedChipClass,
state === "warning" && warningChipClass,
state === "parked" && parkedChipClass,
WARNING_STATES.has(state) && warningChipClass,
className,
)}
>
@@ -1,24 +1,44 @@
"use client";
/**
* Apple Health — the one integration with no ledger row and no cron.
*
* The iOS app pushes; nothing here polls. The card used to derive its status
* from the mere presence of `lastSyncedAt`, so any timestamp at all painted the
* green "data received" chip — a pipe dead for three weeks looked exactly like
* one that delivered this morning. It now reads the server's verdict, which
* applies a seven-day threshold to the same timestamp, and renders the shared
* pill instead of a bespoke badge so the flagship integration speaks the same
* status language as its siblings.
*/
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { CheckCircle2, HeartPulse, Info, Upload } from "lucide-react";
import { HeartPulse, Upload } from "lucide-react";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { SettingsCard } from "@/components/settings/settings-card";
import { Badge } from "@/components/ui/badge";
import { IntegrationStatusPill } from "@/components/settings/integration-status-pill";
import { Button } from "@/components/ui/button";
import { apiGet } from "@/lib/api/api-fetch";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { useTranslations } from "@/lib/i18n/context";
import type {
MetricFreshnessEntry,
SyncHealth,
} from "@/lib/integrations/sync-verdict";
import { queryKeys } from "@/lib/query-keys";
import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure";
import { IntegrationErrorMessage, pillStateForVerdict } from "./shared";
interface HealthKitStatus {
lastSyncedAt: string | null;
syncHealth?: SyncHealth;
metricFreshness?: MetricFreshnessEntry[];
}
export function AppleHealthCard({ enabled }: { enabled: boolean }) {
const { t } = useTranslations();
const format = useFormatters();
const {
data: status,
isLoading,
@@ -30,28 +50,17 @@ export function AppleHealthCard({ enabled }: { enabled: boolean }) {
refetchOnWindowFocus: true,
});
const lastSyncedAt = status?.lastSyncedAt ?? null;
const statusState = isLoading
? "checking"
: isError
? "unavailable"
: lastSyncedAt
? "recent-data"
: "setup";
const statusLabel = isLoading
? t("settings.appleHealth.status.checking")
: isError
? t("settings.appleHealth.status.unavailable")
: lastSyncedAt
? t("settings.appleHealth.status.dataReceived")
: t("settings.appleHealth.status.setup");
const verdict = status?.syncHealth?.verdict;
const pillState = pillStateForVerdict(verdict);
// A failed status read is an error line, not a status state — the card says
// nothing about the pipe it could not ask about.
const showPill = !isLoading && !isError && !!status;
return (
<SettingsCard
as="section"
aria-labelledby="apple-health-card-title"
data-testid="apple-health-card"
className="space-y-4"
>
<SettingsCardHeader
icon={HeartPulse}
@@ -59,64 +68,68 @@ export function AppleHealthCard({ enabled }: { enabled: boolean }) {
title={t("settings.appleHealth.title")}
description={t("settings.appleHealth.description")}
status={
<Badge
variant={lastSyncedAt ? undefined : "outline"}
data-testid="apple-health-status"
data-state={statusState}
className={
lastSyncedAt
? "border-success/30 bg-success/15 text-success max-w-full whitespace-nowrap"
: "max-w-full whitespace-nowrap"
}
>
{lastSyncedAt ? (
<CheckCircle2 className="h-3 w-3" aria-hidden="true" />
) : (
<Info className="h-3 w-3" aria-hidden="true" />
)}
<span className="truncate">{statusLabel}</span>
</Badge>
showPill ? (
<IntegrationStatusPill
testId="apple-health-status"
state={pillState}
lastSyncAt={status?.lastSyncedAt ?? null}
/>
) : null
}
/>
{lastSyncedAt && (
<p
className="text-muted-foreground text-xs"
data-testid="apple-health-last-data"
>
{t("settings.appleHealth.lastDataLabel")}{" "}
<time dateTime={lastSyncedAt}>{format.dateTime(lastSyncedAt)}</time>
</p>
)}
<hr
data-testid="integration-card-divider"
className="border-border/60 mt-4"
/>
<div className="space-y-2">
<h3 className="text-sm font-semibold">
{t("settings.appleHealth.setupTitle")}
</h3>
<ol className="text-muted-foreground list-decimal space-y-2 pl-5 text-sm">
<li>{t("settings.appleHealth.permissionStep")}</li>
<li>{t("settings.appleHealth.backgroundStep")}</li>
</ol>
</div>
<div className="mt-4 space-y-4 pl-7">
{isError && (
<IntegrationErrorMessage
message={t("settings.appleHealth.status.unavailable")}
/>
)}
<div className="border-border bg-muted/30 flex flex-col gap-3 rounded-lg border p-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-muted-foreground text-xs sm:max-w-xl">
{t("settings.appleHealth.importNote")}
</p>
<Button
asChild
variant="outline"
size="sm"
className="min-h-11 shrink-0 sm:min-h-9"
>
<Link
href="/settings/export#settings-section-import-title"
data-testid="apple-health-import-link"
{verdict === "stale" && (
<p className="text-warning text-sm" data-testid="apple-health-stale">
{t("settings.appleHealth.staleNote")}
</p>
)}
<MetricFreshnessDisclosure
entries={status?.metricFreshness}
idPrefix="apple-health"
/>
<div className="space-y-2">
<h3 className="text-sm font-semibold">
{t("settings.appleHealth.setupTitle")}
</h3>
<ol className="text-muted-foreground list-decimal space-y-2 pl-5 text-sm">
<li>{t("settings.appleHealth.permissionStep")}</li>
<li>{t("settings.appleHealth.backgroundStep")}</li>
</ol>
</div>
<div className="border-border bg-muted/30 flex flex-col gap-3 rounded-lg border p-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-muted-foreground text-xs sm:max-w-xl">
{t("settings.appleHealth.importNote")}
</p>
<Button
asChild
variant="outline"
size="sm"
className="min-h-11 shrink-0 sm:min-h-9"
>
<Upload className="h-3.5 w-3.5" aria-hidden="true" />
{t("settings.appleHealth.importAction")}
</Link>
</Button>
<Link
href="/settings/export#settings-section-import-title"
data-testid="apple-health-import-link"
>
<Upload className="h-3.5 w-3.5" aria-hidden="true" />
{t("settings.appleHealth.importAction")}
</Link>
</Button>
</div>
</div>
</SettingsCard>
);
@@ -20,6 +20,7 @@ import { AppleHealthCard } from "@/components/settings/integrations/apple-health
import { FitbitCard } from "@/components/settings/integrations/fitbit-card";
import { GarminInfoNote } from "@/components/settings/integrations/garmin-info-note";
import { GoogleHealthCard } from "@/components/settings/integrations/google-health-card";
import { IntegrationFallbackRow } from "@/components/settings/integrations/integration-fallback-row";
import { NightscoutCard } from "@/components/settings/integrations/nightscout-card";
import type { OAuthProviderStatus } from "@/components/settings/integrations/oauth-provider-card";
import { OuraCard } from "@/components/settings/integrations/oura-card";
@@ -28,6 +29,7 @@ import { StravaCard } from "@/components/settings/integrations/strava-card";
import {
pickStatus,
useIntegrationStatuses,
type IntegrationKey,
type IntegrationStatusViewModel,
} from "@/components/settings/integrations/shared";
import { WhoopCard } from "@/components/settings/integrations/whoop-card";
@@ -164,9 +166,29 @@ function toOAuthStatus(
lastSuccessAt: vm.lastSuccessAt,
lastAttemptAt: vm.lastAttemptAt,
lastError: vm.lastError,
legacyLastSyncedAt: vm.legacyLastSyncedAt,
syncHealth: vm.syncHealth,
metricFreshness: vm.metricFreshness,
};
}
/**
* The keys a bespoke card on this panel renders. Every OTHER configured
* envelope entry falls through to `IntegrationFallbackRow` — the panel's unit
* of coverage is the envelope entry, not the card, so a provider can never
* again be shipped on the wire and rendered nowhere.
*/
const CARD_BACKED_INTEGRATIONS: ReadonlySet<IntegrationKey> = new Set([
"withings",
"whoop",
"fitbit",
"google-health",
"polar",
"oura",
"strava",
"nightscout",
]);
export function ConnectionsPanel() {
const { t } = useTranslations();
const { isAuthenticated } = useAuth();
@@ -269,6 +291,12 @@ export function ConnectionsPanel() {
const stravaViewModel = toOAuthStatus(
pickStatus(integrationStatus, "strava"),
);
const nightscoutViewModel = pickStatus(integrationStatus, "nightscout");
const unclaimedIntegrations = (integrationStatus?.integrations ?? []).filter(
(entry) =>
!CARD_BACKED_INTEGRATIONS.has(entry.integration) &&
(entry.configured || entry.connected),
);
return (
<div className="space-y-6" data-slot="connections-panel">
@@ -290,17 +318,30 @@ export function ConnectionsPanel() {
<AppleHealthCard enabled={isAuthenticated} />
</div>
<div id="polar" className="scroll-mt-28">
<PolarCard enabled={isAuthenticated} viewModel={polarViewModel} />
<PolarCard viewModel={polarViewModel} />
</div>
<div id="oura" className="scroll-mt-28">
<OuraCard enabled={isAuthenticated} viewModel={ouraViewModel} />
<OuraCard viewModel={ouraViewModel} />
</div>
<div id="strava" className="scroll-mt-28">
<StravaCard enabled={isAuthenticated} viewModel={stravaViewModel} />
<StravaCard viewModel={stravaViewModel} />
</div>
<div id="nightscout" className="scroll-mt-28">
<NightscoutCard enabled={isAuthenticated} />
<NightscoutCard viewModel={nightscoutViewModel} />
</div>
{/* Every configured envelope entry no card above claims. Normally this
renders nothing; on an account carrying a dropped-but-still-running
integration it renders the honest status that provider otherwise has
nowhere to appear. */}
{unclaimedIntegrations.map((entry) => (
<div
key={entry.integration}
id={entry.integration}
className="scroll-mt-28"
>
<IntegrationFallbackRow status={entry} />
</div>
))}
{/* Garmin: no direct connector (business-partner-only). A quiet
non-connector note pointing to the Apple Health / Health Connect
path — not an OAuthProviderCard (nothing to connect). */}
@@ -8,8 +8,10 @@
// the same view-model like WHOOP.
import { useState } from "react";
import Link from "next/link";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
ArrowRight,
HeartPulse,
Link2,
Loader2,
@@ -37,7 +39,6 @@ import { PasswordInput } from "@/components/ui/password-input";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { IntegrationStatusPill } from "@/components/settings/integration-status-pill";
import type { IntegrationPillState } from "@/components/settings/integration-status-pill";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
@@ -50,8 +51,10 @@ import {
import {
IntegrationErrorMessage,
pillStateFor,
pillTimestampFor,
type IntegrationStatusViewModel,
} from "./shared";
import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure";
import {
IntegrationCardDescription,
IntegrationRedirectGuide,
@@ -181,13 +184,16 @@ export function FitbitCard({
setCredsSaving(false);
}
const pillState: IntegrationPillState = status?.connected
? pillStateFor(viewModel)
: "disconnected";
const pillLastSyncAt =
status?.legacyLastSyncedAt ?? viewModel?.lastSuccessAt ?? null;
// The server resolves the verdict; the card only projects it. `connected`
// rides into that resolution, so a disconnected provider still lands on the
// "Not connected" pill without a second local rule here.
const pillState = pillStateFor(viewModel);
const pillLastSyncAt = pillTimestampFor(viewModel);
const errorMessage =
(pillState === "error" || pillState === "parked") && viewModel?.lastError
(pillState === "error" ||
pillState === "parked" ||
pillState === "warning") &&
viewModel?.lastError
? viewModel.lastError
: null;
@@ -272,6 +278,13 @@ export function FitbitCard({
{t("settings.integrationPill.resumeError")}
</p>
)}
{status?.connected && (
<MetricFreshnessDisclosure
entries={viewModel?.metricFreshness}
idPrefix="fitbit"
/>
)}
{resume.isSuccess && resume.data?.wasParked && (
<p
role="status"
@@ -468,6 +481,17 @@ export function FitbitCard({
{syncMsg}
</p>
)}
{/* connect→data loop: a discreet link to where this provider's
readings now surface — doubles as the "your data is richer"
cue. */}
<Link
href="/insights/sleep"
data-testid="fitbit-data-link"
className="text-primary inline-flex items-center gap-1 text-xs underline-offset-2 hover:underline"
>
{t("settings.fitbitViewData")}
<ArrowRight className="h-3 w-3" />
</Link>
</>
) : status?.configured ? (
<Button
@@ -16,8 +16,17 @@
// separate from the connect/disconnect state.
import { useState } from "react";
import Link from "next/link";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Link2, Loader2, RefreshCw, Save, Unlink, Watch } from "lucide-react";
import {
ArrowRight,
Link2,
Loader2,
RefreshCw,
Save,
Unlink,
Watch,
} from "lucide-react";
import {
AlertDialog,
@@ -38,7 +47,6 @@ import { PasswordInput } from "@/components/ui/password-input";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { IntegrationStatusPill } from "@/components/settings/integration-status-pill";
import type { IntegrationPillState } from "@/components/settings/integration-status-pill";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
@@ -51,8 +59,10 @@ import {
import {
IntegrationErrorMessage,
pillStateFor,
pillTimestampFor,
type IntegrationStatusViewModel,
} from "./shared";
import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure";
import { IntegrationCardDescription } from "./setup-guide-link";
export function GoogleHealthCard({
@@ -178,13 +188,16 @@ export function GoogleHealthCard({
setCredsSaving(false);
}
const pillState: IntegrationPillState = status?.connected
? pillStateFor(viewModel)
: "disconnected";
const pillLastSyncAt =
status?.legacyLastSyncedAt ?? viewModel?.lastSuccessAt ?? null;
// The server resolves the verdict; the card only projects it. `connected`
// rides into that resolution, so a disconnected provider still lands on the
// "Not connected" pill without a second local rule here.
const pillState = pillStateFor(viewModel);
const pillLastSyncAt = pillTimestampFor(viewModel);
const errorMessage =
(pillState === "error" || pillState === "parked") && viewModel?.lastError
(pillState === "error" ||
pillState === "parked" ||
pillState === "warning") &&
viewModel?.lastError
? viewModel.lastError
: null;
// The re-consent banner only makes sense while the connection still exists —
@@ -298,6 +311,13 @@ export function GoogleHealthCard({
{t("settings.integrationPill.resumeError")}
</p>
)}
{status?.connected && (
<MetricFreshnessDisclosure
entries={viewModel?.metricFreshness}
idPrefix="google-health"
/>
)}
{resume.isSuccess && resume.data?.wasParked && (
<p
role="status"
@@ -490,6 +510,17 @@ export function GoogleHealthCard({
{syncMsg}
</p>
)}
{/* connect→data loop: a discreet link to where this provider's
readings now surface — doubles as the "your data is richer"
cue. */}
<Link
href="/insights/steps"
data-testid="google-health-data-link"
className="text-primary inline-flex items-center gap-1 text-xs underline-offset-2 hover:underline"
>
{t("settings.googleHealthViewData")}
<ArrowRight className="h-3 w-3" />
</Link>
</>
) : status?.configured ? (
<Button
@@ -0,0 +1,100 @@
"use client";
/**
* The catch-all row — no envelope entry renders nowhere.
*
* A provider whose Settings card was removed kept its sync cron, its ledger
* row and its block on the status envelope, and lost the only surface that
* could have shown it dying. It then stopped syncing for a month and nothing,
* anywhere, said so.
*
* This row closes the class rather than the instance: the connections panel
* iterates the envelope and renders this for every configured entry no bespoke
* card claims. Adding a provider to the envelope without a card now produces a
* visible row instead of a silent JSON block, and a structural test pins that
* nothing can go unrendered.
*
* Read-only by design. A deliberately dropped integration does not get its
* management surface back; it gets an honest status.
*/
import { Plug } from "lucide-react";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import {
formatRelative,
IntegrationStatusPill,
} from "@/components/settings/integration-status-pill";
import { useTranslations } from "@/lib/i18n/context";
import {
pillStateFor,
pillTimestampFor,
type IntegrationKey,
type IntegrationStatusViewModel,
} from "./shared";
/**
* Display names, mirroring the server-side `INTEGRATION_LABELS`. Only the
* card-less keys can ever reach this row, but the map stays complete so a
* future drop needs no edit here.
*/
export const INTEGRATION_DISPLAY_NAMES: Record<IntegrationKey, string> = {
withings: "Withings",
whoop: "WHOOP",
fitbit: "Fitbit",
nightscout: "Nightscout",
polar: "Polar",
oura: "Oura",
"google-health": "Google Health",
strava: "Strava",
moodlog: "moodLog",
};
export function IntegrationFallbackRow({
status,
now,
}: {
status: IntegrationStatusViewModel;
/** Override "now" for deterministic testing. */
now?: Date;
}) {
const { t } = useTranslations();
const pillState = pillStateFor(status);
const reference = now ?? new Date();
const lastAttemptAt =
status.lastAttemptAt ?? status.syncHealth?.since ?? null;
const meta = lastAttemptAt
? t("settings.integrationFallback.lastAttempt", {
relative: formatRelative(
Math.max(0, reference.getTime() - new Date(lastAttemptAt).getTime()),
t,
),
})
: t("settings.integrationFallback.noAttempt");
return (
<SettingsCard
data-testid="integration-fallback-row"
data-integration={status.integration}
>
<SettingsCardHeader
icon={Plug}
title={
INTEGRATION_DISPLAY_NAMES[status.integration] ?? status.integration
}
description={t("settings.integrationFallback.description")}
status={
<IntegrationStatusPill
state={pillState}
lastSyncAt={pillTimestampFor(status)}
now={now}
/>
}
/>
<p className="text-muted-foreground mt-4 pl-7 text-xs">{meta}</p>
</SettingsCard>
);
}
@@ -0,0 +1,154 @@
"use client";
/**
* Per-metric freshness, rendered.
*
* The server has computed a last-seen timestamp per `(source, type)` on every
* status call since v1.27.30 and nothing rendered it. That is the one signal
* that separates "the connection is broken" from "one pipe inside a healthy
* connection is dead" — a revoked HealthKit permission, a per-collection 403,
* an upstream that quietly stopped returning one data type. The integration
* pill cannot express it, because the integration itself is fine.
*
* Collapsed by default: a Withings or Google Health user carries 15-25 types
* and an always-open list would bury the card's actions. The collapsed summary
* still names how many types have gone quiet, so the signal never hides behind
* a click.
*
* Honest absence is structural: the server only reports `(source, type)` pairs
* that have rows, so a type a provider never delivered simply is not here. No
* "expected but missing" row is invented.
*/
import { useState } from "react";
import { AlertTriangle, ChevronDown } from "lucide-react";
import { formatRelative } from "@/components/settings/integration-status-pill";
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";
import { cn } from "@/lib/utils";
/** The pseudo-type carrying a source's newest workout. */
const WORKOUT_TYPE = "WORKOUTS";
/**
* Turn an unmapped enum name into something readable. `MEASUREMENT_TYPE_LABEL_KEYS`
* is a plain record, not enum-complete, so a newly-added type must degrade to
* "Walking asymmetry" rather than leaking `WALKING_ASYMMETRY` at the user.
*/
function humanise(type: string): string {
const words = type.toLowerCase().split("_").join(" ");
return words.charAt(0).toUpperCase() + words.slice(1);
}
export function MetricFreshnessDisclosure({
entries,
idPrefix,
now,
}: {
entries: MetricFreshnessEntry[] | undefined;
/** Namespaces the disclosure's ids so several cards can coexist. */
idPrefix: string;
/** Override "now" for deterministic testing. */
now?: Date;
}) {
const { t } = useTranslations();
const [open, setOpen] = useState(false);
if (!entries?.length) return null;
const reference = now ?? new Date();
const label = (type: string): string => {
if (type === WORKOUT_TYPE)
return t("settings.integrationFreshness.workouts");
const key = MEASUREMENT_TYPE_LABEL_KEYS[type];
return key ? t(key) : humanise(type);
};
const relative = (lastSeenAt: string): string =>
formatRelative(
Math.max(0, reference.getTime() - new Date(lastSeenAt).getTime()),
t,
);
// Dead pipes surface without scrolling; everything else stays alphabetical.
const sorted = [...entries].sort(
(a, b) =>
Number(b.stale) - Number(a.stale) ||
label(a.type).localeCompare(label(b.type)),
);
const staleCount = entries.filter((entry) => entry.stale).length;
const newest = entries.reduce((max, entry) =>
entry.lastSeenAt > max.lastSeenAt ? entry : max,
);
const panelId = `${idPrefix}-metric-freshness-panel`;
return (
<div data-slot="metric-freshness" className="space-y-2">
<button
type="button"
onClick={() => setOpen((value) => !value)}
aria-expanded={open}
aria-controls={panelId}
data-slot="metric-freshness-toggle"
className="flex w-full items-center gap-2 text-left"
>
<ChevronDown
aria-hidden="true"
className={cn(
"text-muted-foreground size-3.5 shrink-0 transition-transform",
open && "rotate-180",
)}
/>
<span className="text-sm font-medium">
{t("settings.integrationFreshness.title")}
</span>
<span className="text-muted-foreground min-w-0 truncate text-xs">
{t("settings.integrationFreshness.summary", {
count: entries.length,
relative: relative(newest.lastSeenAt),
})}
{staleCount > 0 ? (
<span className="text-warning">
{" · "}
{t("settings.integrationFreshness.quiet", { count: staleCount })}
</span>
) : null}
</span>
</button>
{open && (
<ul id={panelId} className="space-y-1">
{sorted.map((entry) => (
<li
key={entry.type}
data-slot="metric-freshness-row"
data-metric={entry.type}
data-state={entry.stale ? "stale" : "fresh"}
className={cn(
"flex items-center justify-between gap-3 text-xs",
entry.stale ? "text-warning" : "text-muted-foreground",
)}
>
<span className="flex min-w-0 items-center gap-1.5">
{entry.stale ? (
<AlertTriangle
aria-hidden="true"
className="size-3 shrink-0"
/>
) : null}
<span className="truncate">{label(entry.type)}</span>
</span>
<time
dateTime={entry.lastSeenAt}
className="shrink-0 tabular-nums"
>
{relative(entry.lastSeenAt)}
</time>
</li>
))}
</ul>
)}
</div>
);
}
@@ -3,13 +3,14 @@
// v1.17.0 — Nightscout CGM integration card. Unlike WHOOP / Fitbit (OAuth,
// BYO-key), Nightscout is a URL + token the self-hoster pastes once: the user
// runs their own instance and HealthLog pulls continuous glucose off it. The
// card has its own status read (`/api/nightscout/status`) rather than the
// consolidated envelope. Warm copy, mobile-first, the private-network opt-in
// toggle maps to `nightscoutAllowPrivateHost`.
// card reads the consolidated `/api/integrations/status` envelope like every
// sibling — it was the last card firing its own status round-trip, and the
// last place a fourth status dialect could hide. Warm copy, mobile-first, the
// private-network opt-in toggle maps to `nightscoutAllowPrivateHost`.
import { useRef, useState } from "react";
import Link from "next/link";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
ArrowRight,
Droplet,
@@ -38,12 +39,9 @@ import { PasswordInput } from "@/components/ui/password-input";
import { Switch } from "@/components/ui/switch";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import {
IntegrationStatusPill,
type IntegrationPillState,
} from "@/components/settings/integration-status-pill";
import { IntegrationStatusPill } from "@/components/settings/integration-status-pill";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import { apiFetchRaw, apiGet, apiPost } from "@/lib/api/api-fetch";
import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import {
invalidateKeys,
@@ -51,43 +49,20 @@ import {
queryKeys,
} from "@/lib/query-keys";
import { IntegrationErrorMessage } from "./shared";
import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure";
import {
IntegrationErrorMessage,
pillStateForVerdict,
pillTimestampFor,
type IntegrationStatusViewModel,
} from "./shared";
import { IntegrationCardDescription } from "./setup-guide-link";
interface NightscoutStatus {
connected: boolean;
configured: boolean;
hasToken?: boolean;
allowPrivateHost?: boolean;
state?:
| "connected"
| "error_transient"
| "error_reauth"
| "disconnected"
| "parked";
lastSuccessAt?: string | null;
lastAttemptAt?: string | null;
lastError?: string | null;
}
/** Map the shared ledger state onto the pill's display state. */
function pillStateFor(
status: NightscoutStatus | undefined,
): IntegrationPillState {
if (!status?.connected) return "disconnected";
switch (status.state) {
case "parked":
return "parked";
case "error_reauth":
return "error";
case "error_transient":
return "warning";
default:
return "connected";
}
}
export function NightscoutCard({ enabled = true }: { enabled?: boolean }) {
export function NightscoutCard({
viewModel,
}: {
viewModel?: IntegrationStatusViewModel;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
@@ -103,12 +78,7 @@ export function NightscoutCard({ enabled = true }: { enabled?: boolean }) {
null,
);
const { data: status } = useQuery({
queryKey: queryKeys.nightscoutStatus(),
queryFn: async () => apiGet<NightscoutStatus>("/api/nightscout/status"),
enabled,
refetchOnWindowFocus: true,
});
const status = viewModel;
const formRef = useRef<HTMLFormElement | null>(null);
@@ -120,12 +90,8 @@ export function NightscoutCard({ enabled = true }: { enabled?: boolean }) {
setUrl("");
setToken("");
setAllowPrivateHost(false);
// The status read keys on `nightscoutStatus()`; invalidate it so the
// pill flips back after a disconnect (matching the Withings / WHOOP
// pattern), alongside the cross-integration envelope.
queryClient.invalidateQueries({
queryKey: queryKeys.nightscoutStatus(),
});
// The card reads the consolidated envelope, so that is the one key a
// disconnect has to invalidate for the pill to flip back.
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
@@ -152,9 +118,6 @@ export function NightscoutCard({ enabled = true }: { enabled?: boolean }) {
setMsgType("success");
setToken("");
void invalidateKeys(queryClient, measurementDependentKeys);
queryClient.invalidateQueries({
queryKey: queryKeys.nightscoutStatus(),
});
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
@@ -191,9 +154,6 @@ export function NightscoutCard({ enabled = true }: { enabled?: boolean }) {
);
setSyncMsgType("success");
void invalidateKeys(queryClient, measurementDependentKeys);
queryClient.invalidateQueries({
queryKey: queryKeys.nightscoutStatus(),
});
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
@@ -209,9 +169,15 @@ export function NightscoutCard({ enabled = true }: { enabled?: boolean }) {
}
}
const pillState = pillStateFor(status);
const pillState = pillStateForVerdict(status?.syncHealth?.verdict);
// `warning` joins the set now that a transient failure paints amber rather
// than red — the detail belongs on this line, not in a colour that tells the
// user to reconnect against something reconnecting cannot fix.
const errorMessage =
(pillState === "error" || pillState === "parked") && status?.lastError
(pillState === "error" ||
pillState === "parked" ||
pillState === "warning") &&
status?.lastError
? status.lastError
: null;
@@ -234,12 +200,15 @@ export function NightscoutCard({ enabled = true }: { enabled?: boolean }) {
status={
<IntegrationStatusPill
state={pillState}
lastSyncAt={status?.lastSuccessAt ?? null}
lastSyncAt={pillTimestampFor(status)}
/>
}
/>
<hr className="border-border/60 mt-4" />
<hr
data-testid="integration-card-divider"
className="border-border/60 mt-4"
/>
<div className="mt-4 space-y-4 pl-7">
{errorMessage && <IntegrationErrorMessage message={errorMessage} />}
@@ -279,6 +248,13 @@ export function NightscoutCard({ enabled = true }: { enabled?: boolean }) {
</div>
)}
{status?.connected && (
<MetricFreshnessDisclosure
entries={status.metricFreshness}
idPrefix="nightscout"
/>
)}
<form ref={formRef} onSubmit={handleConnect} className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="nightscout-url">
@@ -10,7 +10,7 @@
import { useState } from "react";
import Link from "next/link";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
ArrowRight,
Link2,
@@ -38,18 +38,21 @@ import { Label } from "@/components/ui/label";
import { PasswordInput } from "@/components/ui/password-input";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import {
IntegrationStatusPill,
type IntegrationPillState,
} from "@/components/settings/integration-status-pill";
import { IntegrationStatusPill } from "@/components/settings/integration-status-pill";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import { apiFetchRaw, apiGet, apiPost } from "@/lib/api/api-fetch";
import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import type {
MetricFreshnessEntry,
SyncHealth,
} from "@/lib/integrations/sync-verdict";
import {
invalidateKeys,
measurementDependentKeys,
queryKeys,
} from "@/lib/query-keys";
import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure";
import { IntegrationErrorMessage, pillStateForVerdict } from "./shared";
import {
IntegrationCardDescription,
IntegrationRedirectGuide,
@@ -71,28 +74,16 @@ export interface OAuthProviderStatus {
lastSuccessAt?: string | null;
lastAttemptAt?: string | null;
lastError?: string | null;
}
function pillStateFor(
status: OAuthProviderStatus | undefined,
): IntegrationPillState {
if (!status?.connected) return "disconnected";
switch (status.state) {
case "parked":
return "parked";
case "error_reauth":
return "error";
case "error_transient":
return "warning";
default:
return "connected";
}
legacyLastSyncedAt?: string | null;
/** The server-resolved liveness verdict; the pill is a projection of it. */
syncHealth?: SyncHealth;
metricFreshness?: MetricFreshnessEntry[];
}
export interface OAuthProviderCardProps {
/** Lower-case provider key, used for routes + query keys + testids. */
provider: "polar" | "oura" | "strava";
/** The query-key array shared by the status read + the invalidations. */
/** The provider's own query key, invalidated after connect / sync / save. */
statusQueryKey: readonly unknown[];
/** i18n key prefix (e.g. `settings.polar`). */
i18nPrefix: string;
@@ -103,12 +94,14 @@ export interface OAuthProviderCardProps {
/** When set, render a per-user BYO OAuth-credentials form above the connect
* button. The endpoint is the PUT target (e.g. `/api/polar/credentials`). */
credentials?: boolean;
enabled?: boolean;
/**
* When provided, the card reads its status from this view-model (sourced off
* the consolidated `/api/integrations/status` envelope) instead of firing its
* own `/api/<provider>/status` round-trip. v1.17.1 folds Polar/Oura onto the
* same envelope WHOOP/Fitbit already use, so the page reads from one source.
* The card's status, sourced off the consolidated
* `/api/integrations/status` envelope. The card used to fall back to its own
* `/api/<provider>/status` round-trip when this was absent; that fallback is
* gone. Every mount passes a view-model, the per-provider response carries no
* `syncHealth` so the fallback would have painted a wrong verdict, and during
* envelope load it fired three redundant requests for a caller that does not
* exist.
*/
viewModel?: OAuthProviderStatus;
}
@@ -120,7 +113,6 @@ export function OAuthProviderCard({
icon,
dataHref,
credentials = false,
enabled = true,
viewModel,
}: OAuthProviderCardProps) {
const { t } = useTranslations();
@@ -139,17 +131,7 @@ export function OAuthProviderCard({
null,
);
// Read off the consolidated envelope when a view-model is passed; otherwise
// fall back to the per-card status fetch (still used by any caller that has
// not been migrated onto the envelope). The fetch is disabled once a
// view-model is supplied so the page makes one request, not one-per-card.
const { data: fetchedStatus } = useQuery({
queryKey: statusQueryKey,
queryFn: async () => apiGet<OAuthProviderStatus>(`/api/${provider}/status`),
enabled: enabled && !viewModel,
refetchOnWindowFocus: true,
});
const status = viewModel ?? fetchedStatus;
const status = viewModel;
// Sync now. Same shape as the WHOOP card's incremental arm, minus the
// full-history dialog — none of these providers has a full-sync arm to call.
@@ -235,9 +217,16 @@ export function OAuthProviderCard({
},
});
const pillState = pillStateFor(status);
const pillState = pillStateForVerdict(status?.syncHealth?.verdict);
// The pill says what; this line says why. `warning` joins the set now that a
// transient failure no longer paints red — the detail moved here from a
// colour that told the user to reconnect against something reconnecting
// cannot fix.
const errorMessage =
(pillState === "error" || pillState === "parked") && status?.lastError
(pillState === "error" ||
pillState === "parked" ||
pillState === "warning") &&
status?.lastError
? status.lastError
: null;
const serverUnavailable = status && !status.available;
@@ -262,22 +251,25 @@ export function OAuthProviderCard({
status={
<IntegrationStatusPill
state={pillState}
lastSyncAt={status?.lastSuccessAt ?? null}
lastSyncAt={
pillState === "stale" || pillState === "stalled"
? (status?.syncHealth?.since ?? null)
: (status?.legacyLastSyncedAt ?? status?.lastSuccessAt ?? null)
}
/>
}
/>
<hr className="border-border/60 mt-4" />
<hr
data-testid="integration-card-divider"
className="border-border/60 mt-4"
/>
<div className="mt-4 space-y-4 pl-7">
{errorMessage && (
<p
role="alert"
data-testid={`${provider}-error`}
className="text-destructive text-sm break-words"
>
{errorMessage}
</p>
<div data-testid={`${provider}-error`}>
<IntegrationErrorMessage message={errorMessage} />
</div>
)}
{/* Parked-integration resume CTA. Surfaces only when the row state
@@ -310,6 +302,13 @@ export function OAuthProviderCard({
</div>
)}
{status?.connected && (
<MetricFreshnessDisclosure
entries={status.metricFreshness}
idPrefix={provider}
/>
)}
{credentials && (
<div className="space-y-3" data-testid={`${provider}-credentials`}>
<h3 className="text-sm font-semibold">
@@ -12,13 +12,7 @@ import {
} from "@/components/settings/integrations/oauth-provider-card";
import { queryKeys } from "@/lib/query-keys";
export function OuraCard({
enabled = true,
viewModel,
}: {
enabled?: boolean;
viewModel?: OAuthProviderStatus;
}) {
export function OuraCard({ viewModel }: { viewModel?: OAuthProviderStatus }) {
return (
<OAuthProviderCard
provider="oura"
@@ -27,7 +21,6 @@ export function OuraCard({
icon={CircleDot}
dataHref="/insights/sleep"
credentials
enabled={enabled}
viewModel={viewModel}
/>
);
@@ -12,13 +12,7 @@ import {
} from "@/components/settings/integrations/oauth-provider-card";
import { queryKeys } from "@/lib/query-keys";
export function PolarCard({
enabled = true,
viewModel,
}: {
enabled?: boolean;
viewModel?: OAuthProviderStatus;
}) {
export function PolarCard({ viewModel }: { viewModel?: OAuthProviderStatus }) {
return (
<OAuthProviderCard
provider="polar"
@@ -27,7 +21,6 @@ export function PolarCard({
icon={Watch}
dataHref="/insights/sleep"
credentials
enabled={enabled}
viewModel={viewModel}
/>
);
+68 -28
View File
@@ -12,6 +12,11 @@ import { AlertCircle } from "lucide-react";
import type { IntegrationPillState } from "@/components/settings/integration-status-pill";
import { apiGet } from "@/lib/api/api-fetch";
import type {
MetricFreshnessEntry,
SyncHealth,
SyncVerdict,
} from "@/lib/integrations/sync-verdict";
import { queryKeys } from "@/lib/query-keys";
// v1.4.15 Phase B2: shared status payload for both integration cards.
@@ -28,7 +33,10 @@ export type IntegrationKey =
// v1.27.0 — Google Health (Fitbit + Pixel Watch + Fitbit Air).
| "google-health"
// v1.28.x — Strava OAuth workout source.
| "strava";
| "strava"
// Nightscout folds onto the envelope so the card stops fetching its own
// status and inherits the shared verdict.
| "nightscout";
export type IntegrationState =
"connected" | "error_transient" | "error_reauth" | "disconnected" | "parked";
@@ -68,6 +76,16 @@ export interface IntegrationStatusViewModel {
// drives the saved-placeholder UI.
available?: boolean;
hasOwnCredentials?: boolean;
// Nightscout: a stored token, and the private-network opt-in.
hasToken?: boolean;
allowPrivateHost?: boolean;
/**
* The server-resolved liveness verdict. Present on every envelope entry;
* `undefined` only while the envelope is still loading.
*/
syncHealth?: SyncHealth;
/** Per-metric last-seen timestamps with the server-computed stale flag. */
metricFreshness?: MetricFreshnessEntry[];
}
export interface IntegrationStatusEnvelope {
@@ -100,44 +118,66 @@ export function pickStatus(
}
/**
* Collapse the API's five-state machine into the four states the
* pill UI cares about. `error_transient` and `error_reauth` both
* surface as the same "Error — reconnect" pill, the actionable
* difference (whether the user must reconnect vs wait for the next
* retry) is conveyed via the inline error text underneath. `parked`
* (v1.4.43 W14) is its own pill state — the integration has been
* disabled after 24h of persistent failures and needs an explicit
* "Wieder verbinden" click to resume.
* The one verdict→pill mapping in the tree.
*
* There used to be three of these, one per card family, and they disagreed:
* the same ledger state painted red on one card and orange on the next, on the
* same page. The server now resolves the verdict once and this projects it once
* — a fourth dialect cannot be minted without the structural guard noticing.
*
* `failing` deliberately lands on the warning tone, not the destructive one:
* **red is reserved for "your action fixes this"** (reconnect, resume). Telling
* a user to reconnect against a 503 sends them clicking at something they
* cannot repair; the streak detail survives in the inline error line and the
* "{n}/{threshold} consecutive failures" string the envelope single-sources.
*/
export function pillStateFor(
status: IntegrationStatusViewModel | undefined,
export function pillStateForVerdict(
verdict: SyncVerdict | undefined,
): IntegrationPillState {
if (!status) return "disconnected";
switch (status.state) {
case "connected":
switch (verdict) {
case "fresh":
return "connected";
case "error_transient":
// v1.4.43 W4 H3 — a `persistent` failure-kind streak (Withings
// rate-limit 601 / contract-mismatch 293/294) maps to the same
// `error_transient` DB state as a normal retryable failure but
// tells the user a different story: the access token still
// works, the upstream is responding with a non-recoverable
// status. Surfacing it as a "warning" pill (orange) instead of
// the red "Fehler — neu verbinden" stops the user from clicking
// reconnect ten times when reconnect can't fix it.
if ((status.consecutiveFailuresByKind?.persistent ?? 0) > 0) {
return "warning";
}
return "error";
case "error_reauth":
case "stale":
return "stale";
case "stalled":
return "stalled";
case "failing":
return "warning";
case "reauth_required":
return "error";
case "parked":
return "parked";
case "pending_first_sync":
return "pending-setup";
case "disconnected":
case undefined:
return "disconnected";
}
}
/** Project a view-model onto its pill state. Undefined = still loading. */
export function pillStateFor(
status: IntegrationStatusViewModel | undefined,
): IntegrationPillState {
return pillStateForVerdict(status?.syncHealth?.verdict);
}
/**
* The timestamp the pill shows inline. `connected` reads the last success (a
* connection row's own `lastSyncedAt` wins when it is newer, which is what
* repaints a fresh "just now" straight after a manual sync); the two aging
* states read the instant that triggered the verdict.
*/
export function pillTimestampFor(
status: IntegrationStatusViewModel | undefined,
): string | null {
const verdict = status?.syncHealth?.verdict;
if (verdict === "stale" || verdict === "stalled") {
return status?.syncHealth?.since ?? null;
}
return status?.legacyLastSyncedAt ?? status?.lastSuccessAt ?? null;
}
/**
* Inline actionable error message that surfaces under the pill when a
* sync attempt failed. The pill conveys "something is wrong"; this
@@ -13,13 +13,7 @@ import {
} from "@/components/settings/integrations/oauth-provider-card";
import { queryKeys } from "@/lib/query-keys";
export function StravaCard({
enabled = true,
viewModel,
}: {
enabled?: boolean;
viewModel?: OAuthProviderStatus;
}) {
export function StravaCard({ viewModel }: { viewModel?: OAuthProviderStatus }) {
return (
<OAuthProviderCard
provider="strava"
@@ -28,7 +22,6 @@ export function StravaCard({
icon={Activity}
dataHref="/insights/workouts"
credentials
enabled={enabled}
viewModel={viewModel}
/>
);
@@ -1,9 +1,11 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
Activity,
ArrowRight,
Link2,
Loader2,
RefreshCw,
@@ -29,7 +31,6 @@ import { PasswordInput } from "@/components/ui/password-input";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { IntegrationStatusPill } from "@/components/settings/integration-status-pill";
import type { IntegrationPillState } from "@/components/settings/integration-status-pill";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
@@ -42,8 +43,10 @@ import {
import {
IntegrationErrorMessage,
pillStateFor,
pillTimestampFor,
type IntegrationStatusViewModel,
} from "./shared";
import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure";
import {
IntegrationCardDescription,
IntegrationRedirectGuide,
@@ -175,18 +178,21 @@ export function WhoopCard({
setCredsSaving(false);
}
const pillState: IntegrationPillState = status?.connected
? pillStateFor(viewModel)
: "disconnected";
const pillLastSyncAt =
status?.legacyLastSyncedAt ?? viewModel?.lastSuccessAt ?? null;
// The server resolves the verdict; the card only projects it. `connected`
// rides into that resolution, so a disconnected provider still lands on the
// "Not connected" pill without a second local rule here.
const pillState = pillStateFor(viewModel);
const pillLastSyncAt = pillTimestampFor(viewModel);
const errorMessage =
(pillState === "error" || pillState === "parked") && viewModel?.lastError
(pillState === "error" ||
pillState === "parked" ||
pillState === "warning") &&
viewModel?.lastError
? viewModel.lastError
: null;
return (
<SettingsCard>
<SettingsCard data-testid="whoop-card">
<SettingsCardHeader
icon={Activity}
title={t("settings.whoop")}
@@ -253,6 +259,13 @@ export function WhoopCard({
{t("settings.integrationPill.resumeError")}
</p>
)}
{status?.connected && (
<MetricFreshnessDisclosure
entries={viewModel?.metricFreshness}
idPrefix="whoop"
/>
)}
{resume.isSuccess && resume.data?.wasParked && (
<p
role="status"
@@ -449,6 +462,17 @@ export function WhoopCard({
{syncMsg}
</p>
)}
{/* connect→data loop: a discreet link to where this provider's
readings now surface — doubles as the "your data is richer"
cue. */}
<Link
href="/insights/recovery"
data-testid="whoop-data-link"
className="text-primary inline-flex items-center gap-1 text-xs underline-offset-2 hover:underline"
>
{t("settings.whoopViewData")}
<ArrowRight className="h-3 w-3" />
</Link>
</>
) : status?.configured ? (
<Button
@@ -1,8 +1,16 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Link2, Loader2, RefreshCw, Save, Unlink } from "lucide-react";
import {
ArrowRight,
Link2,
Loader2,
RefreshCw,
Save,
Unlink,
} from "lucide-react";
import {
AlertDialog,
@@ -22,7 +30,6 @@ import { PasswordInput } from "@/components/ui/password-input";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { IntegrationStatusPill } from "@/components/settings/integration-status-pill";
import type { IntegrationPillState } from "@/components/settings/integration-status-pill";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
@@ -35,8 +42,10 @@ import {
import {
IntegrationErrorMessage,
pillStateFor,
pillTimestampFor,
type IntegrationStatusViewModel,
} from "./shared";
import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure";
import {
IntegrationCardDescription,
IntegrationRedirectGuide,
@@ -180,21 +189,24 @@ export function WithingsCard({
// still paints a fresh "X min ago" right after a manual sync once
// the envelope refetches. When the envelope hasn't answered yet we
// fall back to "disconnected" so the card never renders status-less.
const pillState: IntegrationPillState = status?.connected
? pillStateFor(viewModel)
: "disconnected";
const pillLastSyncAt =
status?.legacyLastSyncedAt ?? viewModel?.lastSuccessAt ?? null;
// The server resolves the verdict; the card only projects it. `connected`
// rides into that resolution, so a disconnected provider still lands on the
// "Not connected" pill without a second local rule here.
const pillState = pillStateFor(viewModel);
const pillLastSyncAt = pillTimestampFor(viewModel);
// v1.4.43 W14 — `parked` and `error` both want the underlying error
// message surfaced under the pill (the pill says "what" — the
// message says "why"). Other states leave the inline line off.
const errorMessage =
(pillState === "error" || pillState === "parked") && viewModel?.lastError
(pillState === "error" ||
pillState === "parked" ||
pillState === "warning") &&
viewModel?.lastError
? viewModel.lastError
: null;
return (
<SettingsCard>
<SettingsCard data-testid="withings-card">
<SettingsCardHeader
icon={Link2}
title={t("settings.withings")}
@@ -289,6 +301,13 @@ export function WithingsCard({
{t("settings.integrationPill.resumeError")}
</p>
)}
{status?.connected && (
<MetricFreshnessDisclosure
entries={viewModel?.metricFreshness}
idPrefix="withings"
/>
)}
{resume.isSuccess && resume.data?.wasParked && (
<p
role="status"
@@ -494,6 +513,17 @@ export function WithingsCard({
{syncMsg}
</p>
)}
{/* connect→data loop: a discreet link to where this provider's
readings now surface — doubles as the "your data is richer"
cue. */}
<Link
href="/measurements"
data-testid="withings-data-link"
className="text-primary inline-flex items-center gap-1 text-xs underline-offset-2 hover:underline"
>
{t("settings.withingsViewData")}
<ArrowRight className="h-3 w-3" />
</Link>
</>
) : status?.configured ? (
<Button
+3 -4
View File
@@ -43,11 +43,10 @@ export const integrationKeys = {
// never collides with the classic `fitbit` key.
googleHealth: () => ["google-health"] as const,
// v1.17.0 — Nightscout CGM integration card. Self-contained status read
// (not the consolidated /api/integrations/status envelope), so the
// connect/disconnect mutations invalidate this single key.
// v1.17.0 — Nightscout CGM integration card. Reads status off the
// consolidated /api/integrations/status envelope (like Fitbit/WHOOP); this
// key stays for the card's other reads and invalidations.
nightscout: () => ["nightscout"] as const,
nightscoutStatus: () => ["nightscout", "status"] as const,
// v1.17.0 (F4) — Polar + Oura OAuth integration cards. Self-contained status
// reads (not the consolidated /api/integrations/status envelope), so the