feat(settings): one integration card for every connection

Every connection in Settings renders the same card from the server's health
field: a badge with one word, one sentence per reason, the newest data as a
meta line, a collapsed details block whose text is exactly what the copy
button copies, and an action row with the rare actions in a menu and at most
one primary button. The per-provider banners, the raw provider error line,
the token expiry, the failure counter and the button rows are gone; every
capability they carried is reachable from the sentence, the one button, the
menu or the details. The managed-profile view renders the same card,
read-only. A structural guard keeps the cards off the retired fields.
This commit is contained in:
Marc-André Bombeck committed 2026-10-15 15:06:03 +02:00
1 parent b17a250894
commit 1a5b09a73c
39 files changed
+1505 -5082

No files matched your search

+14 -1
View File
@@ -628,8 +628,21 @@ test.describe.serial("scoped sharing browser journeys", () => {
);
await expect(integrations).toBeVisible();
await expect(integrations).toHaveAttribute("data-record-id", accountId);
// Read-only: the shared integration cards render, but nothing on them
// acts on a provider. The only buttons are each card's own "details for a
// bug report" toggle and its copy button.
await expect(
integrations.locator("button, input, select, textarea"),
integrations.locator('[data-slot="integration-card"]'),
).toHaveCount(8);
await expect(
integrations.locator(
'[data-slot="integration-primary-action"], [data-slot="integration-menu-trigger"], input, select, textarea',
),
).toHaveCount(0);
await expect(
integrations.locator(
'button:not([data-slot="integration-details-toggle"]):not([data-slot="integration-diagnostics-copy"])',
),
).toHaveCount(0);
await page.goBack();
+70 -23
View File
@@ -5315,7 +5315,8 @@
"unavailableTitle": "In diesem geteilten Datensatz nicht verfügbar",
"unavailableDescription": "Dieser Einstellungsbereich ist beim Anzeigen des Datensatzes einer anderen Person bewusst nicht verfügbar.",
"integrationStatusDescription": "Verbindungsstatus für diesen verwalteten Datensatz.",
"integrationStatusDetail": "Verbindungssteuerungen sind nicht verfügbar."
"integrationStatusDetail": "Ein betreutes Profil zeigt seine verbundenen Dienste nur als Status. Verbinden, neu verbinden, synchronisieren und trennen ist für Betreuende nicht möglich.",
"integrationStatusDocs": "So funktioniert das Teilen"
},
"sections": {
"account": {
@@ -6391,7 +6392,6 @@
"whoopDisconnectDescription": "Die Verbindung zu WHOOP wird getrennt. Bereits synchronisierte Daten bleiben erhalten.",
"whoopConnect": "Mit WHOOP verbinden",
"whoopNoCredentials": "Bitte gib oben deine API-Zugangsdaten ein, um WHOOP zu verbinden.",
"whoopBackfillInProgress": "Dein WHOOP-Verlauf wird im Hintergrund importiert…",
"whoopOauthConnected": "WHOOP verbunden. Die erste Synchronisierung startet im Hintergrund.",
"whoopOauthFailed": "WHOOP-Verbindung fehlgeschlagen",
"whoopOauthError": {
@@ -6427,7 +6427,6 @@
"fitbitDisconnectDescription": "Die Verbindung zu Fitbit wird getrennt. Zuvor synchronisierte Daten bleiben erhalten.",
"fitbitConnect": "Mit Fitbit verbinden",
"fitbitNoCredentials": "Bitte gib oben deine API-Zugangsdaten ein, um Fitbit zu verbinden.",
"fitbitBackfillInProgress": "Dein Fitbit-Verlauf wird im Hintergrund importiert…",
"fitbitOauthConnected": "Fitbit verbunden. Die erste Synchronisierung startet im Hintergrund.",
"fitbitOauthFailed": "Fitbit-Verbindung fehlgeschlagen",
"googleHealth": "Google Health",
@@ -6472,9 +6471,6 @@
"googleHealthDisconnectDescription": "Die Verbindung zu Google Health wird getrennt. Bereits synchronisierte Daten bleiben erhalten.",
"googleHealthConnect": "Mit Google verbinden",
"googleHealthNoCredentials": "Bitte gib oben deine API-Zugangsdaten ein, um Google Health zu verbinden.",
"googleHealthBackfillInProgress": "Dein Google-Health-Verlauf wird im Hintergrund importiert…",
"googleHealthReauthBanner": "Google hat die Verbindung ablaufen lassen. Das passiert regelmäßig, solange deine OAuth-App im Testmodus bleibt. Verbinde neu, um die Synchronisierung fortzusetzen.",
"googleHealthReconnect": "Neu verbinden",
"googleHealthOauthConnected": "Google Health verbunden. Die erste Synchronisierung startet im Hintergrund.",
"googleHealthOauthFailed": "Verbindung zu Google Health fehlgeschlagen",
"googleHealthOauthError": {
@@ -6499,16 +6495,7 @@
},
"integrations": {
"withings": {
"reconnect": {
"banner": {
"title": "Aktivitäts-Sync verfügbar",
"body": "Deine Withings-Verbindung wurde autorisiert, bevor der Aktivitäts-Sync hinzugefügt wurde. Verbinde einmal neu, um Schritte, aktive Energie, Geh- und Laufdistanz sowie Stockwerke aus deinen Withings-Geräten zu übernehmen.",
"action": "Withings neu verbinden"
}
},
"plan": {
"title": "Einige Werte brauchen den kostenpflichtigen API-Plan von Withings",
"body": "Withings bietet {list} jetzt nur noch im kostenpflichtigen API-Plan an, und deine Withings-Entwickler-App nutzt den kostenlosen Plan. Gewicht, Blutdruck, Körperzusammensetzung, Herzfrequenz und Schlaf werden weiter synchronisiert.",
"link": "Mehr zu den API-Plänen von Withings",
"items": {
"spo2": "SpO2",
@@ -6538,10 +6525,7 @@
},
"oura": {
"scope": {
"body": "Verbinde Oura einmal neu, um {list} freizugeben. Alles andere wird weiter synchronisiert.",
"action": "Oura neu verbinden",
"items": {
"spo2": "SpO2",
"vo2max": "VO2max",
"cardio_age": "kardiovaskuläres Alter",
"resilience": "Resilienz"
@@ -6560,7 +6544,6 @@
"minutesAgo": "vor {count} min",
"hoursAgo": "vor {count} Std.",
"daysAgo": "vor {count} d",
"resumeCta": "Wieder verbinden",
"resumeSuccess": "Integration wieder aktiv",
"resumeError": "Wieder verbinden fehlgeschlagen",
"stale": "Verbunden, keine neuen Daten",
@@ -6574,9 +6557,7 @@
"workouts": "Workouts"
},
"integrationFallback": {
"description": "Für dieses Konto eingerichtet, hat aber keine eigene Einstellungskarte.",
"lastAttempt": "Letzter Versuch {relative}",
"noAttempt": "Kein Sync-Versuch aufgezeichnet"
"description": "Für dieses Konto eingerichtet, hat aber keine eigene Einstellungskarte."
},
"withingsViewData": "Messwerte ansehen",
"whoopViewData": "Erholung ansehen",
@@ -7124,7 +7105,6 @@
"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",
"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.",
"progress": {
"title": "Sync-Fortschritt",
"receivedLabel": "Empfangene Datensätze",
@@ -7217,6 +7197,73 @@
"displayCard": {
"title": "Darstellung",
"description": "Sprache, Einheiten und Formate, gespeichert, sobald du sie änderst."
},
"integrationCard": {
"state": {
"ok": "Läuft",
"limited": "Eingeschränkt",
"degraded": "Verzögert",
"actionRequired": "Aktion nötig"
},
"reason": {
"dataArriving": "Daten kommen an.",
"dataArrivingHistory": "Daten kommen an. Der ältere Verlauf wird im Hintergrund geladen.",
"firstSyncPending": "Verbunden. Die erste Synchronisierung läuft in Kürze.",
"planLimited": "Daten kommen an. Dein {provider}-Tarif enthält {list} nicht.",
"permissionLimited": "Daten kommen an. Für {list} fehlt die Freigabe.",
"providerErrors": "{provider} antwortet seit {since} nicht zuverlässig. HealthLog versucht es weiter, du musst nichts tun.",
"providerErrorsNoSince": "{provider} antwortet gerade nicht zuverlässig. HealthLog versucht es weiter, du musst nichts tun.",
"syncStalled": "Seit {since} hat HealthLog nicht mehr synchronisiert. Das liegt am Server, nicht an deinem Gerät.",
"syncStalledNoSince": "HealthLog synchronisiert gerade nicht. Das liegt am Server, nicht an deinem Gerät.",
"noNewData": "Seit {since} sind keine neuen Daten gekommen.",
"noNewDataNoSince": "Seit einer Weile sind keine neuen Daten gekommen.",
"noNewDataApple": "Seit {since} sind keine neuen Daten gekommen. Öffne HealthLog einmal auf dem iPhone.",
"noNewDataAppleNoSince": "Seit einer Weile sind keine neuen Daten gekommen. Öffne HealthLog einmal auf dem iPhone.",
"unexpectedSilence": "Seit {since} kommen keine Daten, obwohl {provider} sonst täglich liefert.",
"unexpectedSilenceNoSince": "Es kommen keine Daten, obwohl {provider} sonst täglich liefert.",
"reauthRequired": "Die Verbindung zu {provider} ist abgelaufen. Verbinde neu, damit wieder Daten kommen.",
"pausedAfterErrors": "{provider} ist pausiert, weil es seit {since} nur Fehler gab.",
"pausedAfterErrorsNoSince": "{provider} ist pausiert, weil es zuletzt nur Fehler gab."
},
"when": {
"today": "heute, {time}",
"yesterday": "gestern, {time}",
"date": "{date}, {time}"
},
"items": {
"activity": "Aktivität",
"sleep": "Schlaf"
},
"moreValuesOne": "{count} weiteren Wert",
"moreValuesFew": "{count} weitere Werte",
"moreValuesOther": "{count} weitere Werte",
"notConnected": "Nicht verbunden.",
"lastData": "Letzte Daten: {when}",
"lastDataNone": "Letzte Daten: noch keine",
"reconnect": "Neu verbinden",
"resume": "Fortsetzen",
"menuLabel": "Weitere Aktionen für {provider}",
"menu": {
"credentials": "Zugangsdaten ändern",
"address": "Adresse und Token ändern"
},
"syncing": "Synchronisierung läuft…",
"details": {
"title": "Details für eine Fehlermeldung",
"none": "keine",
"state": "Zustand",
"since": "seit",
"lastSync": "Letzte Synchronisierung",
"lastData": "Letzte Daten",
"failingParts": "fehlende Teile",
"recentCalls": "Letzte Aufrufe",
"unavailable": "Nicht verfügbar",
"unavailableList": "Nicht verfügbar: {list}",
"causePlan": "Tarif",
"causePermission": "Freigabe",
"failureStreak": "Fehlerserie: {count} von {threshold}",
"copyFailed": "Kopieren hat nicht geklappt. Markiere den Text und kopiere ihn von Hand."
}
}
},
"admin": {
+70 -23
View File
@@ -5315,7 +5315,8 @@
"unavailableTitle": "Unavailable in this shared record",
"unavailableDescription": "This Settings area is intentionally unavailable while viewing another person's record.",
"integrationStatusDescription": "Connection status for this managed record.",
"integrationStatusDetail": "Connection controls are unavailable."
"integrationStatusDetail": "A managed profile shows its connected services as a status only. Connecting, reconnecting, syncing and disconnecting are not available to guardians.",
"integrationStatusDocs": "How sharing works"
},
"sections": {
"account": {
@@ -6391,7 +6392,6 @@
"whoopDisconnectDescription": "The connection to WHOOP will be disconnected. Previously synced data will be preserved.",
"whoopConnect": "Connect with WHOOP",
"whoopNoCredentials": "Please enter your API credentials above to connect WHOOP.",
"whoopBackfillInProgress": "Importing your WHOOP history in the background…",
"whoopOauthConnected": "WHOOP connected. The first sync starts in the background.",
"whoopOauthFailed": "WHOOP connection failed",
"whoopOauthError": {
@@ -6427,7 +6427,6 @@
"fitbitDisconnectDescription": "The connection to Fitbit will be disconnected. Previously synced data will be preserved.",
"fitbitConnect": "Connect with Fitbit",
"fitbitNoCredentials": "Please enter your API credentials above to connect Fitbit.",
"fitbitBackfillInProgress": "Importing your Fitbit history in the background…",
"fitbitOauthConnected": "Fitbit connected. The first sync starts in the background.",
"fitbitOauthFailed": "Fitbit connection failed",
"googleHealth": "Google Health",
@@ -6472,9 +6471,6 @@
"googleHealthDisconnectDescription": "The connection to Google Health will be disconnected. Previously synced data will be preserved.",
"googleHealthConnect": "Connect with Google",
"googleHealthNoCredentials": "Please enter your API credentials above to connect Google Health.",
"googleHealthBackfillInProgress": "Importing your Google Health history in the background…",
"googleHealthReauthBanner": "Google has expired the connection. This happens periodically while your OAuth app stays in Testing mode. Reconnect to resume syncing.",
"googleHealthReconnect": "Reconnect",
"googleHealthOauthConnected": "Google Health connected. The first sync starts in the background.",
"googleHealthOauthFailed": "Google Health connection failed",
"googleHealthOauthError": {
@@ -6499,16 +6495,7 @@
},
"integrations": {
"withings": {
"reconnect": {
"banner": {
"title": "Activity sync available",
"body": "Your Withings connection was authorised before activity sync was added. Reconnect once to enable steps, active energy, walking + running distance, and floors-climbed ingest from your Withings devices.",
"action": "Reconnect Withings"
}
},
"plan": {
"title": "Some readings need Withings' paid API plan",
"body": "Withings now offers {list} only in its paid API plan, and your Withings developer app is on the free plan. Weight, blood pressure, body composition, heart rate and sleep keep syncing.",
"link": "About the Withings API plans",
"items": {
"spo2": "SpO2",
@@ -6538,10 +6525,7 @@
},
"oura": {
"scope": {
"body": "Reconnect Oura once to allow {list}. Everything else keeps syncing.",
"action": "Reconnect Oura",
"items": {
"spo2": "SpO2",
"vo2max": "VO2 max",
"cardio_age": "cardiovascular age",
"resilience": "resilience"
@@ -6560,7 +6544,6 @@
"minutesAgo": "{count} min ago",
"hoursAgo": "{count} h ago",
"daysAgo": "{count} d ago",
"resumeCta": "Reconnect",
"resumeSuccess": "Integration resumed",
"resumeError": "Reconnect failed",
"stale": "Connected, no recent data",
@@ -6574,9 +6557,7 @@
"workouts": "Workouts"
},
"integrationFallback": {
"description": "Configured on this account without a settings card of its own.",
"lastAttempt": "Last attempt {relative}",
"noAttempt": "No sync attempt recorded"
"description": "Configured on this account without a settings card of its own."
},
"withingsViewData": "View your measurements",
"whoopViewData": "View your recovery",
@@ -7124,7 +7105,6 @@
"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",
"staleNote": "No Apple Health data has arrived for over a week. Open the HealthLog app on your iPhone and check the Apple Health permissions.",
"progress": {
"title": "Sync progress",
"receivedLabel": "Records received",
@@ -7217,6 +7197,73 @@
"displayCard": {
"title": "Display",
"description": "Language, units and formats, saved as soon as you change them."
},
"integrationCard": {
"state": {
"ok": "Working",
"limited": "Limited",
"degraded": "Delayed",
"actionRequired": "Action needed"
},
"reason": {
"dataArriving": "Data is arriving.",
"dataArrivingHistory": "Data is arriving. Older history is loading in the background.",
"firstSyncPending": "Connected. The first sync runs shortly.",
"planLimited": "Data is arriving. Your {provider} plan does not include {list}.",
"permissionLimited": "Data is arriving. Permission is missing for {list}.",
"providerErrors": "{provider} has not answered reliably since {since}. HealthLog keeps trying; nothing to do on your side.",
"providerErrorsNoSince": "{provider} is not answering reliably right now. HealthLog keeps trying; nothing to do on your side.",
"syncStalled": "HealthLog has not synced since {since}. That is on the server, not your device.",
"syncStalledNoSince": "HealthLog is not syncing right now. That is on the server, not your device.",
"noNewData": "No new data since {since}.",
"noNewDataNoSince": "No new data for a while.",
"noNewDataApple": "No new data since {since}. Open HealthLog on your iPhone once.",
"noNewDataAppleNoSince": "No new data for a while. Open HealthLog on your iPhone once.",
"unexpectedSilence": "Nothing has arrived since {since}, although {provider} usually delivers daily.",
"unexpectedSilenceNoSince": "Nothing is arriving, although {provider} usually delivers daily.",
"reauthRequired": "The {provider} connection has expired. Reconnect to keep data coming.",
"pausedAfterErrors": "{provider} is paused after failing since {since}.",
"pausedAfterErrorsNoSince": "{provider} is paused after repeated failures."
},
"when": {
"today": "today, {time}",
"yesterday": "yesterday, {time}",
"date": "{date}, {time}"
},
"items": {
"activity": "activity",
"sleep": "sleep"
},
"moreValuesOne": "{count} more",
"moreValuesFew": "{count} more",
"moreValuesOther": "{count} more",
"notConnected": "Not connected.",
"lastData": "Last data: {when}",
"lastDataNone": "Last data: none yet",
"reconnect": "Reconnect",
"resume": "Resume",
"menuLabel": "More actions for {provider}",
"menu": {
"credentials": "Change credentials",
"address": "Change address and token"
},
"syncing": "Syncing…",
"details": {
"title": "Details for a bug report",
"none": "none",
"state": "State",
"since": "since",
"lastSync": "Last sync",
"lastData": "Last data",
"failingParts": "failing parts",
"recentCalls": "Recent calls",
"unavailable": "Not available",
"unavailableList": "Not available: {list}",
"causePlan": "plan",
"causePermission": "permission",
"failureStreak": "Failure streak: {count} of {threshold}",
"copyFailed": "Could not copy. Select the text and copy it by hand."
}
}
},
"admin": {
+70 -23
View File
@@ -5315,7 +5315,8 @@
"unavailableTitle": "No disponible en este registro compartido",
"unavailableDescription": "Esta sección de Ajustes no está disponible intencionadamente mientras consultas el registro de otra persona.",
"integrationStatusDescription": "Estado de conexión de este registro gestionado.",
"integrationStatusDetail": "Los controles de conexión no están disponibles."
"integrationStatusDetail": "Un perfil gestionado muestra sus servicios conectados solo como estado. Conectar, volver a conectar, sincronizar y desconectar no está disponible para quienes lo gestionan.",
"integrationStatusDocs": "Cómo funciona compartir"
},
"sections": {
"account": {
@@ -6391,7 +6392,6 @@
"whoopDisconnectDescription": "Se desconectará la conexión con WHOOP. Los datos ya sincronizados se conservarán.",
"whoopConnect": "Conectar con WHOOP",
"whoopNoCredentials": "Introduce tus credenciales de API arriba para conectar WHOOP.",
"whoopBackfillInProgress": "Importando tu historial de WHOOP en segundo plano…",
"whoopOauthConnected": "WHOOP conectado: la primera sincronización empieza en segundo plano.",
"whoopOauthFailed": "Error al conectar con WHOOP",
"whoopOauthError": {
@@ -6427,7 +6427,6 @@
"fitbitDisconnectDescription": "Se desconectará la conexión con Fitbit. Los datos sincronizados previamente se conservarán.",
"fitbitConnect": "Conectar con Fitbit",
"fitbitNoCredentials": "Introduce tus credenciales de API arriba para conectar Fitbit.",
"fitbitBackfillInProgress": "Importando tu historial de Fitbit en segundo plano…",
"fitbitOauthConnected": "Fitbit conectado: la primera sincronización empieza en segundo plano.",
"fitbitOauthFailed": "Error al conectar con Fitbit",
"googleHealth": "Google Health",
@@ -6472,9 +6471,6 @@
"googleHealthDisconnectDescription": "Se desconectará la conexión con Google Health. Los datos ya sincronizados se conservarán.",
"googleHealthConnect": "Conectar con Google",
"googleHealthNoCredentials": "Introduce tus credenciales de API arriba para conectar Google Health.",
"googleHealthBackfillInProgress": "Importando tu historial de Google Health en segundo plano…",
"googleHealthReauthBanner": "Google ha caducado la conexión: ocurre de forma periódica mientras tu app de OAuth permanece en modo de prueba. Vuelve a conectar para reanudar la sincronización.",
"googleHealthReconnect": "Volver a conectar",
"googleHealthOauthConnected": "Google Health conectado: la primera sincronización empieza en segundo plano.",
"googleHealthOauthFailed": "Error al conectar con Google Health",
"googleHealthOauthError": {
@@ -6499,16 +6495,7 @@
},
"integrations": {
"withings": {
"reconnect": {
"banner": {
"title": "Sincronización de actividad disponible",
"body": "Tu conexión con Withings se autorizó antes de añadir la sincronización de actividad. Vuelve a conectar una vez para habilitar la importación de pasos, energía activa, distancia caminando y corriendo, y pisos subidos desde tus dispositivos Withings.",
"action": "Reconectar Withings"
}
},
"plan": {
"title": "Algunos valores requieren el plan de API de pago de Withings",
"body": "Withings ahora ofrece {list} solo en su plan de API de pago, y tu app de desarrollador de Withings usa el plan gratuito. El peso, la presión arterial, la composición corporal, la frecuencia cardíaca y el sueño se siguen sincronizando.",
"link": "Sobre los planes de API de Withings",
"items": {
"spo2": "SpO2",
@@ -6538,10 +6525,7 @@
},
"oura": {
"scope": {
"body": "Vuelve a conectar Oura una vez para permitir {list}. Todo lo demás se sigue sincronizando.",
"action": "Volver a conectar Oura",
"items": {
"spo2": "SpO2",
"vo2max": "VO2 máx.",
"cardio_age": "edad cardiovascular",
"resilience": "resiliencia"
@@ -6560,7 +6544,6 @@
"minutesAgo": "hace {count} min",
"hoursAgo": "hace {count} h",
"daysAgo": "hace {count} d",
"resumeCta": "Reconectar",
"resumeSuccess": "Integración reanudada",
"resumeError": "Reconexión fallida",
"stale": "Conectado, sin datos recientes",
@@ -6574,9 +6557,7 @@
"workouts": "Entrenamientos"
},
"integrationFallback": {
"description": "Configurado en esta cuenta pero sin tarjeta de ajustes propia.",
"lastAttempt": "Último intento {relative}",
"noAttempt": "Ningún intento de sincronización registrado"
"description": "Configurado en esta cuenta pero sin tarjeta de ajustes propia."
},
"withingsViewData": "Ver tus mediciones",
"whoopViewData": "Ver tu recuperación",
@@ -7124,7 +7105,6 @@
"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",
"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.",
"progress": {
"title": "Progreso de sincronización",
"receivedLabel": "Registros recibidos",
@@ -7217,6 +7197,73 @@
"displayCard": {
"title": "Visualización",
"description": "Idioma, unidades y formatos; se guardan en cuanto los cambias."
},
"integrationCard": {
"state": {
"ok": "Funciona",
"limited": "Limitada",
"degraded": "Con retraso",
"actionRequired": "Requiere acción"
},
"reason": {
"dataArriving": "Están llegando datos.",
"dataArrivingHistory": "Están llegando datos. El historial más antiguo se carga en segundo plano.",
"firstSyncPending": "Conectado. La primera sincronización se hará en breve.",
"planLimited": "Están llegando datos. Tu plan de {provider} no incluye {list}.",
"permissionLimited": "Están llegando datos. Falta el permiso para {list}.",
"providerErrors": "{provider} no responde de forma fiable desde {since}. HealthLog sigue intentándolo; no tienes que hacer nada.",
"providerErrorsNoSince": "{provider} no responde de forma fiable en este momento. HealthLog sigue intentándolo; no tienes que hacer nada.",
"syncStalled": "HealthLog no sincroniza desde {since}. Es cosa del servidor, no de tu dispositivo.",
"syncStalledNoSince": "HealthLog no está sincronizando en este momento. Es cosa del servidor, no de tu dispositivo.",
"noNewData": "No han llegado datos nuevos desde {since}.",
"noNewDataNoSince": "Hace tiempo que no llegan datos nuevos.",
"noNewDataApple": "No han llegado datos nuevos desde {since}. Abre HealthLog una vez en el iPhone.",
"noNewDataAppleNoSince": "Hace tiempo que no llegan datos nuevos. Abre HealthLog una vez en el iPhone.",
"unexpectedSilence": "No llega nada desde {since}, aunque {provider} suele enviar datos a diario.",
"unexpectedSilenceNoSince": "No llega nada, aunque {provider} suele enviar datos a diario.",
"reauthRequired": "La conexión con {provider} ha caducado. Vuelve a conectar para seguir recibiendo datos.",
"pausedAfterErrors": "{provider} está en pausa porque desde {since} solo ha habido errores.",
"pausedAfterErrorsNoSince": "{provider} está en pausa porque últimamente solo ha habido errores."
},
"when": {
"today": "hoy, {time}",
"yesterday": "ayer, {time}",
"date": "{date}, {time}"
},
"items": {
"activity": "actividad",
"sleep": "sueño"
},
"moreValuesOne": "{count} valor más",
"moreValuesFew": "{count} valores más",
"moreValuesOther": "{count} valores más",
"notConnected": "No conectado.",
"lastData": "Últimos datos: {when}",
"lastDataNone": "Últimos datos: todavía ninguno",
"reconnect": "Volver a conectar",
"resume": "Reanudar",
"menuLabel": "Más acciones para {provider}",
"menu": {
"credentials": "Cambiar credenciales",
"address": "Cambiar dirección y token"
},
"syncing": "Sincronizando…",
"details": {
"title": "Detalles para un informe de error",
"none": "ninguno",
"state": "Estado",
"since": "desde",
"lastSync": "Última sincronización",
"lastData": "Últimos datos",
"failingParts": "partes con fallos",
"recentCalls": "Últimas llamadas",
"unavailable": "No disponible",
"unavailableList": "No disponible: {list}",
"causePlan": "plan",
"causePermission": "permiso",
"failureStreak": "Racha de fallos: {count} de {threshold}",
"copyFailed": "No se pudo copiar. Selecciona el texto y cópialo a mano."
}
}
},
"admin": {
+70 -23
View File
@@ -5315,7 +5315,8 @@
"unavailableTitle": "Indisponible dans ce dossier partagé",
"unavailableDescription": "Cette section des réglages est volontairement indisponible lorsque vous consultez le dossier d’une autre personne.",
"integrationStatusDescription": "État de connexion pour ce dossier géré.",
"integrationStatusDetail": "Les commandes de connexion ne sont pas disponibles."
"integrationStatusDetail": "Un profil géré affiche ses services connectés uniquement sous forme d’état. Les personnes qui le gèrent ne peuvent ni connecter, ni reconnecter, ni synchroniser, ni déconnecter.",
"integrationStatusDocs": "Comment fonctionne le partage"
},
"sections": {
"account": {
@@ -6391,7 +6392,6 @@
"whoopDisconnectDescription": "La connexion à WHOOP sera déconnectée. Les données déjà synchronisées seront conservées.",
"whoopConnect": "Se connecter avec WHOOP",
"whoopNoCredentials": "Veuillez saisir vos identifiants API ci-dessus pour connecter WHOOP.",
"whoopBackfillInProgress": "Importation de votre historique WHOOP en arrière-plan…",
"whoopOauthConnected": "WHOOP connecté. La première synchronisation démarre en arrière-plan.",
"whoopOauthFailed": "Échec de la connexion à WHOOP",
"whoopOauthError": {
@@ -6427,7 +6427,6 @@
"fitbitDisconnectDescription": "La connexion à Fitbit sera déconnectée. Les données déjà synchronisées seront conservées.",
"fitbitConnect": "Se connecter à Fitbit",
"fitbitNoCredentials": "Veuillez saisir vos identifiants d'API ci-dessus pour connecter Fitbit.",
"fitbitBackfillInProgress": "Importation de votre historique Fitbit en arrière-plan…",
"fitbitOauthConnected": "Fitbit connecté. La première synchronisation démarre en arrière-plan.",
"fitbitOauthFailed": "Échec de la connexion à Fitbit",
"googleHealth": "Google Health",
@@ -6472,9 +6471,6 @@
"googleHealthDisconnectDescription": "La connexion à Google Health sera déconnectée. Les données déjà synchronisées seront conservées.",
"googleHealthConnect": "Se connecter avec Google",
"googleHealthNoCredentials": "Saisis tes identifiants d'API ci-dessus pour connecter Google Health.",
"googleHealthBackfillInProgress": "Importation de ton historique Google Health en arrière-plan…",
"googleHealthReauthBanner": "Google a expiré la connexion. Cela arrive régulièrement tant que ton application OAuth reste en mode Test. Reconnecte-toi pour reprendre la synchronisation.",
"googleHealthReconnect": "Se reconnecter",
"googleHealthOauthConnected": "Google Health connecté. La première synchronisation démarre en arrière-plan.",
"googleHealthOauthFailed": "Échec de la connexion à Google Health",
"googleHealthOauthError": {
@@ -6499,16 +6495,7 @@
},
"integrations": {
"withings": {
"reconnect": {
"banner": {
"title": "Synchronisation d’activité disponible",
"body": "Votre connexion Withings a été autorisée avant l’ajout de la synchronisation d’activité. Reconnectez-vous une fois pour activer l’import des pas, de l’énergie active, de la distance à pied et en course, et des étages gravis depuis vos appareils Withings.",
"action": "Reconnecter Withings"
}
},
"plan": {
"title": "Certaines mesures nécessitent l’offre API payante de Withings",
"body": "Withings ne propose désormais {list} que dans son offre API payante, et votre application développeur Withings utilise l’offre gratuite. Le poids, la tension artérielle, la composition corporelle, la fréquence cardiaque et le sommeil continuent d’être synchronisés.",
"link": "En savoir plus sur les offres API de Withings",
"items": {
"spo2": "la SpO2",
@@ -6538,10 +6525,7 @@
},
"oura": {
"scope": {
"body": "Reconnectez Oura une fois pour autoriser {list}. Tout le reste continue d’être synchronisé.",
"action": "Reconnecter Oura",
"items": {
"spo2": "la SpO2",
"vo2max": "la VO2 max",
"cardio_age": "l’âge cardiovasculaire",
"resilience": "la résilience"
@@ -6560,7 +6544,6 @@
"minutesAgo": "il y a {count} min",
"hoursAgo": "il y a {count} h",
"daysAgo": "il y a {count} j",
"resumeCta": "Reconnecter",
"resumeSuccess": "Intégration reprise",
"resumeError": "Échec de la reconnexion",
"stale": "Connecté, aucune donnée récente",
@@ -6574,9 +6557,7 @@
"workouts": "Séances"
},
"integrationFallback": {
"description": "Configuré sur ce compte sans carte de réglages dédiée.",
"lastAttempt": "Dernière tentative {relative}",
"noAttempt": "Aucune tentative de synchronisation enregistrée"
"description": "Configuré sur ce compte sans carte de réglages dédiée."
},
"withingsViewData": "Voir vos mesures",
"whoopViewData": "Voir votre récupération",
@@ -7124,7 +7105,6 @@
"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",
"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.",
"progress": {
"title": "Progression de la synchronisation",
"receivedLabel": "Enregistrements reçus",
@@ -7217,6 +7197,73 @@
"displayCard": {
"title": "Affichage",
"description": "Langue, unités et formats, enregistrés dès que vous les modifiez."
},
"integrationCard": {
"state": {
"ok": "Fonctionne",
"limited": "Limitée",
"degraded": "Retardée",
"actionRequired": "Action requise"
},
"reason": {
"dataArriving": "Les données arrivent.",
"dataArrivingHistory": "Les données arrivent. L’historique plus ancien se charge en arrière-plan.",
"firstSyncPending": "Connecté. La première synchronisation aura lieu sous peu.",
"planLimited": "Les données arrivent. Votre forfait {provider} n’inclut pas {list}.",
"permissionLimited": "Les données arrivent. L’autorisation manque pour {list}.",
"providerErrors": "{provider} ne répond plus de façon fiable depuis {since}. HealthLog continue d’essayer, vous n’avez rien à faire.",
"providerErrorsNoSince": "{provider} ne répond pas de façon fiable en ce moment. HealthLog continue d’essayer, vous n’avez rien à faire.",
"syncStalled": "HealthLog n’a plus synchronisé depuis {since}. Le problème vient du serveur, pas de votre appareil.",
"syncStalledNoSince": "HealthLog ne synchronise pas en ce moment. Le problème vient du serveur, pas de votre appareil.",
"noNewData": "Aucune nouvelle donnée depuis {since}.",
"noNewDataNoSince": "Aucune nouvelle donnée depuis un moment.",
"noNewDataApple": "Aucune nouvelle donnée depuis {since}. Ouvrez HealthLog une fois sur votre iPhone.",
"noNewDataAppleNoSince": "Aucune nouvelle donnée depuis un moment. Ouvrez HealthLog une fois sur votre iPhone.",
"unexpectedSilence": "Rien n’est arrivé depuis {since}, alors que {provider} envoie d’habitude des données chaque jour.",
"unexpectedSilenceNoSince": "Rien n’arrive, alors que {provider} envoie d’habitude des données chaque jour.",
"reauthRequired": "La connexion à {provider} a expiré. Reconnectez-vous pour continuer à recevoir des données.",
"pausedAfterErrors": "{provider} est en pause, car il n’y a eu que des erreurs depuis {since}.",
"pausedAfterErrorsNoSince": "{provider} est en pause, car il n’y a eu que des erreurs récemment."
},
"when": {
"today": "aujourd’hui, {time}",
"yesterday": "hier, {time}",
"date": "{date}, {time}"
},
"items": {
"activity": "l’activité",
"sleep": "le sommeil"
},
"moreValuesOne": "{count} autre valeur",
"moreValuesFew": "{count} autres valeurs",
"moreValuesOther": "{count} autres valeurs",
"notConnected": "Non connecté.",
"lastData": "Dernières données : {when}",
"lastDataNone": "Dernières données : aucune pour l’instant",
"reconnect": "Reconnecter",
"resume": "Reprendre",
"menuLabel": "Plus d’actions pour {provider}",
"menu": {
"credentials": "Modifier les identifiants",
"address": "Modifier l’adresse et le jeton"
},
"syncing": "Synchronisation en cours…",
"details": {
"title": "Détails pour un rapport de bug",
"none": "aucune",
"state": "État",
"since": "depuis",
"lastSync": "Dernière synchronisation",
"lastData": "Dernières données",
"failingParts": "parties en échec",
"recentCalls": "Derniers appels",
"unavailable": "Non disponible",
"unavailableList": "Non disponible : {list}",
"causePlan": "forfait",
"causePermission": "autorisation",
"failureStreak": "Série d’échecs : {count} sur {threshold}",
"copyFailed": "La copie a échoué. Sélectionnez le texte et copiez-le à la main."
}
}
},
"admin": {
+70 -23
View File
@@ -5315,7 +5315,8 @@
"unavailableTitle": "Non disponibile in questo record condiviso",
"unavailableDescription": "Questa area delle Impostazioni non è disponibile intenzionalmente mentre visualizzi il record di un’altra persona.",
"integrationStatusDescription": "Stato di connessione per questo record gestito.",
"integrationStatusDetail": "I controlli di connessione non sono disponibili."
"integrationStatusDetail": "Un profilo gestito mostra i servizi collegati solo come stato. Chi lo gestisce non può collegare, ricollegare, sincronizzare o scollegare.",
"integrationStatusDocs": "Come funziona la condivisione"
},
"sections": {
"account": {
@@ -6391,7 +6392,6 @@
"whoopDisconnectDescription": "La connessione a WHOOP verrà disconnessa. I dati già sincronizzati saranno conservati.",
"whoopConnect": "Connetti con WHOOP",
"whoopNoCredentials": "Inserisci le tue credenziali API qui sopra per connettere WHOOP.",
"whoopBackfillInProgress": "Importazione dello storico WHOOP in background…",
"whoopOauthConnected": "WHOOP collegato: la prima sincronizzazione parte in background.",
"whoopOauthFailed": "Connessione a WHOOP non riuscita",
"whoopOauthError": {
@@ -6427,7 +6427,6 @@
"fitbitDisconnectDescription": "La connessione a Fitbit verrà disconnessa. I dati già sincronizzati saranno conservati.",
"fitbitConnect": "Connetti con Fitbit",
"fitbitNoCredentials": "Inserisci le tue credenziali API qui sopra per connettere Fitbit.",
"fitbitBackfillInProgress": "Importazione dello storico Fitbit in background…",
"fitbitOauthConnected": "Fitbit collegato: la prima sincronizzazione parte in background.",
"fitbitOauthFailed": "Connessione a Fitbit non riuscita",
"googleHealth": "Google Health",
@@ -6472,9 +6471,6 @@
"googleHealthDisconnectDescription": "La connessione a Google Health verrà disconnessa. I dati già sincronizzati saranno conservati.",
"googleHealthConnect": "Connetti con Google",
"googleHealthNoCredentials": "Inserisci le tue credenziali API qui sopra per connettere Google Health.",
"googleHealthBackfillInProgress": "Importazione della cronologia di Google Health in background…",
"googleHealthReauthBanner": "Google ha fatto scadere la connessione. Accade periodicamente finché la tua app OAuth resta in modalità Test. Riconnettiti per riprendere la sincronizzazione.",
"googleHealthReconnect": "Riconnetti",
"googleHealthOauthConnected": "Google Health connesso: la prima sincronizzazione parte in background.",
"googleHealthOauthFailed": "Connessione a Google Health non riuscita",
"googleHealthOauthError": {
@@ -6499,16 +6495,7 @@
},
"integrations": {
"withings": {
"reconnect": {
"banner": {
"title": "Sincronizzazione attività disponibile",
"body": "La tua connessione Withings è stata autorizzata prima dell’aggiunta della sincronizzazione attività. Riconnettiti una volta per abilitare l’importazione di passi, energia attiva, distanza a piedi e di corsa, e piani saliti dai tuoi dispositivi Withings.",
"action": "Riconnetti Withings"
}
},
"plan": {
"title": "Alcuni valori richiedono il piano API a pagamento di Withings",
"body": "Withings ora offre {list} solo nel suo piano API a pagamento, e la tua app sviluppatore Withings usa il piano gratuito. Peso, pressione arteriosa, composizione corporea, frequenza cardiaca e sonno continuano a sincronizzarsi.",
"link": "Informazioni sui piani API di Withings",
"items": {
"spo2": "SpO2",
@@ -6538,10 +6525,7 @@
},
"oura": {
"scope": {
"body": "Riconnetti Oura una volta per consentire {list}. Tutto il resto continua a sincronizzarsi.",
"action": "Riconnetti Oura",
"items": {
"spo2": "SpO2",
"vo2max": "VO2 max",
"cardio_age": "età cardiovascolare",
"resilience": "resilienza"
@@ -6560,7 +6544,6 @@
"minutesAgo": "{count} min fa",
"hoursAgo": "{count} h fa",
"daysAgo": "{count} g fa",
"resumeCta": "Riconnetti",
"resumeSuccess": "Integrazione ripresa",
"resumeError": "Riconnessione fallita",
"stale": "Connesso, nessun dato recente",
@@ -6574,9 +6557,7 @@
"workouts": "Allenamenti"
},
"integrationFallback": {
"description": "Configurato su questo account ma senza una scheda di impostazioni propria.",
"lastAttempt": "Ultimo tentativo {relative}",
"noAttempt": "Nessun tentativo di sincronizzazione registrato"
"description": "Configurato su questo account ma senza una scheda di impostazioni propria."
},
"withingsViewData": "Vedi le tue misurazioni",
"whoopViewData": "Vedi il tuo recupero",
@@ -7124,7 +7105,6 @@
"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",
"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.",
"progress": {
"title": "Avanzamento della sincronizzazione",
"receivedLabel": "Record ricevuti",
@@ -7217,6 +7197,73 @@
"displayCard": {
"title": "Visualizzazione",
"description": "Lingua, unità e formati, salvati appena li modifichi."
},
"integrationCard": {
"state": {
"ok": "Funziona",
"limited": "Limitata",
"degraded": "In ritardo",
"actionRequired": "Azione richiesta"
},
"reason": {
"dataArriving": "I dati stanno arrivando.",
"dataArrivingHistory": "I dati stanno arrivando. Lo storico meno recente si carica in background.",
"firstSyncPending": "Connesso. La prima sincronizzazione partirà a breve.",
"planLimited": "I dati stanno arrivando. Il tuo piano {provider} non include {list}.",
"permissionLimited": "I dati stanno arrivando. Manca l’autorizzazione per {list}.",
"providerErrors": "{provider} non risponde in modo affidabile da {since}. HealthLog continua a riprovare, non devi fare nulla.",
"providerErrorsNoSince": "{provider} al momento non risponde in modo affidabile. HealthLog continua a riprovare, non devi fare nulla.",
"syncStalled": "HealthLog non sincronizza più da {since}. Dipende dal server, non dal tuo dispositivo.",
"syncStalledNoSince": "HealthLog al momento non sincronizza. Dipende dal server, non dal tuo dispositivo.",
"noNewData": "Nessun dato nuovo da {since}.",
"noNewDataNoSince": "Nessun dato nuovo da un po’ di tempo.",
"noNewDataApple": "Nessun dato nuovo da {since}. Apri HealthLog una volta sull’iPhone.",
"noNewDataAppleNoSince": "Nessun dato nuovo da un po’ di tempo. Apri HealthLog una volta sull’iPhone.",
"unexpectedSilence": "Non arriva nulla da {since}, anche se di solito {provider} invia dati ogni giorno.",
"unexpectedSilenceNoSince": "Non arriva nulla, anche se di solito {provider} invia dati ogni giorno.",
"reauthRequired": "La connessione a {provider} è scaduta. Riconnetti per continuare a ricevere dati.",
"pausedAfterErrors": "{provider} è in pausa perché da {since} ci sono stati solo errori.",
"pausedAfterErrorsNoSince": "{provider} è in pausa perché ultimamente ci sono stati solo errori."
},
"when": {
"today": "oggi, {time}",
"yesterday": "ieri, {time}",
"date": "{date}, {time}"
},
"items": {
"activity": "attività",
"sleep": "sonno"
},
"moreValuesOne": "{count} altro valore",
"moreValuesFew": "altri {count} valori",
"moreValuesOther": "altri {count} valori",
"notConnected": "Non connesso.",
"lastData": "Ultimi dati: {when}",
"lastDataNone": "Ultimi dati: ancora nessuno",
"reconnect": "Riconnetti",
"resume": "Riprendi",
"menuLabel": "Altre azioni per {provider}",
"menu": {
"credentials": "Modifica credenziali",
"address": "Modifica indirizzo e token"
},
"syncing": "Sincronizzazione in corso…",
"details": {
"title": "Dettagli per una segnalazione di errore",
"none": "nessuno",
"state": "Stato",
"since": "da",
"lastSync": "Ultima sincronizzazione",
"lastData": "Ultimi dati",
"failingParts": "parti con errori",
"recentCalls": "Ultime chiamate",
"unavailable": "Non disponibile",
"unavailableList": "Non disponibile: {list}",
"causePlan": "piano",
"causePermission": "autorizzazione",
"failureStreak": "Serie di errori: {count} su {threshold}",
"copyFailed": "Copia non riuscita. Seleziona il testo e copialo a mano."
}
}
},
"admin": {
+70 -23
View File
@@ -5315,7 +5315,8 @@
"unavailableTitle": "이 공유 기록에서는 사용할 수 없어요",
"unavailableDescription": "다른 사람의 기록을 보는 동안에는 이 설정 영역을 사용할 수 없어요.",
"integrationStatusDescription": "이 관리 기록의 연결 상태예요.",
"integrationStatusDetail": "연결 설정은 사용할 수 없어요."
"integrationStatusDetail": "관리 프로필은 연결된 서비스를 상태로만 보여 줘요. 보호자는 연결, 다시 연결, 동기화, 연결 해제를 할 수 없어요.",
"integrationStatusDocs": "공유 방식 알아보기"
},
"sections": {
"account": {
@@ -6391,7 +6392,6 @@
"whoopDisconnectDescription": "WHOOP 연결이 해제돼요. 이미 동기화한 데이터는 그대로 남아요.",
"whoopConnect": "WHOOP으로 연결",
"whoopNoCredentials": "WHOOP을 연결하려면 위에 API 자격 증명을 입력해 주세요.",
"whoopBackfillInProgress": "WHOOP 기록을 백그라운드에서 가져오는 중이에요…",
"whoopOauthConnected": "WHOOP을 연결했어요. 첫 동기화가 백그라운드에서 시작돼요.",
"whoopOauthFailed": "WHOOP 연결에 실패했어요",
"whoopOauthError": {
@@ -6427,7 +6427,6 @@
"fitbitDisconnectDescription": "Fitbit 연결이 해제돼요. 이미 동기화한 데이터는 그대로 남아요.",
"fitbitConnect": "Fitbit으로 연결",
"fitbitNoCredentials": "Fitbit을 연결하려면 위에 API 자격 증명을 입력해 주세요.",
"fitbitBackfillInProgress": "Fitbit 기록을 백그라운드에서 가져오는 중이에요…",
"fitbitOauthConnected": "Fitbit을 연결했어요. 첫 동기화가 백그라운드에서 시작돼요.",
"fitbitOauthFailed": "Fitbit 연결에 실패했어요",
"googleHealth": "Google Health",
@@ -6472,9 +6471,6 @@
"googleHealthDisconnectDescription": "Google Health 연결이 해제돼요. 이미 동기화한 데이터는 그대로 남아요.",
"googleHealthConnect": "Google로 연결",
"googleHealthNoCredentials": "Google Health를 연결하려면 위에 API 자격 증명을 입력해 주세요.",
"googleHealthBackfillInProgress": "Google Health 기록을 백그라운드에서 가져오는 중이에요…",
"googleHealthReauthBanner": "Google이 연결을 만료시켰어요. OAuth 앱이 테스트 모드로 있는 동안 주기적으로 생기는 일이에요. 다시 연결하면 동기화가 이어져요.",
"googleHealthReconnect": "다시 연결",
"googleHealthOauthConnected": "Google Health를 연결했어요. 첫 동기화가 백그라운드에서 시작돼요.",
"googleHealthOauthFailed": "Google Health 연결에 실패했어요",
"googleHealthOauthError": {
@@ -6499,16 +6495,7 @@
},
"integrations": {
"withings": {
"reconnect": {
"banner": {
"title": "활동 동기화를 쓸 수 있어요",
"body": "활동 동기화가 추가되기 전에 Withings 연결을 승인했어요. 한 번만 다시 연결하면 Withings 기기에서 걸음 수, 활동 에너지, 걷기·달리기 거리, 오른 층수를 가져올 수 있어요.",
"action": "Withings 다시 연결"
}
},
"plan": {
"title": "일부 측정값은 Withings 유료 API 요금제가 필요해요",
"body": "Withings는 이제 {list} 데이터를 유료 API 요금제에서만 제공하고, 사용 중인 Withings 개발자 앱은 무료 요금제예요. 체중, 혈압, 체성분, 심박수, 수면은 계속 동기화돼요.",
"link": "Withings API 요금제 알아보기",
"items": {
"spo2": "SpO2",
@@ -6538,10 +6525,7 @@
},
"oura": {
"scope": {
"body": "{list} 데이터를 허용하려면 Oura를 한 번 다시 연결하세요. 나머지는 계속 동기화돼요.",
"action": "Oura 다시 연결",
"items": {
"spo2": "SpO2",
"vo2max": "VO2 max",
"cardio_age": "심혈관 나이",
"resilience": "회복탄력성"
@@ -6560,7 +6544,6 @@
"minutesAgo": "{count}분 전",
"hoursAgo": "{count}시간 전",
"daysAgo": "{count}일 전",
"resumeCta": "다시 연결",
"resumeSuccess": "연동을 다시 시작했어요",
"resumeError": "다시 연결하지 못했어요",
"stale": "연결됨, 최근 데이터 없음",
@@ -6574,9 +6557,7 @@
"workouts": "운동"
},
"integrationFallback": {
"description": "이 계정에 설정돼 있지만 전용 설정 카드는 없어요.",
"lastAttempt": "마지막 시도 {relative}",
"noAttempt": "동기화 시도 기록이 없어요"
"description": "이 계정에 설정돼 있지만 전용 설정 카드는 없어요."
},
"withingsViewData": "측정값 보기",
"whoopViewData": "회복도 보기",
@@ -7124,7 +7105,6 @@
"backgroundStep": "백그라운드 앱 새로고침을 켠 채로 두세요. 앱은 화면에 떠 있을 때와 iOS가 백그라운드 시간을 줄 때 새 데이터를 자동으로 동기화해요.",
"importNote": "예전 기록이 필요하거나 iOS 앱을 쓸 수 없나요? Apple Health XML/ZIP 업로드는 한 번만 쓰는 대안이라 실시간 동기화는 켜지지 않아요.",
"importAction": "일회성 불러오기 열기",
"staleNote": "일주일 넘게 Apple Health 데이터가 들어오지 않았어요. iPhone에서 HealthLog 앱을 열고 Apple Health 권한을 확인해 주세요.",
"progress": {
"title": "동기화 진행 상황",
"receivedLabel": "받은 기록 수",
@@ -7217,6 +7197,73 @@
"displayCard": {
"title": "표시",
"description": "언어, 단위, 형식은 바꾸는 즉시 저장돼요."
},
"integrationCard": {
"state": {
"ok": "작동 중",
"limited": "제한됨",
"degraded": "지연됨",
"actionRequired": "조치 필요"
},
"reason": {
"dataArriving": "데이터가 들어오고 있어요.",
"dataArrivingHistory": "데이터가 들어오고 있어요. 이전 기록은 백그라운드에서 불러오는 중이에요.",
"firstSyncPending": "연결됐어요. 첫 동기화가 곧 시작돼요.",
"planLimited": "데이터가 들어오고 있어요. {provider} 요금제에는 {list} 항목이 포함되지 않아요.",
"permissionLimited": "데이터가 들어오고 있어요. {list} 항목에 대한 권한이 없어요.",
"providerErrors": "{since}부터 {provider}의 응답이 불안정해요. HealthLog가 계속 다시 시도하니 따로 하실 일은 없어요.",
"providerErrorsNoSince": "지금 {provider}의 응답이 불안정해요. HealthLog가 계속 다시 시도하니 따로 하실 일은 없어요.",
"syncStalled": "{since}부터 HealthLog가 동기화하지 못했어요. 기기가 아니라 서버 쪽 문제예요.",
"syncStalledNoSince": "지금 HealthLog가 동기화하지 못하고 있어요. 기기가 아니라 서버 쪽 문제예요.",
"noNewData": "{since}부터 새 데이터가 들어오지 않았어요.",
"noNewDataNoSince": "한동안 새 데이터가 들어오지 않았어요.",
"noNewDataApple": "{since}부터 새 데이터가 들어오지 않았어요. iPhone에서 HealthLog를 한 번 열어 주세요.",
"noNewDataAppleNoSince": "한동안 새 데이터가 들어오지 않았어요. iPhone에서 HealthLog를 한 번 열어 주세요.",
"unexpectedSilence": "{provider}는 보통 매일 데이터를 보내는데 {since}부터 아무것도 들어오지 않았어요.",
"unexpectedSilenceNoSince": "{provider}는 보통 매일 데이터를 보내는데 지금은 아무것도 들어오지 않아요.",
"reauthRequired": "{provider} 연결이 만료됐어요. 데이터를 계속 받으려면 다시 연결해 주세요.",
"pausedAfterErrors": "{since}부터 오류만 발생해서 {provider}를 일시 중지했어요.",
"pausedAfterErrorsNoSince": "최근 오류만 발생해서 {provider}를 일시 중지했어요."
},
"when": {
"today": "오늘 {time}",
"yesterday": "어제 {time}",
"date": "{date} {time}"
},
"items": {
"activity": "활동",
"sleep": "수면"
},
"moreValuesOne": "그 외 {count}개",
"moreValuesFew": "그 외 {count}개",
"moreValuesOther": "그 외 {count}개",
"notConnected": "연결되지 않음.",
"lastData": "마지막 데이터: {when}",
"lastDataNone": "마지막 데이터: 아직 없음",
"reconnect": "다시 연결",
"resume": "재개",
"menuLabel": "{provider} 추가 작업",
"menu": {
"credentials": "인증 정보 변경",
"address": "주소와 토큰 변경"
},
"syncing": "동기화 중…",
"details": {
"title": "오류 신고용 세부 정보",
"none": "없음",
"state": "상태",
"since": "시작",
"lastSync": "마지막 동기화",
"lastData": "마지막 데이터",
"failingParts": "실패한 부분",
"recentCalls": "최근 호출",
"unavailable": "사용 불가",
"unavailableList": "사용 불가: {list}",
"causePlan": "요금제",
"causePermission": "권한",
"failureStreak": "연속 실패: {threshold}회 중 {count}회",
"copyFailed": "복사하지 못했어요. 텍스트를 선택해서 직접 복사해 주세요."
}
}
},
"admin": {
+70 -23
View File
@@ -5315,7 +5315,8 @@
"unavailableTitle": "Niedostępne w tym udostępnionym rekordzie",
"unavailableDescription": "Ten obszar Ustawień jest celowo niedostępny podczas przeglądania rekordu innej osoby.",
"integrationStatusDescription": "Stan połączenia dla tego zarządzanego rekordu.",
"integrationStatusDetail": "Elementy sterujące połączeniem są niedostępne."
"integrationStatusDetail": "Profil zarządzany pokazuje połączone usługi tylko jako stan. Opiekunowie nie mogą łączyć, ponownie łączyć, synchronizować ani rozłączać.",
"integrationStatusDocs": "Jak działa udostępnianie"
},
"sections": {
"account": {
@@ -6391,7 +6392,6 @@
"whoopDisconnectDescription": "Połączenie z WHOOP zostanie zakończone. Wcześniej zsynchronizowane dane zostaną zachowane.",
"whoopConnect": "Połącz z WHOOP",
"whoopNoCredentials": "Wprowadź powyżej swoje dane API, aby połączyć WHOOP.",
"whoopBackfillInProgress": "Importowanie historii WHOOP w tle…",
"whoopOauthConnected": "WHOOP połączony. Pierwsza synchronizacja startuje w tle.",
"whoopOauthFailed": "Połączenie z WHOOP nie powiodło się",
"whoopOauthError": {
@@ -6427,7 +6427,6 @@
"fitbitDisconnectDescription": "Połączenie z Fitbit zostanie rozłączone. Wcześniej zsynchronizowane dane zostaną zachowane.",
"fitbitConnect": "Połącz z Fitbit",
"fitbitNoCredentials": "Wprowadź powyżej swoje dane uwierzytelniające API, aby połączyć Fitbit.",
"fitbitBackfillInProgress": "Importowanie historii Fitbit w tle…",
"fitbitOauthConnected": "Fitbit połączony. Pierwsza synchronizacja startuje w tle.",
"fitbitOauthFailed": "Połączenie z Fitbit nie powiodło się",
"googleHealth": "Google Health",
@@ -6472,9 +6471,6 @@
"googleHealthDisconnectDescription": "Połączenie z Google Health zostanie rozłączone. Wcześniej zsynchronizowane dane zostaną zachowane.",
"googleHealthConnect": "Połącz z Google",
"googleHealthNoCredentials": "Wprowadź powyżej swoje dane API, aby połączyć Google Health.",
"googleHealthBackfillInProgress": "Importowanie historii Google Health w tle…",
"googleHealthReauthBanner": "Google unieważnił połączenie. Dzieje się to okresowo, dopóki Twoja aplikacja OAuth pozostaje w trybie testowym. Połącz ponownie, aby wznowić synchronizację.",
"googleHealthReconnect": "Połącz ponownie",
"googleHealthOauthConnected": "Google Health połączony. Pierwsza synchronizacja startuje w tle.",
"googleHealthOauthFailed": "Połączenie z Google Health nie powiodło się",
"googleHealthOauthError": {
@@ -6499,16 +6495,7 @@
},
"integrations": {
"withings": {
"reconnect": {
"banner": {
"title": "Dostępna synchronizacja aktywności",
"body": "Twoje połączenie z Withings zostało autoryzowane przed dodaniem synchronizacji aktywności. Połącz ponownie jeden raz, aby włączyć import kroków, energii aktywnej, dystansu marszu i biegu oraz pokonanych pięter z urządzeń Withings.",
"action": "Połącz ponownie Withings"
}
},
"plan": {
"title": "Niektóre pomiary wymagają płatnego planu API Withings",
"body": "Withings udostępnia teraz te dane tylko w płatnym planie API: {list}. Twoja aplikacja deweloperska Withings korzysta z bezpłatnego planu. Waga, ciśnienie krwi, skład ciała, tętno i sen są nadal synchronizowane.",
"link": "Więcej o planach API Withings",
"items": {
"spo2": "SpO2",
@@ -6538,10 +6525,7 @@
},
"oura": {
"scope": {
"body": "Połącz ponownie Oura jeden raz, aby zezwolić na te dane: {list}. Wszystko inne jest nadal synchronizowane.",
"action": "Połącz ponownie Oura",
"items": {
"spo2": "SpO2",
"vo2max": "VO2 max",
"cardio_age": "wiek sercowo-naczyniowy",
"resilience": "odporność"
@@ -6560,7 +6544,6 @@
"minutesAgo": "{count} min temu",
"hoursAgo": "{count} godz. temu",
"daysAgo": "{count} dni temu",
"resumeCta": "Połącz ponownie",
"resumeSuccess": "Integracja wznowiona",
"resumeError": "Ponowne połączenie nieudane",
"stale": "Połączono, brak nowych danych",
@@ -6574,9 +6557,7 @@
"workouts": "Treningi"
},
"integrationFallback": {
"description": "Skonfigurowane na tym koncie, ale bez własnej karty ustawień.",
"lastAttempt": "Ostatnia próba {relative}",
"noAttempt": "Brak zapisanej próby synchronizacji"
"description": "Skonfigurowane na tym koncie, ale bez własnej karty ustawień."
},
"withingsViewData": "Zobacz swoje pomiary",
"whoopViewData": "Zobacz swoją regenerację",
@@ -7124,7 +7105,6 @@
"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",
"staleNote": "Od ponad tygodnia nie napłynęły żadne dane z Apple Health. Otwórz aplikację HealthLog na iPhonie i sprawdź uprawnienia Apple Health.",
"progress": {
"title": "Postęp synchronizacji",
"receivedLabel": "Odebrane rekordy",
@@ -7217,6 +7197,73 @@
"displayCard": {
"title": "Wyświetlanie",
"description": "Język, jednostki i formaty zapisywane od razu po zmianie."
},
"integrationCard": {
"state": {
"ok": "Działa",
"limited": "Ograniczone",
"degraded": "Opóźnione",
"actionRequired": "Wymaga działania"
},
"reason": {
"dataArriving": "Dane napływają.",
"dataArrivingHistory": "Dane napływają. Starsza historia wczytuje się w tle.",
"firstSyncPending": "Połączono. Pierwsza synchronizacja ruszy za chwilę.",
"planLimited": "Dane napływają. Twój plan {provider} nie obejmuje tych danych: {list}.",
"permissionLimited": "Dane napływają. Brakuje uprawnień do tych danych: {list}.",
"providerErrors": "{provider} od {since} nie odpowiada niezawodnie. HealthLog próbuje dalej, nie musisz nic robić.",
"providerErrorsNoSince": "{provider} nie odpowiada teraz niezawodnie. HealthLog próbuje dalej, nie musisz nic robić.",
"syncStalled": "HealthLog nie synchronizuje od {since}. To problem po stronie serwera, nie twojego urządzenia.",
"syncStalledNoSince": "HealthLog teraz nie synchronizuje. To problem po stronie serwera, nie twojego urządzenia.",
"noNewData": "Od {since} nie napłynęły nowe dane.",
"noNewDataNoSince": "Od dłuższego czasu nie napłynęły nowe dane.",
"noNewDataApple": "Od {since} nie napłynęły nowe dane. Otwórz raz HealthLog na iPhonie.",
"noNewDataAppleNoSince": "Od dłuższego czasu nie napłynęły nowe dane. Otwórz raz HealthLog na iPhonie.",
"unexpectedSilence": "Od {since} nic nie napływa, chociaż {provider} zwykle dostarcza dane codziennie.",
"unexpectedSilenceNoSince": "Nic nie napływa, chociaż {provider} zwykle dostarcza dane codziennie.",
"reauthRequired": "Połączenie z {provider} wygasło. Połącz ponownie, aby dane znów napływały.",
"pausedAfterErrors": "{provider} jest wstrzymany, bo od {since} występowały same błędy.",
"pausedAfterErrorsNoSince": "{provider} jest wstrzymany, bo ostatnio występowały same błędy."
},
"when": {
"today": "dziś, {time}",
"yesterday": "wczoraj, {time}",
"date": "{date}, {time}"
},
"items": {
"activity": "aktywność",
"sleep": "sen"
},
"moreValuesOne": "{count} inna wartość",
"moreValuesFew": "{count} inne wartości",
"moreValuesOther": "{count} innych wartości",
"notConnected": "Nie połączono.",
"lastData": "Ostatnie dane: {when}",
"lastDataNone": "Ostatnie dane: jeszcze brak",
"reconnect": "Połącz ponownie",
"resume": "Wznów",
"menuLabel": "Więcej działań dla {provider}",
"menu": {
"credentials": "Zmień dane dostępowe",
"address": "Zmień adres i token"
},
"syncing": "Trwa synchronizacja…",
"details": {
"title": "Szczegóły do zgłoszenia błędu",
"none": "brak",
"state": "Stan",
"since": "od",
"lastSync": "Ostatnia synchronizacja",
"lastData": "Ostatnie dane",
"failingParts": "części z błędami",
"recentCalls": "Ostatnie wywołania",
"unavailable": "Niedostępne",
"unavailableList": "Niedostępne: {list}",
"causePlan": "plan",
"causePermission": "uprawnienie",
"failureStreak": "Seria błędów: {count} z {threshold}",
"copyFailed": "Nie udało się skopiować. Zaznacz tekst i skopiuj go ręcznie."
}
}
},
"admin": {
@@ -126,12 +126,10 @@ const NO_REFETCH_ALLOWLIST = [
// Background integration-sync completion on the Settings surface — the user
// is configuring an integration, not acting on the hero; convergence via the
// daily reads' own poll/focus refetch is the intended behaviour.
"components/settings/integrations/fitbit-card.tsx",
"components/settings/integrations/google-health-card.tsx",
"components/settings/integrations/integration-actions.tsx",
"components/settings/integrations/nightscout-card.tsx",
"components/settings/integrations/oauth-provider-card.tsx",
"components/settings/integrations/whoop-card.tsx",
"components/settings/integrations/withings-card.tsx",
"components/settings/integrations/oauth-integration-card.tsx",
// Cycle writes: `cycleDependentKeys` carries no daily-read key and neither
// the dashboard snapshot nor the Today digest reads cycle rows today, so
// the forced refetch would be a no-op round-trip. The bundle is in the
+18 -14
View File
@@ -616,21 +616,25 @@ const REGISTRY: DestructiveEntry[] = [
},
// ── Integrations ────────────────────────────────────────────────────────
...(
[
["withings-card.tsx", "Withings"],
["fitbit-card.tsx", "Fitbit"],
["whoop-card.tsx", "WHOOP"],
["google-health-card.tsx", "Google Health"],
["nightscout-card.tsx", "Nightscout"],
["oauth-provider-card.tsx", "the generic OAuth providers"],
] as const
).map(([f, name]): DestructiveEntry => ({
file: `components/settings/integrations/${f}`,
destroys: `the stored ${name} credentials — reconnecting means authorising again`,
// Every connection renders the shared integration card, whose three-dot
// menu raises the confirmation for the destructive entry; the controllers
// below only hold the request.
{
file: "components/settings/integrations/oauth-integration-card.tsx",
destroys:
"the stored Withings, WHOOP, Fitbit, Google Health, Polar, Oura or Strava grant — reconnecting means authorising again",
triggers: ["components/settings/integrations/integration-card.tsx"],
recovery: "permanent",
confirm: ["AlertDialog"],
})),
confirm: ["ConfirmDialog"],
},
{
file: "components/settings/integrations/nightscout-card.tsx",
destroys:
"the stored Nightscout address and token — reconnecting means typing them again",
triggers: ["components/settings/integrations/integration-card.tsx"],
recovery: "permanent",
confirm: ["ConfirmDialog"],
},
{
file: "components/settings/integrations/document-sources-card.tsx",
destroys:
@@ -441,7 +441,7 @@ function assertBucketConsumerLineage(
const declaration = file.statements.find(
(statement): statement is ts.FunctionDeclaration =>
ts.isFunctionDeclaration(statement) &&
statement.name?.text === "pillFailurePropsFor",
statement.name?.text === "failureStreakFor",
);
expect(declaration?.body).toBeDefined();
let bucketInitializer: string | null = null;
@@ -473,7 +473,7 @@ function assertBucketConsumerLineage(
for (const kind of kinds) {
expect(
accesses,
`pillFailurePropsFor must read buckets.${kind} from that response value`,
`failureStreakFor must read buckets.${kind} from that response value`,
).toContain(`buckets.${kind}`);
}
}
+17 -29
View File
@@ -369,20 +369,26 @@ const PINNED_AFFORDANCES: Record<
"src/components/settings/integrations/connections-panel.tsx": {
"toast.success": 2,
},
"src/components/settings/integrations/fitbit-card.tsx": { "text-success": 2 },
"src/components/settings/integrations/google-health-card.tsx": {
// The saved-credentials confirmation line.
"src/components/settings/integrations/credentials-form.tsx": {
"text-success": 1,
},
// "Test connection" from the menu: the same ok line the button shows.
"src/components/settings/integrations/integration-actions.tsx": {
CheckCircle2: 2,
"text-success": 1,
},
// The "Working" badge tone, and "Copied" beside the diagnostics copy button.
"src/components/settings/integrations/integration-card.tsx": {
"text-success": 2,
},
"src/components/settings/integrations/nightscout-card.tsx": {
"text-success": 1,
},
"src/components/settings/integrations/oauth-provider-card.tsx": {
// A paused connection resumed.
"src/components/settings/integrations/oauth-integration-card.tsx": {
"text-success": 1,
},
"src/components/settings/integrations/whoop-card.tsx": { "text-success": 2 },
"src/components/settings/integrations/withings-card.tsx": {
"text-success": 2,
},
"src/components/settings/mcp-section.tsx": {
"text-success": 4,
"toast.success": 2,
@@ -466,9 +472,11 @@ const RESULT_SURFACES: ReadonlyArray<{
through: /IntakeImportSkipGroups/,
forbidsMarkers: true,
},
// The seven surfaces that were still gated on the transport.
// The provider sync surfaces that were still gated on the transport. Every
// integration card reports a sync through the shared action lines; Google
// Health keeps its own because a run can outlive its request.
{
path: "src/components/settings/integrations/fitbit-card.tsx",
path: "src/components/settings/integrations/integration-actions.tsx",
through: /WrittenOutcomeLine/,
forbidsMarkers: false,
},
@@ -477,26 +485,6 @@ const RESULT_SURFACES: ReadonlyArray<{
through: /WrittenOutcomeLine/,
forbidsMarkers: false,
},
{
path: "src/components/settings/integrations/nightscout-card.tsx",
through: /WrittenOutcomeLine/,
forbidsMarkers: false,
},
{
path: "src/components/settings/integrations/whoop-card.tsx",
through: /WrittenOutcomeLine/,
forbidsMarkers: false,
},
{
path: "src/components/settings/integrations/withings-card.tsx",
through: /WrittenOutcomeLine/,
forbidsMarkers: false,
},
{
path: "src/components/settings/integrations/oauth-provider-card.tsx",
through: /WrittenOutcomeLine/,
forbidsMarkers: false,
},
{
path: "src/components/labs/ocr-review-dialog.tsx",
through: /toastWrittenOutcome/,
+3 -1
View File
@@ -30,7 +30,9 @@ const REQUIRED_KEYS = [
"common.networkError",
"settings.googleHealthSyncResult",
"settings.googleHealthSyncFailed",
"settings.googleHealthBackfillInProgress",
// `settings.googleHealthBackfillInProgress` left this list in v1.42.4: the
// history-loading line became a sentence variant of the shared integration
// card (`settings.integrationCard.reason.dataArrivingHistory`).
"settings.syncOutcome.empty",
"settings.syncOutcome.partial",
"settings.syncOutcome.failed",
@@ -644,7 +644,7 @@ const SYNC_ROUTES: readonly SyncRouteSpec[] = [
id: "fitbit",
path: join(ROOT, "src/app/api/fitbit/sync/route.ts"),
responseClass: "resolved-with-full-sync-echo",
card: join(SYNC_CARD_ROOT, "fitbit-card.tsx"),
card: join(SYNC_CARD_ROOT, "integration-actions.tsx"),
exceptions: { fullSync: FULL_SYNC_EXCEPTION },
},
{
@@ -663,42 +663,42 @@ const SYNC_ROUTES: readonly SyncRouteSpec[] = [
id: "nightscout",
path: join(ROOT, "src/app/api/nightscout/sync/route.ts"),
responseClass: "resolved",
card: join(SYNC_CARD_ROOT, "nightscout-card.tsx"),
card: join(SYNC_CARD_ROOT, "integration-actions.tsx"),
exceptions: {},
},
{
id: "oura",
path: join(ROOT, "src/app/api/oura/sync/route.ts"),
responseClass: "resolved",
card: join(SYNC_CARD_ROOT, "oauth-provider-card.tsx"),
card: join(SYNC_CARD_ROOT, "integration-actions.tsx"),
exceptions: {},
},
{
id: "polar",
path: join(ROOT, "src/app/api/polar/sync/route.ts"),
responseClass: "resolved",
card: join(SYNC_CARD_ROOT, "oauth-provider-card.tsx"),
card: join(SYNC_CARD_ROOT, "integration-actions.tsx"),
exceptions: {},
},
{
id: "strava",
path: join(ROOT, "src/app/api/strava/sync/route.ts"),
responseClass: "resolved",
card: join(SYNC_CARD_ROOT, "oauth-provider-card.tsx"),
card: join(SYNC_CARD_ROOT, "integration-actions.tsx"),
exceptions: {},
},
{
id: "whoop",
path: join(ROOT, "src/app/api/whoop/sync/route.ts"),
responseClass: "resolved-with-full-sync-echo",
card: join(SYNC_CARD_ROOT, "whoop-card.tsx"),
card: join(SYNC_CARD_ROOT, "integration-actions.tsx"),
exceptions: { fullSync: FULL_SYNC_EXCEPTION },
},
{
id: "withings",
path: join(ROOT, "src/app/api/withings/sync/route.ts"),
responseClass: "resolved-with-full-sync-echo",
card: join(SYNC_CARD_ROOT, "withings-card.tsx"),
card: join(SYNC_CARD_ROOT, "integration-actions.tsx"),
exceptions: { fullSync: FULL_SYNC_EXCEPTION },
},
] as const;
@@ -908,7 +908,7 @@ function assertSyncCardConsumesFetchedResponse(
return;
}
const endpoint = responseInitializer.arguments[0];
const expectedEndpoint = path.endsWith("oauth-provider-card.tsx")
const expectedEndpoint = path.endsWith("integration-actions.tsx")
? "`/api/${provider}/sync`"
: `"/api/${routeId}/sync"`;
expect(
@@ -1,28 +1,17 @@
/**
* v1.4.19 Phase A5 — Settings → Integrations status consolidation.
* Settings → Integrations: one status display per card.
*
* The maintainer's verbatim concern (paraphrased):
* - Withings card had 3 status displays — top-right "connected"
* badge, mid-card "connected / last successful / last attempt"
* trio container, and the badge inside that container.
* - Mood Log card had 4 — same as Withings PLUS a "letzter Sync"
* line at the very bottom.
* v1.4.19 consolidated three and four status surfaces per card into one pill.
* v1.42.4 replaces the pill with the shared card's badge, rendered from the
* server's `health`, and removes what had grown back around it: the raw
* provider error line, the failure counter, the per-provider banners.
*
* After A5: each card has EXACTLY ONE status display (the pill, top
* right). Error context (the failure message itself, when actionable)
* is allowed to surface below the pill since it carries information
* the pill can't fit. Timestamp repetition is gone.
*
* The tests pin:
* 1. Healthy Withings + Mood Log: ONE pill per card, no banner.
* 2. Withings transient error: ONE pill per card + error message,
* NO redundant "{n}/{threshold} consecutive failures" badge,
* NO "Last successful sync / Last attempt" trio.
* 3. Withings reauth: pill carries the "Error — reconnect" label.
* 4. Mood Log disconnected: pill says "Not connected", no other
* status display surfaces.
* 5. no card paints a divider under its header
* — consistency the maintainer explicitly called out.
* The tests pin, on the whole section:
* 1. one badge per connected card, and nothing else that states a status;
* 2. the stored provider error text never reaches the page;
* 3. a paused connection offers exactly one Resume;
* 4. no card paints a divider under its header;
* 5. every not-connected card links its setup guide.
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
@@ -104,227 +93,162 @@ function count(html: string, needle: string): number {
return n;
}
describe("IntegrationsSection — single-status-display contract (A5)", () => {
const PROVIDERS = [
"withings",
"whoop",
"fitbit",
"google-health",
"polar",
"oura",
"strava",
"nightscout",
] as const;
function health(overrides: Record<string, unknown> = {}) {
return {
state: "ok",
reason: "data_arriving",
since: null,
action: null,
unavailable: [],
lastSuccessAt: "2026-05-09T18:00:00.000Z",
lastDataAt: "2026-05-09T17:00:00.000Z",
diagnostics: {
ledgerState: "connected",
failingLegs: [],
failingSince: null,
lastAttemptAt: "2026-05-09T18:00:00.000Z",
verdict: "fresh",
recentCalls: [],
},
...overrides,
};
}
function disconnected(integration: string) {
return {
integration,
lastError: null,
connected: false,
configured: false,
available: false,
health: null,
};
}
/** The Withings card's markup, up to the next card. */
function withingsCard(html: string): string {
const start = html.indexOf('data-testid="withings-card"');
const next = html.indexOf('data-slot="integration-card"', start + 1);
return html.slice(start, next === -1 ? undefined : next);
}
/** Withings as given, every other provider present and not connected. */
function envelopeWith(withings: Record<string, unknown>) {
return {
threshold: 3,
integrations: [
{
integration: "withings",
lastError: null,
connected: true,
configured: true,
...withings,
},
...PROVIDERS.filter((p) => p !== "withings").map(disconnected),
],
};
}
describe("IntegrationsSection — one status display per card", () => {
beforeEach(() => {
integrationStatusPayload = null;
});
it("renders exactly ONE status pill per card when both integrations are healthy", () => {
setIntegrationStatus({
threshold: 3,
integrations: [
{
integration: "withings",
state: "connected",
lastSuccessAt: "2026-05-09T18:00:00.000Z",
lastAttemptAt: "2026-05-09T18:00:00.000Z",
lastError: null,
connected: true,
configured: true,
legacyLastSyncedAt: "2026-05-09T18:00:00.000Z",
hasActivityScope: true,
syncHealth: { verdict: "fresh", since: null },
},
],
});
it("renders exactly one badge for the connected card and no other status surface", () => {
setIntegrationStatus(envelopeWith({ health: health() }));
const html = render();
// Exactly one pill per card → 8 (Withings, WHOOP, Fitbit, Google Health,
// Polar, Oura, Strava, Nightscout). The Garmin info note carries no pill;
// the Apple Health card's pill keeps its own testid.
expect(count(html, 'data-testid="integration-status-pill"')).toBe(8);
// The redundant banner from v1.4.15 is gone.
expect(count(html, 'data-slot="integration-health-badge"')).toBe(1);
expect(html).not.toContain('data-testid="integration-status-pill"');
expect(html).not.toContain('data-testid="integration-status-banner"');
// Card-body "letzter Sync" repetition is gone — no
// "Last sync:" label outside the pill.
expect(html).not.toContain("Last sync:");
// No "Last successful sync" / "Last attempt" trio.
expect(html).not.toContain("Last successful sync");
expect(html).not.toContain("Last attempt");
// The seven other providers and Apple Health are not connected and say so
// once each.
expect(count(html, ">Not connected.<")).toBe(8);
});
it("Withings transient error keeps the pill + error text but drops the {n}/{threshold} duplicate badge", () => {
setIntegrationStatus({
threshold: 3,
integrations: [
{
integration: "withings",
state: "error_transient",
lastSuccessAt: "2026-05-09T08:00:00.000Z",
lastAttemptAt: "2026-05-09T18:00:00.000Z",
lastError: "Withings refresh error: 503 - upstream",
connected: true,
configured: true,
legacyLastSyncedAt: "2026-05-09T08:00:00.000Z",
hasActivityScope: true,
syncHealth: { verdict: "failing", since: "2026-05-09T08:00:00.000Z" },
it("never shows the stored provider error or a failure counter", () => {
setIntegrationStatus(
envelopeWith({
lastError: "Withings refresh error: 503 - upstream",
consecutiveFailuresByKind: {
transient: 2,
reauth_required: 0,
persistent: 0,
},
],
});
health: health({
state: "degraded",
reason: "provider_errors",
since: "2026-05-09T08:00:00.000Z",
}),
}),
);
const html = render();
// A 503 is not the user's to fix, so the pill carries the warning tone —
// red is reserved for "reconnect actually helps".
expect(html).toContain('data-state="warning"');
expect(html).not.toContain('data-state="error"');
// Actionable error message still surfaces (we kept it because
// the pill can't fit "Withings refresh error: 503 - upstream").
expect(html).toContain("Withings refresh error: 503 - upstream");
// …but the consecutive-failure badge that doubled the pill's
// signal is gone.
expect(html).toMatch(
/data-slot="integration-health-badge"[^>]*data-state="degraded"/,
);
expect(html).not.toContain("Withings refresh error");
expect(html).not.toContain("2/3 consecutive failures");
expect(html).not.toContain("Last successful sync");
expect(html).not.toContain("Last attempt");
// Not the user's to fix: the Withings card offers no button.
expect(withingsCard(html)).toContain('data-testid="withings-menu"');
expect(withingsCard(html)).not.toContain(
'data-slot="integration-primary-action"',
);
});
it("Withings reauth state surfaces 'Error — reconnect' on the pill (the maintainer's wording)", () => {
setIntegrationStatus({
threshold: 3,
integrations: [
{
integration: "withings",
state: "error_reauth",
lastSuccessAt: "2026-05-08T12:00:00.000Z",
lastAttemptAt: "2026-05-09T18:00:00.000Z",
lastError: "Withings refresh error: 100 - invalid_grant",
connected: true,
configured: true,
legacyLastSyncedAt: "2026-05-08T12:00:00.000Z",
tokenExpired: true,
hasActivityScope: true,
syncHealth: {
verdict: "reauth_required",
since: "2026-05-09T18:00:00.000Z",
},
},
],
});
it("a paused connection offers exactly one Resume", () => {
setIntegrationStatus(
envelopeWith({
lastError: "Withings activity error: 293",
health: health({
state: "action_required",
reason: "paused_after_errors",
since: "2026-05-09T18:00:00.000Z",
action: "resume",
}),
}),
);
const html = render();
expect(html).toContain('data-state="error"');
// Old "Reconnect required" banner is gone — the pill is now
// the canonical surface.
expect(html).not.toContain('data-testid="integration-status-banner"');
});
it("disconnected state shows 'Not connected' on the pill, no extra status surfaces", () => {
setIntegrationStatus({
threshold: 3,
integrations: [
{
integration: "withings",
state: "disconnected",
lastSuccessAt: "2026-05-08T10:00:00.000Z",
lastAttemptAt: "2026-05-08T10:00:00.000Z",
lastError: null,
syncHealth: { verdict: "disconnected", since: null },
},
],
});
const html = render();
expect(html).toContain("Not connected");
expect(html).not.toContain('data-testid="integration-status-banner"');
// No trailing "letzter Sync" line outside the pill.
expect(html).not.toContain("Last sync:");
});
// v1.4.43 W14 — parked-state surface check.
// The pill flips to `data-state="parked"` and the resume banner
// renders with the reconnect CTA. The legacy `error` reconnect text
// (red) must NOT appear because the user-facing copy is the lighter
// "Paused: reconnect" string, not "Error — reconnect".
it("Withings parked state surfaces the parked pill + resume CTA", () => {
setIntegrationStatus({
threshold: 3,
integrations: [
{
integration: "withings",
state: "parked",
lastSuccessAt: "2026-05-08T12:00:00.000Z",
lastAttemptAt: "2026-05-09T18:00:00.000Z",
lastError: "Withings activity error: 293",
connected: true,
configured: true,
legacyLastSyncedAt: "2026-05-08T12:00:00.000Z",
hasActivityScope: true,
syncHealth: { verdict: "parked", since: "2026-05-09T18:00:00.000Z" },
},
],
});
const html = render();
expect(html).toContain('data-state="parked"');
expect(html).toContain("Paused");
expect(html).toContain("Paused: reconnect");
expect(html).toContain('data-testid="withings-parked-banner"');
expect(html).toMatch(
/data-slot="integration-health-badge"[^>]*data-state="action_required"/,
);
expect(html).toContain('data-testid="withings-resume-button"');
// The lastError is surfaced under the pill so the operator sees
// the contract-mismatch reason without opening Audit Log.
expect(html).toContain("Withings activity error: 293");
expect(count(html, 'data-action="resume"')).toBe(1);
expect(html).not.toContain('data-testid="withings-parked-banner"');
expect(html).not.toContain("Withings activity error: 293");
});
it("no integration card paints a divider under its header", () => {
setIntegrationStatus({
threshold: 3,
integrations: [
{
integration: "withings",
state: "connected",
lastSuccessAt: "2026-05-09T18:00:00.000Z",
lastAttemptAt: "2026-05-09T18:00:00.000Z",
lastError: null,
connected: true,
configured: true,
legacyLastSyncedAt: "2026-05-09T18:00:00.000Z",
hasActivityScope: true,
syncHealth: { verdict: "fresh", since: null },
},
],
});
const html = render();
// The `<hr>` under the header existed only in `integrations/` — nine
// cards carrying a rule no other Settings card has. The card's own gap
// does the separating now, identically here and everywhere else.
expect(count(html, 'data-testid="integration-card-divider"')).toBe(0);
setIntegrationStatus(envelopeWith({ health: health() }));
expect(count(render(), 'data-testid="integration-card-divider"')).toBe(0);
});
// v1.17.1 — every integration card carries the same discreet "Setup guide"
// doc-link pointing at its runbook under the single shared docs base. The
// affordance is one family across all six providers.
it("every integration card carries the shared Setup-guide doc-link", () => {
// Every card that is not connected yet carries the same discreet "Setup
// guide" link to its runbook under the single shared docs base. Once
// connected, the link moves into the card's menu.
it("every not-connected integration card carries the shared Setup-guide link", () => {
setIntegrationStatus({
threshold: 3,
integrations: [
{
integration: "withings",
state: "connected",
lastSuccessAt: "2026-05-09T18:00:00.000Z",
lastAttemptAt: "2026-05-09T18:00:00.000Z",
lastError: null,
connected: true,
configured: true,
legacyLastSyncedAt: "2026-05-09T18:00:00.000Z",
hasActivityScope: true,
},
],
integrations: PROVIDERS.map(disconnected),
});
const html = render();
// One setup-guide link per card → eight providers (the Garmin info note is
// not an OAuth card and carries its own docs link, not a setup-guide).
expect(count(html, 'data-slot="integration-setup-guide"')).toBe(8);
for (const provider of [
"withings",
"whoop",
"fitbit",
"google-health",
"polar",
"oura",
"strava",
"nightscout",
]) {
for (const provider of PROVIDERS) {
expect(html).toContain(`data-testid="${provider}-setup-guide"`);
expect(html).toContain(
`href="https://docs.healthlog.dev/integrations/${provider}"`,
@@ -14,7 +14,7 @@ type Payload = {
lastSyncedAt: string | null;
lastSyncTrigger?: "foreground" | "background" | "push" | null;
lastBackgroundSyncAt?: string | null;
syncHealth?: { verdict: string; since: string | null };
health?: ConnectionHealth | null;
metricFreshness?: Array<{
type: string;
lastSeenAt: string;
@@ -38,7 +38,10 @@ vi.mock("@tanstack/react-query", () => ({
}),
}));
import type { ConnectionHealth } from "@/lib/integrations/connection-health";
import { AppleHealthCard, ArrivalDetail } from "../apple-health-card";
import { healthFixture } from "./health-fixture";
function render(locale: Locale = "en") {
return renderToStaticMarkup(
@@ -56,76 +59,62 @@ describe("<AppleHealthCard>", () => {
});
it("explains that live sync uses the iOS app rather than OAuth", () => {
statusPayload = {
lastSyncedAt: null,
syncHealth: { verdict: "pending_first_sync", since: null },
};
statusPayload = { lastSyncedAt: null, health: null };
const html = render();
expect(html).toContain('data-testid="apple-health-card"');
expect(html).toContain('data-state="not_connected"');
expect(html).toContain("HealthLog iOS app");
expect(html).toContain("not from a web or OAuth connection");
expect(html).toContain("Settings → Apple Health");
expect(html).toContain("when iOS grants background time");
expect(html).not.toContain(">Connected<");
expect(html).toContain("Not connected.");
expect(html).not.toContain('data-slot="integration-health-badge"');
});
/**
* The defect this card carried: any `lastSyncedAt` at all painted the green
* "data received" chip, so a pipe dead for three weeks looked exactly like
* one that delivered this morning. The verdict now decides.
*/
it("shows a week-old delivery as stale, not as data received", () => {
it("says when data stopped and what to do, from the server's health", () => {
const eightDaysAgo = new Date(
Date.now() - 8 * 24 * 60 * 60 * 1000,
).toISOString();
statusPayload = {
lastSyncedAt: eightDaysAgo,
syncHealth: { verdict: "stale", since: eightDaysAgo },
health: healthFixture({
state: "degraded",
reason: "no_new_data",
since: eightDaysAgo,
lastDataAt: eightDaysAgo,
}),
};
const html = render();
expect(html).toContain('data-testid="apple-health-status"');
expect(html).toContain('data-state="stale"');
expect(html).toContain("no recent data");
// And it says what to do about it.
expect(html).toContain('data-testid="apple-health-stale"');
expect(html).not.toContain('data-state="connected"');
expect(html).toMatch(
/data-slot="integration-health-badge"[^>]*data-state="degraded"/,
);
expect(html).toContain("Delayed");
// Apple Health's own variant: the iPhone app is what moves the data.
expect(html).toContain("Open HealthLog on your iPhone once.");
});
it("shows a recent delivery as connected with its relative time", () => {
it("reads a recent delivery as working", () => {
const anHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
statusPayload = {
lastSyncedAt: anHourAgo,
syncHealth: { verdict: "fresh", since: null },
health: healthFixture({ lastDataAt: anHourAgo }),
};
const html = render();
expect(html).toContain('data-state="connected"');
expect(html).toMatch(/1\s+h\s+ago/);
expect(html).not.toContain('data-testid="apple-health-stale"');
expect(html).toContain('data-state="ok"');
expect(html).toContain("Data is arriving.");
expect(html).toContain("Last data: today,");
});
it("waits for the first delivery rather than claiming a connection", () => {
statusPayload = {
lastSyncedAt: null,
syncHealth: { verdict: "pending_first_sync", since: null },
};
const html = render();
expect(html).toContain('data-state="pending-setup"');
expect(html).toContain("Waiting for first data");
expect(html).not.toContain('data-state="connected"');
});
it("renders per-metric freshness so one dead permission is visible", () => {
it("keeps per-metric freshness in the details so one dead permission stays findable", () => {
statusPayload = {
lastSyncedAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
syncHealth: { verdict: "fresh", since: null },
health: healthFixture(),
metricFreshness: [
{
type: "RESPIRATORY_RATE",
@@ -143,26 +132,14 @@ describe("<AppleHealthCard>", () => {
};
const html = render();
const details = html.slice(html.indexOf('data-slot="integration-details"'));
expect(html).toContain('data-slot="metric-freshness"');
// The collapsed summary already names the problem — the signal must not
// hide behind a click.
expect(html).toContain("quiet");
});
it("carries no card divider — the card gap does that job", () => {
statusPayload = {
lastSyncedAt: null,
syncHealth: { verdict: "pending_first_sync", since: null },
};
expect(render()).not.toContain('data-testid="integration-card-divider"');
expect(details).toContain('data-slot="metric-freshness"');
expect(details).toContain("1 quiet");
});
it("links to the existing one-shot Apple Health import fallback", () => {
statusPayload = {
lastSyncedAt: null,
syncHealth: { verdict: "pending_first_sync", since: null },
};
statusPayload = { lastSyncedAt: null, health: null };
const html = render();
@@ -173,14 +150,15 @@ describe("<AppleHealthCard>", () => {
expect(html).toContain("Open one-shot import");
});
it("claims no status at all while the read is in flight", () => {
it("paints the card and its header while the read is in flight, and no state", () => {
statusPayload = undefined;
statusLoading = true;
const html = render();
expect(html).not.toContain('data-testid="apple-health-status"');
expect(html).not.toContain("Connected");
expect(html).toContain('data-state="loading"');
expect(html).not.toContain('data-slot="integration-health-badge"');
expect(html).not.toContain("Not connected.");
});
it("treats a failed status read as an error line, not a status", () => {
@@ -189,9 +167,8 @@ describe("<AppleHealthCard>", () => {
const html = render();
expect(html).toContain("Apple Health status unavailable");
expect(html).toContain('data-testid="integration-error-message"');
expect(html).not.toContain('data-testid="apple-health-status"');
expect(html).not.toContain("Connected");
expect(html).not.toContain('data-slot="integration-health-badge"');
expect(html).not.toContain("Not connected.");
});
it("defines every card key in all six locale catalogs", () => {
@@ -203,7 +180,6 @@ describe("<AppleHealthCard>", () => {
"backgroundStep",
"importNote",
"importAction",
"staleNote",
] as const;
for (const catalog of [en, de, es, fr, itMessages, pl]) {
@@ -212,8 +188,6 @@ describe("<AppleHealthCard>", () => {
expect(appleHealth[key]).toBeTypeOf("string");
expect(appleHealth[key].length).toBeGreaterThan(0);
}
// The status copy the pill now owns is gone from the catalog; only the
// failed-read line survives here.
expect(Object.keys(appleHealth.status).sort()).toEqual(["unavailable"]);
}
});
@@ -221,10 +195,7 @@ describe("<AppleHealthCard>", () => {
it.each(["en", "de", "es", "fr", "it", "pl", "ko"] as const)(
"renders localized Apple Health copy for %s without leaking keys",
(locale) => {
statusPayload = {
lastSyncedAt: null,
syncHealth: { verdict: "pending_first_sync", since: null },
};
statusPayload = { lastSyncedAt: null, health: null };
const html = render(locale);
@@ -246,7 +217,7 @@ describe("<AppleHealthCard> — delivery diagnostic (#586)", () => {
lastSyncedAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
lastSyncTrigger: "background",
lastBackgroundSyncAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
syncHealth: { verdict: "fresh", since: null },
health: healthFixture(),
};
const html = render();
@@ -261,7 +232,7 @@ describe("<AppleHealthCard> — delivery diagnostic (#586)", () => {
lastSyncedAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
lastSyncTrigger: "foreground",
lastBackgroundSyncAt: null,
syncHealth: { verdict: "fresh", since: null },
health: healthFixture(),
};
const html = render();
@@ -277,7 +248,7 @@ describe("<AppleHealthCard> — delivery diagnostic (#586)", () => {
lastSyncedAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
lastSyncTrigger: null,
lastBackgroundSyncAt: null,
syncHealth: { verdict: "fresh", since: null },
health: healthFixture(),
};
const html = render();
@@ -287,15 +258,16 @@ describe("<AppleHealthCard> — delivery diagnostic (#586)", () => {
);
});
it("says there is nothing to report when no sync has ever arrived", () => {
it("shows the setup steps instead of an empty delivery report before any sync", () => {
statusPayload = {
lastSyncedAt: null,
syncHealth: { verdict: "pending_first_sync", since: null },
health: null,
};
const html = render();
expect(html).toContain("No sync has arrived yet");
expect(html).toContain("Turn on live sync");
expect(html).not.toContain('data-testid="apple-health-delivery"');
expect(html).not.toContain("Last background sync");
});
});
@@ -312,7 +284,7 @@ describe("<AppleHealthCard> — sync progress (#778)", () => {
it("shows the accepted-row count and the oldest reading reached", () => {
statusPayload = {
lastSyncedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
syncHealth: { verdict: "fresh", since: null },
health: healthFixture(),
syncProgress: {
recordsAccepted: 12345,
oldestMeasuredAt: "2019-05-04T06:00:00.000Z",
@@ -334,7 +306,7 @@ describe("<AppleHealthCard> — sync progress (#778)", () => {
it("reads as flowing while batches keep arriving", () => {
statusPayload = {
lastSyncedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
syncHealth: { verdict: "fresh", since: null },
health: healthFixture(),
syncProgress: { recordsAccepted: 100, oldestMeasuredAt: null },
};
@@ -348,7 +320,7 @@ describe("<AppleHealthCard> — sync progress (#778)", () => {
it("waits honestly instead of inventing throttle state when batches pause", () => {
statusPayload = {
lastSyncedAt: new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString(),
syncHealth: { verdict: "fresh", since: null },
health: healthFixture(),
syncProgress: { recordsAccepted: 100, oldestMeasuredAt: null },
};
@@ -364,7 +336,7 @@ describe("<AppleHealthCard> — sync progress (#778)", () => {
it("renders no progress section before anything has arrived", () => {
statusPayload = {
lastSyncedAt: null,
syncHealth: { verdict: "pending_first_sync", since: null },
health: null,
syncProgress: { recordsAccepted: 0, oldestMeasuredAt: null },
};
@@ -377,7 +349,7 @@ describe("<AppleHealthCard> — sync progress (#778)", () => {
it("renders no progress section when the server read failed", () => {
statusPayload = {
lastSyncedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
syncHealth: { verdict: "fresh", since: null },
health: healthFixture(),
syncProgress: null,
};
@@ -472,7 +444,7 @@ describe("<AppleHealthCard> — per-type arrivals (#1173)", () => {
it("titles the list by data type", () => {
statusPayload = {
lastSyncedAt: hoursAgo(1),
syncHealth: { verdict: "fresh", since: null },
health: healthFixture(),
metricFreshness: [{ ...base }],
};
expect(render()).toContain("By data type");
@@ -49,6 +49,7 @@ vi.mock("@tanstack/react-query", () => ({
import { I18nProvider } from "@/lib/i18n/context";
import { ConnectionsPanel } from "../connections-panel";
import { INTEGRATION_DISPLAY_NAMES } from "../integration-fallback-row";
import { healthFixture } from "./health-fixture";
const MONTH_AGO = new Date(Date.now() - 28 * 24 * 60 * 60 * 1000).toISOString();
@@ -64,14 +65,16 @@ const CARD_LESS_KEY = "example-provider";
function entry(integration: string) {
return {
integration,
state: "error_transient",
lastSuccessAt: MONTH_AGO,
lastAttemptAt: MONTH_AGO,
lastError: null,
configured: true,
connected: true,
available: true,
syncHealth: { verdict: "stalled", since: MONTH_AGO },
health: healthFixture({
state: "degraded",
reason: "sync_stalled",
since: MONTH_AGO,
lastDataAt: MONTH_AGO,
}),
metricFreshness: [],
};
}
@@ -117,7 +120,7 @@ describe("ConnectionsPanel — every envelope entry surfaces", () => {
}
});
it("gives a card-less provider a fallback row with the stalled pill", () => {
it("gives a card-less provider the shared card, read-only, with its server state", () => {
// The shape the mechanism exists for. With no display name registered the
// row falls back to the raw key, which is still an honest surface — far
// better than the provider rendering nowhere at all.
@@ -130,9 +133,15 @@ describe("ConnectionsPanel — every envelope entry surfaces", () => {
expect(html).toContain('data-testid="integration-fallback-row"');
expect(html).toContain(`data-integration="${CARD_LESS_KEY}"`);
expect(html).toContain(CARD_LESS_KEY);
expect(html).toContain('data-state="stalled"');
expect(html).toContain("Sync stopped");
expect(html).toContain("Last attempt");
const row = html.slice(
html.indexOf('data-testid="integration-fallback-row"'),
);
expect(row).toMatch(
/data-slot="integration-health-badge"[^>]*data-state="degraded"/,
);
expect(row).toContain("HealthLog has not synced since");
// Read-only: no menu and no button for a dropped integration.
expect(html).not.toContain(`data-testid="${CARD_LESS_KEY}-menu"`);
});
it("renders no fallback row when every entry is card-backed", () => {
@@ -149,12 +158,9 @@ describe("ConnectionsPanel — every envelope entry surfaces", () => {
integrations: [
{
...entry(CARD_LESS_KEY),
state: "disconnected",
lastSuccessAt: null,
lastAttemptAt: null,
configured: false,
connected: false,
syncHealth: { verdict: "disconnected", since: null },
health: null,
},
],
};
@@ -1,23 +1,23 @@
/**
* Nightscout parity + correctness.
* Nightscout on the shared integration card.
*
* 1. A `parked` verdict renders the warning banner + reconnect button
* (matching the WHOOP card's parked treatment, byte-for-byte classes).
* 2. A healthy connection renders the shared TestConnectionButton AND the
* connect→data link to /insights/blood-glucose.
* 3. The card reads the consolidated envelope — it was the last card firing
* its own status round-trip — so a disconnect invalidates that one key.
* 1. A paused connection offers "Resume", which opens the address form (there
* is no OAuth round-trip to re-run).
* 2. A healthy connection keeps sync, test, the data link and disconnect in
* the menu, and disconnect asks first.
* 3. The card reads the consolidated envelope, so a disconnect invalidates
* that one key.
* 4. An unapproved private origin is named for what it is, an operator
* action, without echoing the stored error text.
*/
import { describe, expect, it, vi } from "vitest";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { queryKeys } from "@/lib/query-keys";
// Capture the disconnect mutation's onSuccess so the test can assert which
// query keys it invalidates.
const invalidateSpy = vi.fn();
type OnSuccess = () => void;
const capturedDisconnectOnSuccess: { fn: OnSuccess | null } = { fn: null };
const capturedDisconnectOnSuccess: { fn: (() => void) | null } = { fn: null };
vi.mock("@tanstack/react-query", () => ({
useMutation: ({ onSuccess }: { onSuccess?: () => void }) => {
@@ -28,11 +28,30 @@ vi.mock("@tanstack/react-query", () => ({
useQueryClient: () => ({ invalidateQueries: invalidateSpy }),
}));
let lastCardProps: IntegrationCardProps | null = null;
vi.mock("../integration-card", async (importOriginal) => {
const actual = await importOriginal<typeof import("../integration-card")>();
return {
...actual,
IntegrationCard: (props: IntegrationCardProps) => {
lastCardProps = props;
return createElement(actual.IntegrationCard, props);
},
};
});
import { I18nProvider } from "@/lib/i18n/context";
import type { IntegrationCardProps } from "../integration-card";
import { NightscoutCard } from "../nightscout-card";
import type { IntegrationStatusViewModel } from "../shared";
import { healthFixture } from "./health-fixture";
function render(viewModel?: Partial<IntegrationStatusViewModel>) {
/** The envelope carries more than the card declares; the card must ignore it. */
type Envelope = Partial<IntegrationStatusViewModel> & {
lastError?: string | null;
};
function render(viewModel?: Envelope) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<NightscoutCard
@@ -40,9 +59,6 @@ function render(viewModel?: Partial<IntegrationStatusViewModel>) {
viewModel
? ({
integration: "nightscout",
state: "connected",
lastSuccessAt: null,
lastAttemptAt: null,
lastError: null,
...viewModel,
} as IntegrationStatusViewModel)
@@ -53,121 +69,126 @@ function render(viewModel?: Partial<IntegrationStatusViewModel>) {
);
}
describe("NightscoutCard — parked + test + data-link + invalidation", () => {
it("renders the parked banner + reconnect button when the verdict is parked", () => {
function menuIds(): string[] {
return (lastCardProps?.menu ?? []).map((item) => item.id);
}
describe("NightscoutCard on the shared card", () => {
it("offers Resume on a paused connection, and Resume opens the address form", () => {
const html = render({
connected: true,
configured: true,
state: "parked",
lastError: "Nightscout unreachable",
syncHealth: { verdict: "parked", since: null },
health: healthFixture({
state: "action_required",
reason: "paused_after_errors",
since: "2026-10-12T14:00:00.000Z",
action: "resume",
}),
});
expect(html).toContain('data-state="parked"');
expect(html).toContain('data-testid="nightscout-parked-banner"');
expect(html).toContain('data-testid="nightscout-resume-button"');
expect(html).toContain("border-warning/30 bg-warning/10");
expect(html).toMatch(
/data-slot="integration-health-badge"[^>]*data-state="action_required"/,
);
expect(html).toContain('data-action="resume"');
expect(html).toContain("Nightscout is paused after failing since");
expect(lastCardProps?.primary?.onResume).toBeTypeOf("function");
// The form is closed until the button asks for it.
expect(html).not.toContain('id="nightscout-url"');
});
it("renders the test-connection button + data link when connected", () => {
const html = render({
it("keeps sync, test, the data link and disconnect in the menu", () => {
render({
connected: true,
configured: true,
lastSuccessAt: "2026-06-01T00:00:00.000Z",
syncHealth: { verdict: "fresh", since: null },
health: healthFixture(),
metricFreshness: [],
});
expect(html).toContain("Test connection");
expect(html).toContain('data-testid="nightscout-data-link"');
expect(html).toContain('href="/insights/blood-glucose"');
expect(menuIds()).toEqual([
"sync",
"test",
"view-data",
"credentials",
"setup-guide",
"disconnect",
]);
const viewData = lastCardProps?.menu?.find((i) => i.id === "view-data");
expect(viewData?.href).toBe("/insights/blood-glucose");
const disconnect = lastCardProps?.menu?.find((i) => i.id === "disconnect");
expect(disconnect?.destructive).toBe(true);
expect(disconnect?.confirm?.title).toBeTruthy();
});
it("carries no card divider — the card gap does that job", () => {
it("puts the per-metric list in the details, stale rows included", () => {
const html = render({
connected: true,
configured: true,
syncHealth: { verdict: "fresh", since: null },
});
expect(html).not.toContain('data-testid="integration-card-divider"');
});
it("renders the per-metric freshness disclosure with a stale row", () => {
const html = render({
connected: true,
configured: true,
syncHealth: { verdict: "fresh", since: null },
health: healthFixture(),
metricFreshness: [
{
type: "BLOOD_GLUCOSE",
lastSeenAt: "2026-06-01T00:00:00.000Z",
lastSeenAt: new Date(Date.now() - 3 * 86_400_000).toISOString(),
stale: true,
},
],
});
expect(html).toContain('data-slot="metric-freshness"');
expect(html).toContain("quiet");
const details = html.slice(html.indexOf('data-slot="integration-details"'));
expect(details).toContain('data-metric="BLOOD_GLUCOSE"');
expect(details).toContain('data-state="stale"');
});
it("disconnect invalidates the envelope the card actually reads", () => {
render({ connected: true, configured: true, health: healthFixture() });
invalidateSpy.mockClear();
capturedDisconnectOnSuccess.fn = null;
render({
connected: true,
configured: true,
syncHealth: { verdict: "fresh", since: null },
capturedDisconnectOnSuccess.fn?.();
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: queryKeys.integrationsStatus(),
});
const onSuccess = capturedDisconnectOnSuccess.fn as (() => void) | null;
expect(typeof onSuccess).toBe("function");
onSuccess?.();
const invalidatedKeys = invalidateSpy.mock.calls.map((c) => c[0]?.queryKey);
expect(invalidatedKeys).toContainEqual(queryKeys.integrationsStatus());
});
});
describe("NightscoutCard — sync-now action", () => {
it("offers the sync action on a connected instance", () => {
const html = render({
connected: true,
configured: true,
syncHealth: { verdict: "fresh", since: null },
});
expect(html).toContain('data-testid="nightscout-sync"');
expect(html).toContain("Sync now");
});
it("does not offer it before an instance is configured", () => {
expect(
render({
connected: false,
configured: false,
state: "disconnected",
syncHealth: { verdict: "disconnected", since: null },
}),
).not.toContain('data-testid="nightscout-sync"');
it("shows the address form and Connect while not connected", () => {
const html = render({ connected: false, configured: false, health: null });
expect(html).toContain('data-state="not_connected"');
expect(html).toContain('id="nightscout-url"');
expect(html).toContain('data-testid="nightscout-connect"');
expect(html).toContain('data-testid="nightscout-setup-guide"');
});
});
describe("NightscoutCard — operator-owned private access", () => {
it("does not offer an ordinary user a private-host enforcement bypass", () => {
const html = render({
connected: false,
configured: false,
state: "disconnected",
syncHealth: { verdict: "disconnected", since: null },
});
const html = render({ connected: false, configured: false, health: null });
expect(html).not.toContain('id="nightscout-private"');
expect(html).not.toContain("This site runs on a private network");
expect(html).not.toContain("Enable only if your Nightscout instance");
});
it("shows a redacted operator-action state for an unapproved legacy private connection", () => {
it("names the operator action for an unapproved private origin without echoing the error", () => {
const html = render({
connected: true,
configured: true,
state: "error_reauth",
// Still on the wire for one release; the card must not show it.
lastError:
"private_origin_not_approved: http://10.0.0.4:1337/api/v1/entries.json?token=ns-secret",
syncHealth: { verdict: "failing", since: null },
allowPrivateHost: true,
health: healthFixture({
state: "degraded",
reason: "provider_errors",
since: "2026-10-14T18:40:00.000Z",
diagnostics: {
ledgerState: "error_transient",
failingLegs: ["sync"],
failingSince: "2026-10-14T18:40:00.000Z",
lastAttemptAt: "2026-10-15T09:15:00.000Z",
verdict: "failing",
recentCalls: [
{
endpoint: "sync",
status: "persistent",
at: "2026-10-15T09:15:00.000Z",
errorCode: "private_origin_not_approved",
},
],
},
}),
});
expect(html).toContain(
@@ -176,35 +197,27 @@ describe("NightscoutCard — operator-owned private access", () => {
expect(html).toContain(
"Private Nightscout access requires server operator approval.",
);
expect(html).toContain(
"Ask your server operator to approve this exact Nightscout origin.",
);
expect(html).not.toContain("private_origin_not_approved");
// The short code appears in the diagnostics block, the stored message
// nowhere: no address, no token.
expect(html).not.toContain("private_origin_not_approved:");
expect(html).not.toContain("10.0.0.4");
expect(html).not.toContain("ns-secret");
expect(html).not.toContain('data-testid="nightscout-sync"');
expect(html).not.toContain("Test connection");
expect(html).toContain("Disconnect");
// Sync and test cannot help until the operator approves the origin.
expect(menuIds()).not.toContain("sync");
expect(menuIds()).not.toContain("test");
expect(menuIds()).toContain("disconnect");
});
it.each([
["public", false],
["operator-approved private", true],
])("keeps a healthy %s connection usable", (_label, allowPrivateHost) => {
it("keeps a healthy connection usable", () => {
const html = render({
connected: true,
configured: true,
state: "connected",
lastError: null,
syncHealth: { verdict: "fresh", since: null },
// Compatibility/display metadata only; it is not the authority asserted
// by this positive flow.
allowPrivateHost,
health: healthFixture(),
});
expect(html).toContain('data-testid="nightscout-sync"');
expect(html).toContain("Test connection");
expect(html).toContain("Disconnect");
expect(menuIds()).toContain("sync");
expect(menuIds()).toContain("test");
expect(html).not.toContain(
'data-testid="nightscout-private-operator-required"',
);
@@ -1,274 +0,0 @@
/**
* v1.17.0 — interaction-parity check for the shared OAuth integration card
* (Polar / Oura). The new cards must match the existing WHOOP card's
* parked-state + test-connection + connect→data treatment:
*
* 1. A `parked` status renders the warning banner + reconnect button.
* 2. A `connected` status renders the shared TestConnectionButton (its
* "Test connection" affordance) AND the connect→data link.
* 3. The data link resolves to the provider's insight surface.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { Watch } from "lucide-react";
// The card reads the consolidated envelope only — its per-card status fetch is
// gone, so every fixture is a view-model.
vi.mock("@tanstack/react-query", () => ({
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { OAuthProviderCard } from "../oauth-provider-card";
type ViewModel = Parameters<typeof OAuthProviderCard>[0]["viewModel"];
function render({
credentials = false,
viewModel,
}: {
credentials?: boolean;
viewModel?: ViewModel;
} = {}) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<OAuthProviderCard
provider="polar"
statusQueryKey={["polar"]}
i18nPrefix="settings.polar"
icon={Watch}
dataHref="/insights/sleep"
credentials={credentials}
viewModel={viewModel}
callbackUrl="https://app.example/api/polar/callback"
/>
</I18nProvider>,
);
}
describe("OAuthProviderCard — parked + test + data-link parity", () => {
it("renders the parked banner + reconnect button when state is parked", () => {
const html = render({
viewModel: {
connected: true,
configured: true,
available: true,
state: "parked",
lastSuccessAt: null,
lastError: "Polar grant expired",
syncHealth: { verdict: "parked", since: null },
},
});
expect(html).toContain('data-state="parked"');
expect(html).toContain('data-testid="polar-parked-banner"');
expect(html).toContain('data-testid="polar-resume-button"');
expect(html).toContain("Paused: reconnect");
// The parked banner uses the same warning treatment as the WHOOP card.
expect(html).toContain("border-warning/30 bg-warning/10");
});
it("renders the test-connection button + data link when connected", () => {
const html = render({
viewModel: {
connected: true,
configured: true,
available: true,
state: "connected",
lastSuccessAt: "2026-06-01T00:00:00.000Z",
lastError: null,
syncHealth: { verdict: "fresh", since: null },
},
});
// The shared TestConnectionButton surfaces its "Test connection" label.
expect(html).toContain("Test connection");
// connect→data link points at the provider's insight surface.
expect(html).toContain('data-testid="polar-data-link"');
expect(html).toContain('href="/insights/sleep"');
});
it("reads off the passed view-model — the per-card fetch is gone", () => {
// There is no `useQuery` left in this component: the envelope is the only
// source. A per-provider status response carries no verdict, so falling
// back to it would have painted a status the server never resolved.
const html = render({
viewModel: {
connected: true,
configured: true,
available: true,
state: "connected",
lastSuccessAt: "2026-06-01T00:00:00.000Z",
lastError: null,
syncHealth: { verdict: "fresh", since: null },
},
});
expect(html).toContain('data-testid="polar-data-link"');
expect(html).toContain("Test connection");
});
it("does not render the data link or test button when disconnected", () => {
const html = render({
viewModel: {
connected: false,
configured: false,
available: true,
syncHealth: { verdict: "disconnected", since: null },
},
});
expect(html).not.toContain('data-testid="polar-data-link"');
expect(html).not.toContain("Test connection");
// The connect CTA stands in instead.
expect(html).toContain('data-testid="polar-connect"');
});
});
describe("OAuthProviderCard — per-user BYO credentials form (v1.17.1)", () => {
it("renders the credentials form only when the `credentials` prop is set", () => {
const viewModel: ViewModel = {
connected: false,
configured: false,
available: true,
hasOwnCredentials: false,
syncHealth: { verdict: "disconnected", since: null },
};
// Opt-in: the BYO client-id/secret form + save button appear.
const withForm = render({ credentials: true, viewModel });
expect(withForm).toContain('data-testid="polar-credentials"');
expect(withForm).toContain('id="polar-clientid"');
expect(withForm).toContain('id="polar-secret"');
// Default: no credential inputs (env-only behaviour preserved).
const withoutForm = render({ viewModel });
expect(withoutForm).not.toContain('data-testid="polar-credentials"');
expect(withoutForm).not.toContain('id="polar-clientid"');
});
it("shows the saved-placeholder once the user has stored their own pair", () => {
const html = render({
credentials: true,
viewModel: {
connected: true,
configured: true,
available: true,
hasOwnCredentials: true,
state: "connected",
lastSuccessAt: null,
lastError: null,
syncHealth: { verdict: "pending_first_sync", since: null },
},
});
expect(html).toContain("Saved. Enter new to replace");
});
});
describe("OAuthProviderCard — redirect-URI mini-guide (v1.29.x, UX audit H2)", () => {
it("shows the callback URL guide before the user has BYO credentials", () => {
const html = render({
credentials: true,
viewModel: {
connected: false,
configured: false,
available: true,
hasOwnCredentials: false,
syncHealth: { verdict: "disconnected", since: null },
},
});
expect(html).toContain('data-testid="polar-redirect-guide"');
expect(html).toContain('data-testid="polar-redirect-uri"');
expect(html).toContain("https://app.example/api/polar/callback");
});
it("hides the guide once the user has stored their own credentials", () => {
const html = render({
credentials: true,
viewModel: {
connected: true,
configured: true,
available: true,
hasOwnCredentials: true,
state: "connected",
lastSuccessAt: null,
lastError: null,
syncHealth: { verdict: "fresh", since: null },
},
});
expect(html).not.toContain('data-testid="polar-redirect-guide"');
});
});
/**
* v1.32.28 — sync now. The route without the button would be an endpoint with
* no caller; the button is what makes the manual sync a feature rather than an
* API detail. Rendered only once the provider is connected, in the same action
* row as Test connection.
*/
describe("OAuthProviderCard — sync-now action", () => {
it("offers the sync action on a connected provider", () => {
const html = render({
viewModel: {
connected: true,
configured: true,
available: true,
state: "connected",
lastSuccessAt: null,
lastError: null,
syncHealth: { verdict: "fresh", since: null },
},
});
expect(html).toContain('data-testid="polar-sync"');
expect(html).toContain("Sync now");
});
it("does not offer it before the provider is connected", () => {
expect(
render({
viewModel: {
connected: false,
configured: true,
available: true,
syncHealth: { verdict: "disconnected", since: null },
},
}),
).not.toContain('data-testid="polar-sync"');
});
});
describe("OAuthProviderCard — Oura scope note", () => {
const scopeGap = {
connected: true,
configured: true,
available: true,
state: "connected" as const,
lastSuccessAt: "2026-10-14T00:00:00.000Z",
lastError: null,
syncHealth: { verdict: "fresh" as const, since: null },
scopeReconnect: ["spo2" as const, "vo2max" as const],
};
function renderOura(viewModel: ViewModel) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<OAuthProviderCard
provider="oura"
statusQueryKey={["oura"]}
i18nPrefix="settings.oura"
icon={Watch}
dataHref="/insights/sleep"
viewModel={viewModel}
callbackUrl="https://app.example/api/oura/callback"
/>
</I18nProvider>,
);
}
it("asks for one reconnect beside a healthy pill, not an error", () => {
const html = renderOura(scopeGap);
expect(html).toContain('data-testid="oura-scope-note"');
expect(html).toContain("Reconnect Oura once to allow SpO2 and VO2 max.");
expect(html).not.toContain('data-testid="oura-error"');
});
it("stays away from the other OAuth providers", () => {
expect(render({ viewModel: scopeGap })).not.toContain("scope-note");
});
});
@@ -1,138 +0,0 @@
/**
* One verdict, one pill mapping.
*
* There used to be three `pillStateFor` implementations — one per card family —
* and they disagreed with each other. The same ledger state painted red on the
* Withings card and orange on the Polar card, on the same page. The server now
* resolves the verdict once; this pins that the client projects it once, and
* that a fourth dialect cannot be minted without someone editing this file.
*/
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { walkSourceFiles } from "@/__tests__/helpers/source-files";
import type { SyncVerdict } from "@/lib/integrations/sync-verdict";
import {
pickStatus,
pillFailurePropsFor,
pillStateForVerdict,
} from "../shared";
describe("pillStateForVerdict — the verdict→pill table", () => {
const table: Array<[SyncVerdict | undefined, string]> = [
["fresh", "connected"],
["stale", "stale"],
["stalled", "stalled"],
["failing", "warning"],
["reauth_required", "error"],
["parked", "parked"],
["pending_first_sync", "pending-setup"],
["disconnected", "disconnected"],
// Still loading: the card renders its neutral default rather than
// guessing at a status it has not been told.
[undefined, "disconnected"],
];
it.each(table)("maps %s to the %s pill", (verdict, expected) => {
expect(pillStateForVerdict(verdict)).toBe(expected);
});
/**
* The decision this release makes and must not silently lose: **red means
* "your action fixes this"**. Reconnecting cannot repair an upstream 503 or
* a cron that stopped running, so neither of those may reach the destructive
* pill.
*/
it("reserves the error pill for the states a reconnect actually fixes", () => {
const red = table
.filter(([, state]) => state === "error")
.map(([verdict]) => verdict);
expect(red).toEqual(["reauth_required"]);
expect(pillStateForVerdict("failing")).not.toBe("error");
expect(pillStateForVerdict("stalled")).not.toBe("error");
});
});
describe("pillFailurePropsFor", () => {
it("projects the active bucket and envelope threshold onto the pill", () => {
const status = pickStatus(
{
threshold: 3,
integrations: [
{
integration: "withings",
state: "error_transient",
lastSuccessAt: null,
lastAttemptAt: "2026-07-28T12:00:00.000Z",
lastError: "upstream unavailable",
consecutiveFailuresByKind: {
transient: 2,
reauth_required: 0,
persistent: 1,
},
},
],
},
"withings",
);
expect(pillFailurePropsFor(status)).toEqual({
failureCount: 2,
failureThreshold: 3,
});
});
it("returns no ratio for a healthy zero-count status", () => {
const status = pickStatus(
{
threshold: 3,
integrations: [
{
integration: "withings",
state: "connected",
lastSuccessAt: "2026-07-28T12:00:00.000Z",
lastAttemptAt: "2026-07-28T12:00:00.000Z",
lastError: null,
consecutiveFailuresByKind: {
transient: 0,
reauth_required: 0,
persistent: 0,
},
},
],
},
"withings",
);
expect(pillFailurePropsFor(status)).toEqual({});
});
});
describe("structural guard — no second pill dialect", () => {
const SRC = join(process.cwd(), "src");
function sourceFiles(): string[] {
return walkSourceFiles(SRC, { floor: 3000 })
.filter((p) => !p.startsWith("generated/"))
.filter((p) => !p.includes("__tests__"))
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"));
}
/**
* The one module allowed to define a status→pill mapping. A tripwire, not a
* proof: it cannot show the mapping is right, only that nobody added a
* second one without editing this file.
*/
const MAPPER_HOME = "components/settings/integrations/shared.tsx";
it("defines the mapping in exactly one module", () => {
const definers = sourceFiles().filter((rel) =>
/function\s+pillStateFor\w*\s*\(/.test(
readFileSync(join(SRC, rel), "utf8"),
),
);
expect(definers).toEqual([MAPPER_HOME]);
});
});
@@ -1,65 +0,0 @@
/**
* The two "withheld by contract" notes: the Withings card names what the
* account's API plan refuses, the Oura card asks for one reconnect when the
* grant lacks a scope. Both render nothing when nothing is withheld, and both
* render the server-resolved list rather than deriving one.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { WithingsPlanNote } from "../withings-plan-note";
import { OuraScopeNote } from "../oura-scope-note";
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
describe("<WithingsPlanNote>", () => {
it("lists the withheld readings and links the Withings plans page", () => {
const html = render(
<WithingsPlanNote features={["spo2", "vascularAge", "ecg"]} />,
);
expect(html).toContain('data-testid="withings-plan-note"');
expect(html).toContain("SpO2, vascular age, and ECG and AFib");
expect(html).toContain("Weight, blood pressure, body composition");
expect(html).toContain(
"https://static.withings.com/content/api/plans/plans.html",
);
});
it("speaks the reader's language", () => {
const html = render(<WithingsPlanNote features={["vascularAge"]} />, "de");
expect(html).toContain("Gefäßalter");
expect(html).toContain("kostenpflichtigen API-Plan");
});
it("renders nothing when the plan withholds nothing", () => {
expect(render(<WithingsPlanNote features={[]} />)).toBe("");
expect(render(<WithingsPlanNote features={undefined} />)).toBe("");
});
});
describe("<OuraScopeNote>", () => {
it("asks for one reconnect naming the collections the grant lacks", () => {
const html = render(
<OuraScopeNote
collections={["spo2", "vo2max", "cardio_age", "resilience"]}
onReconnect={vi.fn()}
/>,
);
expect(html).toContain('data-testid="oura-scope-note"');
expect(html).toContain(
"Reconnect Oura once to allow SpO2, VO2 max, cardiovascular age, and resilience.",
);
expect(html).toContain('data-testid="oura-scope-reconnect"');
});
it("renders nothing when the grant covers everything", () => {
expect(
render(<OuraScopeNote collections={[]} onReconnect={vi.fn()} />),
).toBe("");
});
});
@@ -1,51 +0,0 @@
/**
* v1.29.x — UX audit H2: the WHOOP card shows a compact callback-URL guide
* while the account has no BYO credentials saved, and steps out of the way
* once configured. Mirrors the OAuthProviderCard coverage for Polar/Oura.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("@tanstack/react-query", () => ({
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { WhoopCard } from "../whoop-card";
import type { IntegrationStatusViewModel } from "../shared";
function render(
viewModel?: Partial<IntegrationStatusViewModel>,
callbackUrl: string | null = "https://app.example/api/whoop/callback",
) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<WhoopCard
viewModel={viewModel as IntegrationStatusViewModel | undefined}
callbackUrl={callbackUrl}
/>
</I18nProvider>,
);
}
describe("<WhoopCard> redirect-URI mini-guide", () => {
it("shows the callback URL guide before credentials are configured", () => {
const html = render({ connected: false, configured: false });
expect(html).toContain('data-testid="whoop-redirect-guide"');
expect(html).toContain('data-testid="whoop-redirect-uri"');
expect(html).toContain("https://app.example/api/whoop/callback");
});
it("falls back to the fixed path when the server has no redirect URI", () => {
const html = render({ connected: false, configured: false }, null);
expect(html).toContain('data-testid="whoop-redirect-uri"');
expect(html).toContain("/api/whoop/callback");
expect(html).not.toContain("undefined/api/whoop/callback");
});
it("hides the guide once credentials are configured", () => {
const html = render({ connected: false, configured: true });
expect(html).not.toContain('data-testid="whoop-redirect-guide"');
});
});
@@ -1,36 +1,29 @@
"use client";
/**
* Apple Health — the one integration with no ledger row and no cron.
* 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.
* The iOS app pushes; nothing here polls. The card renders the shared
* integration card from the server's `health`, so the flagship integration
* speaks the same status language as its siblings. What only Apple Health
* knows (how the last batch arrived, how far the first backfill reached, when
* each data type last came in) lives in the card's details.
*/
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { HeartPulse, Upload } from "lucide-react";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { SettingsCard } from "@/components/settings/settings-card";
import {
formatRelative,
IntegrationStatusPill,
} from "@/components/settings/integration-status-pill";
import { formatRelative } 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 type { ConnectionHealth } from "@/lib/integrations/connection-health";
import type { HealthKitFreshnessEntry } from "@/lib/integrations/healthkit-type-sync";
import type { SyncHealth } from "@/lib/integrations/sync-verdict";
import { queryKeys } from "@/lib/query-keys";
import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure";
import { IntegrationErrorMessage, pillStateForVerdict } from "./shared";
import { IntegrationCard } from "./integration-card";
import { MetricFreshnessList } from "./metric-freshness-list";
type SyncTrigger = "foreground" | "background" | "push" | "manual";
@@ -40,7 +33,8 @@ interface HealthKitStatus {
lastSyncTrigger?: SyncTrigger | null;
/** When data last arrived without the app being opened. */
lastBackgroundSyncAt?: string | null;
syncHealth?: SyncHealth;
/** The unified connection state; null until the phone has delivered. */
health?: ConnectionHealth | null;
/** Per type: newest sample, plus when it last arrived and how (#1173). */
metricFreshness?: HealthKitFreshnessEntry[];
/** Server-side backfill progress (#778); null when the read failed. */
@@ -188,7 +182,7 @@ export function ArrivalDetail({
data-state="received"
data-trigger={trigger ?? "unknown"}
>
{parts.join(" · ")}
{parts.join(", ")}
</span>
);
}
@@ -279,88 +273,84 @@ export function AppleHealthCard({ enabled }: { enabled: boolean }) {
refetchOnWindowFocus: true,
});
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;
const health = status?.health ?? null;
// Apple Health has nothing to connect: it counts as connected once the
// phone has delivered anything, which is exactly when the server resolves a
// health for it. A failed read is neither, so the card says it could not
// ask instead of claiming "not connected".
const connected = isError || health !== null;
const importLink = (
<Button
asChild
variant="outline"
size="sm"
className="min-h-11 w-full sm:min-h-9 sm:w-auto"
>
<Link
href="/settings/export#settings-section-import-title"
data-testid="apple-health-import-link"
>
<Upload className="size-3.5" aria-hidden="true" />
{t("settings.appleHealth.importAction")}
</Link>
</Button>
);
return (
<SettingsCard
as="section"
aria-labelledby="apple-health-card-title"
data-testid="apple-health-card"
>
<SettingsCardHeader
icon={HeartPulse}
titleId="apple-health-card-title"
title={t("settings.appleHealth.title")}
description={t("settings.appleHealth.description")}
status={
showPill ? (
<IntegrationStatusPill
testId="apple-health-status"
state={pillState}
lastSyncAt={status?.lastSyncedAt ?? null}
/>
) : null
}
/>
<div className="space-y-4">
{isError && (
<IntegrationErrorMessage
message={t("settings.appleHealth.status.unavailable")}
/>
)}
{verdict === "stale" && (
<p className="text-warning text-sm" data-testid="apple-health-stale">
{t("settings.appleHealth.staleNote")}
</p>
)}
{showPill && <DeliveryDiagnostic status={status} t={t} />}
{showPill && <SyncProgress status={status} t={t} />}
<MetricFreshnessDisclosure
entries={status?.metricFreshness}
idPrefix="apple-health"
title={t("settings.appleHealth.freshness.title")}
detail={(entry) => <ArrivalDetail entry={entry} t={t} />}
/>
<div className="space-y-2">
<h3 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{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">
<IntegrationCard
provider="apple-health"
providerName={t("settings.appleHealth.title")}
icon={HeartPulse}
description={t("settings.appleHealth.description")}
loading={enabled && isLoading}
connected={connected}
health={health}
appleHealth
setup={
<div className="space-y-3">
<div className="space-y-2">
<h3 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{t("settings.appleHealth.setupTitle")}
</h3>
<ol className="list-decimal space-y-2 pl-5 text-sm">
<li>{t("settings.appleHealth.permissionStep")}</li>
<li>{t("settings.appleHealth.backgroundStep")}</li>
</ol>
</div>
<p className="text-muted-foreground text-xs">
{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"
>
<Upload className="h-3.5 w-3.5" aria-hidden="true" />
{t("settings.appleHealth.importAction")}
</Link>
</Button>
</div>
</div>
</SettingsCard>
}
connectAction={importLink}
menu={[
{
id: "import",
label: t("settings.appleHealth.importAction"),
icon: Upload,
href: "/settings/export#settings-section-import-title",
},
]}
details={
status && !isError ? (
<>
<DeliveryDiagnostic status={status} t={t} />
<SyncProgress status={status} t={t} />
<MetricFreshnessList
entries={status.metricFreshness}
title={t("settings.appleHealth.freshness.title")}
detail={(entry) => <ArrivalDetail entry={entry} t={t} />}
/>
</>
) : undefined
}
>
{isError ? (
<p role="alert" className="text-destructive text-sm">
{t("settings.appleHealth.status.unavailable")}
</p>
) : null}
</IntegrationCard>
);
}
@@ -17,23 +17,21 @@ import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { AppleHealthCard } from "@/components/settings/integrations/apple-health-card";
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";
import { PolarCard } from "@/components/settings/integrations/polar-card";
import { StravaCard } from "@/components/settings/integrations/strava-card";
import {
OAUTH_INTEGRATIONS,
OAuthIntegrationCard,
} from "@/components/settings/integrations/oauth-integration-card";
import {
pickStatus,
useIntegrationStatuses,
type IntegrationKey,
type IntegrationStatusViewModel,
} from "@/components/settings/integrations/shared";
import { WhoopCard } from "@/components/settings/integrations/whoop-card";
import { WithingsCard } from "@/components/settings/integrations/withings-card";
import { Badge } from "@/components/ui/badge";
import { TagChip } from "@/components/ui/tag-chip";
import { useAuth } from "@/hooks/use-auth";
import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls";
import { useTranslations } from "@/lib/i18n/context";
@@ -179,32 +177,6 @@ export function oauthReasonKey(
: `${base}.generic`;
}
/**
* Adapt the consolidated-envelope view-model into the OAuth card's status
* shape. Returns `undefined` when the envelope hasn't loaded so the card
* renders its loading/disconnected default rather than a half-populated state.
*/
function toOAuthStatus(
vm: IntegrationStatusViewModel | undefined,
): OAuthProviderStatus | undefined {
if (!vm) return undefined;
return {
connected: vm.connected ?? false,
configured: vm.configured ?? false,
available: vm.available ?? false,
hasOwnCredentials: vm.hasOwnCredentials,
state: vm.state,
lastSuccessAt: vm.lastSuccessAt,
lastAttemptAt: vm.lastAttemptAt,
lastError: vm.lastError,
legacyLastSyncedAt: vm.legacyLastSyncedAt,
consecutiveFailuresByKind: vm.consecutiveFailuresByKind,
failureThreshold: vm.failureThreshold,
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
@@ -320,21 +292,6 @@ export function ConnectionsPanel({
}
}, [oauthOutcome, router, queryClient, t]);
const withingsViewModel = pickStatus(integrationStatus, "withings");
const whoopViewModel = pickStatus(integrationStatus, "whoop");
const fitbitViewModel = pickStatus(integrationStatus, "fitbit");
// v1.27.0 — Google Health reads off the same consolidated envelope; the
// ledger integration key is the hyphenated `google-health`.
const googleHealthViewModel = pickStatus(integrationStatus, "google-health");
// v1.17.1 — Polar/Oura now read off the same consolidated envelope; the cards
// no longer fire their own /api/<provider>/status round-trip.
const polarViewModel = toOAuthStatus(pickStatus(integrationStatus, "polar"));
const ouraViewModel = toOAuthStatus(pickStatus(integrationStatus, "oura"));
// v1.28.x — Strava reads off the same consolidated envelope.
const stravaViewModel = toOAuthStatus(
pickStatus(integrationStatus, "strava"),
);
const nightscoutViewModel = pickStatus(integrationStatus, "nightscout");
const unclaimedIntegrations = (integrationStatus?.integrations ?? [])
.filter(
(entry) =>
@@ -351,26 +308,41 @@ export function ConnectionsPanel({
{/* Per-provider anchors so deep-links (onboarding "more sources" chips,
`/settings/integrations#oura`) scroll straight to the right card. */}
<div id="withings" className="scroll-mt-28">
<WithingsCard
viewModel={withingsViewModel}
<OAuthIntegrationCard
config={OAUTH_INTEGRATIONS.withings}
viewModel={pickStatus(integrationStatus, "withings")}
callbackUrl={callbackUrls.withings}
/>
</div>
<div id="whoop" className="scroll-mt-28">
<WhoopCard
viewModel={whoopViewModel}
<OAuthIntegrationCard
config={OAUTH_INTEGRATIONS.whoop}
viewModel={pickStatus(integrationStatus, "whoop")}
callbackUrl={callbackUrls.whoop}
/>
</div>
<div id="fitbit" className="scroll-mt-28">
<FitbitCard
viewModel={fitbitViewModel}
<OAuthIntegrationCard
config={OAUTH_INTEGRATIONS.fitbit}
viewModel={pickStatus(integrationStatus, "fitbit")}
callbackUrl={callbackUrls.fitbit}
titleAccessory={
<>
<TagChip>{t("settings.fitbitTag")}</TagChip>
<Badge
variant="outline"
data-testid="fitbit-experimental-badge"
className="border-warning/50 text-warning"
>
{t("settings.fitbitExperimentalBadge")}
</Badge>
</>
}
/>
</div>
<div id="google-health" className="scroll-mt-28">
<GoogleHealthCard
viewModel={googleHealthViewModel}
viewModel={pickStatus(integrationStatus, "google-health")}
callbackUrl={callbackUrls["google-health"]}
/>
</div>
@@ -378,22 +350,30 @@ export function ConnectionsPanel({
<AppleHealthCard enabled={isAuthenticated} />
</div>
<div id="polar" className="scroll-mt-28">
<PolarCard
viewModel={polarViewModel}
<OAuthIntegrationCard
config={OAUTH_INTEGRATIONS.polar}
viewModel={pickStatus(integrationStatus, "polar")}
callbackUrl={callbackUrls.polar}
/>
</div>
<div id="oura" className="scroll-mt-28">
<OuraCard viewModel={ouraViewModel} callbackUrl={callbackUrls.oura} />
<OAuthIntegrationCard
config={OAUTH_INTEGRATIONS.oura}
viewModel={pickStatus(integrationStatus, "oura")}
callbackUrl={callbackUrls.oura}
/>
</div>
<div id="strava" className="scroll-mt-28">
<StravaCard
viewModel={stravaViewModel}
<OAuthIntegrationCard
config={OAUTH_INTEGRATIONS.strava}
viewModel={pickStatus(integrationStatus, "strava")}
callbackUrl={callbackUrls.strava}
/>
</div>
<div id="nightscout" className="scroll-mt-28">
<NightscoutCard viewModel={nightscoutViewModel} />
<NightscoutCard
viewModel={pickStatus(integrationStatus, "nightscout")}
/>
</div>
{/* Every configured envelope entry no card above claims. Normally this
renders nothing; on an account carrying a dropped-but-still-running
@@ -1,537 +0,0 @@
"use client";
// v1.12.0 — Fitbit (Fitbit & Pixel) card. Mirrors the WHOOP card: a
// BYO-key credentials form first, then an OAuth connect, then the
// sync/test/disconnect action row + parked-resume banner. v1.12.1 — status
// reads off the consolidated /api/integrations/status envelope (the per-card
// /api/fitbit/status round-trip is gone); the pill/error/parked state comes off
// 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,
RefreshCw,
Save,
Unlink,
} from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { PasswordInput } from "@/components/ui/password-input";
import { TagChip } from "@/components/ui/tag-chip";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { IntegrationStatusPill } from "@/components/settings/integration-status-pill";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch";
import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line";
import { useTranslations } from "@/lib/i18n/context";
import {
invalidateKeys,
measurementDependentKeys,
queryKeys,
} from "@/lib/query-keys";
import {
IntegrationErrorMessage,
pillFailurePropsFor,
pillStateFor,
pillTimestampFor,
type IntegrationStatusViewModel,
} from "./shared";
import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure";
import {
readSyncOutcome,
useSyncOutcomeMessage,
type SyncOutcomeState,
} from "./sync-outcome";
import {
CallbackMismatchNotice,
IntegrationCardDescription,
IntegrationRedirectGuide,
} from "./setup-guide-link";
import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls";
import { loadDocument } from "@/lib/navigation/load-document";
export function FitbitCard({
viewModel,
callbackUrl,
}: {
viewModel: IntegrationStatusViewModel | undefined;
/** Server-resolved OAuth callback URL; see `getIntegrationCallbackUrls`. */
callbackUrl: IntegrationCallbackUrls["fitbit"];
}) {
const { t } = useTranslations();
const describeSyncOutcome = useSyncOutcomeMessage();
const [syncing, setSyncing] = useState(false);
const [syncResult, setSyncResult] = useState<SyncOutcomeState | null>(null);
const [clientId, setClientId] = useState("");
const [clientSecret, setClientSecret] = useState("");
const [credsSaving, setCredsSaving] = useState(false);
const [credsMsg, setCredsMsg] = useState<string | null>(null);
const [credsMsgType, setCredsMsgType] = useState<"success" | "error" | null>(
null,
);
const queryClient = useQueryClient();
const status = viewModel;
const disconnect = useMutation({
mutationFn: async () => {
await apiPost("/api/fitbit/disconnect");
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.fitbit() });
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
},
});
// Clear a parked integration via the resume endpoint. The CTA is
// rendered inside the parked banner below; success invalidates both
// the per-card status (so the pill flips back to connected
// immediately) and the cross-integration envelope (so any other view
// picks up the change on its next focus).
const resume = useMutation({
mutationFn: async () => {
return apiPost<{ resumed: boolean; wasParked: boolean }>(
"/api/integrations/fitbit/resume",
);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.fitbit() });
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
},
});
async function handleSync(fullSync = false) {
setSyncing(true);
setSyncResult(null);
try {
const res = await apiFetchRaw("/api/fitbit/sync", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fullSync }),
});
const json = await res.json();
const result = res.ok ? readSyncOutcome(json) : null;
if (result) {
// The tone comes off what the run wrote, not off `res.ok`.
setSyncResult({
outcome: result.outcome,
message: describeSyncOutcome(
result,
t("settings.fitbitSyncResult", { count: result.imported }),
),
});
void invalidateKeys(queryClient, measurementDependentKeys);
queryClient.invalidateQueries({ queryKey: queryKeys.fitbit() });
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
} else {
setSyncResult({
outcome: "failed",
message: json?.error || t("settings.fitbitSyncFailed"),
});
}
} catch {
setSyncResult({
outcome: "failed",
message: t("settings.fitbitSyncFailed"),
});
} finally {
setSyncing(false);
}
}
async function handleSaveCredentials(e: React.FormEvent) {
e.preventDefault();
setCredsSaving(true);
setCredsMsg(null);
setCredsMsgType(null);
try {
const res = await apiFetchRaw("/api/fitbit/credentials", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
clientId: clientId.trim(),
clientSecret: clientSecret.trim(),
}),
});
if (res.ok) {
setCredsMsg(t("settings.fitbitCredentialsSaved"));
setCredsMsgType("success");
setClientId("");
setClientSecret("");
queryClient.invalidateQueries({ queryKey: queryKeys.fitbit() });
} else {
try {
const json = await res.json();
setCredsMsg(json.error || t("settings.savingError"));
} catch {
setCredsMsg(t("settings.savingError"));
}
setCredsMsgType("error");
}
} catch {
setCredsMsg(t("common.networkError"));
setCredsMsgType("error");
}
setCredsSaving(false);
}
// 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" ||
pillState === "warning") &&
viewModel?.lastError
? viewModel.lastError
: null;
return (
<SettingsCard data-testid="fitbit-card">
<SettingsCardHeader
icon={HeartPulse}
title={t("settings.fitbit")}
titleAccessory={
<>
<TagChip>{t("settings.fitbitTag")}</TagChip>
<Badge
variant="outline"
data-testid="fitbit-experimental-badge"
className="border-warning/50 text-warning"
>
{t("settings.fitbitExperimentalBadge")}
</Badge>
</>
}
description={
<IntegrationCardDescription
i18nPrefix="settings.fitbit"
provider="fitbit"
/>
}
status={
<IntegrationStatusPill
state={pillState}
lastSyncAt={pillLastSyncAt}
{...pillFailurePropsFor(viewModel)}
/>
}
/>
<div className="space-y-4">
{errorMessage && <IntegrationErrorMessage message={errorMessage} />}
{/* Parked-integration resume CTA. Surfaces only when the row
state is `parked` (>24h of persistent failures). The button
POSTs to /api/integrations/fitbit/resume which calls
`resumeIntegrationFromPark`; on success the per-card status
invalidates and the pill flips back to connected without a
page refresh. */}
{pillState === "parked" && (
<div
data-testid="fitbit-parked-banner"
className="border-warning/30 bg-warning/10 flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm"
>
<span className="text-warning min-w-0 text-xs break-words">
{t("settings.integrationPill.parkedReconnect")}
</span>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => resume.mutate()}
disabled={resume.isPending}
data-testid="fitbit-resume-button"
className="min-h-11"
>
{resume.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Link2 className="h-3.5 w-3.5" />
)}
{t("settings.integrationPill.resumeCta")}
</Button>
</div>
)}
{resume.isError && (
<p
role="alert"
className="text-destructive text-sm"
data-testid="fitbit-resume-error"
>
{t("settings.integrationPill.resumeError")}
</p>
)}
{status?.connected && (
<MetricFreshnessDisclosure
entries={viewModel?.metricFreshness}
idPrefix="fitbit"
/>
)}
{resume.isSuccess && resume.data?.wasParked && (
<p
role="status"
className="text-success text-xs"
data-testid="fitbit-resume-success"
>
{t("settings.integrationPill.resumeSuccess")}
</p>
)}
<div className="space-y-3">
<h3 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{t("settings.fitbitCredentials")}
</h3>
{!status?.configured && (
<IntegrationRedirectGuide
provider="fitbit"
providerLabel={t("settings.fitbit")}
callbackUrl={callbackUrl}
/>
)}
<form onSubmit={handleSaveCredentials} className="space-y-3">
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="fitbit-clientid">
{t("settings.fitbitClientId")}
</Label>
<Input
id="fitbit-clientid"
value={clientId}
onChange={(e) => setClientId(e.target.value)}
placeholder={
status?.configured
? t("settings.fitbitCredentialsSavedPlaceholder")
: t("settings.fitbitClientId")
}
maxLength={200}
autoComplete="off"
inputMode="text"
spellCheck={false}
autoCapitalize="none"
enterKeyHint="next"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="fitbit-secret">
{t("settings.fitbitClientSecret")}
</Label>
<PasswordInput
id="fitbit-secret"
value={clientSecret}
onChange={(e) => setClientSecret(e.target.value)}
placeholder={
status?.configured
? t("settings.fitbitCredentialsSavedPlaceholderSecret")
: t("settings.fitbitClientSecret")
}
maxLength={200}
autoComplete="off"
inputMode="text"
spellCheck={false}
autoCapitalize="none"
enterKeyHint="done"
/>
</div>
</div>
<SettingsCardActions>
<Button
type="submit"
variant="outline"
size="sm"
className="min-h-11 w-full sm:w-auto"
disabled={
credsSaving || !clientId.trim() || !clientSecret.trim()
}
>
{credsSaving ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Save className="h-3.5 w-3.5" />
)}
{t("settings.fitbitSaveCredentials")}
</Button>
</SettingsCardActions>
{credsMsg && (
<p
role="alert"
className={`text-sm ${credsMsgType === "success" ? "text-success" : "text-destructive"}`}
>
{credsMsg}
</p>
)}
</form>
</div>
{status?.connected ? (
<>
<div className="flex flex-wrap items-start gap-2 [&>*]:min-w-[10rem] sm:[&>*]:min-w-0">
<Button
variant="outline"
size="sm"
className="min-h-11"
onClick={() => handleSync(false)}
disabled={syncing}
>
{syncing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
{t("settings.fitbitSync")}
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="min-h-11"
disabled={syncing}
>
{syncing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
{t("settings.fitbitFullSync")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.fitbitFullSyncTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.fitbitFullSyncDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={() => handleSync(true)}>
{t("settings.fitbitSynchronize")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<TestConnectionButton
endpoint="/api/integrations/fitbit/test"
disabled={!status?.connected}
/>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="text-destructive min-h-11"
>
<Unlink className="h-3.5 w-3.5" />
{t("settings.fitbitDisconnect")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.fitbitDisconnectTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.fitbitDisconnectDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => disconnect.mutate()}
>
{t("settings.fitbitDisconnect")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
{status?.backfillCompleted === false && (
<p className="text-muted-foreground text-xs">
{t("settings.fitbitBackfillInProgress")}
</p>
)}
{syncResult && (
<WrittenOutcomeLine
outcome={syncResult.outcome}
message={syncResult.message}
testId="fitbit-sync-result"
/>
)}
{/* 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 ? (
<SettingsCardActions>
<Button
size="sm"
className="min-h-11 w-full sm:w-auto"
data-testid="fitbit-connect"
onClick={() => {
loadDocument("/api/fitbit/connect");
}}
>
<Link2 className="h-3.5 w-3.5" />
{t("settings.fitbitConnect")}
</Button>
</SettingsCardActions>
) : (
<p
className="text-muted-foreground text-xs"
data-testid="integration-unavailable"
>
{t("settings.fitbitNoCredentials")}
</p>
)}
</div>
<CallbackMismatchNotice
provider={t("settings.fitbit")}
callbackUrl={callbackUrl}
/>
</SettingsCard>
);
}
@@ -1,72 +1,32 @@
"use client";
// v1.27.0 — Google Health (Fitbit + Pixel Watch + Fitbit Air) card. Reads
// Fitbit and Pixel Watch data through the successor Google Health API — a
// separate, coexisting integration from the classic `fitbit` transport, which
// sunsets Sept 2026. Mirrors the Fitbit card anatomy: a BYO Google-Cloud
// client-id/secret form first, then an OAuth connect, then the
// sync/test/disconnect action row + parked-resume banner. Status reads off the
// consolidated /api/integrations/status envelope (no per-card round-trip).
// Google Health (Fitbit + Pixel Watch + Fitbit Air), read through the
// successor Google Health API: a separate, coexisting integration from the
// classic `fitbit` transport, which sunsets in September 2026.
//
// One thing this card carries that the classic Fitbit card does not: a distinct
// RE-CONSENT CTA. Google expires the refresh token after 7 days while the
// operator's OAuth client stays in "Testing" publishing mode (the CASA-free
// path), so a connected user is periodically pushed back through OAuth. When the
// envelope reports `needsReauth`, the card surfaces a clear "Reconnect" banner
// separate from the connect/disconnect state.
// It renders the shared OAuth integration card. What it adds is its own sync
// choreography: a manual sync may outlive its request, so the card polls the
// authenticated status route until the run settles, shows the run's progress
// while it does, and lists the resources that did not complete in the details.
// Google's 7-day refresh expiry in "Testing" publishing mode is no longer a
// banner of its own: the server reports it as `action_required` /
// `reauth_required`, and the card's one primary button reconnects.
import { useState } from "react";
import Link from "next/link";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
ArrowRight,
Link2,
Loader2,
RefreshCw,
Save,
Unlink,
Watch,
} from "lucide-react";
import { useQueryClient } from "@tanstack/react-query";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { PasswordInput } from "@/components/ui/password-input";
import { TagChip } from "@/components/ui/tag-chip";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { IntegrationStatusPill } from "@/components/settings/integration-status-pill";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch";
import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line";
import { Badge } from "@/components/ui/badge";
import { TagChip } from "@/components/ui/tag-chip";
import { apiFetchRaw } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls";
import {
invalidateKeys,
measurementDependentKeys,
queryKeys,
} from "@/lib/query-keys";
import {
IntegrationErrorMessage,
pillFailurePropsFor,
pillStateFor,
pillTimestampFor,
type IntegrationStatusViewModel,
} from "./shared";
import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure";
import {
completedGoogleHealthResourceCount,
failedGoogleHealthResources,
@@ -74,18 +34,17 @@ import {
readGoogleHealthProgress,
type GoogleHealthProgress,
} from "./google-health-progress-view";
import {
OAUTH_INTEGRATIONS,
OAuthIntegrationCardView,
} from "./oauth-integration-card";
import type { IntegrationStatusViewModel } from "./shared";
import {
readSyncOutcome,
useSyncOutcomeMessage,
type SyncOutcomeResource,
type SyncOutcomeState,
} from "./sync-outcome";
import {
CallbackMismatchNotice,
IntegrationCardDescription,
} from "./setup-guide-link";
import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls";
import { loadDocument } from "@/lib/navigation/load-document";
export function GoogleHealthCard({
viewModel,
@@ -102,17 +61,8 @@ export function GoogleHealthCard({
const [syncProgress, setSyncProgress] = useState<GoogleHealthProgress | null>(
null,
);
const [clientId, setClientId] = useState("");
const [clientSecret, setClientSecret] = useState("");
const [credsSaving, setCredsSaving] = useState(false);
const [credsMsg, setCredsMsg] = useState<string | null>(null);
const [credsMsgType, setCredsMsgType] = useState<"success" | "error" | null>(
null,
);
const queryClient = useQueryClient();
const status = viewModel;
function invalidateCommittedWorkout(resources: SyncOutcomeResource[]) {
const workoutCommitted = resources.some(
(resource) =>
@@ -216,36 +166,6 @@ export function GoogleHealthCard({
}
}
const disconnect = useMutation({
mutationFn: async () => {
await apiPost("/api/google-health/disconnect");
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.googleHealth() });
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
},
});
// Clear a parked integration via the resume endpoint. The CTA is rendered
// inside the parked banner below; success invalidates both the per-card
// status and the cross-integration envelope so any other view picks up the
// change on its next focus.
const resume = useMutation({
mutationFn: async () => {
return apiPost<{ resumed: boolean; wasParked: boolean }>(
"/api/integrations/google-health/resume",
);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.googleHealth() });
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
},
});
async function handleSync(fullSync = false) {
setSyncing(true);
setSyncResult(null);
@@ -335,360 +255,48 @@ export function GoogleHealthCard({
}
}
async function handleSaveCredentials(e: React.FormEvent) {
e.preventDefault();
setCredsSaving(true);
setCredsMsg(null);
setCredsMsgType(null);
try {
const res = await apiFetchRaw("/api/google-health/credentials", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
clientId: clientId.trim(),
clientSecret: clientSecret.trim(),
}),
});
if (res.ok) {
setCredsMsg(t("settings.googleHealthCredentialsSaved"));
setCredsMsgType("success");
setClientId("");
setClientSecret("");
queryClient.invalidateQueries({ queryKey: queryKeys.googleHealth() });
} else {
try {
const json = await res.json();
setCredsMsg(json.error || t("settings.savingError"));
} catch {
setCredsMsg(t("settings.savingError"));
}
setCredsMsgType("error");
}
} catch {
setCredsMsg(t("common.networkError"));
setCredsMsgType("error");
}
setCredsSaving(false);
}
// 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" ||
pillState === "warning") &&
viewModel?.lastError
? viewModel.lastError
: null;
// The re-consent banner only makes sense while the connection still exists —
// a disconnected card has no token to renew. `needsReauth` is the 7-day
// Testing-mode expiry (or a revoked grant) surfaced by the status route.
const showReauth = Boolean(status?.connected && status?.needsReauth);
const resourceFailures = failedGoogleHealthResources(syncProgress);
const completedResources = completedGoogleHealthResourceCount(syncProgress);
const inProgress = syncProgress?.state === "in_progress";
return (
<SettingsCard data-testid="google-health-card">
<SettingsCardHeader
icon={Watch}
title={t("settings.googleHealth")}
titleAccessory={
<>
<TagChip>{t("settings.googleHealthTag")}</TagChip>
<Badge
variant="outline"
data-testid="google-health-beta-badge"
className="border-warning/50 text-warning"
>
{t("settings.googleHealthBetaBadge")}
</Badge>
</>
}
description={
<IntegrationCardDescription
i18nPrefix="settings.googleHealth"
provider="google-health"
/>
}
status={
<IntegrationStatusPill
state={pillState}
lastSyncAt={pillLastSyncAt}
{...pillFailurePropsFor(viewModel)}
/>
}
/>
<div className="space-y-4">
{errorMessage && <IntegrationErrorMessage message={errorMessage} />}
{/* Re-consent CTA — distinct from parked/disconnected. Google expires
the refresh token after 7 days in "Testing" publishing mode (the
CASA-free path), so a connected user is periodically pushed back
through OAuth. Re-running connect mints a fresh grant. */}
{showReauth && (
<div
data-testid="google-health-reauth-banner"
className="border-warning/30 bg-warning/10 flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm"
<OAuthIntegrationCardView
config={OAUTH_INTEGRATIONS["google-health"]}
viewModel={viewModel}
callbackUrl={callbackUrl}
titleAccessory={
<>
<TagChip>{t("settings.googleHealthTag")}</TagChip>
<Badge
variant="outline"
data-testid="google-health-beta-badge"
className="border-warning/50 text-warning"
>
<span className="text-warning min-w-0 text-xs break-words">
{t("settings.googleHealthReauthBanner")}
</span>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
loadDocument("/api/google-health/connect");
}}
data-testid="google-health-reconnect-button"
className="min-h-11"
>
<RefreshCw className="h-3.5 w-3.5" />
{t("settings.googleHealthReconnect")}
</Button>
</div>
)}
{/* Parked-integration resume CTA — surfaces only when the row state is
`parked` (>24h of persistent failures). */}
{pillState === "parked" && (
<div
data-testid="google-health-parked-banner"
className="border-warning/30 bg-warning/10 flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm"
>
<span className="text-warning min-w-0 text-xs break-words">
{t("settings.integrationPill.parkedReconnect")}
</span>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => resume.mutate()}
disabled={resume.isPending}
data-testid="google-health-resume-button"
className="min-h-11"
>
{resume.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Link2 className="h-3.5 w-3.5" />
)}
{t("settings.integrationPill.resumeCta")}
</Button>
</div>
)}
{resume.isError && (
<p
role="alert"
className="text-destructive text-sm"
data-testid="google-health-resume-error"
>
{t("settings.integrationPill.resumeError")}
</p>
)}
{status?.connected && (
<MetricFreshnessDisclosure
entries={viewModel?.metricFreshness}
idPrefix="google-health"
/>
)}
{resume.isSuccess && resume.data?.wasParked && (
<p
role="status"
className="text-success text-xs"
data-testid="google-health-resume-success"
>
{t("settings.integrationPill.resumeSuccess")}
</p>
)}
<div className="space-y-3">
<h3 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{t("settings.googleHealthCredentials")}
</h3>
<form onSubmit={handleSaveCredentials} className="space-y-3">
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="google-health-clientid">
{t("settings.googleHealthClientId")}
</Label>
<Input
id="google-health-clientid"
value={clientId}
onChange={(e) => setClientId(e.target.value)}
placeholder={
status?.configured
? t("settings.googleHealthCredentialsSavedPlaceholder")
: t("settings.googleHealthClientId")
}
maxLength={200}
autoComplete="off"
inputMode="text"
spellCheck={false}
autoCapitalize="none"
enterKeyHint="next"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="google-health-secret">
{t("settings.googleHealthClientSecret")}
</Label>
<PasswordInput
id="google-health-secret"
value={clientSecret}
onChange={(e) => setClientSecret(e.target.value)}
placeholder={
status?.configured
? t(
"settings.googleHealthCredentialsSavedPlaceholderSecret",
)
: t("settings.googleHealthClientSecret")
}
maxLength={200}
autoComplete="off"
inputMode="text"
spellCheck={false}
autoCapitalize="none"
enterKeyHint="done"
/>
</div>
</div>
<SettingsCardActions>
<Button
type="submit"
variant="outline"
size="sm"
className="min-h-11 w-full sm:w-auto"
disabled={
credsSaving || !clientId.trim() || !clientSecret.trim()
}
>
{credsSaving ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Save className="h-3.5 w-3.5" />
)}
{t("settings.googleHealthSaveCredentials")}
</Button>
</SettingsCardActions>
{credsMsg && (
<p
role="alert"
className={`text-sm ${credsMsgType === "success" ? "text-success" : "text-destructive"}`}
>
{credsMsg}
</p>
)}
</form>
</div>
{status?.connected ? (
<>
<div className="flex flex-wrap items-start gap-2 [&>*]:min-w-[10rem] sm:[&>*]:min-w-0">
<Button
variant="outline"
size="sm"
className="min-h-11"
onClick={() => {
if (syncProgress?.state === "in_progress") {
void refreshSyncStatus();
} else {
void handleSync(false);
}
}}
disabled={syncing}
>
{syncing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
{syncProgress?.state === "in_progress"
? t("settings.googleHealthSyncCheckStatus")
: t("settings.googleHealthSync")}
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="min-h-11"
disabled={syncing || syncProgress?.state === "in_progress"}
>
{syncing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
{t("settings.googleHealthFullSync")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.googleHealthFullSyncTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.googleHealthFullSyncDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={() => handleSync(true)}>
{t("settings.googleHealthSynchronize")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<TestConnectionButton
endpoint="/api/integrations/google-health/test"
disabled={!status?.connected}
/>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="text-destructive min-h-11"
>
<Unlink className="h-3.5 w-3.5" />
{t("settings.googleHealthDisconnect")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.googleHealthDisconnectTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.googleHealthDisconnectDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => disconnect.mutate()}
>
{t("settings.googleHealthDisconnect")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
{status?.backfillCompleted === false && (
<p className="text-muted-foreground text-xs">
{t("settings.googleHealthBackfillInProgress")}
</p>
)}
{syncProgress?.state === "in_progress" && (
{t("settings.googleHealthBetaBadge")}
</Badge>
</>
}
sync={{
syncing,
result: syncResult,
run: (fullSync) => {
if (!fullSync && syncProgress?.state === "in_progress") {
void refreshSyncStatus();
} else {
void handleSync(fullSync);
}
},
}}
syncLabel={
inProgress
? t("settings.googleHealthSyncCheckStatus")
: t("settings.googleHealthSync")
}
fullSyncDisabled={inProgress}
syncResults={
inProgress || syncResult ? (
<div className="space-y-2">
{inProgress ? (
<p
role="status"
aria-live="polite"
@@ -701,75 +309,40 @@ export function GoogleHealthCard({
total: syncProgress.resources.length,
})}
</p>
)}
{syncResult && (
) : null}
{syncResult ? (
<WrittenOutcomeLine
outcome={syncResult.outcome}
message={syncResult.message}
testId="google-health-sync-result"
/>
)}
{resourceFailures.length > 0 && (
<div
role="alert"
data-testid="google-health-resource-failures"
className="border-warning/30 bg-warning/10 rounded-md border px-3 py-2"
>
<p className="text-foreground text-xs font-medium">
{t("settings.googleHealthResourceFailuresTitle")}
</p>
<ul className="text-muted-foreground mt-1 space-y-1 text-xs">
{resourceFailures.map((resource) => (
<li key={resource.resource}>
<span className="text-foreground font-medium">
{resourceLabel(resource.resource)}
</span>
{": "}
{resourceFailureMessage(resource)}
</li>
))}
</ul>
</div>
)}
{/* 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 ? (
<SettingsCardActions>
<Button
size="sm"
className="min-h-11 w-full sm:w-auto"
data-testid="googlehealth-connect"
onClick={() => {
loadDocument("/api/google-health/connect");
}}
>
<Link2 className="h-3.5 w-3.5" />
{t("settings.googleHealthConnect")}
</Button>
</SettingsCardActions>
) : (
<p
className="text-muted-foreground text-xs"
data-testid="integration-unavailable"
) : null}
</div>
) : null
}
details={
resourceFailures.length > 0 ? (
<div
data-testid="google-health-resource-failures"
className="space-y-1"
>
{t("settings.googleHealthNoCredentials")}
</p>
)}
</div>
<CallbackMismatchNotice
provider={t("settings.googleHealth")}
callbackUrl={callbackUrl}
/>
</SettingsCard>
<p className="text-sm font-medium">
{t("settings.googleHealthResourceFailuresTitle")}
</p>
<ul className="space-y-1 text-sm">
{resourceFailures.map((resource) => (
<li key={resource.resource}>
<span className="font-medium">
{resourceLabel(resource.resource)}
</span>
{": "}
{resourceFailureMessage(resource)}
</li>
))}
</ul>
</div>
) : undefined
}
/>
);
}
@@ -20,21 +20,10 @@
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 {
pillFailurePropsFor,
pillStateFor,
pillTimestampFor,
type IntegrationKey,
type IntegrationStatusViewModel,
} from "./shared";
import { IntegrationCard } from "./integration-card";
import type { IntegrationKey, IntegrationStatusViewModel } from "./shared";
/**
* Display names, mirroring the server-side `INTEGRATION_LABELS`. Only the
@@ -52,50 +41,28 @@ export const INTEGRATION_DISPLAY_NAMES: Record<IntegrationKey, string> = {
strava: "Strava",
};
/**
* The same card every integration renders, read-only: the server's health,
* its sentence and its details, and no menu, because a dropped integration
* does not get its management surface back.
*/
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");
const name =
INTEGRATION_DISPLAY_NAMES[status.integration] ?? status.integration;
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)}
{...pillFailurePropsFor(status)}
now={now}
/>
}
/>
<p className="text-muted-foreground text-xs">{meta}</p>
</SettingsCard>
<IntegrationCard
provider={status.integration}
providerName={name}
icon={Plug}
description={t("settings.integrationFallback.description")}
connected={(status.connected ?? status.configured) === true}
health={status.health ?? null}
testId="integration-fallback-row"
/>
);
}
@@ -1,184 +0,0 @@
"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, type ReactNode } 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<
Entry extends MetricFreshnessEntry = MetricFreshnessEntry,
>({
entries,
idPrefix,
now,
title,
detail,
}: {
entries: Entry[] | undefined;
/** Namespaces the disclosure's ids so several cards can coexist. */
idPrefix: string;
/** Override "now" for deterministic testing. */
now?: Date;
/** Replaces the shared "received data" heading. */
title?: string;
/**
* A second line under a row, for a card that knows more per type than the
* newest sample (the Apple Health card's arrival facts, #1173).
*/
detail?: (entry: Entry) => ReactNode;
}) {
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">
{title ?? 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={detail ? "space-y-2.5" : "space-y-1"}>
{sorted.map((entry) => {
const extra = detail?.(entry);
return (
<li
key={entry.type}
data-slot="metric-freshness-row"
data-metric={entry.type}
data-state={entry.stale ? "stale" : "fresh"}
className={cn(
"text-xs",
entry.stale ? "text-warning" : "text-muted-foreground",
)}
>
<div className="flex items-center justify-between gap-3">
<span className="flex min-w-0 items-center gap-1.5">
{entry.stale ? (
<AlertTriangle
aria-hidden="true"
className="size-3 shrink-0"
/>
) : null}
{/* With a detail line under it, the type name is the
row's content and the line its meta. */}
<span
className={cn(
"truncate",
extra && !entry.stale && "text-foreground",
)}
>
{label(entry.type)}
</span>
</span>
<time
dateTime={entry.lastSeenAt}
className="shrink-0 tabular-nums"
>
{relative(entry.lastSeenAt)}
</time>
</div>
{extra ? (
<div data-slot="metric-freshness-detail" className="mt-0.5">
{extra}
</div>
) : null}
</li>
);
})}
</ul>
)}
</div>
);
}
@@ -1,50 +1,36 @@
"use client";
// 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 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 access is operator-owned through an exact-origin server
// allowlist; ordinary users cannot weaken the outbound-request policy.
// Nightscout CGM. Unlike the OAuth providers, Nightscout is a URL and an
// optional token the self-hoster pastes once: they run their own instance and
// HealthLog pulls continuous glucose from it. The card reads the consolidated
// `/api/integrations/status` envelope like every sibling and renders the
// shared integration card. Private-network access is operator-owned through an
// exact-origin server allowlist; an ordinary user cannot weaken the
// outbound-request policy, and the card says so when that is what stands in
// the way.
import { useRef, useState } from "react";
import Link from "next/link";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
ArrowRight,
BookOpen,
Droplet,
KeyRound,
Link2,
Loader2,
Plug,
RefreshCw,
Save,
Unlink,
} from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { PasswordInput } from "@/components/ui/password-input";
import { TagChip } from "@/components/ui/tag-chip";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { IntegrationStatusPill } from "@/components/settings/integration-status-pill";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch";
import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line";
import { useTranslations } from "@/lib/i18n/context";
import {
invalidateKeys,
@@ -52,20 +38,21 @@ import {
queryKeys,
} from "@/lib/query-keys";
import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure";
import {
readSyncOutcome,
useSyncOutcomeMessage,
type SyncOutcomeState,
} from "./sync-outcome";
ActionResultLines,
useConnectionTest,
useStandardSync,
} from "./integration-actions";
import {
IntegrationErrorMessage,
pillFailurePropsFor,
pillStateForVerdict,
pillTimestampFor,
type IntegrationStatusViewModel,
} from "./shared";
import { IntegrationCardDescription } from "./setup-guide-link";
IntegrationCard,
type IntegrationMenuAction,
} from "./integration-card";
import { MetricFreshnessList } from "./metric-freshness-list";
import {
IntegrationCardDescription,
integrationDocsHref,
} from "./setup-guide-link";
import { failureStreakFor, type IntegrationStatusViewModel } from "./shared";
export function NightscoutCard({
viewModel,
@@ -73,19 +60,17 @@ export function NightscoutCard({
viewModel?: IntegrationStatusViewModel;
}) {
const { t } = useTranslations();
const describeSyncOutcome = useSyncOutcomeMessage();
const queryClient = useQueryClient();
const [url, setUrl] = useState("");
const [token, setToken] = useState("");
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
const [syncing, setSyncing] = useState(false);
const [syncResult, setSyncResult] = useState<SyncOutcomeState | null>(null);
const status = viewModel;
const connected = status?.connected ?? false;
const sync = useStandardSync({
provider: "nightscout",
i18nPrefix: "settings.nightscout",
acceptsFullSync: false,
});
const test = useConnectionTest("/api/nightscout/test");
const [editing, setEditing] = useState(false);
const formRef = useRef<HTMLFormElement | null>(null);
const disconnect = useMutation({
@@ -93,114 +78,50 @@ export function NightscoutCard({
await apiPost("/api/nightscout/disconnect");
},
onSuccess: () => {
setUrl("");
setToken("");
// The card reads the consolidated envelope, so that is the one key a
// disconnect has to invalidate for the pill to flip back.
// disconnect has to invalidate for the card to flip back.
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
},
});
async function handleConnect(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setMsg(null);
setMsgType(null);
try {
const res = await apiFetchRaw("/api/nightscout/connect", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
url: url.trim(),
token: token.trim(),
}),
});
if (res.ok) {
setMsg(t("settings.nightscoutConnected"));
setMsgType("success");
setToken("");
void invalidateKeys(queryClient, measurementDependentKeys);
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
} else {
let detail = t("settings.nightscoutConnectFailed");
try {
const json = await res.json();
if (json.error) detail = json.error;
} catch {
// keep the generic message
}
setMsg(detail);
setMsgType("error");
}
} catch {
setMsg(t("common.networkError"));
setMsgType("error");
}
setSaving(false);
// A pause or a lapsed token is resolved by submitting the address again:
// there is no OAuth round-trip to re-run, so the button opens the form and
// puts the cursor in the URL field.
function openForm() {
setEditing(true);
requestAnimationFrame(() => {
formRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
formRef.current
?.querySelector<HTMLInputElement>("#nightscout-url")
?.focus();
});
}
// Sync now. Same shape as the WHOOP card's incremental arm, minus the
// full-history dialog — Nightscout has no full-sync arm to call.
async function handleSync() {
setSyncing(true);
setSyncResult(null);
try {
const res = await apiFetchRaw("/api/nightscout/sync", { method: "POST" });
const json = await res.json();
const result = res.ok ? readSyncOutcome(json) : null;
if (result) {
// The tone comes off what the run wrote, not off `res.ok`.
setSyncResult({
outcome: result.outcome,
message: describeSyncOutcome(
result,
t("settings.nightscoutSyncResult", { count: result.imported }),
),
});
void invalidateKeys(queryClient, measurementDependentKeys);
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
} else {
setSyncResult({
outcome: "failed",
message: json?.error || t("settings.nightscoutSyncFailed"),
});
}
} catch {
setSyncResult({
outcome: "failed",
message: t("settings.nightscoutSyncFailed"),
});
} finally {
setSyncing(false);
}
}
const pillState = pillStateForVerdict(status?.syncHealth?.verdict);
// The newest recorded attempt names the one failure the person cannot fix
// from here: the instance sits on a private network the operator has not
// approved. Read from the health diagnostics' short error code, never from
// a stored message.
const operatorApprovalRequired =
status?.lastError?.startsWith("private_origin_not_approved") ?? false;
// `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" ||
pillState === "warning") &&
status?.lastError &&
!operatorApprovalRequired
? status.lastError
: null;
status?.health?.diagnostics.recentCalls[0]?.errorCode ===
"private_origin_not_approved";
return (
<SettingsCard data-testid="nightscout-card">
<SettingsCardHeader
const form = (
<NightscoutForm
formRef={formRef}
connected={connected}
configured={status?.configured ?? false}
hasToken={status?.hasToken ?? false}
/>
);
if (!connected) {
return (
<IntegrationCard
provider="nightscout"
providerName={t("settings.nightscout")}
icon={Droplet}
title={t("settings.nightscout")}
titleAccessory={<TagChip>{t("settings.nightscoutTag")}</TagChip>}
description={
<IntegrationCardDescription
@@ -208,231 +129,247 @@ export function NightscoutCard({
provider="nightscout"
/>
}
status={
<IntegrationStatusPill
state={pillState}
lastSyncAt={pillTimestampFor(status)}
{...pillFailurePropsFor(status)}
/>
}
loading={viewModel === undefined}
connected={false}
health={null}
setup={form}
/>
);
}
<div className="space-y-4">
{errorMessage && <IntegrationErrorMessage message={errorMessage} />}
{operatorApprovalRequired && (
<div
data-testid="nightscout-private-operator-required"
className="border-warning/30 bg-warning/10 space-y-1 rounded-md border px-3 py-2"
>
<p className="text-warning text-sm font-medium">
{t("settings.nightscoutPrivateOperatorTitle")}
</p>
<p className="text-muted-foreground text-xs">
{t("settings.nightscoutPrivateOperatorDescription")}
</p>
</div>
)}
const menu: IntegrationMenuAction[] = [
...(operatorApprovalRequired
? []
: [
{
id: "sync",
label: t("settings.nightscoutSync"),
icon: RefreshCw,
disabled: sync.syncing,
onSelect: () => sync.run(false),
},
{
id: "test",
label: t("settings.testConnection.test"),
icon: Plug,
disabled: test.testing,
onSelect: test.run,
},
]),
{
id: "view-data",
label: t("settings.nightscoutViewData"),
icon: ArrowRight,
href: "/insights/blood-glucose",
},
{
id: "credentials",
label: t("settings.integrationCard.menu.address"),
icon: KeyRound,
onSelect: () => setEditing((open) => !open),
},
{
id: "setup-guide",
label: t("settings.integrationSetupGuide"),
icon: BookOpen,
href: integrationDocsHref("nightscout"),
external: true,
},
{
id: "disconnect",
label: t("settings.nightscoutDisconnect"),
icon: Unlink,
destructive: true,
onSelect: () => disconnect.mutate(),
confirm: {
title: t("settings.nightscoutDisconnectTitle"),
body: t("settings.nightscoutDisconnectDescription"),
confirmLabel: t("settings.nightscoutDisconnect"),
},
},
];
{/* Parked-integration resume CTA. Surfaces only when the row state
is `parked` (>24h of persistent failures). Nightscout has no OAuth
redirect — the user re-validates by re-submitting the connect form,
so "reconnect" scrolls the form into view + focuses the URL field.
Markup matches the WHOOP card byte for byte. */}
{pillState === "parked" && (
<div
data-testid="nightscout-parked-banner"
className="border-warning/30 bg-warning/10 flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm"
>
<span className="text-warning min-w-0 text-xs break-words">
{t("settings.integrationPill.parkedReconnect")}
</span>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
formRef.current?.scrollIntoView({
behavior: "smooth",
block: "center",
});
formRef.current
?.querySelector<HTMLInputElement>("#nightscout-url")
?.focus();
}}
data-testid="nightscout-resume-button"
className="min-h-11"
>
<Link2 className="h-3.5 w-3.5" />
{t("settings.integrationPill.resumeCta")}
</Button>
</div>
)}
const streak = failureStreakFor(status);
{status?.connected && (
<MetricFreshnessDisclosure
entries={status.metricFreshness}
idPrefix="nightscout"
/>
)}
return (
<IntegrationCard
provider="nightscout"
providerName={t("settings.nightscout")}
icon={Droplet}
titleAccessory={<TagChip>{t("settings.nightscoutTag")}</TagChip>}
connected
health={status?.health ?? null}
primary={{ onReconnect: openForm, onResume: openForm }}
menu={menu}
diagnosticsExtra={
streak
? [
t("settings.integrationCard.details.failureStreak", {
count: streak.count,
threshold: streak.threshold,
}),
]
: undefined
}
details={
status?.metricFreshness?.length ? (
<MetricFreshnessList entries={status.metricFreshness} />
) : undefined
}
>
{operatorApprovalRequired ? (
// The one fact the generic sentence cannot carry: the instance sits
// on a private network the operator has not approved, which the
// person cannot fix from here. Said in the body, not as a banner.
<p
className="text-sm"
data-testid="nightscout-private-operator-required"
>
{t("settings.nightscoutPrivateOperatorTitle")}{" "}
{t("settings.nightscoutPrivateOperatorDescription")}
</p>
) : null}
<ActionResultLines provider="nightscout" sync={sync} test={test} />
{editing ? form : null}
</IntegrationCard>
);
}
<form ref={formRef} onSubmit={handleConnect} className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="nightscout-url">
{t("settings.nightscoutUrl")}
</Label>
<Input
id="nightscout-url"
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder={
status?.configured
? t("settings.nightscoutUrlSavedPlaceholder")
: "https://your-site.up.railway.app"
}
maxLength={2048}
autoComplete="off"
inputMode="url"
spellCheck={false}
autoCapitalize="none"
enterKeyHint="next"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="nightscout-token">
{t("settings.nightscoutToken")}
</Label>
<PasswordInput
id="nightscout-token"
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder={
status?.hasToken
? t("settings.nightscoutTokenSavedPlaceholder")
: t("settings.nightscoutTokenOptional")
}
maxLength={512}
autoComplete="off"
spellCheck={false}
autoCapitalize="none"
enterKeyHint="done"
/>
<p className="text-muted-foreground text-xs">
{t("settings.nightscoutTokenHelp")}
</p>
</div>
function NightscoutForm({
formRef,
connected,
configured,
hasToken,
}: {
formRef: React.RefObject<HTMLFormElement | null>;
connected: boolean;
configured: boolean;
hasToken: boolean;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [url, setUrl] = useState("");
const [token, setToken] = useState("");
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState<{
kind: "success" | "error";
text: string;
} | null>(null);
<SettingsCardActions>
<Button
type="submit"
variant="outline"
size="sm"
className="min-h-11 w-full sm:w-auto"
disabled={saving || !url.trim()}
>
{saving ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : status?.connected ? (
<Save className="h-3.5 w-3.5" />
) : (
<Link2 className="h-3.5 w-3.5" />
)}
{status?.connected
? t("settings.nightscoutUpdate")
: t("settings.nightscoutConnect")}
</Button>
</SettingsCardActions>
async function handleConnect(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setMsg(null);
try {
const res = await apiFetchRaw("/api/nightscout/connect", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: url.trim(), token: token.trim() }),
});
if (res.ok) {
setMsg({ kind: "success", text: t("settings.nightscoutConnected") });
setToken("");
void invalidateKeys(queryClient, measurementDependentKeys);
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
} else {
let text = t("settings.nightscoutConnectFailed");
try {
const json = await res.json();
if (json.error) text = json.error;
} catch {
// keep the generic message
}
setMsg({ kind: "error", text });
}
} catch {
setMsg({ kind: "error", text: t("common.networkError") });
}
setSaving(false);
}
{msg && (
<p
role="alert"
className={`text-sm ${msgType === "success" ? "text-success" : "text-destructive"}`}
>
{msg}
</p>
return (
<form
ref={formRef}
onSubmit={handleConnect}
className="space-y-3"
data-testid="nightscout-form"
>
<div className="space-y-1.5">
<Label htmlFor="nightscout-url">{t("settings.nightscoutUrl")}</Label>
<Input
id="nightscout-url"
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder={
configured
? t("settings.nightscoutUrlSavedPlaceholder")
: "https://your-site.up.railway.app"
}
maxLength={2048}
autoComplete="off"
inputMode="url"
spellCheck={false}
autoCapitalize="none"
enterKeyHint="next"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="nightscout-token">
{t("settings.nightscoutToken")}
</Label>
<PasswordInput
id="nightscout-token"
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder={
hasToken
? t("settings.nightscoutTokenSavedPlaceholder")
: t("settings.nightscoutTokenOptional")
}
maxLength={512}
autoComplete="off"
spellCheck={false}
autoCapitalize="none"
enterKeyHint="done"
/>
<p className="text-muted-foreground text-xs">
{t("settings.nightscoutTokenHelp")}
</p>
</div>
{msg && (
<p
role={msg.kind === "error" ? "alert" : "status"}
className={`text-sm ${msg.kind === "success" ? "text-success" : "text-destructive"}`}
>
{msg.text}
</p>
)}
<SettingsCardActions>
<Button
type="submit"
size="sm"
variant={connected ? "outline" : "default"}
className="min-h-11 w-full sm:min-h-9 sm:w-auto"
disabled={saving || !url.trim()}
data-slot={connected ? undefined : "integration-primary-action"}
data-action={connected ? undefined : "connect"}
data-testid="nightscout-connect"
>
{saving ? (
<Loader2 className="size-3.5 animate-spin motion-reduce:animate-none" />
) : connected ? (
<Save className="size-3.5" aria-hidden="true" />
) : (
<Link2 className="size-3.5" aria-hidden="true" />
)}
</form>
{status?.connected && (
<>
<div className="flex flex-wrap items-start gap-2 [&>*]:min-w-[10rem] sm:[&>*]:min-w-0">
{!operatorApprovalRequired && (
<>
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11"
onClick={handleSync}
disabled={syncing}
data-testid="nightscout-sync"
>
{syncing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
{t("settings.nightscoutSync")}
</Button>
<TestConnectionButton
endpoint="/api/nightscout/test"
disabled={!status?.connected}
/>
</>
)}
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="text-destructive min-h-11"
>
<Unlink className="h-3.5 w-3.5" />
{t("settings.nightscoutDisconnect")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.nightscoutDisconnectTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.nightscoutDisconnectDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => disconnect.mutate()}
>
{t("settings.nightscoutDisconnect")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
{syncResult && (
<WrittenOutcomeLine
outcome={syncResult.outcome}
message={syncResult.message}
testId="nightscout-sync-result"
/>
)}
{/* connect→data loop: a discreet link to where the glucose
readings now surface — doubles as the "your data is richer"
cue. */}
<Link
href="/insights/blood-glucose"
data-testid="nightscout-data-link"
className="text-primary inline-flex items-center gap-1 text-xs underline-offset-2 hover:underline"
>
{t("settings.nightscoutViewData")}
<ArrowRight className="h-3 w-3" />
</Link>
</>
)}
</div>
</SettingsCard>
{connected
? t("settings.nightscoutUpdate")
: t("settings.nightscoutConnect")}
</Button>
</SettingsCardActions>
</form>
);
}
@@ -1,551 +0,0 @@
"use client";
// v1.17.0 (F4) — shared OAuth integration card for Polar / Oura.
// v1.17.1 — these are now per-user BYO-key integrations (like WHOOP / Fitbit):
// the card renders an optional "your OAuth app credentials" form (driven by the
// `credentials` prop) above the connect button. Credentials resolve DB-first
// then env on the server, so a user who pastes their own client id/secret uses
// their own app while existing env-configured deploys keep working unchanged.
// Mirrors the Nightscout card's self-contained status pattern.
import { useState } from "react";
import Link from "next/link";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
ArrowRight,
Link2,
Loader2,
RefreshCw,
Save,
Unlink,
type LucideIcon,
} from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { Input } from "@/components/ui/input";
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 } from "@/components/settings/integration-status-pill";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch";
import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line";
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 { OuraScopeNote } from "./oura-scope-note";
import {
readSyncOutcome,
useSyncOutcomeMessage,
type SyncOutcomeState,
} from "./sync-outcome";
import {
IntegrationErrorMessage,
pillFailurePropsFor,
pillStateForVerdict,
type IntegrationState,
} from "./shared";
import {
CallbackMismatchNotice,
IntegrationCardDescription,
IntegrationRedirectGuide,
type IntegrationDocsProvider,
} from "./setup-guide-link";
import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls";
import type { OuraScopeGatedCollection } from "@/lib/oura/scope";
import { loadDocument } from "@/lib/navigation/load-document";
export interface OAuthProviderStatus {
connected: boolean;
configured: boolean;
available: boolean;
/** Whether the user has stored their own BYO client id/secret pair. */
hasOwnCredentials?: boolean;
/**
* The raw ledger state, carried for shape parity with the envelope. The
* card paints from `syncHealth.verdict`, never from this — which is why
* v1.38.19's `unknown` member needs no arm here.
*/
state?: IntegrationState;
lastSuccessAt?: string | null;
lastAttemptAt?: string | null;
lastError?: string | null;
legacyLastSyncedAt?: string | null;
consecutiveFailuresByKind?: {
transient: number;
reauth_required: number;
persistent: number;
} | null;
failureThreshold?: number;
/** The server-resolved liveness verdict; the pill is a projection of it. */
syncHealth?: SyncHealth;
metricFreshness?: MetricFreshnessEntry[];
/** Oura: collections the stored grant does not cover. */
scopeReconnect?: OuraScopeGatedCollection[];
}
export interface OAuthProviderCardProps {
/** Lower-case provider key, used for routes + query keys + testids. */
provider: "polar" | "oura" | "strava";
/** The provider's own query key, invalidated after connect / sync / save. */
statusQueryKey: readonly unknown[];
/** i18n key prefix (e.g. `settings.polar`). */
i18nPrefix: string;
/** Distinct card glyph, kept in the same size/treatment as the other cards. */
icon: LucideIcon;
/** Where this provider's synced data surfaces (e.g. `/insights/sleep`). */
dataHref: string;
/** 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;
/**
* 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;
/** Server-resolved OAuth callback URL; see `getIntegrationCallbackUrls`. */
callbackUrl: IntegrationCallbackUrls[OAuthProviderCardProps["provider"]];
}
export function OAuthProviderCard({
provider,
statusQueryKey,
i18nPrefix,
icon,
dataHref,
credentials = false,
viewModel,
callbackUrl,
}: OAuthProviderCardProps) {
const { t } = useTranslations();
const describeSyncOutcome = useSyncOutcomeMessage();
const queryClient = useQueryClient();
const [msg, setMsg] = useState<string | null>(null);
const [syncing, setSyncing] = useState(false);
const [syncResult, setSyncResult] = useState<SyncOutcomeState | null>(null);
const [clientId, setClientId] = useState("");
const [clientSecret, setClientSecret] = useState("");
const [credsSaving, setCredsSaving] = useState(false);
const [credsMsg, setCredsMsg] = useState<string | null>(null);
const [credsMsgType, setCredsMsgType] = useState<"success" | "error" | null>(
null,
);
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.
async function handleSync() {
setSyncing(true);
setSyncResult(null);
try {
const res = await apiFetchRaw(`/api/${provider}/sync`, {
method: "POST",
});
const json = await res.json();
const result = res.ok ? readSyncOutcome(json) : null;
if (result) {
// The tone comes off what the run wrote, not off `res.ok`.
setSyncResult({
outcome: result.outcome,
message: describeSyncOutcome(
result,
t(`${i18nPrefix}SyncResult`, { count: result.imported }),
),
});
void invalidateKeys(queryClient, measurementDependentKeys);
queryClient.invalidateQueries({ queryKey: statusQueryKey });
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
} else {
setSyncResult({
outcome: "failed",
message: json?.error || t(`${i18nPrefix}SyncFailed`),
});
}
} catch {
setSyncResult({
outcome: "failed",
message: t(`${i18nPrefix}SyncFailed`),
});
} finally {
setSyncing(false);
}
}
async function handleSaveCredentials(e: React.FormEvent) {
e.preventDefault();
setCredsSaving(true);
setCredsMsg(null);
setCredsMsgType(null);
try {
const res = await apiFetchRaw(`/api/${provider}/credentials`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
clientId: clientId.trim(),
clientSecret: clientSecret.trim(),
}),
});
if (res.ok) {
setCredsMsg(t(`${i18nPrefix}CredentialsSaved`));
setCredsMsgType("success");
setClientId("");
setClientSecret("");
queryClient.invalidateQueries({ queryKey: statusQueryKey });
// The card may read off the consolidated envelope — invalidate it too
// so the saved-credentials state repaints regardless of the source.
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
} else {
try {
const json = await res.json();
setCredsMsg(json.error || t("settings.savingError"));
} catch {
setCredsMsg(t("settings.savingError"));
}
setCredsMsgType("error");
}
} catch {
setCredsMsg(t("common.networkError"));
setCredsMsgType("error");
}
setCredsSaving(false);
}
const disconnect = useMutation({
mutationFn: async () => {
await apiPost(`/api/${provider}/disconnect`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: statusQueryKey });
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
},
});
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" ||
pillState === "warning") &&
status?.lastError
? status.lastError
: null;
const serverUnavailable = status && !status.available;
function handleConnect() {
setMsg(null);
void invalidateKeys(queryClient, measurementDependentKeys);
loadDocument(`/api/${provider}/connect`);
}
return (
<SettingsCard data-testid={`${provider}-card`}>
<SettingsCardHeader
icon={icon}
title={t(`${i18nPrefix}`)}
description={
<IntegrationCardDescription
i18nPrefix={i18nPrefix}
provider={provider as IntegrationDocsProvider}
/>
}
status={
<IntegrationStatusPill
state={pillState}
lastSyncAt={
pillState === "stale" || pillState === "stalled"
? (status?.syncHealth?.since ?? null)
: (status?.legacyLastSyncedAt ?? status?.lastSuccessAt ?? null)
}
{...pillFailurePropsFor(status)}
/>
}
/>
<div className="space-y-4">
{errorMessage && (
<div data-testid={`${provider}-error`}>
<IntegrationErrorMessage message={errorMessage} />
</div>
)}
{/* Parked-integration resume CTA. Surfaces only when the row state
is `parked` (>24h of persistent failures). For an env-based OAuth
provider there are no stored BYO credentials to re-validate — the
grant itself has to be re-issued, so "reconnect" re-initiates the
existing connect flow (`/api/<provider>/connect`); a successful
callback clears the park. Markup matches the WHOOP card byte for
byte. */}
{pillState === "parked" && (
<div
data-testid={`${provider}-parked-banner`}
className="border-warning/30 bg-warning/10 flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm"
>
<span className="text-warning min-w-0 text-xs break-words">
{t("settings.integrationPill.parkedReconnect")}
</span>
<Button
type="button"
size="sm"
variant="outline"
onClick={handleConnect}
disabled={serverUnavailable}
data-testid={`${provider}-resume-button`}
className="min-h-11"
>
<Link2 className="h-3.5 w-3.5" />
{t("settings.integrationPill.resumeCta")}
</Button>
</div>
)}
{provider === "oura" && status?.connected && (
<OuraScopeNote
collections={status.scopeReconnect}
onReconnect={handleConnect}
disabled={serverUnavailable}
/>
)}
{status?.connected && (
<MetricFreshnessDisclosure
entries={status.metricFreshness}
idPrefix={provider}
/>
)}
{credentials && (
<div className="space-y-3" data-testid={`${provider}-credentials`}>
<h3 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{t(`${i18nPrefix}Credentials`)}
</h3>
{!status?.hasOwnCredentials && (
<IntegrationRedirectGuide
provider={provider}
providerLabel={t(i18nPrefix)}
callbackUrl={callbackUrl}
/>
)}
<form onSubmit={handleSaveCredentials} className="space-y-3">
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor={`${provider}-clientid`}>
{t(`${i18nPrefix}ClientId`)}
</Label>
<Input
id={`${provider}-clientid`}
value={clientId}
onChange={(e) => setClientId(e.target.value)}
placeholder={
status?.hasOwnCredentials
? t(`${i18nPrefix}CredentialsSavedPlaceholder`)
: t(`${i18nPrefix}ClientId`)
}
maxLength={200}
autoComplete="off"
inputMode="text"
spellCheck={false}
autoCapitalize="none"
enterKeyHint="next"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor={`${provider}-secret`}>
{t(`${i18nPrefix}ClientSecret`)}
</Label>
<PasswordInput
id={`${provider}-secret`}
value={clientSecret}
onChange={(e) => setClientSecret(e.target.value)}
placeholder={
status?.hasOwnCredentials
? t(`${i18nPrefix}CredentialsSavedPlaceholder`)
: t(`${i18nPrefix}ClientSecret`)
}
maxLength={200}
autoComplete="off"
inputMode="text"
spellCheck={false}
autoCapitalize="none"
enterKeyHint="done"
/>
</div>
</div>
<SettingsCardActions>
<Button
type="submit"
variant="outline"
size="sm"
className="min-h-11 w-full sm:w-auto"
disabled={
credsSaving || !clientId.trim() || !clientSecret.trim()
}
>
{credsSaving ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Save className="h-3.5 w-3.5" />
)}
{t(`${i18nPrefix}SaveCredentials`)}
</Button>
</SettingsCardActions>
{credsMsg && (
<p
role="alert"
className={`text-sm ${credsMsgType === "success" ? "text-success" : "text-destructive"}`}
>
{credsMsg}
</p>
)}
</form>
</div>
)}
{serverUnavailable && (
<p
className="text-muted-foreground text-xs"
data-testid={`${provider}-unavailable`}
>
{t(`${i18nPrefix}Unavailable`)}
</p>
)}
{status?.connected ? (
<>
<div className="flex flex-wrap items-start gap-2 [&>*]:min-w-[10rem] sm:[&>*]:min-w-0">
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11"
onClick={handleSync}
disabled={syncing}
data-testid={`${provider}-sync`}
>
{syncing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
{t(`${i18nPrefix}Sync`)}
</Button>
<TestConnectionButton
endpoint={`/api/${provider}/test`}
disabled={!status?.connected}
/>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="text-destructive min-h-11"
data-testid={`${provider}-disconnect`}
>
<Unlink className="h-3.5 w-3.5" />
{t(`${i18nPrefix}Disconnect`)}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t(`${i18nPrefix}DisconnectTitle`)}
</AlertDialogTitle>
<AlertDialogDescription>
{t(`${i18nPrefix}DisconnectDescription`)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => disconnect.mutate()}
>
{t(`${i18nPrefix}Disconnect`)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
{syncResult && (
<WrittenOutcomeLine
outcome={syncResult.outcome}
message={syncResult.message}
testId={`${provider}-sync-result`}
/>
)}
{/* connect→data loop: a discreet link to where this provider's
readings now surface — doubles as the "your data is richer"
cue. */}
<Link
href={dataHref}
data-testid={`${provider}-data-link`}
className="text-primary inline-flex items-center gap-1 text-xs underline-offset-2 hover:underline"
>
{t(`${i18nPrefix}ViewData`)}
<ArrowRight className="h-3 w-3" />
</Link>
</>
) : (
<SettingsCardActions>
<Button
type="button"
size="sm"
className="min-h-11 w-full sm:w-auto"
disabled={serverUnavailable}
onClick={handleConnect}
data-testid={`${provider}-connect`}
>
<Link2 className="h-3.5 w-3.5" />
{t(`${i18nPrefix}Connect`)}
</Button>
</SettingsCardActions>
)}
{msg && (
<p role="alert" className="text-destructive text-sm">
{msg}
</p>
)}
</div>
<CallbackMismatchNotice
provider={t(i18nPrefix)}
callbackUrl={callbackUrl}
/>
</SettingsCard>
);
}
@@ -1,35 +0,0 @@
"use client";
// v1.17.0 (F4) — Oura Cloud integration card. Thin wrapper over the shared
// OAuth card.
// v1.17.1 — per-user BYO OAuth credentials (DB-first then env).
import { CircleDot } from "lucide-react";
import {
OAuthProviderCard,
type OAuthProviderStatus,
} from "@/components/settings/integrations/oauth-provider-card";
import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls";
import { queryKeys } from "@/lib/query-keys";
export function OuraCard({
viewModel,
callbackUrl,
}: {
viewModel?: OAuthProviderStatus;
callbackUrl: IntegrationCallbackUrls["oura"];
}) {
return (
<OAuthProviderCard
provider="oura"
statusQueryKey={queryKeys.oura()}
i18nPrefix="settings.oura"
icon={CircleDot}
dataHref="/insights/sleep"
credentials
viewModel={viewModel}
callbackUrl={callbackUrl}
/>
);
}
@@ -1,60 +0,0 @@
"use client";
import { Link2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import type { OuraScopeGatedCollection } from "@/lib/oura/scope";
/**
* A calm note on the Oura card when the stored grant does not cover some
* collections (they answer 401 while their siblings succeed). Not an error and
* not counted toward the reconnect threshold: everything else keeps syncing,
* and one reconnect asks Oura for the missing scopes.
*/
export function OuraScopeNote({
collections,
onReconnect,
disabled,
}: {
collections: readonly OuraScopeGatedCollection[] | undefined;
onReconnect: () => void;
disabled?: boolean;
}) {
const { t, locale } = useTranslations();
if (!collections || collections.length === 0) return null;
const labels: Record<OuraScopeGatedCollection, string> = {
spo2: t("settings.integrations.oura.scope.items.spo2"),
vo2max: t("settings.integrations.oura.scope.items.vo2max"),
cardio_age: t("settings.integrations.oura.scope.items.cardio_age"),
resilience: t("settings.integrations.oura.scope.items.resilience"),
};
const list = new Intl.ListFormat(locale, {
style: "long",
type: "conjunction",
}).format(collections.map((c) => labels[c]));
return (
<div
data-testid="oura-scope-note"
className="border-info/30 bg-info/10 flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm"
>
<p className="min-w-0 break-words">
{t("settings.integrations.oura.scope.body", { list })}
</p>
<Button
type="button"
size="sm"
variant="outline"
onClick={onReconnect}
disabled={disabled}
data-testid="oura-scope-reconnect"
className="min-h-11"
>
<Link2 className="h-3.5 w-3.5" />
{t("settings.integrations.oura.scope.action")}
</Button>
</div>
);
}
@@ -1,35 +0,0 @@
"use client";
// v1.17.0 (F4) — Polar AccessLink integration card. Thin wrapper over the
// shared OAuth card.
// v1.17.1 — per-user BYO OAuth credentials (DB-first then env).
import { Watch } from "lucide-react";
import {
OAuthProviderCard,
type OAuthProviderStatus,
} from "@/components/settings/integrations/oauth-provider-card";
import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls";
import { queryKeys } from "@/lib/query-keys";
export function PolarCard({
viewModel,
callbackUrl,
}: {
viewModel?: OAuthProviderStatus;
callbackUrl: IntegrationCallbackUrls["polar"];
}) {
return (
<OAuthProviderCard
provider="polar"
statusQueryKey={queryKeys.polar()}
i18nPrefix="settings.polar"
icon={Watch}
dataHref="/insights/sleep"
credentials
viewModel={viewModel}
callbackUrl={callbackUrl}
/>
);
}
+34 -161
View File
@@ -8,23 +8,12 @@
*/
import { useQuery } from "@tanstack/react-query";
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 type { ConnectionHealth } from "@/lib/integrations/connection-health";
import type { MetricFreshnessEntry } from "@/lib/integrations/sync-verdict";
import { queryKeys } from "@/lib/query-keys";
import type { WithingsPlanFeature } from "@/lib/withings/plan-features";
import type { OuraScopeGatedCollection } from "@/lib/oura/scope";
// v1.4.15 Phase B2: shared status payload for both integration cards.
// v1.4.19 Phase A5: the redundant in-card status banner is gone — the
// IntegrationStatusPill now owns state + last-sync presentation, and
// the actionable error message is shown inline above the action row.
export type IntegrationKey =
| "withings"
| "whoop"
@@ -38,23 +27,20 @@ export type IntegrationKey =
// Nightscout folds onto the envelope so the card stops fetching its own
// status and inherits the shared verdict.
| "nightscout";
/** Mirrors `IntegrationState` in `src/lib/integrations/status.ts`. */
export type IntegrationState =
| "connected"
| "error_transient"
| "error_reauth"
| "disconnected"
| "parked"
// v1.38.19 — no ledger row: the ledger has no history for this provider.
// Read like `disconnected`; `syncHealth.verdict` owns liveness.
| "unknown";
/**
* One envelope entry as the cards read it. Since v1.42.4 the cards render
* `health` alone; the older health-shaped fields (ledger `state`, `lastError`,
* `syncHealth`, token expiry, plan and scope lists, `needsReauth`) stay on the
* wire for one transition release and are deliberately not declared here, so
* the compiler refuses a card that starts reading them again.
*/
export interface IntegrationStatusViewModel {
integration: IntegrationKey;
state: IntegrationState;
lastSuccessAt: string | null;
lastAttemptAt: string | null;
lastError: string | null;
/**
* The one server-resolved state (ok / limited / degraded / action_required),
* null while the provider is not connected.
*/
health?: ConnectionHealth | null;
consecutiveFailuresByKind?: {
transient: number;
reauth_required: number;
@@ -64,38 +50,17 @@ export interface IntegrationStatusViewModel {
failureThreshold?: number;
configured?: boolean;
connected?: boolean;
connectedAt?: string | null;
legacyLastSyncedAt?: string | null;
tokenExpiresAt?: string | null;
tokenExpired?: boolean | null;
enabled?: boolean;
// Withings activity-scope reconnect banner.
scope?: string | null;
hasActivityScope?: boolean;
// Withings: readings the account's API plan withholds (server-resolved).
planUnavailable?: WithingsPlanFeature[];
// Oura: collections the stored grant does not cover (server-resolved).
scopeReconnect?: OuraScopeGatedCollection[];
// WHOOP / Fitbit backfill-in-progress note.
/** WHOOP / Fitbit / Google Health: older history still loading. */
backfillCompleted?: boolean | null;
// v1.27.0 — Google Health surfaces `needsReauth` when the refresh token has
// lapsed (the 7-day "Testing"-mode expiry, or a revoked grant). The card
// paints a distinct re-consent CTA off this flag, separate from the parked
// state. Server-populated on the `/api/integrations/status` envelope.
needsReauth?: boolean;
// Polar / Oura OAuth card: usable-credentials + BYO-key flags. `available`
// greys out the connect button when no credentials resolve; `hasOwnCredentials`
// 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.
* Polar / Oura / Strava: usable credentials resolve (BYO or the shared
* app). Greys out "Connect" when false.
*/
syncHealth?: SyncHealth;
available?: boolean;
/** Polar / Oura / Strava: the person stored their own client id/secret. */
hasOwnCredentials?: boolean;
/** Nightscout: a stored token. */
hasToken?: boolean;
/** Per-metric last-seen timestamps with the server-computed stale flag. */
metricFreshness?: MetricFreshnessEntry[];
}
@@ -106,10 +71,8 @@ export interface IntegrationStatusEnvelope {
}
/**
* Shared status fetch for the Settings → Integrations card. Returns
* the per-integration view-model AND the global threshold so the
* "{n}/{threshold} consecutive failures" string in the UI is single-
* sourced from the server.
* Shared status fetch for the Settings → Integrations cards. Returns the
* per-integration view-model and the global failure threshold.
*/
export function useIntegrationStatuses(enabled: boolean) {
return useQuery({
@@ -135,116 +98,26 @@ export function pickStatus(
}
/**
* 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.
* The current failure streak paired with the server-owned alert threshold, for
* the card's copied diagnostics. The card itself shows no counter: whether a
* streak matters is already in `health`. The alert ladder uses the same
* maximum, so the ratio cannot disagree with when a notification goes out.
*/
export function pillStateForVerdict(
verdict: SyncVerdict | undefined,
): IntegrationPillState {
switch (verdict) {
case "fresh":
return "connected";
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);
}
export interface IntegrationPillFailureProps {
failureCount?: number;
failureThreshold?: number;
}
/**
* Pair the largest per-kind streak with the server-owned alert threshold. The
* alert ladder uses the same maximum, so the ratio cannot disagree with when a
* notification becomes eligible.
*/
export function pillFailurePropsFor(
export function failureStreakFor(
status:
| Pick<
IntegrationStatusViewModel,
"consecutiveFailuresByKind" | "failureThreshold"
>
| undefined,
): IntegrationPillFailureProps {
): { count: number; threshold: number } | null {
const buckets = status?.consecutiveFailuresByKind;
const failureThreshold = status?.failureThreshold;
if (!buckets || failureThreshold === undefined) return {};
const failureCount = Math.max(
const threshold = status?.failureThreshold;
if (!buckets || threshold === undefined) return null;
const count = Math.max(
buckets.transient,
buckets.reauth_required,
buckets.persistent,
);
return failureCount > 0 ? { failureCount, failureThreshold } : {};
}
/**
* 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 aging and
* failing states read the instant that triggered the verdict.
*
* `failing` must NOT fall through to the `legacyLastSyncedAt` arm below. That
* field is a connection row's own clock, kept fresh by the very sync that is
* erroring, so a provider that had delivered nothing for a fortnight painted a
* "just now" beside its warning.
*/
export function pillTimestampFor(
status: IntegrationStatusViewModel | undefined,
): string | null {
const verdict = status?.syncHealth?.verdict;
if (verdict === "stale" || verdict === "stalled" || verdict === "failing") {
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
* line tells the user *what* is wrong so they can act on it. Keeping
* it deliberately small (one icon + one line) so it doesn't recreate
* the v1.4.18 redundant banner the maintainer removed.
*/
export function IntegrationErrorMessage({ message }: { message: string }) {
return (
<p
data-testid="integration-error-message"
className="text-destructive flex items-start gap-1.5 text-sm"
>
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span className="min-w-0 break-words">{message}</span>
</p>
);
return count > 0 ? { count, threshold } : null;
}
@@ -1,36 +0,0 @@
"use client";
// v1.28.x — Strava integration card. Thin wrapper over the shared OAuth card
// (per-user BYO OAuth credentials, DB-first then env). Strava is a workout
// source: connecting it syncs your activities into the workout surface, where
// the cross-source picker collapses any twin already captured by a wearable.
import { Activity } from "lucide-react";
import {
OAuthProviderCard,
type OAuthProviderStatus,
} from "@/components/settings/integrations/oauth-provider-card";
import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls";
import { queryKeys } from "@/lib/query-keys";
export function StravaCard({
viewModel,
callbackUrl,
}: {
viewModel?: OAuthProviderStatus;
callbackUrl: IntegrationCallbackUrls["strava"];
}) {
return (
<OAuthProviderCard
provider="strava"
statusQueryKey={queryKeys.strava()}
i18nPrefix="settings.strava"
icon={Activity}
dataHref="/insights/workouts"
credentials
viewModel={viewModel}
callbackUrl={callbackUrl}
/>
);
}
@@ -1,519 +0,0 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
Activity,
ArrowRight,
Link2,
Loader2,
RefreshCw,
Save,
Unlink,
} from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { Input } from "@/components/ui/input";
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 } from "@/components/settings/integration-status-pill";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch";
import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line";
import { useTranslations } from "@/lib/i18n/context";
import {
invalidateKeys,
measurementDependentKeys,
queryKeys,
} from "@/lib/query-keys";
import {
IntegrationErrorMessage,
pillFailurePropsFor,
pillStateFor,
pillTimestampFor,
type IntegrationStatusViewModel,
} from "./shared";
import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure";
import {
readSyncOutcome,
useSyncOutcomeMessage,
type SyncOutcomeState,
} from "./sync-outcome";
import {
CallbackMismatchNotice,
IntegrationCardDescription,
IntegrationRedirectGuide,
} from "./setup-guide-link";
import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls";
import { loadDocument } from "@/lib/navigation/load-document";
export function WhoopCard({
viewModel,
callbackUrl,
}: {
viewModel: IntegrationStatusViewModel | undefined;
/** Server-resolved OAuth callback URL; see `getIntegrationCallbackUrls`. */
callbackUrl: IntegrationCallbackUrls["whoop"];
}) {
const { t } = useTranslations();
const describeSyncOutcome = useSyncOutcomeMessage();
const [syncing, setSyncing] = useState(false);
const [syncResult, setSyncResult] = useState<SyncOutcomeState | null>(null);
const [clientId, setClientId] = useState("");
const [clientSecret, setClientSecret] = useState("");
const [credsSaving, setCredsSaving] = useState(false);
const [credsMsg, setCredsMsg] = useState<string | null>(null);
const [credsMsgType, setCredsMsgType] = useState<"success" | "error" | null>(
null,
);
const queryClient = useQueryClient();
// v1.12.1 — read off the consolidated /api/integrations/status
// envelope; the per-card /api/whoop/status round-trip is gone.
const status = viewModel;
const disconnect = useMutation({
mutationFn: async () => {
await apiPost("/api/whoop/disconnect");
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.whoop() });
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
},
});
// Clear a parked integration via the resume endpoint. The CTA is
// rendered inside the parked banner below; success invalidates both
// the per-card status (so the pill flips back to connected
// immediately) and the cross-integration envelope (so any other view
// picks up the change on its next focus).
const resume = useMutation({
mutationFn: async () => {
return apiPost<{ resumed: boolean; wasParked: boolean }>(
"/api/integrations/whoop/resume",
);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.whoop() });
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
},
});
async function handleSync(fullSync = false) {
setSyncing(true);
setSyncResult(null);
try {
const res = await apiFetchRaw("/api/whoop/sync", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fullSync }),
});
const json = await res.json();
const result = res.ok ? readSyncOutcome(json) : null;
if (result) {
// The tone comes off what the run wrote, not off `res.ok`.
setSyncResult({
outcome: result.outcome,
message: describeSyncOutcome(
result,
t("settings.whoopSyncResult", { count: result.imported }),
),
});
void invalidateKeys(queryClient, measurementDependentKeys);
queryClient.invalidateQueries({ queryKey: queryKeys.whoop() });
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
} else {
setSyncResult({
outcome: "failed",
message: json?.error || t("settings.whoopSyncFailed"),
});
}
} catch {
setSyncResult({
outcome: "failed",
message: t("settings.whoopSyncFailed"),
});
} finally {
setSyncing(false);
}
}
async function handleSaveCredentials(e: React.FormEvent) {
e.preventDefault();
setCredsSaving(true);
setCredsMsg(null);
setCredsMsgType(null);
try {
const res = await apiFetchRaw("/api/whoop/credentials", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
clientId: clientId.trim(),
clientSecret: clientSecret.trim(),
}),
});
if (res.ok) {
setCredsMsg(t("settings.whoopCredentialsSaved"));
setCredsMsgType("success");
setClientId("");
setClientSecret("");
queryClient.invalidateQueries({ queryKey: queryKeys.whoop() });
} else {
try {
const json = await res.json();
setCredsMsg(json.error || t("settings.savingError"));
} catch {
setCredsMsg(t("settings.savingError"));
}
setCredsMsgType("error");
}
} catch {
setCredsMsg(t("common.networkError"));
setCredsMsgType("error");
}
setCredsSaving(false);
}
// 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" ||
pillState === "warning") &&
viewModel?.lastError
? viewModel.lastError
: null;
return (
<SettingsCard data-testid="whoop-card">
<SettingsCardHeader
icon={Activity}
title={t("settings.whoop")}
description={
<IntegrationCardDescription
i18nPrefix="settings.whoop"
provider="whoop"
/>
}
status={
<IntegrationStatusPill
state={pillState}
lastSyncAt={pillLastSyncAt}
{...pillFailurePropsFor(viewModel)}
/>
}
/>
<div className="space-y-4">
{errorMessage && <IntegrationErrorMessage message={errorMessage} />}
{/* Parked-integration resume CTA. Surfaces only when the row
state is `parked` (>24h of persistent failures). The button
POSTs to /api/integrations/whoop/resume which calls
`resumeIntegrationFromPark`; on success the per-card status
invalidates and the pill flips back to connected without a
page refresh. Wider tap target than the inline action row so
it stays reachable on a Pixel 5 viewport. */}
{pillState === "parked" && (
<div
data-testid="whoop-parked-banner"
className="border-warning/30 bg-warning/10 flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm"
>
<span className="text-warning min-w-0 text-xs break-words">
{t("settings.integrationPill.parkedReconnect")}
</span>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => resume.mutate()}
disabled={resume.isPending}
data-testid="whoop-resume-button"
className="min-h-11"
>
{resume.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Link2 className="h-3.5 w-3.5" />
)}
{t("settings.integrationPill.resumeCta")}
</Button>
</div>
)}
{resume.isError && (
<p
role="alert"
className="text-destructive text-sm"
data-testid="whoop-resume-error"
>
{t("settings.integrationPill.resumeError")}
</p>
)}
{status?.connected && (
<MetricFreshnessDisclosure
entries={viewModel?.metricFreshness}
idPrefix="whoop"
/>
)}
{resume.isSuccess && resume.data?.wasParked && (
<p
role="status"
className="text-success text-xs"
data-testid="whoop-resume-success"
>
{t("settings.integrationPill.resumeSuccess")}
</p>
)}
<div className="space-y-3">
<h3 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{t("settings.whoopCredentials")}
</h3>
{!status?.configured && (
<IntegrationRedirectGuide
provider="whoop"
providerLabel={t("settings.whoop")}
callbackUrl={callbackUrl}
/>
)}
<form onSubmit={handleSaveCredentials} className="space-y-3">
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="whoop-clientid">
{t("settings.whoopClientId")}
</Label>
<Input
id="whoop-clientid"
value={clientId}
onChange={(e) => setClientId(e.target.value)}
placeholder={
status?.configured
? t("settings.whoopCredentialsSavedPlaceholder")
: t("settings.whoopClientId")
}
maxLength={200}
autoComplete="off"
inputMode="text"
spellCheck={false}
autoCapitalize="none"
enterKeyHint="next"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="whoop-secret">
{t("settings.whoopClientSecret")}
</Label>
<PasswordInput
id="whoop-secret"
value={clientSecret}
onChange={(e) => setClientSecret(e.target.value)}
placeholder={
status?.configured
? t("settings.whoopCredentialsSavedPlaceholderSecret")
: t("settings.whoopClientSecret")
}
maxLength={200}
autoComplete="off"
inputMode="text"
spellCheck={false}
autoCapitalize="none"
enterKeyHint="done"
/>
</div>
</div>
<SettingsCardActions>
<Button
type="submit"
variant="outline"
size="sm"
className="min-h-11 w-full sm:w-auto"
disabled={
credsSaving || !clientId.trim() || !clientSecret.trim()
}
>
{credsSaving ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Save className="h-3.5 w-3.5" />
)}
{t("settings.whoopSaveCredentials")}
</Button>
</SettingsCardActions>
{credsMsg && (
<p
role="alert"
className={`text-sm ${credsMsgType === "success" ? "text-success" : "text-destructive"}`}
>
{credsMsg}
</p>
)}
</form>
</div>
{status?.connected ? (
<>
<div className="flex flex-wrap items-start gap-2 [&>*]:min-w-[10rem] sm:[&>*]:min-w-0">
<Button
variant="outline"
size="sm"
className="min-h-11"
onClick={() => handleSync(false)}
disabled={syncing}
>
{syncing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
{t("settings.whoopSync")}
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="min-h-11"
disabled={syncing}
>
{syncing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
{t("settings.whoopFullSync")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.whoopFullSyncTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.whoopFullSyncDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={() => handleSync(true)}>
{t("settings.whoopSynchronize")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<TestConnectionButton
endpoint="/api/integrations/whoop/test"
disabled={!status?.connected}
/>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="text-destructive min-h-11"
>
<Unlink className="h-3.5 w-3.5" />
{t("settings.whoopDisconnect")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.whoopDisconnectTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.whoopDisconnectDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => disconnect.mutate()}
>
{t("settings.whoopDisconnect")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
{status?.backfillCompleted === false && (
<p className="text-muted-foreground text-xs">
{t("settings.whoopBackfillInProgress")}
</p>
)}
{syncResult && (
<WrittenOutcomeLine
outcome={syncResult.outcome}
message={syncResult.message}
testId="whoop-sync-result"
/>
)}
{/* 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 ? (
<SettingsCardActions>
<Button
size="sm"
className="min-h-11 w-full sm:w-auto"
data-testid="whoop-connect"
onClick={() => {
loadDocument("/api/whoop/connect");
}}
>
<Link2 className="h-3.5 w-3.5" />
{t("settings.whoopConnect")}
</Button>
</SettingsCardActions>
) : (
<p
className="text-muted-foreground text-xs"
data-testid="integration-unavailable"
>
{t("settings.whoopNoCredentials")}
</p>
)}
</div>
<CallbackMismatchNotice
provider={t("settings.whoop")}
callbackUrl={callbackUrl}
/>
</SettingsCard>
);
}
@@ -1,573 +0,0 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
ArrowRight,
Link2,
Loader2,
RefreshCw,
Save,
Unlink,
} from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { Input } from "@/components/ui/input";
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 } from "@/components/settings/integration-status-pill";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch";
import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line";
import { useTranslations } from "@/lib/i18n/context";
import {
invalidateKeys,
measurementDependentKeys,
queryKeys,
} from "@/lib/query-keys";
import {
IntegrationErrorMessage,
pillStateFor,
pillFailurePropsFor,
pillTimestampFor,
type IntegrationStatusViewModel,
} from "./shared";
import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure";
import { WithingsPlanNote } from "./withings-plan-note";
import {
readSyncOutcome,
useSyncOutcomeMessage,
type SyncOutcomeState,
} from "./sync-outcome";
import {
CallbackMismatchNotice,
IntegrationCardDescription,
IntegrationRedirectGuide,
} from "./setup-guide-link";
import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls";
import { loadDocument } from "@/lib/navigation/load-document";
export function WithingsCard({
viewModel,
callbackUrl,
}: {
viewModel: IntegrationStatusViewModel | undefined;
/** Server-resolved OAuth callback URL; see `getIntegrationCallbackUrls`. */
callbackUrl: IntegrationCallbackUrls["withings"];
}) {
const { t } = useTranslations();
const describeSyncOutcome = useSyncOutcomeMessage();
const [syncing, setSyncing] = useState(false);
const [syncResult, setSyncResult] = useState<SyncOutcomeState | null>(null);
const [clientId, setClientId] = useState("");
const [clientSecret, setClientSecret] = useState("");
const [credsSaving, setCredsSaving] = useState(false);
const [credsMsg, setCredsMsg] = useState<string | null>(null);
const [credsMsgType, setCredsMsgType] = useState<"success" | "error" | null>(
null,
);
const queryClient = useQueryClient();
// v1.12.1 — the Withings status is read off the consolidated
// /api/integrations/status envelope (the `viewModel` prop). The
// per-card /api/withings/status round-trip is gone; the envelope
// carries every field this card renders (connected / configured /
// last-sync / activity scope).
const status = viewModel;
const disconnect = useMutation({
mutationFn: async () => {
await apiPost("/api/withings/disconnect");
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.withings() });
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
},
});
// v1.4.43 W14 — clear a parked integration via the resume endpoint.
// The CTA is rendered inside the parked banner below; success
// invalidates both the per-card status (so the pill flips back to
// connected immediately) and the cross-integration envelope (so any
// other view picks up the change on its next focus).
const resume = useMutation({
mutationFn: async () => {
return apiPost<{ resumed: boolean; wasParked: boolean }>(
"/api/integrations/withings/resume",
);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.withings() });
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
},
});
async function handleSync(fullSync = false) {
setSyncing(true);
setSyncResult(null);
try {
const res = await apiFetchRaw("/api/withings/sync", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fullSync }),
});
const json = await res.json();
const result = res.ok ? readSyncOutcome(json) : null;
if (result) {
// The tone comes off what the run wrote, not off `res.ok`.
setSyncResult({
outcome: result.outcome,
message: describeSyncOutcome(
result,
fullSync
? t("settings.withingsFullSyncResult", { count: result.imported })
: t("settings.withingsSyncResult", { count: result.imported }),
),
});
void invalidateKeys(queryClient, measurementDependentKeys);
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
} else {
setSyncResult({
outcome: "failed",
message: json?.error || t("settings.withingsSyncFailed"),
});
}
} catch {
setSyncResult({
outcome: "failed",
message: t("settings.withingsSyncFailed"),
});
} finally {
setSyncing(false);
}
}
async function handleSaveCredentials(e: React.FormEvent) {
e.preventDefault();
setCredsSaving(true);
setCredsMsg(null);
setCredsMsgType(null);
try {
const res = await apiFetchRaw("/api/withings/credentials", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
clientId: clientId.trim(),
clientSecret: clientSecret.trim(),
}),
});
if (res.ok) {
setCredsMsg(t("settings.withingsCredentialsSaved"));
setCredsMsgType("success");
setClientId("");
setClientSecret("");
queryClient.invalidateQueries({ queryKey: queryKeys.withings() });
} else {
try {
const json = await res.json();
setCredsMsg(json.error || t("settings.savingError"));
} catch {
setCredsMsg(t("settings.savingError"));
}
setCredsMsgType("error");
}
} catch {
setCredsMsg(t("common.networkError"));
setCredsMsgType("error");
}
setCredsSaving(false);
}
// The pill state derives from the cross-integration status
// envelope. `legacyLastSyncedAt` is the connection row's own
// last-sync timestamp (carried in the same envelope) so the pill
// 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.
// 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" ||
pillState === "warning") &&
viewModel?.lastError
? viewModel.lastError
: null;
return (
<SettingsCard data-testid="withings-card">
<SettingsCardHeader
icon={Link2}
title={t("settings.withings")}
description={
<IntegrationCardDescription
i18nPrefix="settings.withings"
provider="withings"
/>
}
status={
<IntegrationStatusPill
state={pillState}
lastSyncAt={pillLastSyncAt}
{...pillFailurePropsFor(viewModel)}
/>
}
/>
<div className="space-y-4">
{errorMessage && <IntegrationErrorMessage message={errorMessage} />}
{/* v1.4.25 W5d — Withings activity-scope reconnect banner.
Only paints when (a) the user has an active Withings
connection (so the credentials are already saved + a token
exists) AND (b) the persisted scope is missing
`user.activity`. Reconnecting takes the user through the
standard /api/withings/connect → Withings → callback flow;
the callback persists the upgraded scope and the banner
vanishes on the next status poll. */}
{status?.connected && status?.hasActivityScope === false && (
<a
href="/api/withings/connect"
data-testid="withings-reconnect-banner"
className="border-warning/30 bg-warning/10 text-foreground hover:bg-warning/20 block rounded-md border px-3 py-2 text-sm transition-colors"
>
<span className="font-medium">
{t("settings.integrations.withings.reconnect.banner.title")}
</span>
<span className="text-muted-foreground block text-xs">
{t("settings.integrations.withings.reconnect.banner.body")}
</span>
<span className="text-primary mt-1 inline-block text-xs font-medium">
{t("settings.integrations.withings.reconnect.banner.action")}
{" →"}
</span>
</a>
)}
{/* v1.4.43 W14 — parked-integration resume CTA. Surfaces only
when the row state is `parked` (>24h of persistent
failures). The button POSTs to /api/integrations/withings/
resume which calls `resumeIntegrationFromPark`; on success
the per-card status invalidates and the pill flips back to
connected without a page refresh. The button is the
primary action the user can take from this card — wider
tap target than the inline action row so it's reachable
on a Pixel 5 viewport. */}
{pillState === "parked" && (
<div
data-testid="withings-parked-banner"
className="border-warning/30 bg-warning/10 flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm"
>
<span className="text-warning min-w-0 text-xs break-words">
{t("settings.integrationPill.parkedReconnect")}
</span>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => resume.mutate()}
disabled={resume.isPending}
data-testid="withings-resume-button"
className="min-h-11"
>
{resume.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Link2 className="h-3.5 w-3.5" />
)}
{t("settings.integrationPill.resumeCta")}
</Button>
</div>
)}
{resume.isError && (
<p
role="alert"
className="text-destructive text-sm"
data-testid="withings-resume-error"
>
{t("settings.integrationPill.resumeError")}
</p>
)}
{status?.connected && (
<WithingsPlanNote features={status.planUnavailable} />
)}
{status?.connected && (
<MetricFreshnessDisclosure
entries={viewModel?.metricFreshness}
idPrefix="withings"
/>
)}
{resume.isSuccess && resume.data?.wasParked && (
<p
role="status"
className="text-success text-xs"
data-testid="withings-resume-success"
>
{t("settings.integrationPill.resumeSuccess")}
</p>
)}
<div className="space-y-3">
<h3 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{t("settings.withingsCredentials")}
</h3>
{!status?.configured && (
<IntegrationRedirectGuide
provider="withings"
providerLabel={t("settings.withings")}
callbackUrl={callbackUrl}
/>
)}
<form onSubmit={handleSaveCredentials} className="space-y-3">
{/* v1.4.27 MB7 / CF-53 — the credentials grid drops from
a 3-column row (client-id / secret / save) to a
2-column row of inputs at `sm:`, with the Save button
lifted out into its own row below. The previous
"invisible Label" hack to align the button with the
input baselines fell apart on Galaxy Fold; lifting
the button into a dedicated `flex justify-end` row
gives it consistent placement on every viewport and
lets the input pair span the full width. */}
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="w-clientid">
{t("settings.withingsClientId")}
</Label>
<Input
id="w-clientid"
value={clientId}
onChange={(e) => setClientId(e.target.value)}
placeholder={
status?.configured
? t("settings.withingsCredentialsSavedPlaceholder")
: t("settings.withingsClientId")
}
maxLength={200}
autoComplete="off"
inputMode="text"
spellCheck={false}
autoCapitalize="none"
enterKeyHint="next"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="w-secret">
{t("settings.withingsClientSecret")}
</Label>
<PasswordInput
id="w-secret"
value={clientSecret}
onChange={(e) => setClientSecret(e.target.value)}
placeholder={
status?.configured
? t("settings.withingsCredentialsSavedPlaceholderSecret")
: t("settings.withingsClientSecret")
}
maxLength={200}
autoComplete="off"
inputMode="text"
spellCheck={false}
autoCapitalize="none"
enterKeyHint="done"
/>
</div>
</div>
<SettingsCardActions>
<Button
type="submit"
variant="outline"
size="sm"
className="min-h-11 w-full sm:w-auto"
disabled={
credsSaving || !clientId.trim() || !clientSecret.trim()
}
>
{credsSaving ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Save className="h-3.5 w-3.5" />
)}
{t("settings.withingsSaveCredentials")}
</Button>
</SettingsCardActions>
{credsMsg && (
<p
role="alert"
className={`text-sm ${credsMsgType === "success" ? "text-success" : "text-destructive"}`}
>
{credsMsg}
</p>
)}
</form>
</div>
{status?.connected ? (
<>
{/* v1.4.27 MB7 / CF-57 — the action row already wraps via
`flex-wrap`, but on Pixel 5 the four AlertDialog +
test-connection triggers each took a fractional slot
that read jagged. Force each button to a `min-w-[10rem]`
on `<sm` so they stack two per row at most and fill
their column cleanly. Tablet+ keeps the inline row. */}
<div className="flex flex-wrap items-start gap-2 [&>*]:min-w-[10rem] sm:[&>*]:min-w-0">
<Button
variant="outline"
size="sm"
className="min-h-11"
onClick={() => handleSync(false)}
disabled={syncing}
>
{syncing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
{t("settings.withingsSync")}
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="min-h-11"
disabled={syncing}
>
{syncing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
{t("settings.withingsFullSync")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.withingsFullSyncTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.withingsFullSyncDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={() => handleSync(true)}>
{t("settings.withingsSynchronize")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<TestConnectionButton
endpoint="/api/integrations/withings/test"
disabled={!status?.connected}
/>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="text-destructive min-h-11"
>
<Unlink className="h-3.5 w-3.5" />
{t("settings.withingsDisconnect")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.withingsDisconnectTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.withingsDisconnectDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => disconnect.mutate()}
>
{t("settings.withingsDisconnect")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
{syncResult && (
<WrittenOutcomeLine
outcome={syncResult.outcome}
message={syncResult.message}
testId="withings-sync-result"
/>
)}
{/* 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 ? (
<SettingsCardActions>
<Button
size="sm"
className="min-h-11 w-full sm:w-auto"
data-testid="withings-connect"
onClick={() => {
loadDocument("/api/withings/connect");
}}
>
<Link2 className="h-3.5 w-3.5" />
{t("settings.withingsConnect")}
</Button>
</SettingsCardActions>
) : (
<p
className="text-muted-foreground text-xs"
data-testid="integration-unavailable"
>
{t("settings.withingsNoCredentials")}
</p>
)}
</div>
<CallbackMismatchNotice
provider={t("settings.withings")}
callbackUrl={callbackUrl}
/>
</SettingsCard>
);
}
@@ -1,84 +0,0 @@
"use client";
import { ExternalLink } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import {
WITHINGS_API_PLANS_URL,
type WithingsPlanFeature,
} from "@/lib/withings/plan-features";
/**
* A calm note on the Withings card when the account's API plan withholds some
* readings (status 277 since the 2026-10-12 plan split). Not an error: the
* sync skips those readings and keeps importing the rest, so this sits in the
* card body in the info wash, never in the pill.
*/
export function WithingsPlanNote({
features,
}: {
features: readonly WithingsPlanFeature[] | undefined;
}) {
const { t, locale } = useTranslations();
if (!features || features.length === 0) return null;
const labels: Record<WithingsPlanFeature, string> = {
spo2: t("settings.integrations.withings.plan.items.spo2"),
vascularAge: t("settings.integrations.withings.plan.items.vascularAge"),
pulseWaveVelocity: t(
"settings.integrations.withings.plan.items.pulseWaveVelocity",
),
vo2Max: t("settings.integrations.withings.plan.items.vo2Max"),
visceralFat: t("settings.integrations.withings.plan.items.visceralFat"),
skinTemperature: t(
"settings.integrations.withings.plan.items.skinTemperature",
),
ecg: t("settings.integrations.withings.plan.items.ecg"),
sleepScore: t("settings.integrations.withings.plan.items.sleepScore"),
sleepHrv: t("settings.integrations.withings.plan.items.sleepHrv"),
sleepHeartRate: t(
"settings.integrations.withings.plan.items.sleepHeartRate",
),
sleepRespiratoryRate: t(
"settings.integrations.withings.plan.items.sleepRespiratoryRate",
),
sleepSpo2: t("settings.integrations.withings.plan.items.sleepSpo2"),
bodyTemperature: t(
"settings.integrations.withings.plan.items.bodyTemperature",
),
bodyFat: t("settings.integrations.withings.plan.items.bodyFat"),
fatMass: t("settings.integrations.withings.plan.items.fatMass"),
fatFreeMass: t("settings.integrations.withings.plan.items.fatFreeMass"),
muscleMass: t("settings.integrations.withings.plan.items.muscleMass"),
bodyWater: t("settings.integrations.withings.plan.items.bodyWater"),
boneMass: t("settings.integrations.withings.plan.items.boneMass"),
heartRate: t("settings.integrations.withings.plan.items.heartRate"),
bloodPressure: t("settings.integrations.withings.plan.items.bloodPressure"),
weight: t("settings.integrations.withings.plan.items.weight"),
};
const list = new Intl.ListFormat(locale, {
style: "long",
type: "conjunction",
}).format(features.map((f) => labels[f]));
return (
<div
data-testid="withings-plan-note"
className="border-info/30 bg-info/10 space-y-1 rounded-md border px-3 py-2 text-sm"
>
<p className="font-medium">
{t("settings.integrations.withings.plan.title")}
</p>
<p>{t("settings.integrations.withings.plan.body", { list })}</p>
<a
href={WITHINGS_API_PLANS_URL}
target="_blank"
rel="noopener noreferrer"
className="text-primary inline-flex items-center gap-1 text-xs font-medium underline-offset-2 hover:underline"
>
{t("settings.integrations.withings.plan.link")}
<ExternalLink className="h-3 w-3" aria-hidden="true" />
</a>
</div>
);
}
@@ -1,28 +1,27 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Plug } from "lucide-react";
import { Droplet, ExternalLink, Plug } from "lucide-react";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { IntegrationStatusPill } from "@/components/settings/integration-status-pill";
import { SettingsCard } from "@/components/settings/settings-card";
import { IntegrationCard } from "@/components/settings/integrations/integration-card";
import { OAUTH_INTEGRATIONS } from "@/components/settings/integrations/oauth-integration-card";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { SectionHeading } from "@/components/ui/section-heading";
import { apiGet } from "@/lib/api/api-fetch";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import type { ConnectionHealth } from "@/lib/integrations/connection-health";
import { queryKeys } from "@/lib/query-keys";
import { assertRecordSettingsResponseForRecord } from "@/lib/record-settings";
type ManagedIntegrationState =
"connected" | "error_transient" | "error_reauth" | "disconnected" | "parked";
type ManagedIntegrationKey = keyof typeof INTEGRATION_DISPLAY_NAMES;
interface ManagedIntegrationStatusResponse {
recordId: string;
integrations: Array<{
integration: keyof typeof INTEGRATION_DISPLAY_NAMES;
state: ManagedIntegrationState;
lastSuccessAt: string | null;
lastAttemptAt: string | null;
integration: ManagedIntegrationKey;
/** The server-resolved state; null while the provider is not connected. */
health: ConnectionHealth | null;
}>;
}
@@ -37,25 +36,24 @@ const INTEGRATION_DISPLAY_NAMES = {
strava: "Strava",
} as const;
function recordStatusPillState(state: ManagedIntegrationState) {
switch (state) {
case "connected":
return "connected" as const;
case "error_transient":
return "warning" as const;
case "error_reauth":
return "error" as const;
case "parked":
return "parked" as const;
case "disconnected":
return "disconnected" as const;
}
/** Where the guardian boundary is explained, connected services included. */
const SHARING_DOCS_HREF =
"https://docs.healthlog.dev/self-hosting/account-sharing#records-with-no-login";
function iconFor(key: ManagedIntegrationKey) {
if (key === "nightscout") return Droplet;
return OAUTH_INTEGRATIONS[key]?.icon ?? Plug;
}
/**
* The managed-record integration view is deliberately a status projection.
* It reads the active record's id from the resolved account-access payload and
* has no connection, credential, sync, or provider-control affordance.
* The managed record's connections, on the same card every integration in
* Settings renders, from the same server-resolved `health`.
*
* Read-only by construction: a guardian cannot connect, reconnect, resume,
* sync, test or disconnect a managed record's provider, because an OAuth flow
* cannot safely carry a record selector. So the cards carry no menu and no
* button, and one sentence above them says why, with the page that explains
* the boundary.
*/
export function ManagedIntegrationStatus() {
const { t } = useTranslations();
@@ -78,60 +76,58 @@ export function ManagedIntegrationStatus() {
return <QueryErrorCard onRetry={() => void statusQuery.refetch()} />;
}
if (statusQuery.isLoading || !statusQuery.data) {
return (
<SettingsCard
aria-busy="true"
aria-live="polite"
data-record-id={recordId ?? undefined}
data-record-settings-family="integrations"
>
<SettingsCardHeader
icon={Plug}
title={t("settings.sections.integrations.title")}
description={t("settings.sharedRecord.integrationStatusDescription")}
/>
<p className="text-sm">
{t("settings.sharedRecord.integrationStatusDetail")}
</p>
</SettingsCard>
const loading = statusQuery.isLoading || !statusQuery.data;
const entries =
statusQuery.data?.integrations ??
(Object.keys(INTEGRATION_DISPLAY_NAMES) as ManagedIntegrationKey[]).map(
(integration) => ({ integration, health: null }),
);
}
return (
<SettingsCard
aria-labelledby="managed-integration-status-title"
<section
className="space-y-4"
aria-labelledby={loading ? undefined : "managed-integration-status-title"}
aria-busy={loading || undefined}
aria-live={loading ? "polite" : undefined}
data-record-id={recordId ?? undefined}
data-record-settings-family="integrations"
>
<SettingsCardHeader
<SectionHeading
icon={Plug}
id="managed-integration-status-title"
title={t("settings.sections.integrations.title")}
titleId="managed-integration-status-title"
description={t("settings.sharedRecord.integrationStatusDescription")}
subtitle={t("settings.sharedRecord.integrationStatusDescription")}
/>
<p className="text-sm" data-slot="managed-integration-read-only">
{t("settings.sharedRecord.integrationStatusDetail")}{" "}
<a
href={SHARING_DOCS_HREF}
target="_blank"
rel="noopener noreferrer"
className="text-primary inline-flex items-center gap-1 underline-offset-2 hover:underline"
>
{t("settings.sharedRecord.integrationStatusDocs")}
<ExternalLink className="size-3" aria-hidden="true" />
</a>
</p>
<ul
className="space-y-3"
className="space-y-4"
aria-label={activeRecord?.displayName ?? undefined}
>
{statusQuery.data.integrations.map((integration) => (
<li
key={integration.integration}
className="flex items-center justify-between gap-3"
>
<span className="text-sm font-medium">
{INTEGRATION_DISPLAY_NAMES[integration.integration]}
</span>
<IntegrationStatusPill
state={recordStatusPillState(integration.state)}
lastSyncAt={
integration.lastSuccessAt ?? integration.lastAttemptAt
}
testId={`managed-integration-${integration.integration}`}
{entries.map((entry) => (
<li key={entry.integration}>
<IntegrationCard
provider={entry.integration}
providerName={INTEGRATION_DISPLAY_NAMES[entry.integration]}
icon={iconFor(entry.integration)}
loading={loading}
connected={entry.health !== null}
health={entry.health}
testId={`managed-integration-${entry.integration}`}
/>
</li>
))}
</ul>
</SettingsCard>
</section>
);
}