diff --git a/CHANGELOG.md b/CHANGELOG.md index a313eb314..111f0f5d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,47 @@ ## [Unreleased] +## [1.32.31] — 2026-07-25 + +The integrations page could tell you a connection was failing. It could not +tell you a connection had stopped trying. Those look the same from the outside: +the page keeps showing whatever error was recorded last, and it keeps showing +it for as long as nobody looks, because nothing anywhere read how old the last +attempt actually was. This release adds that reading, and every status on the +page now comes from it. + +- **A sync that has stopped running is labelled as stopped.** When a connection + has not even attempted to sync for a day, it says so, with how long it has + been. Previously it kept displaying the last error it managed to record, which + reads like something that is still retrying. +- **Per data type, when a value last arrived.** Each connected card now opens a + short list: every data type that connection has ever delivered, and how long + ago the newest one came in. A type that has gone quiet for two weeks while the + rest of the connection is healthy is called out, which is the shape a single + broken pipe takes: one revoked permission, one endpoint that started returning + nothing, while the sync itself keeps reporting success. Types the connection + has never delivered are simply not listed. Nothing is invented. +- **Apple Health has a staleness threshold.** Any timestamp at all used to paint + the green "data received" badge, so a phone that stopped delivering three + weeks ago looked exactly like one that delivered this morning. After a week of + silence the card now says so and points you at the app's Apple Health + permissions. It also lists the same per data type detail as the others. +- **A configured connection with no card of its own now appears.** If your + account has a connection set up that the page has no card for, it gets a plain + status row instead of being left off entirely. That is a structural fix: a + connection can no longer be running, failing and invisible at the same time. +- **The status colours mean one consistent thing.** Red is now reserved for the + cases your own action fixes, which is reconnecting or resuming. An upstream + outage or a rate limit carries the amber tone instead, because clicking + reconnect against a server error has never helped anyone. Every card uses the + same mapping. Three of them used to disagree with each other on the same page. +- **Nightscout reads the same status source as everything else.** It was the + last card fetching its own status separately, which is why it could show a + different colour for the same condition. + +The assistant tools report the new verdict too, so asking why your data looks +stale gets the real answer instead of "connected". + ## [1.32.30] — 2026-07-25 Height was the last value still asked for in centimetres regardless of the diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 8a5654572..1fab50617 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.32.30 + version: 1.32.31 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -32471,12 +32471,21 @@ components: pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" description: When HealthKit last synced for this user; null when never (and always null on the PATCH echo). + syncHealth: + description: Present on the GET read; omitted from the PATCH echo. + $ref: "#/components/schemas/SyncHealth" + metricFreshness: + description: Per-metric-type freshness for the `APPLE_HEALTH` source. Present on the GET read; omitted from the PATCH + echo. + type: array + items: + $ref: "#/components/schemas/MetricFreshnessEntry" required: - entries - lastSyncedAt additionalProperties: false description: "The resolved HealthKit integration config: the default metric set merged with the user's stored per-metric - overrides." + overrides, plus the sync-health verdict and per-metric freshness." HealthKitEntry: type: object properties: @@ -32503,6 +32512,57 @@ components: - enabled additionalProperties: false description: One resolved HealthKit metric mapping (defaults merged with the user's stored overrides). + SyncHealth: + type: object + properties: + verdict: + type: string + enum: + - fresh + - stale + - stalled + - failing + - reauth_required + - parked + - pending_first_sync + - disconnected + description: "Liveness verdict. For Apple Health: `fresh` when data arrived within the last 7 days, `stale` when it did + not, `pending_first_sync` when none ever arrived." + since: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + description: The instant that triggered the verdict; null when none did. + required: + - verdict + - since + additionalProperties: false + description: Server-resolved sync-health verdict. The single source of liveness truth — `lastSyncedAt` alone cannot + express 'this pipe stopped delivering'. + MetricFreshnessEntry: + type: object + properties: + type: + type: string + description: The measurement type (e.g. `RESPIRATORY_RATE`), or `WORKOUTS` for the workout leg. + lastSeenAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + description: Newest recorded reading for this type from this source. + stale: + type: boolean + description: This type has gone quiet while the source around it reads healthy — the dead-pipe signature (e.g. a single + revoked HealthKit permission). Only ever true when the verdict is `fresh`. + required: + - type + - lastSeenAt + - stale + additionalProperties: false + description: Per-metric-type last-seen timestamp with the server-computed staleness flag. Only types that have actually + delivered appear — absence is absence, never an invented row. HealthKitConfigPatchEnvelope: type: object properties: diff --git a/e2e/settings-integrations-mobile.spec.ts b/e2e/settings-integrations-mobile.spec.ts index 22cf88593..926a78a66 100644 --- a/e2e/settings-integrations-mobile.spec.ts +++ b/e2e/settings-integrations-mobile.spec.ts @@ -53,6 +53,9 @@ test.describe("/settings/integrations Pixel-5 layout", () => { ); const recent = new Date(Date.now() - 12 * 60 * 1000).toISOString(); + const monthAgo = new Date( + Date.now() - 28 * 24 * 60 * 60 * 1000, + ).toISOString(); await page.route("**/api/integrations/status", (route) => route.fulfill({ @@ -81,6 +84,10 @@ test.describe("/settings/integrations Pixel-5 layout", () => { tokenExpired: false, scope: "user.info,user.metrics,user.activity", hasActivityScope: true, + syncHealth: { verdict: "fresh", since: null }, + metricFreshness: [ + { type: "WEIGHT", lastSeenAt: recent, stale: false }, + ], }, { integration: "moodlog", @@ -91,9 +98,13 @@ test.describe("/settings/integrations Pixel-5 layout", () => { consecutiveFailures: 0, configured: true, enabled: true, - legacyLastSyncedAt: recent, + legacyLastSyncedAt: monthAgo, webhookSecret: "ml_xxxxxxxxxxxxxxxxxxxxxxxxxxxx", entryCount: 42, + // Configured, card-less, and no longer attempting: the case the + // fallback row exists for. + syncHealth: { verdict: "stalled", since: monthAgo }, + metricFreshness: [], }, // v1.28.x — Strava OAuth card reads off this consolidated // envelope; without a `strava` entry the new card's query key @@ -108,6 +119,23 @@ test.describe("/settings/integrations Pixel-5 layout", () => { configured: false, available: false, hasOwnCredentials: false, + syncHealth: { verdict: "disconnected", since: null }, + metricFreshness: [], + }, + // Nightscout reads the envelope too since the card dropped its + // own status round-trip; without an entry its pill never resolves. + { + integration: "nightscout", + state: "disconnected", + lastSuccessAt: null, + lastAttemptAt: null, + lastError: null, + connected: false, + configured: false, + hasToken: false, + allowPrivateHost: false, + syncHealth: { verdict: "disconnected", since: null }, + metricFreshness: [], }, ], }, @@ -157,10 +185,27 @@ test.describe("/settings/integrations Pixel-5 layout", () => { // Wait for the pills to mount (queries resolve after first paint). // One pill per card: Withings, WHOOP, Fitbit, Google Health, Polar, - // Oura, Strava, Nightscout. (The Garmin info note is not an OAuth card and - // carries no pill.) + // Oura, Strava, Nightscout — plus the fallback row for the configured, + // card-less moodLog entry. (The Garmin info note is not an OAuth card and + // carries no pill; the Apple Health card's pill keeps its own testid.) const pills = page.locator('[data-testid="integration-status-pill"]'); - await expect(pills).toHaveCount(8, { timeout: 10_000 }); + await expect(pills).toHaveCount(9, { timeout: 10_000 }); + + // The acceptance case: a configured integration with no card of its own, + // which has stopped even attempting, is visible on the page. + const fallback = page.locator( + '[data-testid="integration-fallback-row"][data-integration="moodlog"]', + ); + await expect(fallback).toHaveCount(1); + await expect( + fallback.locator('[data-testid="integration-status-pill"]'), + ).toHaveAttribute("data-state", "stalled"); + + // Card anatomy is uniform now that Nightscout, the OAuth cards and Apple + // Health all carry the divider. + await expect( + page.locator('[data-testid="integration-card-divider"]'), + ).toHaveCount(9); // Withings is connected in this fixture; WHOOP is a BYO-keys card that // stays dormant until an operator adds credentials, so assert the one @@ -210,7 +255,12 @@ test.describe("Apple Health guidance", () => { status: 200, contentType: "application/json", body: JSON.stringify({ - data: { entries: [], lastSyncedAt: null }, + data: { + entries: [], + lastSyncedAt: null, + syncHealth: { verdict: "pending_first_sync", since: null }, + metricFreshness: [], + }, error: null, }), }), @@ -224,7 +274,7 @@ test.describe("Apple Health guidance", () => { await expect(card).toContainText("not a web or OAuth connection"); await expect(page.getByTestId("apple-health-status")).toHaveAttribute( "data-state", - "setup", + "pending-setup", ); const importLink = page.getByTestId("apple-health-import-link"); diff --git a/messages/de.json b/messages/de.json index b7b6373a8..7bde66b09 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5279,7 +5279,7 @@ "integrationPill": { "connected": "Verbunden", "errorReconnect": "Fehler — neu verbinden", - "warningServerError": "Verbunden, aber Serverfehler", + "warningServerError": "Verbunden, Sync-Problem", "parkedReconnect": "Pausiert — manuell wieder verbinden", "notConnected": "Nicht verbunden", "justNow": "gerade eben", @@ -5289,8 +5289,26 @@ "ariaLabel": "Integrationsstatus", "resumeCta": "Wieder verbinden", "resumeSuccess": "Integration wieder aktiv", - "resumeError": "Wieder verbinden fehlgeschlagen" + "resumeError": "Wieder verbinden fehlgeschlagen", + "stale": "Verbunden, keine neuen Daten", + "stalled": "Sync gestoppt", + "pendingSetup": "Warte auf erste Daten" }, + "integrationFreshness": { + "title": "Empfangene Daten", + "summary": "{count} Datentypen · neuestes {relative}", + "quiet": "{count} still", + "workouts": "Workouts" + }, + "integrationFallback": { + "description": "Für dieses Konto eingerichtet, hat aber keine eigene Einstellungskarte. Diese Zeile sorgt dafür, dass eine Verbindung nicht unbemerkt ausfallen kann.", + "lastAttempt": "Letzter Versuch {relative}", + "noAttempt": "Kein Sync-Versuch aufgezeichnet" + }, + "withingsViewData": "Messwerte ansehen", + "whoopViewData": "Erholung ansehen", + "fitbitViewData": "Schlaf ansehen", + "googleHealthViewData": "Schritte ansehen", "apiTokens": "API-Tokens", "apiTokensDescription": "API-Tokens für die externe Medikamenten-Einnahme prüfen und widerrufen. Tokens werden pro Medikament über dessen API-Endpoint-Schalter ausgestellt.", "tokenMintMovedDescription": "Tokens werden pro Medikament ausgestellt. Öffne ein Medikament und aktiviere dessen API-Endpoint, um ein Token zu erhalten, das nur für dieses Medikament gilt.", @@ -5850,17 +5868,14 @@ "title": "Apple Health", "description": "Die Live-Synchronisierung erfolgt über die HealthLog-iOS-App – nicht über eine Web- oder OAuth-Verbindung.", "status": { - "checking": "Apple-Health-Daten werden geprüft…", - "dataReceived": "Apple-Health-Daten empfangen", - "setup": "In der iOS-App einrichten", "unavailable": "Apple-Health-Status nicht verfügbar" }, - "lastDataLabel": "Letzte Apple-Health-Daten:", "setupTitle": "Live-Synchronisierung aktivieren", "permissionStep": "Öffne in der HealthLog-iOS-App Einstellungen → Apple Health und erlaube die Gesundheitskategorien, die du teilen möchtest.", "backgroundStep": "Lass die Hintergrundaktualisierung aktiviert. Die App synchronisiert neue Daten automatisch im Vordergrund und wenn iOS Hintergrundzeit gewährt.", "importNote": "Du brauchst ältere Daten oder kannst die iOS-App nicht nutzen? Der Apple-Health-XML/ZIP-Upload ist ein einmaliger Ersatzweg und aktiviert keine Live-Synchronisierung.", - "importAction": "Einmaligen Import öffnen" + "importAction": "Einmaligen Import öffnen", + "staleNote": "Seit über einer Woche sind keine Apple-Health-Daten angekommen. Öffne die HealthLog-App auf deinem iPhone und prüfe die Apple-Health-Berechtigungen." }, "garminInfo": { "title": "Garmin", diff --git a/messages/en.json b/messages/en.json index eb739414e..e6b4d44d8 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5269,7 +5269,7 @@ "integrationPill": { "connected": "Connected", "errorReconnect": "Error — reconnect", - "warningServerError": "Connected, server error", + "warningServerError": "Connected, sync issue", "parkedReconnect": "Paused — reconnect manually", "notConnected": "Not connected", "justNow": "just now", @@ -5279,8 +5279,26 @@ "ariaLabel": "Integration status", "resumeCta": "Reconnect", "resumeSuccess": "Integration resumed", - "resumeError": "Reconnect failed" + "resumeError": "Reconnect failed", + "stale": "Connected, no recent data", + "stalled": "Sync stopped", + "pendingSetup": "Waiting for first data" }, + "integrationFreshness": { + "title": "Data received", + "summary": "{count} data types · newest {relative}", + "quiet": "{count} quiet", + "workouts": "Workouts" + }, + "integrationFallback": { + "description": "Configured on this account without a settings card of its own. This row exists so a connection cannot stop working unnoticed.", + "lastAttempt": "Last attempt {relative}", + "noAttempt": "No sync attempt recorded" + }, + "withingsViewData": "View your measurements", + "whoopViewData": "View your recovery", + "fitbitViewData": "View your sleep", + "googleHealthViewData": "View your steps", "apiTokens": "API Tokens", "apiTokensDescription": "Review and revoke API tokens for external medication intake. Tokens are issued per medication from the medication's API endpoint toggle.", "tokenMintMovedDescription": "Tokens are issued per medication. Open a medication and enable its API endpoint to get a token scoped to that medication alone.", @@ -5840,17 +5858,14 @@ "title": "Apple Health", "description": "Live sync comes from the HealthLog iOS app. It is not a web or OAuth connection.", "status": { - "checking": "Checking Apple Health data…", - "dataReceived": "Apple Health data received", - "setup": "Set up in iOS app", "unavailable": "Apple Health status unavailable" }, - "lastDataLabel": "Last Apple Health data:", "setupTitle": "Turn on live sync", "permissionStep": "In the HealthLog iOS app, open Settings → Apple Health and allow the health categories you want to share.", "backgroundStep": "Keep Background App Refresh enabled. The app syncs new data automatically in the foreground and when iOS grants background time.", "importNote": "Need older history or cannot use the iOS app? The Apple Health XML/ZIP upload is a one-shot fallback and does not enable live sync.", - "importAction": "Open one-shot import" + "importAction": "Open one-shot import", + "staleNote": "No Apple Health data has arrived for over a week. Open the HealthLog app on your iPhone and check the Apple Health permissions." }, "garminInfo": { "title": "Garmin", diff --git a/messages/es.json b/messages/es.json index 5799a20cf..75b5da539 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5281,7 +5281,7 @@ "integrationPill": { "connected": "Conectado", "errorReconnect": "Error — reconectar", - "warningServerError": "Conectado, error del servidor", + "warningServerError": "Conectado, problema de sincronización", "parkedReconnect": "Pausado — reconectar manualmente", "notConnected": "No conectado", "justNow": "ahora mismo", @@ -5291,8 +5291,26 @@ "ariaLabel": "Estado de la integración", "resumeCta": "Reconectar", "resumeSuccess": "Integración reanudada", - "resumeError": "Reconexión fallida" + "resumeError": "Reconexión fallida", + "stale": "Conectado, sin datos recientes", + "stalled": "Sincronización detenida", + "pendingSetup": "Esperando los primeros datos" }, + "integrationFreshness": { + "title": "Datos recibidos", + "summary": "{count} tipos de datos · más reciente {relative}", + "quiet": "{count} en silencio", + "workouts": "Entrenamientos" + }, + "integrationFallback": { + "description": "Configurado en esta cuenta pero sin tarjeta de ajustes propia. Esta fila existe para que una conexión no deje de funcionar sin que nadie lo note.", + "lastAttempt": "Último intento {relative}", + "noAttempt": "Ningún intento de sincronización registrado" + }, + "withingsViewData": "Ver tus mediciones", + "whoopViewData": "Ver tu recuperación", + "fitbitViewData": "Ver tu sueño", + "googleHealthViewData": "Ver tus pasos", "apiTokens": "Tokens de API", "apiTokensDescription": "Revisa y revoca tokens de API para el registro externo de tomas de medicación. Los tokens se emiten por medicamento desde su interruptor de endpoint de API.", "tokenMintMovedDescription": "Los tokens se emiten por medicamento. Abre un medicamento y activa su endpoint de API para obtener un token limitado únicamente a ese medicamento.", @@ -5852,17 +5870,14 @@ "title": "Apple Health", "description": "La sincronización en vivo se realiza desde la app HealthLog para iOS, no mediante una conexión web ni OAuth.", "status": { - "checking": "Comprobando datos de Apple Health…", - "dataReceived": "Datos de Apple Health recibidos", - "setup": "Configurar en la app de iOS", "unavailable": "Estado de Apple Health no disponible" }, - "lastDataLabel": "Últimos datos de Apple Health:", "setupTitle": "Activar la sincronización en vivo", "permissionStep": "En la app HealthLog para iOS, abre Ajustes → Apple Health y permite las categorías de salud que quieras compartir.", "backgroundStep": "Mantén activada la actualización en segundo plano. La app sincroniza datos nuevos automáticamente en primer plano y cuando iOS le concede tiempo en segundo plano.", "importNote": "¿Necesitas un historial anterior o no puedes usar la app de iOS? La carga XML/ZIP de Apple Health es una alternativa puntual y no activa la sincronización en vivo.", - "importAction": "Abrir importación puntual" + "importAction": "Abrir importación puntual", + "staleNote": "Hace más de una semana que no llegan datos de Apple Health. Abre la app HealthLog en tu iPhone y revisa los permisos de Apple Health." }, "garminInfo": { "title": "Garmin", diff --git a/messages/fr.json b/messages/fr.json index e2c6159af..a0a638b3d 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5283,7 +5283,7 @@ "integrationPill": { "connected": "Connecté", "errorReconnect": "Erreur — reconnecter", - "warningServerError": "Connecté, erreur serveur", + "warningServerError": "Connecté, problème de synchronisation", "parkedReconnect": "En pause — reconnecter manuellement", "notConnected": "Non connecté", "justNow": "à l’instant", @@ -5293,8 +5293,26 @@ "ariaLabel": "Statut de l’intégration", "resumeCta": "Reconnecter", "resumeSuccess": "Intégration reprise", - "resumeError": "Échec de la reconnexion" + "resumeError": "Échec de la reconnexion", + "stale": "Connecté, aucune donnée récente", + "stalled": "Synchronisation arrêtée", + "pendingSetup": "En attente des premières données" }, + "integrationFreshness": { + "title": "Données reçues", + "summary": "{count} types de données · plus récent {relative}", + "quiet": "{count} silencieux", + "workouts": "Séances" + }, + "integrationFallback": { + "description": "Configuré sur ce compte sans carte de réglages dédiée. Cette ligne existe pour qu'une connexion ne puisse pas cesser de fonctionner sans que personne ne le voie.", + "lastAttempt": "Dernière tentative {relative}", + "noAttempt": "Aucune tentative de synchronisation enregistrée" + }, + "withingsViewData": "Voir vos mesures", + "whoopViewData": "Voir votre récupération", + "fitbitViewData": "Voir votre sommeil", + "googleHealthViewData": "Voir vos pas", "apiTokens": "Jetons API", "apiTokensDescription": "Consultez et révoquez les jetons API pour l’enregistrement externe des prises de médicaments. Les jetons sont émis par médicament depuis son interrupteur de point de terminaison API.", "tokenMintMovedDescription": "Les jetons sont émis par médicament. Ouvrez un médicament et activez son point de terminaison API pour obtenir un jeton limité à ce seul médicament.", @@ -5854,17 +5872,14 @@ "title": "Apple Health", "description": "La synchronisation en direct passe par l’app HealthLog pour iOS, et non par une connexion web ou OAuth.", "status": { - "checking": "Vérification des données Apple Health…", - "dataReceived": "Données Apple Health reçues", - "setup": "Configurer dans l’app iOS", "unavailable": "Statut Apple Health indisponible" }, - "lastDataLabel": "Dernières données Apple Health :", "setupTitle": "Activer la synchronisation en direct", "permissionStep": "Dans l’app HealthLog pour iOS, ouvre Réglages → Apple Health et autorise les catégories de santé que tu souhaites partager.", "backgroundStep": "Laisse l’actualisation en arrière-plan activée. L’app synchronise automatiquement les nouvelles données au premier plan et lorsque iOS lui accorde du temps en arrière-plan.", "importNote": "Besoin d’un ancien historique ou impossible d’utiliser l’app iOS ? L’envoi XML/ZIP Apple Health est une solution ponctuelle et n’active pas la synchronisation en direct.", - "importAction": "Ouvrir l’import ponctuel" + "importAction": "Ouvrir l’import ponctuel", + "staleNote": "Aucune donnée Apple Health n'est arrivée depuis plus d'une semaine. Ouvrez l'app HealthLog sur votre iPhone et vérifiez les autorisations Apple Health." }, "garminInfo": { "title": "Garmin", diff --git a/messages/it.json b/messages/it.json index ada7a6ab9..114b1fc07 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5273,7 +5273,7 @@ "integrationPill": { "connected": "Connesso", "errorReconnect": "Errore — riconnetti", - "warningServerError": "Connesso, errore del server", + "warningServerError": "Connesso, problema di sincronizzazione", "parkedReconnect": "In pausa — riconnettere manualmente", "notConnected": "Non connesso", "justNow": "proprio ora", @@ -5283,8 +5283,26 @@ "ariaLabel": "Stato dell’integrazione", "resumeCta": "Riconnetti", "resumeSuccess": "Integrazione ripresa", - "resumeError": "Riconnessione fallita" + "resumeError": "Riconnessione fallita", + "stale": "Connesso, nessun dato recente", + "stalled": "Sincronizzazione ferma", + "pendingSetup": "In attesa dei primi dati" }, + "integrationFreshness": { + "title": "Dati ricevuti", + "summary": "{count} tipi di dati · più recente {relative}", + "quiet": "{count} in silenzio", + "workouts": "Allenamenti" + }, + "integrationFallback": { + "description": "Configurato su questo account ma senza una scheda di impostazioni propria. Questa riga esiste perché una connessione non possa smettere di funzionare senza che nessuno se ne accorga.", + "lastAttempt": "Ultimo tentativo {relative}", + "noAttempt": "Nessun tentativo di sincronizzazione registrato" + }, + "withingsViewData": "Vedi le tue misurazioni", + "whoopViewData": "Vedi il tuo recupero", + "fitbitViewData": "Vedi il tuo sonno", + "googleHealthViewData": "Vedi i tuoi passi", "apiTokens": "Token API", "apiTokensDescription": "Rivedi e revoca i token API per la registrazione esterna delle assunzioni di farmaci. I token vengono emessi per singolo farmaco dal relativo interruttore dell’endpoint API.", "tokenMintMovedDescription": "I token vengono emessi per singolo farmaco. Apri un farmaco e attiva il suo endpoint API per ottenere un token valido solo per quel farmaco.", @@ -5844,17 +5862,14 @@ "title": "Apple Health", "description": "La sincronizzazione in tempo reale avviene tramite l’app HealthLog per iOS, non tramite una connessione web o OAuth.", "status": { - "checking": "Controllo dei dati Apple Health…", - "dataReceived": "Dati Apple Health ricevuti", - "setup": "Configura nell’app iOS", "unavailable": "Stato di Apple Health non disponibile" }, - "lastDataLabel": "Ultimi dati Apple Health:", "setupTitle": "Attiva la sincronizzazione in tempo reale", "permissionStep": "Nell’app HealthLog per iOS, apri Impostazioni → Apple Health e autorizza le categorie di salute che vuoi condividere.", "backgroundStep": "Mantieni attivo l’aggiornamento in background. L’app sincronizza automaticamente i nuovi dati in primo piano e quando iOS concede tempo in background.", "importNote": "Ti serve lo storico precedente o non puoi usare l’app iOS? Il caricamento XML/ZIP di Apple Health è un’alternativa una tantum e non attiva la sincronizzazione in tempo reale.", - "importAction": "Apri importazione una tantum" + "importAction": "Apri importazione una tantum", + "staleNote": "Da più di una settimana non arrivano dati da Apple Health. Apri l'app HealthLog sull'iPhone e controlla i permessi di Apple Health." }, "garminInfo": { "title": "Garmin", diff --git a/messages/pl.json b/messages/pl.json index 309fb99b2..85bcb9a2f 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5267,7 +5267,7 @@ "integrationPill": { "connected": "Połączono", "errorReconnect": "Błąd — połącz ponownie", - "warningServerError": "Połączono, błąd serwera", + "warningServerError": "Połączono, problem z synchronizacją", "parkedReconnect": "Wstrzymane — połącz ręcznie", "notConnected": "Nie połączono", "justNow": "przed chwilą", @@ -5277,8 +5277,26 @@ "ariaLabel": "Status integracji", "resumeCta": "Połącz ponownie", "resumeSuccess": "Integracja wznowiona", - "resumeError": "Ponowne połączenie nieudane" + "resumeError": "Ponowne połączenie nieudane", + "stale": "Połączono, brak nowych danych", + "stalled": "Synchronizacja zatrzymana", + "pendingSetup": "Czekam na pierwsze dane" }, + "integrationFreshness": { + "title": "Odebrane dane", + "summary": "{count} typów danych · najnowsze {relative}", + "quiet": "{count} bez danych", + "workouts": "Treningi" + }, + "integrationFallback": { + "description": "Skonfigurowane na tym koncie, ale bez własnej karty ustawień. Ten wiersz istnieje po to, by przerwane połączenie nie pozostało niezauważone.", + "lastAttempt": "Ostatnia próba {relative}", + "noAttempt": "Brak zapisanej próby synchronizacji" + }, + "withingsViewData": "Zobacz swoje pomiary", + "whoopViewData": "Zobacz swoją regenerację", + "fitbitViewData": "Zobacz swój sen", + "googleHealthViewData": "Zobacz swoje kroki", "apiTokens": "Tokeny API", "apiTokensDescription": "Przeglądaj i unieważniaj tokeny API do zewnętrznego rejestrowania przyjęć leków. Tokeny są wydawane dla pojedynczego leku z jego przełącznika punktu końcowego API.", "tokenMintMovedDescription": "Tokeny są wydawane dla pojedynczego leku. Otwórz lek i włącz jego punkt końcowy API, aby otrzymać token obejmujący wyłącznie ten lek.", @@ -5838,17 +5856,14 @@ "title": "Apple Health", "description": "Synchronizacja na żywo odbywa się przez aplikację HealthLog na iOS, a nie przez połączenie internetowe ani OAuth.", "status": { - "checking": "Sprawdzanie danych Apple Health…", - "dataReceived": "Odebrano dane Apple Health", - "setup": "Skonfiguruj w aplikacji iOS", "unavailable": "Stan Apple Health jest niedostępny" }, - "lastDataLabel": "Najnowsze dane Apple Health:", "setupTitle": "Włącz synchronizację na żywo", "permissionStep": "W aplikacji HealthLog na iOS otwórz Ustawienia → Apple Health i zezwól na udostępnianie wybranych kategorii zdrowotnych.", "backgroundStep": "Pozostaw włączone odświeżanie aplikacji w tle. Aplikacja synchronizuje nowe dane automatycznie na pierwszym planie oraz gdy iOS przydzieli jej czas w tle.", "importNote": "Potrzebujesz starszej historii lub nie możesz użyć aplikacji iOS? Przesłanie pliku XML/ZIP z Apple Health jest jednorazową alternatywą i nie włącza synchronizacji na żywo.", - "importAction": "Otwórz jednorazowy import" + "importAction": "Otwórz jednorazowy import", + "staleNote": "Od ponad tygodnia nie napłynęły żadne dane z Apple Health. Otwórz aplikację HealthLog na iPhonie i sprawdź uprawnienia Apple Health." }, "garminInfo": { "title": "Garmin", diff --git a/package.json b/package.json index ea37d0e0c..315591f8c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.32.30", + "version": "1.32.31", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index 88628ec5e..0c44f3498 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.32.30"; + /* @sw-version-fallback */ "v1.32.31"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/src/app/api/integrations/healthkit/__tests__/route.test.ts b/src/app/api/integrations/healthkit/__tests__/route.test.ts index 53d411dab..977316ea1 100644 --- a/src/app/api/integrations/healthkit/__tests__/route.test.ts +++ b/src/app/api/integrations/healthkit/__tests__/route.test.ts @@ -6,6 +6,9 @@ vi.mock("@/lib/db", () => ({ user: { findUnique: vi.fn(), update: vi.fn() }, // v1.4.43 W6 — audit-ledger breadcrumb for validation-failed paths. auditLog: { create: vi.fn() }, + // Per-metric freshness for the APPLE_HEALTH source. + measurement: { groupBy: vi.fn() }, + workout: { groupBy: vi.fn() }, }, toJson: (v: T) => v, })); @@ -34,6 +37,7 @@ vi.mock("next/headers", () => ({ import { GET, PATCH } from "../route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; +import { APPLE_HEALTH_DATA_STALE_AFTER_MS } from "@/lib/integrations/sync-verdict"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -48,6 +52,8 @@ beforeEach(() => { } as never); vi.mocked(prisma.user.update).mockResolvedValue({} as never); vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); + vi.mocked(prisma.measurement.groupBy).mockResolvedValue([] as never); + vi.mocked(prisma.workout.groupBy).mockResolvedValue([] as never); }); const callGet = GET as unknown as (req: NextRequest) => Promise; @@ -199,3 +205,104 @@ describe("PATCH /api/integrations/healthkit — 422 multi-issue (v1.4.43 W6)", ( expect(res.status).toBe(422); }); }); + +/** + * Apple Health is push-based and has no ledger row, so `lastSyncedAt` was the + * only signal the card had — and its mere presence painted green, whether the + * data arrived this morning or three weeks ago. These pin the seven-day + * threshold and the per-metric read that makes a silently revoked HealthKit + * permission visible inside an otherwise healthy connection. + */ +describe("GET /api/integrations/healthkit — sync health", () => { + const DAY = 24 * 60 * 60 * 1000; + const ago = (ms: number) => new Date(Date.now() - ms); + + type Body = { + lastSyncedAt: string | null; + syncHealth: { verdict: string; since: string | null }; + metricFreshness: Array<{ + type: string; + lastSeenAt: string; + stale: boolean; + }>; + }; + + async function read(): Promise { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await callGet(makeGetReq()); + expect(res.status).toBe(200); + return ((await res.json()) as { data: Body }).data; + } + + it("calls a recent delivery fresh", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + healthKitConfigJson: null, + healthKitLastSyncedAt: ago(2 * DAY), + } as never); + expect((await read()).syncHealth.verdict).toBe("fresh"); + }); + + it("calls a pipe silent for over a week stale", async () => { + const lastSyncedAt = ago(APPLE_HEALTH_DATA_STALE_AFTER_MS + DAY); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + healthKitConfigJson: null, + healthKitLastSyncedAt: lastSyncedAt, + } as never); + const body = await read(); + expect(body.syncHealth.verdict).toBe("stale"); + expect(body.syncHealth.since).toBe(lastSyncedAt.toISOString()); + }); + + it("calls an account that never delivered pending, not disconnected", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + healthKitConfigJson: null, + healthKitLastSyncedAt: null, + } as never); + expect((await read()).syncHealth.verdict).toBe("pending_first_sync"); + }); + + it("flags one quiet metric inside an otherwise healthy connection", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + healthKitConfigJson: null, + healthKitLastSyncedAt: ago(60 * 60 * 1000), + } as never); + vi.mocked(prisma.measurement.groupBy).mockResolvedValue([ + { + source: "APPLE_HEALTH", + type: "RESPIRATORY_RATE", + _max: { measuredAt: ago(30 * DAY) }, + }, + { + source: "APPLE_HEALTH", + type: "PULSE", + _max: { measuredAt: ago(60 * 60 * 1000) }, + }, + ] as never); + + const body = await read(); + // Only the Apple Health source is read. + const groupByArgs = vi.mocked(prisma.measurement.groupBy).mock + .calls[0]?.[0] as unknown as { + where: { source: { in: string[] } }; + }; + expect(groupByArgs.where.source.in).toEqual(["APPLE_HEALTH"]); + const byType = Object.fromEntries( + body.metricFreshness.map((entry) => [entry.type, entry.stale]), + ); + expect(byType.RESPIRATORY_RATE).toBe(true); + expect(byType.PULSE).toBe(false); + }); + + it("degrades to no per-metric data rather than failing the config read", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + healthKitConfigJson: null, + healthKitLastSyncedAt: ago(DAY), + } as never); + vi.mocked(prisma.measurement.groupBy).mockRejectedValue( + new Error("groupBy hiccup"), + ); + const body = await read(); + expect(body.metricFreshness).toEqual([]); + expect(body.syncHealth.verdict).toBe("fresh"); + }); +}); diff --git a/src/app/api/integrations/healthkit/route.ts b/src/app/api/integrations/healthkit/route.ts index e0ba191f9..b2b354faa 100644 --- a/src/app/api/integrations/healthkit/route.ts +++ b/src/app/api/integrations/healthkit/route.ts @@ -20,6 +20,12 @@ import { import { annotate } from "@/lib/logging/context"; import { prisma, toJson } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; +import { getSourceFreshness } from "@/lib/integrations/metric-freshness"; +import { + classifyMetricFreshness, + PUSH_CADENCE, + resolveSyncVerdict, +} from "@/lib/integrations/sync-verdict"; export const directionEnum = z.enum([ "bidirectional", @@ -129,10 +135,30 @@ export const GET = apiHandler(async () => { const stored = parseStored(row?.healthKitConfigJson ?? null); const entries = mergeWithDefaults(stored); + const lastSyncedAt = row?.healthKitLastSyncedAt?.toISOString() ?? null; + + // Apple Health is push-based: the iOS app delivers and nothing here polls, so + // liveness is a data question, not an attempt question. Without a threshold a + // three-week-dead pipe rendered the same green as one that delivered an hour + // ago. Fail-soft on the per-metric read — an honesty signal is never worth + // 500-ing the config the iOS client needs. + // No `connected` hint: Apple Health has nothing to connect or disconnect, so + // "never delivered" resolves to `pending_first_sync`, not `disconnected`. + const syncHealth = resolveSyncVerdict({ + configured: true, + lastDataAt: lastSyncedAt, + legacyLastSyncedAt: lastSyncedAt, + cadence: PUSH_CADENCE, + }); + const samples = await getSourceFreshness(user.id, "APPLE_HEALTH").catch( + () => [], + ); return apiSuccess({ entries, - lastSyncedAt: row?.healthKitLastSyncedAt?.toISOString() ?? null, + lastSyncedAt, + syncHealth, + metricFreshness: classifyMetricFreshness(samples, syncHealth.verdict), }); }); diff --git a/src/app/api/integrations/status/__tests__/route.test.ts b/src/app/api/integrations/status/__tests__/route.test.ts index fbfec1368..468a2adb4 100644 --- a/src/app/api/integrations/status/__tests__/route.test.ts +++ b/src/app/api/integrations/status/__tests__/route.test.ts @@ -13,6 +13,8 @@ vi.mock("@/lib/db", () => ({ fitbitConnection: { findUnique: vi.fn(async () => null) }, googleHealthConnection: { findUnique: vi.fn(async () => null) }, moodEntry: { count: vi.fn(async () => 0) }, + measurement: { groupBy: vi.fn(async () => []) }, + workout: { groupBy: vi.fn(async () => []) }, }, })); @@ -31,10 +33,11 @@ const ledger: Record = { vi.mock("@/lib/integrations/status", () => ({ getIntegrationStatus: vi.fn(async (_u: string, integration: string) => ({ integration, - ...ledger, + ...(perIntegrationLedger[integration] ?? ledger), })), getPersistentFailureThreshold: () => 5, })); +const perIntegrationLedger: Record> = {}; vi.mock("@/lib/withings/client", () => ({ hasActivityScope: () => false })); vi.mock("@/lib/moodlog-secret", () => ({ readMoodLogSecret: () => null })); @@ -50,6 +53,7 @@ vi.mock("@/lib/oura/credentials", () => ({ import { GET } from "../route"; import { prisma } from "@/lib/db"; +import { ATTEMPT_STALE_AFTER_MS } from "@/lib/integrations/sync-verdict"; const userFind = prisma.user.findUnique as ReturnType; const whoopFind = prisma.whoopConnection.findUnique as ReturnType; @@ -60,6 +64,10 @@ type Entry = { configured?: boolean; available?: boolean; hasOwnCredentials?: boolean; + hasToken?: boolean; + allowPrivateHost?: boolean; + syncHealth?: { verdict: string; since: string | null }; + metricFreshness?: Array<{ type: string; lastSeenAt: string; stale: boolean }>; }; async function fetchEntries(): Promise { @@ -71,6 +79,9 @@ async function fetchEntries(): Promise { describe("/api/integrations/status — Polar/Oura fold (04-M2)", () => { beforeEach(() => { vi.clearAllMocks(); + for (const key of Object.keys(perIntegrationLedger)) { + delete perIntegrationLedger[key]; + } polarAvailable.mockResolvedValue(true); ouraAvailable.mockResolvedValue(false); }); @@ -139,3 +150,95 @@ describe("/api/integrations/status — WHOOP identity ownership", () => { expect(whoop?.configured).toBe(true); }); }); + +/** + * The envelope is the unit of coverage for liveness. Every entry carries the + * verdict, whether or not a card renders it — a card-level verdict misses a + * card-less provider by construction, which is exactly how a month-dead sync + * stayed invisible. + */ +describe("/api/integrations/status — sync-health verdict on every entry", () => { + beforeEach(() => { + vi.clearAllMocks(); + for (const key of Object.keys(perIntegrationLedger)) { + delete perIntegrationLedger[key]; + } + polarAvailable.mockResolvedValue(true); + ouraAvailable.mockResolvedValue(true); + }); + + it("carries a syncHealth verdict on every entry", async () => { + userFind.mockResolvedValue({}); + const entries = await fetchEntries(); + expect(entries.length).toBeGreaterThan(0); + for (const entry of entries) { + expect(entry.syncHealth, entry.integration).toBeDefined(); + expect(typeof entry.syncHealth!.verdict).toBe("string"); + expect(Array.isArray(entry.metricFreshness)).toBe(true); + } + }); + + it("reports a configured, month-silent card-less provider as stalled", async () => { + // The live incident, on the wire: moodLog has no Settings card, so this + // entry is the only thing that can tell anyone the sync has stopped. + const lastAttemptAt = new Date( + Date.now() - 28 * 24 * 60 * 60 * 1000, + ).toISOString(); + perIntegrationLedger.moodlog = { + state: "error_transient", + lastSuccessAt: new Date( + Date.now() - 55 * 24 * 60 * 60 * 1000, + ).toISOString(), + lastAttemptAt, + lastError: null, + }; + userFind.mockResolvedValue({ + moodLogUrlEncrypted: "enc", + moodLogEnabled: true, + }); + + const moodlog = (await fetchEntries()).find( + (entry) => entry.integration === "moodlog", + )!; + expect(moodlog.configured).toBe(true); + expect(moodlog.syncHealth).toEqual({ + verdict: "stalled", + since: lastAttemptAt, + }); + }); + + it("keeps an integration that attempted within the window off the stalled arm", async () => { + perIntegrationLedger.moodlog = { + state: "error_transient", + lastSuccessAt: null, + lastAttemptAt: new Date( + Date.now() - (ATTEMPT_STALE_AFTER_MS - 60_000), + ).toISOString(), + lastError: null, + }; + userFind.mockResolvedValue({ moodLogUrlEncrypted: "enc" }); + + const moodlog = (await fetchEntries()).find( + (entry) => entry.integration === "moodlog", + )!; + expect(moodlog.syncHealth!.verdict).toBe("failing"); + }); + + it("includes a nightscout entry with the fields the card's form reads", async () => { + userFind.mockResolvedValue({ + nightscoutUrlEncrypted: "enc", + nightscoutTokenEncrypted: "enc", + nightscoutAllowPrivateHost: true, + }); + + const nightscout = (await fetchEntries()).find( + (entry) => entry.integration === "nightscout", + ); + expect(nightscout).toBeDefined(); + expect(nightscout!.connected).toBe(true); + expect(nightscout!.configured).toBe(true); + expect(nightscout!.hasToken).toBe(true); + expect(nightscout!.allowPrivateHost).toBe(true); + expect(nightscout!.syncHealth).toBeDefined(); + }); +}); diff --git a/src/app/api/integrations/status/route.ts b/src/app/api/integrations/status/route.ts index cbf53029b..cd17301b5 100644 --- a/src/app/api/integrations/status/route.ts +++ b/src/app/api/integrations/status/route.ts @@ -27,7 +27,14 @@ import { import { getSourceMetricFreshness, type MetricFreshnessEntry, + type MetricFreshnessSample, } from "@/lib/integrations/metric-freshness"; +import { + classifyMetricFreshness, + INTEGRATION_CADENCE, + resolveSyncVerdict, + type SyncHealth, +} from "@/lib/integrations/sync-verdict"; import { getOuraClientCredentials } from "@/lib/oura/credentials"; import { getPolarClientCredentials } from "@/lib/polar/credentials"; import { getStravaClientCredentials } from "@/lib/strava/credentials"; @@ -49,6 +56,7 @@ export const GET = apiHandler(async () => { polarStatus, ouraStatus, stravaStatus, + nightscoutStatus, dbUser, withingsConn, whoopConn, @@ -70,6 +78,7 @@ export const GET = apiHandler(async () => { getIntegrationStatus(user.id, "polar"), getIntegrationStatus(user.id, "oura"), getIntegrationStatus(user.id, "strava"), + getIntegrationStatus(user.id, "nightscout"), prisma.user.findUnique({ where: { id: user.id }, select: { @@ -95,6 +104,11 @@ export const GET = apiHandler(async () => { moodLogEnabled: true, moodLogLastSyncedAt: true, moodLogWebhookSecret: true, + // Nightscout folds onto the envelope; the card reads these three off + // it instead of its own /api/nightscout/status round-trip. + nightscoutUrlEncrypted: true, + nightscoutTokenEncrypted: true, + nightscoutAllowPrivateHost: true, }, }), prisma.withingsConnection.findUnique({ @@ -149,26 +163,93 @@ export const GET = apiHandler(async () => { // ("connected · 5 min ago"). Fail-soft: this is an honesty signal, never worth // 500-ing the whole Settings card, so a groupBy hiccup degrades to no data. const metricFreshness = await getSourceMetricFreshness(user.id).catch( - () => ({}) as Partial>, + () => ({}) as Partial>, ); const now = Date.now(); const whoopConnected = !!whoopConn?.whoopUserId; + /** + * The sync-health verdict + the per-metric staleness flags, resolved from + * data already in hand — no additional query. Every entry carries it, + * whether or not a card exists for the provider: a card-level verdict misses + * a card-less provider by construction, which is how a month-dead sync went + * unnoticed. + */ + function resolve( + integration: IntegrationKey, + snapshot: { + state: string; + lastSuccessAt: string | null; + lastAttemptAt: string | null; + }, + opts: { + connected?: boolean; + configured?: boolean; + legacyLastSyncedAt?: string | null; + }, + ): { syncHealth: SyncHealth; metricFreshness: MetricFreshnessEntry[] } { + const syncHealth = resolveSyncVerdict({ + connected: opts.connected, + configured: opts.configured, + state: snapshot.state, + lastSuccessAt: snapshot.lastSuccessAt, + lastAttemptAt: snapshot.lastAttemptAt, + legacyLastSyncedAt: opts.legacyLastSyncedAt ?? null, + cadence: INTEGRATION_CADENCE[integration], + now, + }); + return { + syncHealth, + metricFreshness: classifyMetricFreshness( + metricFreshness[integration] ?? [], + syncHealth.verdict, + now, + ), + }; + } + + const withingsConfigured = + !!dbUser?.withingsClientIdEncrypted && + !!dbUser?.withingsClientSecretEncrypted; + const withingsLegacyLastSyncedAt = + withingsConn?.lastSyncedAt?.toISOString() ?? null; + const moodLogConfigured = !!dbUser?.moodLogUrlEncrypted; + const moodLogLegacyLastSyncedAt = + dbUser?.moodLogLastSyncedAt?.toISOString() ?? null; + const whoopConfigured = + !!dbUser?.whoopClientIdEncrypted && !!dbUser?.whoopClientSecretEncrypted; + const whoopLegacyLastSyncedAt = whoopConnected + ? (whoopConn?.lastSyncedAt?.toISOString() ?? null) + : null; + const fitbitConnected = !!fitbitConn; + const fitbitConfigured = + !!dbUser?.fitbitClientIdEncrypted && !!dbUser?.fitbitClientSecretEncrypted; + const fitbitLegacyLastSyncedAt = + fitbitConn?.lastSyncedAt?.toISOString() ?? null; + const googleHealthConnected = !!googleHealthConn; + const googleHealthConfigured = + !!dbUser?.googleHealthClientIdEncrypted && + !!dbUser?.googleHealthClientSecretEncrypted; + const googleHealthLegacyLastSyncedAt = + googleHealthConn?.lastSyncedAt?.toISOString() ?? null; + const polarConnected = !!dbUser?.polarAccessTokenEncrypted; + const ouraConnected = !!dbUser?.ouraAccessTokenEncrypted; + const stravaConnected = !!dbUser?.stravaAccessTokenEncrypted; + const nightscoutConfigured = !!dbUser?.nightscoutUrlEncrypted; + return apiSuccess({ threshold: getPersistentFailureThreshold(), integrations: [ { ...withingsStatus, - configured: - !!dbUser?.withingsClientIdEncrypted && - !!dbUser?.withingsClientSecretEncrypted, + configured: withingsConfigured, connected: !!withingsConn, connectedAt: withingsConn?.createdAt?.toISOString() ?? null, // Surface the connection row's success time too so the UI // can fall back to it when no IntegrationStatus row exists // yet (legacy connections established before this feature). - legacyLastSyncedAt: withingsConn?.lastSyncedAt?.toISOString() ?? null, + legacyLastSyncedAt: withingsLegacyLastSyncedAt, tokenExpiresAt: withingsConn?.tokenExpiresAt?.toISOString() ?? null, tokenExpired: withingsConn ? withingsConn.tokenExpiresAt.getTime() <= now @@ -178,33 +259,39 @@ export const GET = apiHandler(async () => { // Null `scope` = legacy connection that predates activity-scope reads. scope: withingsConn?.scope ?? null, hasActivityScope: hasActivityScope(withingsConn?.scope ?? null), - metricFreshness: metricFreshness.withings ?? [], + ...resolve("withings", withingsStatus, { + connected: !!withingsConn, + configured: withingsConfigured, + legacyLastSyncedAt: withingsLegacyLastSyncedAt, + }), } satisfies IntegrationViewModel & WithingsExtras, { ...moodLogStatus, // moodLog "configured" tracks the URL alone (the API key is // optional for the webhook-only path), matching the legacy // /api/integrations/moodlog/status contract the card relied on. - configured: !!dbUser?.moodLogUrlEncrypted, + configured: moodLogConfigured, enabled: dbUser?.moodLogEnabled ?? false, - legacyLastSyncedAt: dbUser?.moodLogLastSyncedAt?.toISOString() ?? null, + legacyLastSyncedAt: moodLogLegacyLastSyncedAt, // V3 audit STILL-V2-C-2: stored secret is AES-GCM encrypted at rest; // decrypt for the settings page (legacy plaintext is also handled). webhookSecret: readMoodLogSecret(dbUser?.moodLogWebhookSecret ?? null), entryCount: moodLogEntryCount, + // moodLog has no connection row — `configured` alone decides whether it + // is meant to be running, so the verdict gets no `connected` hint. + ...resolve("moodlog", moodLogStatus, { + configured: moodLogConfigured, + legacyLastSyncedAt: moodLogLegacyLastSyncedAt, + }), } satisfies IntegrationViewModel & MoodLogExtras, { ...whoopStatus, - configured: - !!dbUser?.whoopClientIdEncrypted && - !!dbUser?.whoopClientSecretEncrypted, + configured: whoopConfigured, connected: whoopConnected, connectedAt: whoopConnected ? (whoopConn?.createdAt.toISOString() ?? null) : null, - legacyLastSyncedAt: whoopConnected - ? (whoopConn?.lastSyncedAt?.toISOString() ?? null) - : null, + legacyLastSyncedAt: whoopLegacyLastSyncedAt, tokenExpiresAt: whoopConnected ? (whoopConn?.tokenExpiresAt.toISOString() ?? null) : null, @@ -214,32 +301,35 @@ export const GET = apiHandler(async () => { backfillCompleted: whoopConnected ? !!whoopConn?.backfillCompletedAt : null, - metricFreshness: metricFreshness.whoop ?? [], + ...resolve("whoop", whoopStatus, { + connected: whoopConnected, + configured: whoopConfigured, + legacyLastSyncedAt: whoopLegacyLastSyncedAt, + }), } satisfies IntegrationViewModel & WhoopExtras, { ...fitbitStatus, - configured: - !!dbUser?.fitbitClientIdEncrypted && - !!dbUser?.fitbitClientSecretEncrypted, - connected: !!fitbitConn, + configured: fitbitConfigured, + connected: fitbitConnected, connectedAt: fitbitConn?.createdAt?.toISOString() ?? null, - legacyLastSyncedAt: fitbitConn?.lastSyncedAt?.toISOString() ?? null, + legacyLastSyncedAt: fitbitLegacyLastSyncedAt, tokenExpiresAt: fitbitConn?.tokenExpiresAt?.toISOString() ?? null, tokenExpired: fitbitConn ? fitbitConn.tokenExpiresAt.getTime() <= now : null, backfillCompleted: fitbitConn ? !!fitbitConn.backfillCompletedAt : null, - metricFreshness: metricFreshness.fitbit ?? [], + ...resolve("fitbit", fitbitStatus, { + connected: fitbitConnected, + configured: fitbitConfigured, + legacyLastSyncedAt: fitbitLegacyLastSyncedAt, + }), } satisfies IntegrationViewModel & FitbitExtras, { ...googleHealthStatus, - configured: - !!dbUser?.googleHealthClientIdEncrypted && - !!dbUser?.googleHealthClientSecretEncrypted, - connected: !!googleHealthConn, + configured: googleHealthConfigured, + connected: googleHealthConnected, connectedAt: googleHealthConn?.createdAt?.toISOString() ?? null, - legacyLastSyncedAt: - googleHealthConn?.lastSyncedAt?.toISOString() ?? null, + legacyLastSyncedAt: googleHealthLegacyLastSyncedAt, tokenExpiresAt: googleHealthConn?.tokenExpiresAt?.toISOString() ?? null, tokenExpired: googleHealthConn ? googleHealthConn.tokenExpiresAt.getTime() <= now @@ -251,7 +341,11 @@ export const GET = apiHandler(async () => { // user-revoked grant) surfaced from the connection row; the card reads it // to raise a distinct "Reconnect" CTA separate from parked/disconnected. needsReauth: googleHealthConn ? googleHealthConn.needsReauth : false, - metricFreshness: metricFreshness["google-health"] ?? [], + ...resolve("google-health", googleHealthStatus, { + connected: googleHealthConnected, + configured: googleHealthConfigured, + legacyLastSyncedAt: googleHealthLegacyLastSyncedAt, + }), } satisfies IntegrationViewModel & GoogleHealthExtras, { ...polarStatus, @@ -259,36 +353,60 @@ export const GET = apiHandler(async () => { // OAuth card has no separate "credentials saved but disconnected" view // beyond `hasOwnCredentials`). The card greys out the connect button // when no usable credentials resolve (`available`). - connected: !!dbUser?.polarAccessTokenEncrypted, - configured: !!dbUser?.polarAccessTokenEncrypted, + connected: polarConnected, + configured: polarConnected, available: polarAvailable, hasOwnCredentials: !!dbUser?.polarClientIdEncrypted && !!dbUser?.polarClientSecretEncrypted, - metricFreshness: metricFreshness.polar ?? [], + ...resolve("polar", polarStatus, { + connected: polarConnected, + configured: polarConnected, + }), } satisfies IntegrationViewModel & OAuthProviderExtras, { ...ouraStatus, - connected: !!dbUser?.ouraAccessTokenEncrypted, - configured: !!dbUser?.ouraAccessTokenEncrypted, + connected: ouraConnected, + configured: ouraConnected, available: ouraAvailable, hasOwnCredentials: !!dbUser?.ouraClientIdEncrypted && !!dbUser?.ouraClientSecretEncrypted, - metricFreshness: metricFreshness.oura ?? [], + ...resolve("oura", ouraStatus, { + connected: ouraConnected, + configured: ouraConnected, + }), } satisfies IntegrationViewModel & OAuthProviderExtras, { ...stravaStatus, - connected: !!dbUser?.stravaAccessTokenEncrypted, - configured: !!dbUser?.stravaAccessTokenEncrypted, + connected: stravaConnected, + configured: stravaConnected, available: stravaAvailable, hasOwnCredentials: !!dbUser?.stravaClientIdEncrypted && !!dbUser?.stravaClientSecretEncrypted, - // Strava writes Workout rows, not Measurements, so it has no - // per-metric measurement freshness (like moodLog). - metricFreshness: metricFreshness.strava ?? [], + // Strava writes Workout rows, not Measurements — its freshness list + // carries the `WORKOUTS` pseudo-entry alone. + ...resolve("strava", stravaStatus, { + connected: stravaConnected, + configured: stravaConnected, + }), } satisfies IntegrationViewModel & OAuthProviderExtras, + { + ...nightscoutStatus, + // Nightscout is a URL the self-hoster pastes once; `configured` (a + // stored URL) is the connect marker, and a fully-public instance has + // no token. The card reads these off the envelope now instead of its + // own status round-trip, so the whole page reads from one source. + connected: nightscoutConfigured, + configured: nightscoutConfigured, + hasToken: !!dbUser?.nightscoutTokenEncrypted, + allowPrivateHost: dbUser?.nightscoutAllowPrivateHost ?? false, + ...resolve("nightscout", nightscoutStatus, { + connected: nightscoutConfigured, + configured: nightscoutConfigured, + }), + } satisfies IntegrationViewModel & NightscoutExtras, ], }); }); @@ -300,12 +418,18 @@ interface IntegrationViewModel { lastAttemptAt: string | null; lastError: string | null; /** - * F-SYNC-1 — per-metric-type last-value timestamps for the integration's - * synced measurements. Present on the measurement-backed integrations; - * omitted for moodLog (which writes MoodEntry rows, not Measurements). Lets - * the card flag a silently-dead metric the green integration state hides. + * The server-resolved liveness verdict. Present on EVERY entry, whether or + * not a card renders it — the unit of coverage is the envelope entry, not + * the card. */ - metricFreshness?: MetricFreshnessEntry[]; + syncHealth: SyncHealth; + /** + * Per-metric-type last-value timestamps for the integration's synced data, + * each with the server-computed `stale` flag. Empty for moodLog (which + * writes MoodEntry rows, not Measurements). Lets the card flag a silently + * dead metric the green integration state hides. + */ + metricFreshness: MetricFreshnessEntry[]; } interface WithingsExtras { @@ -371,3 +495,13 @@ interface OAuthProviderExtras { available: boolean; hasOwnCredentials: boolean; } + +// Nightscout folds onto the envelope so the card stops firing its own status +// round-trip and so the provider inherits the shared verdict. `hasToken` and +// `allowPrivateHost` are the two fields the card's form placeholders read. +interface NightscoutExtras { + connected: boolean; + configured: boolean; + hasToken: boolean; + allowPrivateHost: boolean; +} diff --git a/src/components/settings/__tests__/integration-status-pill.test.tsx b/src/components/settings/__tests__/integration-status-pill.test.tsx index 63349ec19..390bee332 100644 --- a/src/components/settings/__tests__/integration-status-pill.test.tsx +++ b/src/components/settings/__tests__/integration-status-pill.test.tsx @@ -117,4 +117,82 @@ describe("IntegrationStatusPill", () => { expect(html).toContain("Pausiert"); expect(html).toContain("manuell wieder verbinden"); }); + + // The three states the server verdict added. `stale` and `stalled` both + // carry an inline timestamp, because "since when" is the whole point of + // them; `pending-setup` has nothing to timestamp yet. + it("renders the stale state in the warning tone with its timestamp", () => { + const html = render( + , + ); + expect(html).toContain('data-state="stale"'); + expect(html).toContain("no recent data"); + expect(html).toMatch(/9\s+d\s+ago/); + expect(html).toContain("bg-warning/15"); + }); + + it("renders the stalled state as a stopped sync, not an error", () => { + const html = render( + , + ); + expect(html).toContain('data-state="stalled"'); + expect(html).toContain("Sync stopped"); + expect(html).toMatch(/28\s+d\s+ago/); + // Warning tone, never destructive: the user cannot fix a cron that + // stopped by clicking reconnect. + expect(html).toContain("bg-warning/15"); + expect(html).not.toContain('data-variant="destructive"'); + }); + + it("renders the pending-setup state with no timestamp", () => { + const html = render( + , + ); + expect(html).toContain('data-state="pending-setup"'); + expect(html).toContain("Waiting for first data"); + expect(html).not.toContain("bg-warning/15"); + }); + + /** + * Red is reserved for "your action fixes this". A transient upstream failure + * is not that: telling the user to reconnect against a 503 sends them + * clicking at something they cannot repair. + */ + it("keeps the destructive variant for reauth alone", () => { + expect( + render(), + ).toContain('data-variant="destructive"'); + for (const state of [ + "warning", + "stale", + "stalled", + "parked", + "pending-setup", + "connected", + ] as const) { + expect( + render(), + state, + ).not.toContain('data-variant="destructive"'); + } + }); + + it("lets a card keep its own established testid", () => { + const html = render( + , + ); + expect(html).toContain('data-testid="apple-health-status"'); + }); }); diff --git a/src/components/settings/__tests__/integrations-section.test.tsx b/src/components/settings/__tests__/integrations-section.test.tsx index 35c806dc6..cdea7f1bd 100644 --- a/src/components/settings/__tests__/integrations-section.test.tsx +++ b/src/components/settings/__tests__/integrations-section.test.tsx @@ -114,27 +114,27 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => { configured: true, legacyLastSyncedAt: "2026-05-09T18:00:00.000Z", hasActivityScope: true, + syncHealth: { verdict: "fresh", since: null }, }, { + // Not configured here, so this entry claims no surface. The + // configured, card-less case has its own coverage test — this one is + // about one status display per CARD. integration: "moodlog", - state: "connected", - lastSuccessAt: "2026-05-09T17:00:00.000Z", - lastAttemptAt: "2026-05-09T17:00:00.000Z", + state: "disconnected", + lastSuccessAt: null, + lastAttemptAt: null, lastError: null, - configured: true, - enabled: true, - legacyLastSyncedAt: "2026-05-09T17:00:00.000Z", - entryCount: 42, - webhookSecret: "secret123", + configured: false, + syncHealth: { verdict: "disconnected", since: null }, }, ], }); const html = render(); - // Exactly one pill per card → 8 pills total (Withings, WHOOP, Fitbit, - // Google Health, Polar, Oura, Strava, Nightscout). The moodLog integration - // was removed; Polar + Oura (F4) were added in v1.17.0; Google Health in - // v1.27.0; Strava in v1.28.x. (The Garmin info note carries no pill.) + // 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(html).not.toContain('data-testid="integration-status-banner"'); @@ -160,6 +160,7 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => { configured: true, legacyLastSyncedAt: "2026-05-09T08:00:00.000Z", hasActivityScope: true, + syncHealth: { verdict: "failing", since: "2026-05-09T08:00:00.000Z" }, }, { integration: "moodlog", @@ -172,9 +173,10 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => { }); const html = render(); - // Pill carries the "Error — reconnect" copy via the pill's - // `data-state="error"` marker. - expect(html).toContain('data-state="error"'); + // 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"); @@ -200,6 +202,10 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => { legacyLastSyncedAt: "2026-05-08T12:00:00.000Z", tokenExpired: true, hasActivityScope: true, + syncHealth: { + verdict: "reauth_required", + since: "2026-05-09T18:00:00.000Z", + }, }, { integration: "moodlog", @@ -228,6 +234,7 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => { lastSuccessAt: "2026-05-08T10:00:00.000Z", lastAttemptAt: "2026-05-08T10:00:00.000Z", lastError: null, + syncHealth: { verdict: "disconnected", since: null }, }, ], }); @@ -258,6 +265,7 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => { configured: true, legacyLastSyncedAt: "2026-05-08T12:00:00.000Z", hasActivityScope: true, + syncHealth: { verdict: "parked", since: "2026-05-09T18:00:00.000Z" }, }, { integration: "moodlog", @@ -294,15 +302,16 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => { configured: true, legacyLastSyncedAt: "2026-05-09T18:00:00.000Z", hasActivityScope: true, + syncHealth: { verdict: "fresh", since: null }, }, ], }); const html = render(); // Every integration card includes the section divider data-testid so the - // header → body separation is visually consistent (Withings, WHOOP, - // Fitbit, Google Health). The moodLog integration was removed. - expect(count(html, 'data-testid="integration-card-divider"')).toBe(4); + // header → body separation is visually consistent: the four legacy cards, + // Apple Health, the three OAuth cards, and Nightscout. + expect(count(html, 'data-testid="integration-card-divider"')).toBe(9); }); // v1.17.1 — every integration card carries the same discreet "Setup guide" diff --git a/src/components/settings/integration-status-pill.tsx b/src/components/settings/integration-status-pill.tsx index b65c4adb9..35d8ef14d 100644 --- a/src/components/settings/integration-status-pill.tsx +++ b/src/components/settings/integration-status-pill.tsx @@ -4,6 +4,8 @@ import { CheckCircle2, AlertTriangle, CircleSlash, + Clock, + Info, PauseCircle, } from "lucide-react"; @@ -32,20 +34,33 @@ import { cn } from "@/lib/utils"; */ /** - * Pill states. + * Pill states — a thin projection of the server's sync-health verdict. * * - connected → happy path; relative "last sync" suffix is shown. - * - warning → connected but the upstream just returned a - * contract-mismatch error (v1.4.43 W4). - * - error → reauth-required / persistent transient error; - * "Error — reconnect" copy. - * - parked → v1.4.43 W14: >24h of persistent failures put the - * integration to sleep; "Pausiert — manuell wieder - * verbinden" copy + reconnect CTA. + * - stale → connected, but no new data for a while; suffix shown. + * - stalled → the sync stopped even attempting; suffix shown. + * - warning → attempting and failing (a 5xx, a rate limit, an upstream + * contract mismatch). Not the user's to fix. + * - error → reauth-required; "Error — reconnect" copy. + * - parked → >24h of persistent failures put the integration to + * sleep; "Paused — reconnect manually" copy + resume CTA. + * - pending-setup → configured but nothing has arrived yet. * - disconnected → user clicked Disconnect; "Not connected" copy. + * + * **Red is reserved for "your action fixes this".** A transient upstream + * failure carries the warning tone, not the destructive one: telling a user to + * reconnect cannot help against a 503, and ten fruitless reconnect clicks are + * the cost of saying otherwise. */ export type IntegrationPillState = - "connected" | "warning" | "error" | "parked" | "disconnected"; + | "connected" + | "stale" + | "stalled" + | "warning" + | "error" + | "parked" + | "pending-setup" + | "disconnected"; interface IntegrationStatusPillProps { state: IntegrationPillState; @@ -56,6 +71,8 @@ interface IntegrationStatusPillProps { lastSyncAt: Date | string | null; /** Override "now" for deterministic testing. Defaults to `new Date()`. */ now?: Date; + /** Override the testid so a card can keep its own established e2e hook. */ + testId?: string; className?: string; } @@ -68,7 +85,7 @@ function toDate(value: Date | string): Date { * string. We keep the buckets small and fixed (just-now / minutes / * hours / days) because the pill must fit on a 360 px wide card. */ -function formatRelative( +export function formatRelative( deltaMs: number, t: (key: string, params?: Record) => string, ): string { @@ -85,26 +102,37 @@ function formatRelative( return t("settings.integrationPill.daysAgo", { count: days }); } +/** The states that paint the semantic warning chip rather than a variant. */ +const WARNING_STATES = new Set([ + "stale", + "stalled", + "warning", + "parked", +]); + +/** The states whose label carries an inline relative timestamp. */ +const RELATIVE_STATES = new Set([ + "connected", + "stale", + "stalled", +]); + export function IntegrationStatusPill({ state, lastSyncAt, now, + testId, className, }: IntegrationStatusPillProps) { const { t } = useTranslations(); - // chipClass only applies to the `connected`, `warning`, and `parked` - // branches — `error` falls back to `variant="destructive"` and - // `disconnected` to `variant="outline"`. The warning and parked chips - // both carry the semantic `warning` tone (an AA-safe amber that clears - // contrast in light mode); their distinct labels + icons carry the - // meaning — "connected, but the upstream returned a contract-level error - // a reauth won't fix" vs "tried to keep going for 24h but the mismatch - // persists, manual intervention required" — distinct from the red - // reconnect pill (`error`). + // The chip classes apply to `connected` and to every warning-tone state — + // `error` falls back to `variant="destructive"`, `disconnected` and + // `pending-setup` to `variant="outline"`. The warning tone is an AA-safe + // amber that clears contrast in light mode; the distinct labels + icons + // carry which flavour of "not right" this is. const connectedChipClass = "border-success/30 bg-success/15 text-success"; const warningChipClass = "border-warning/30 bg-warning/15 text-warning"; - const parkedChipClass = "border-warning/30 bg-warning/15 text-warning"; let label: string; let icon: React.ReactNode; @@ -114,6 +142,14 @@ export function IntegrationStatusPill({ label = t("settings.integrationPill.connected"); icon =