Merge pull request #634 from MBombeck/release/v1.32.31

release/v1.32.31 — the integrations page says what is actually happening
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-25 10:14:50 +02:00
commit 4ed8c8f39b
45 files changed
+2973 -621

No files matched your search

+41
View File
@@ -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
+62 -2
View File
@@ -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:
+56 -6
View File
@@ -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");
+22 -7
View File
@@ -5279,7 +5279,7 @@
"integrationPill": {
"connected": "Verbunden",
"errorReconnect": "Fehler — neu verbinden",
"warningServerError": "Verbunden, aber Serverfehler",
"warningServerError": "Verbunden, Sync-Problem",
"parkedReconnect": "Pausiert — manuell wieder verbinden",
"notConnected": "Nicht verbunden",
"justNow": "gerade eben",
@@ -5289,8 +5289,26 @@
"ariaLabel": "Integrationsstatus",
"resumeCta": "Wieder verbinden",
"resumeSuccess": "Integration wieder aktiv",
"resumeError": "Wieder verbinden fehlgeschlagen"
"resumeError": "Wieder verbinden fehlgeschlagen",
"stale": "Verbunden, keine neuen Daten",
"stalled": "Sync gestoppt",
"pendingSetup": "Warte auf erste Daten"
},
"integrationFreshness": {
"title": "Empfangene Daten",
"summary": "{count} Datentypen · neuestes {relative}",
"quiet": "{count} still",
"workouts": "Workouts"
},
"integrationFallback": {
"description": "Für dieses Konto eingerichtet, hat aber keine eigene Einstellungskarte. Diese Zeile sorgt dafür, dass eine Verbindung nicht unbemerkt ausfallen kann.",
"lastAttempt": "Letzter Versuch {relative}",
"noAttempt": "Kein Sync-Versuch aufgezeichnet"
},
"withingsViewData": "Messwerte ansehen",
"whoopViewData": "Erholung ansehen",
"fitbitViewData": "Schlaf ansehen",
"googleHealthViewData": "Schritte ansehen",
"apiTokens": "API-Tokens",
"apiTokensDescription": "API-Tokens für die externe Medikamenten-Einnahme prüfen und widerrufen. Tokens werden pro Medikament über dessen API-Endpoint-Schalter ausgestellt.",
"tokenMintMovedDescription": "Tokens werden pro Medikament ausgestellt. Öffne ein Medikament und aktiviere dessen API-Endpoint, um ein Token zu erhalten, das nur für dieses Medikament gilt.",
@@ -5850,17 +5868,14 @@
"title": "Apple Health",
"description": "Die Live-Synchronisierung erfolgt über die HealthLog-iOS-App – nicht über eine Web- oder OAuth-Verbindung.",
"status": {
"checking": "Apple-Health-Daten werden geprüft…",
"dataReceived": "Apple-Health-Daten empfangen",
"setup": "In der iOS-App einrichten",
"unavailable": "Apple-Health-Status nicht verfügbar"
},
"lastDataLabel": "Letzte Apple-Health-Daten:",
"setupTitle": "Live-Synchronisierung aktivieren",
"permissionStep": "Öffne in der HealthLog-iOS-App Einstellungen → Apple Health und erlaube die Gesundheitskategorien, die du teilen möchtest.",
"backgroundStep": "Lass die Hintergrundaktualisierung aktiviert. Die App synchronisiert neue Daten automatisch im Vordergrund und wenn iOS Hintergrundzeit gewährt.",
"importNote": "Du brauchst ältere Daten oder kannst die iOS-App nicht nutzen? Der Apple-Health-XML/ZIP-Upload ist ein einmaliger Ersatzweg und aktiviert keine Live-Synchronisierung.",
"importAction": "Einmaligen Import öffnen"
"importAction": "Einmaligen Import öffnen",
"staleNote": "Seit über einer Woche sind keine Apple-Health-Daten angekommen. Öffne die HealthLog-App auf deinem iPhone und prüfe die Apple-Health-Berechtigungen."
},
"garminInfo": {
"title": "Garmin",
+22 -7
View File
@@ -5269,7 +5269,7 @@
"integrationPill": {
"connected": "Connected",
"errorReconnect": "Error — reconnect",
"warningServerError": "Connected, server error",
"warningServerError": "Connected, sync issue",
"parkedReconnect": "Paused — reconnect manually",
"notConnected": "Not connected",
"justNow": "just now",
@@ -5279,8 +5279,26 @@
"ariaLabel": "Integration status",
"resumeCta": "Reconnect",
"resumeSuccess": "Integration resumed",
"resumeError": "Reconnect failed"
"resumeError": "Reconnect failed",
"stale": "Connected, no recent data",
"stalled": "Sync stopped",
"pendingSetup": "Waiting for first data"
},
"integrationFreshness": {
"title": "Data received",
"summary": "{count} data types · newest {relative}",
"quiet": "{count} quiet",
"workouts": "Workouts"
},
"integrationFallback": {
"description": "Configured on this account without a settings card of its own. This row exists so a connection cannot stop working unnoticed.",
"lastAttempt": "Last attempt {relative}",
"noAttempt": "No sync attempt recorded"
},
"withingsViewData": "View your measurements",
"whoopViewData": "View your recovery",
"fitbitViewData": "View your sleep",
"googleHealthViewData": "View your steps",
"apiTokens": "API Tokens",
"apiTokensDescription": "Review and revoke API tokens for external medication intake. Tokens are issued per medication from the medication's API endpoint toggle.",
"tokenMintMovedDescription": "Tokens are issued per medication. Open a medication and enable its API endpoint to get a token scoped to that medication alone.",
@@ -5840,17 +5858,14 @@
"title": "Apple Health",
"description": "Live sync comes from the HealthLog iOS app. It is not a web or OAuth connection.",
"status": {
"checking": "Checking Apple Health data…",
"dataReceived": "Apple Health data received",
"setup": "Set up in iOS app",
"unavailable": "Apple Health status unavailable"
},
"lastDataLabel": "Last Apple Health data:",
"setupTitle": "Turn on live sync",
"permissionStep": "In the HealthLog iOS app, open Settings → Apple Health and allow the health categories you want to share.",
"backgroundStep": "Keep Background App Refresh enabled. The app syncs new data automatically in the foreground and when iOS grants background time.",
"importNote": "Need older history or cannot use the iOS app? The Apple Health XML/ZIP upload is a one-shot fallback and does not enable live sync.",
"importAction": "Open one-shot import"
"importAction": "Open one-shot import",
"staleNote": "No Apple Health data has arrived for over a week. Open the HealthLog app on your iPhone and check the Apple Health permissions."
},
"garminInfo": {
"title": "Garmin",
+22 -7
View File
@@ -5281,7 +5281,7 @@
"integrationPill": {
"connected": "Conectado",
"errorReconnect": "Error — reconectar",
"warningServerError": "Conectado, error del servidor",
"warningServerError": "Conectado, problema de sincronización",
"parkedReconnect": "Pausado — reconectar manualmente",
"notConnected": "No conectado",
"justNow": "ahora mismo",
@@ -5291,8 +5291,26 @@
"ariaLabel": "Estado de la integración",
"resumeCta": "Reconectar",
"resumeSuccess": "Integración reanudada",
"resumeError": "Reconexión fallida"
"resumeError": "Reconexión fallida",
"stale": "Conectado, sin datos recientes",
"stalled": "Sincronización detenida",
"pendingSetup": "Esperando los primeros datos"
},
"integrationFreshness": {
"title": "Datos recibidos",
"summary": "{count} tipos de datos · más reciente {relative}",
"quiet": "{count} en silencio",
"workouts": "Entrenamientos"
},
"integrationFallback": {
"description": "Configurado en esta cuenta pero sin tarjeta de ajustes propia. Esta fila existe para que una conexión no deje de funcionar sin que nadie lo note.",
"lastAttempt": "Último intento {relative}",
"noAttempt": "Ningún intento de sincronización registrado"
},
"withingsViewData": "Ver tus mediciones",
"whoopViewData": "Ver tu recuperación",
"fitbitViewData": "Ver tu sueño",
"googleHealthViewData": "Ver tus pasos",
"apiTokens": "Tokens de API",
"apiTokensDescription": "Revisa y revoca tokens de API para el registro externo de tomas de medicación. Los tokens se emiten por medicamento desde su interruptor de endpoint de API.",
"tokenMintMovedDescription": "Los tokens se emiten por medicamento. Abre un medicamento y activa su endpoint de API para obtener un token limitado únicamente a ese medicamento.",
@@ -5852,17 +5870,14 @@
"title": "Apple Health",
"description": "La sincronización en vivo se realiza desde la app HealthLog para iOS, no mediante una conexión web ni OAuth.",
"status": {
"checking": "Comprobando datos de Apple Health…",
"dataReceived": "Datos de Apple Health recibidos",
"setup": "Configurar en la app de iOS",
"unavailable": "Estado de Apple Health no disponible"
},
"lastDataLabel": "Últimos datos de Apple Health:",
"setupTitle": "Activar la sincronización en vivo",
"permissionStep": "En la app HealthLog para iOS, abre Ajustes → Apple Health y permite las categorías de salud que quieras compartir.",
"backgroundStep": "Mantén activada la actualización en segundo plano. La app sincroniza datos nuevos automáticamente en primer plano y cuando iOS le concede tiempo en segundo plano.",
"importNote": "¿Necesitas un historial anterior o no puedes usar la app de iOS? La carga XML/ZIP de Apple Health es una alternativa puntual y no activa la sincronización en vivo.",
"importAction": "Abrir importación puntual"
"importAction": "Abrir importación puntual",
"staleNote": "Hace más de una semana que no llegan datos de Apple Health. Abre la app HealthLog en tu iPhone y revisa los permisos de Apple Health."
},
"garminInfo": {
"title": "Garmin",
+22 -7
View File
@@ -5283,7 +5283,7 @@
"integrationPill": {
"connected": "Connecté",
"errorReconnect": "Erreur — reconnecter",
"warningServerError": "Connecté, erreur serveur",
"warningServerError": "Connecté, problème de synchronisation",
"parkedReconnect": "En pause — reconnecter manuellement",
"notConnected": "Non connecté",
"justNow": "à l’instant",
@@ -5293,8 +5293,26 @@
"ariaLabel": "Statut de l’intégration",
"resumeCta": "Reconnecter",
"resumeSuccess": "Intégration reprise",
"resumeError": "Échec de la reconnexion"
"resumeError": "Échec de la reconnexion",
"stale": "Connecté, aucune donnée récente",
"stalled": "Synchronisation arrêtée",
"pendingSetup": "En attente des premières données"
},
"integrationFreshness": {
"title": "Données reçues",
"summary": "{count} types de données · plus récent {relative}",
"quiet": "{count} silencieux",
"workouts": "Séances"
},
"integrationFallback": {
"description": "Configuré sur ce compte sans carte de réglages dédiée. Cette ligne existe pour qu'une connexion ne puisse pas cesser de fonctionner sans que personne ne le voie.",
"lastAttempt": "Dernière tentative {relative}",
"noAttempt": "Aucune tentative de synchronisation enregistrée"
},
"withingsViewData": "Voir vos mesures",
"whoopViewData": "Voir votre récupération",
"fitbitViewData": "Voir votre sommeil",
"googleHealthViewData": "Voir vos pas",
"apiTokens": "Jetons API",
"apiTokensDescription": "Consultez et révoquez les jetons API pour l’enregistrement externe des prises de médicaments. Les jetons sont émis par médicament depuis son interrupteur de point de terminaison API.",
"tokenMintMovedDescription": "Les jetons sont émis par médicament. Ouvrez un médicament et activez son point de terminaison API pour obtenir un jeton limité à ce seul médicament.",
@@ -5854,17 +5872,14 @@
"title": "Apple Health",
"description": "La synchronisation en direct passe par l’app HealthLog pour iOS, et non par une connexion web ou OAuth.",
"status": {
"checking": "Vérification des données Apple Health…",
"dataReceived": "Données Apple Health reçues",
"setup": "Configurer dans l’app iOS",
"unavailable": "Statut Apple Health indisponible"
},
"lastDataLabel": "Dernières données Apple Health :",
"setupTitle": "Activer la synchronisation en direct",
"permissionStep": "Dans l’app HealthLog pour iOS, ouvre Réglages → Apple Health et autorise les catégories de santé que tu souhaites partager.",
"backgroundStep": "Laisse l’actualisation en arrière-plan activée. L’app synchronise automatiquement les nouvelles données au premier plan et lorsque iOS lui accorde du temps en arrière-plan.",
"importNote": "Besoin d’un ancien historique ou impossible d’utiliser l’app iOS ? L’envoi XML/ZIP Apple Health est une solution ponctuelle et n’active pas la synchronisation en direct.",
"importAction": "Ouvrir l’import ponctuel"
"importAction": "Ouvrir l’import ponctuel",
"staleNote": "Aucune donnée Apple Health n'est arrivée depuis plus d'une semaine. Ouvrez l'app HealthLog sur votre iPhone et vérifiez les autorisations Apple Health."
},
"garminInfo": {
"title": "Garmin",
+22 -7
View File
@@ -5273,7 +5273,7 @@
"integrationPill": {
"connected": "Connesso",
"errorReconnect": "Errore — riconnetti",
"warningServerError": "Connesso, errore del server",
"warningServerError": "Connesso, problema di sincronizzazione",
"parkedReconnect": "In pausa — riconnettere manualmente",
"notConnected": "Non connesso",
"justNow": "proprio ora",
@@ -5283,8 +5283,26 @@
"ariaLabel": "Stato dell’integrazione",
"resumeCta": "Riconnetti",
"resumeSuccess": "Integrazione ripresa",
"resumeError": "Riconnessione fallita"
"resumeError": "Riconnessione fallita",
"stale": "Connesso, nessun dato recente",
"stalled": "Sincronizzazione ferma",
"pendingSetup": "In attesa dei primi dati"
},
"integrationFreshness": {
"title": "Dati ricevuti",
"summary": "{count} tipi di dati · più recente {relative}",
"quiet": "{count} in silenzio",
"workouts": "Allenamenti"
},
"integrationFallback": {
"description": "Configurato su questo account ma senza una scheda di impostazioni propria. Questa riga esiste perché una connessione non possa smettere di funzionare senza che nessuno se ne accorga.",
"lastAttempt": "Ultimo tentativo {relative}",
"noAttempt": "Nessun tentativo di sincronizzazione registrato"
},
"withingsViewData": "Vedi le tue misurazioni",
"whoopViewData": "Vedi il tuo recupero",
"fitbitViewData": "Vedi il tuo sonno",
"googleHealthViewData": "Vedi i tuoi passi",
"apiTokens": "Token API",
"apiTokensDescription": "Rivedi e revoca i token API per la registrazione esterna delle assunzioni di farmaci. I token vengono emessi per singolo farmaco dal relativo interruttore dell’endpoint API.",
"tokenMintMovedDescription": "I token vengono emessi per singolo farmaco. Apri un farmaco e attiva il suo endpoint API per ottenere un token valido solo per quel farmaco.",
@@ -5844,17 +5862,14 @@
"title": "Apple Health",
"description": "La sincronizzazione in tempo reale avviene tramite l’app HealthLog per iOS, non tramite una connessione web o OAuth.",
"status": {
"checking": "Controllo dei dati Apple Health…",
"dataReceived": "Dati Apple Health ricevuti",
"setup": "Configura nell’app iOS",
"unavailable": "Stato di Apple Health non disponibile"
},
"lastDataLabel": "Ultimi dati Apple Health:",
"setupTitle": "Attiva la sincronizzazione in tempo reale",
"permissionStep": "Nell’app HealthLog per iOS, apri Impostazioni → Apple Health e autorizza le categorie di salute che vuoi condividere.",
"backgroundStep": "Mantieni attivo l’aggiornamento in background. L’app sincronizza automaticamente i nuovi dati in primo piano e quando iOS concede tempo in background.",
"importNote": "Ti serve lo storico precedente o non puoi usare l’app iOS? Il caricamento XML/ZIP di Apple Health è un’alternativa una tantum e non attiva la sincronizzazione in tempo reale.",
"importAction": "Apri importazione una tantum"
"importAction": "Apri importazione una tantum",
"staleNote": "Da più di una settimana non arrivano dati da Apple Health. Apri l'app HealthLog sull'iPhone e controlla i permessi di Apple Health."
},
"garminInfo": {
"title": "Garmin",
+22 -7
View File
@@ -5267,7 +5267,7 @@
"integrationPill": {
"connected": "Połączono",
"errorReconnect": "Błąd — połącz ponownie",
"warningServerError": "Połączono, błąd serwera",
"warningServerError": "Połączono, problem z synchronizacją",
"parkedReconnect": "Wstrzymane — połącz ręcznie",
"notConnected": "Nie połączono",
"justNow": "przed chwilą",
@@ -5277,8 +5277,26 @@
"ariaLabel": "Status integracji",
"resumeCta": "Połącz ponownie",
"resumeSuccess": "Integracja wznowiona",
"resumeError": "Ponowne połączenie nieudane"
"resumeError": "Ponowne połączenie nieudane",
"stale": "Połączono, brak nowych danych",
"stalled": "Synchronizacja zatrzymana",
"pendingSetup": "Czekam na pierwsze dane"
},
"integrationFreshness": {
"title": "Odebrane dane",
"summary": "{count} typów danych · najnowsze {relative}",
"quiet": "{count} bez danych",
"workouts": "Treningi"
},
"integrationFallback": {
"description": "Skonfigurowane na tym koncie, ale bez własnej karty ustawień. Ten wiersz istnieje po to, by przerwane połączenie nie pozostało niezauważone.",
"lastAttempt": "Ostatnia próba {relative}",
"noAttempt": "Brak zapisanej próby synchronizacji"
},
"withingsViewData": "Zobacz swoje pomiary",
"whoopViewData": "Zobacz swoją regenerację",
"fitbitViewData": "Zobacz swój sen",
"googleHealthViewData": "Zobacz swoje kroki",
"apiTokens": "Tokeny API",
"apiTokensDescription": "Przeglądaj i unieważniaj tokeny API do zewnętrznego rejestrowania przyjęć leków. Tokeny są wydawane dla pojedynczego leku z jego przełącznika punktu końcowego API.",
"tokenMintMovedDescription": "Tokeny są wydawane dla pojedynczego leku. Otwórz lek i włącz jego punkt końcowy API, aby otrzymać token obejmujący wyłącznie ten lek.",
@@ -5838,17 +5856,14 @@
"title": "Apple Health",
"description": "Synchronizacja na żywo odbywa się przez aplikację HealthLog na iOS, a nie przez połączenie internetowe ani OAuth.",
"status": {
"checking": "Sprawdzanie danych Apple Health…",
"dataReceived": "Odebrano dane Apple Health",
"setup": "Skonfiguruj w aplikacji iOS",
"unavailable": "Stan Apple Health jest niedostępny"
},
"lastDataLabel": "Najnowsze dane Apple Health:",
"setupTitle": "Włącz synchronizację na żywo",
"permissionStep": "W aplikacji HealthLog na iOS otwórz Ustawienia → Apple Health i zezwól na udostępnianie wybranych kategorii zdrowotnych.",
"backgroundStep": "Pozostaw włączone odświeżanie aplikacji w tle. Aplikacja synchronizuje nowe dane automatycznie na pierwszym planie oraz gdy iOS przydzieli jej czas w tle.",
"importNote": "Potrzebujesz starszej historii lub nie możesz użyć aplikacji iOS? Przesłanie pliku XML/ZIP z Apple Health jest jednorazową alternatywą i nie włącza synchronizacji na żywo.",
"importAction": "Otwórz jednorazowy import"
"importAction": "Otwórz jednorazowy import",
"staleNote": "Od ponad tygodnia nie napłynęły żadne dane z Apple Health. Otwórz aplikację HealthLog na iPhonie i sprawdź uprawnienia Apple Health."
},
"garminInfo": {
"title": "Garmin",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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/*`
@@ -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: <T>(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<Response>;
@@ -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<Body> {
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");
});
});
+27 -1
View File
@@ -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),
});
});
@@ -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<string, unknown> = {
vi.mock("@/lib/integrations/status", () => ({
getIntegrationStatus: vi.fn(async (_u: string, integration: string) => ({
integration,
...ledger,
...(perIntegrationLedger[integration] ?? ledger),
})),
getPersistentFailureThreshold: () => 5,
}));
const perIntegrationLedger: Record<string, Record<string, unknown>> = {};
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<typeof vi.fn>;
const whoopFind = prisma.whoopConnection.findUnique as ReturnType<typeof vi.fn>;
@@ -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<Entry[]> {
@@ -71,6 +79,9 @@ async function fetchEntries(): Promise<Entry[]> {
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();
});
});
+178 -44
View File
@@ -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<Record<IntegrationKey, MetricFreshnessEntry[]>>,
() => ({}) as Partial<Record<IntegrationKey, MetricFreshnessSample[]>>,
);
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;
}
@@ -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(
<IntegrationStatusPill
state="stale"
lastSyncAt={new Date(Date.now() - 9 * 24 * 60 * 60 * 1000)}
now={new Date()}
/>,
);
expect(html).toContain('data-state="stale"');
expect(html).toContain("no recent data");
expect(html).toMatch(/9\s+d\s+ago/);
expect(html).toContain("bg-warning/15");
});
it("renders the stalled state as a stopped sync, not an error", () => {
const html = render(
<IntegrationStatusPill
state="stalled"
lastSyncAt={new Date(Date.now() - 28 * 24 * 60 * 60 * 1000)}
now={new Date()}
/>,
);
expect(html).toContain('data-state="stalled"');
expect(html).toContain("Sync stopped");
expect(html).toMatch(/28\s+d\s+ago/);
// Warning tone, never destructive: the user cannot fix a cron that
// stopped by clicking reconnect.
expect(html).toContain("bg-warning/15");
expect(html).not.toContain('data-variant="destructive"');
});
it("renders the pending-setup state with no timestamp", () => {
const html = render(
<IntegrationStatusPill state="pending-setup" lastSyncAt={null} />,
);
expect(html).toContain('data-state="pending-setup"');
expect(html).toContain("Waiting for first data");
expect(html).not.toContain("bg-warning/15");
});
/**
* Red is reserved for "your action fixes this". A transient upstream failure
* is not that: telling the user to reconnect against a 503 sends them
* clicking at something they cannot repair.
*/
it("keeps the destructive variant for reauth alone", () => {
expect(
render(<IntegrationStatusPill state="error" lastSyncAt={null} />),
).toContain('data-variant="destructive"');
for (const state of [
"warning",
"stale",
"stalled",
"parked",
"pending-setup",
"connected",
] as const) {
expect(
render(<IntegrationStatusPill state={state} lastSyncAt={null} />),
state,
).not.toContain('data-variant="destructive"');
}
});
it("lets a card keep its own established testid", () => {
const html = render(
<IntegrationStatusPill
state="connected"
lastSyncAt={null}
testId="apple-health-status"
/>,
);
expect(html).toContain('data-testid="apple-health-status"');
});
});
@@ -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"
@@ -4,6 +4,8 @@ import {
CheckCircle2,
AlertTriangle,
CircleSlash,
Clock,
Info,
PauseCircle,
} from "lucide-react";
@@ -32,20 +34,33 @@ import { cn } from "@/lib/utils";
*/
/**
* Pill states.
* Pill states — a thin projection of the server's sync-health verdict.
*
* - connected → happy path; relative "last sync" suffix is shown.
* - warning → connected but the upstream just returned a
* contract-mismatch error (v1.4.43 W4).
* - error → reauth-required / persistent transient error;
* "Error — reconnect" copy.
* - parked → v1.4.43 W14: >24h of persistent failures put the
* integration to sleep; "Pausiert — manuell wieder
* verbinden" copy + reconnect CTA.
* - stale → connected, but no new data for a while; suffix shown.
* - stalled → the sync stopped even attempting; suffix shown.
* - warning → attempting and failing (a 5xx, a rate limit, an upstream
* contract mismatch). Not the user's to fix.
* - error → reauth-required; "Error — reconnect" copy.
* - parked → >24h of persistent failures put the integration to
* sleep; "Paused — reconnect manually" copy + resume CTA.
* - pending-setup → configured but nothing has arrived yet.
* - disconnected → user clicked Disconnect; "Not connected" copy.
*
* **Red is reserved for "your action fixes this".** A transient upstream
* failure carries the warning tone, not the destructive one: telling a user to
* reconnect cannot help against a 503, and ten fruitless reconnect clicks are
* the cost of saying otherwise.
*/
export type IntegrationPillState =
"connected" | "warning" | "error" | "parked" | "disconnected";
| "connected"
| "stale"
| "stalled"
| "warning"
| "error"
| "parked"
| "pending-setup"
| "disconnected";
interface IntegrationStatusPillProps {
state: IntegrationPillState;
@@ -56,6 +71,8 @@ interface IntegrationStatusPillProps {
lastSyncAt: Date | string | null;
/** Override "now" for deterministic testing. Defaults to `new Date()`. */
now?: Date;
/** Override the testid so a card can keep its own established e2e hook. */
testId?: string;
className?: string;
}
@@ -68,7 +85,7 @@ function toDate(value: Date | string): Date {
* string. We keep the buckets small and fixed (just-now / minutes /
* hours / days) because the pill must fit on a 360 px wide card.
*/
function formatRelative(
export function formatRelative(
deltaMs: number,
t: (key: string, params?: Record<string, string | number>) => string,
): string {
@@ -85,26 +102,37 @@ function formatRelative(
return t("settings.integrationPill.daysAgo", { count: days });
}
/** The states that paint the semantic warning chip rather than a variant. */
const WARNING_STATES = new Set<IntegrationPillState>([
"stale",
"stalled",
"warning",
"parked",
]);
/** The states whose label carries an inline relative timestamp. */
const RELATIVE_STATES = new Set<IntegrationPillState>([
"connected",
"stale",
"stalled",
]);
export function IntegrationStatusPill({
state,
lastSyncAt,
now,
testId,
className,
}: IntegrationStatusPillProps) {
const { t } = useTranslations();
// chipClass only applies to the `connected`, `warning`, and `parked`
// branches — `error` falls back to `variant="destructive"` and
// `disconnected` to `variant="outline"`. The warning and parked chips
// both carry the semantic `warning` tone (an AA-safe amber that clears
// contrast in light mode); their distinct labels + icons carry the
// meaning — "connected, but the upstream returned a contract-level error
// a reauth won't fix" vs "tried to keep going for 24h but the mismatch
// persists, manual intervention required" — distinct from the red
// reconnect pill (`error`).
// The chip classes apply to `connected` and to every warning-tone state —
// `error` falls back to `variant="destructive"`, `disconnected` and
// `pending-setup` to `variant="outline"`. The warning tone is an AA-safe
// amber that clears contrast in light mode; the distinct labels + icons
// carry which flavour of "not right" this is.
const connectedChipClass = "border-success/30 bg-success/15 text-success";
const warningChipClass = "border-warning/30 bg-warning/15 text-warning";
const parkedChipClass = "border-warning/30 bg-warning/15 text-warning";
let label: string;
let icon: React.ReactNode;
@@ -114,6 +142,14 @@ export function IntegrationStatusPill({
label = t("settings.integrationPill.connected");
icon = <CheckCircle2 aria-hidden="true" className="h-3 w-3" />;
break;
case "stale":
label = t("settings.integrationPill.stale");
icon = <AlertTriangle aria-hidden="true" className="h-3 w-3" />;
break;
case "stalled":
label = t("settings.integrationPill.stalled");
icon = <Clock aria-hidden="true" className="h-3 w-3" />;
break;
case "warning":
label = t("settings.integrationPill.warningServerError");
icon = <AlertTriangle aria-hidden="true" className="h-3 w-3" />;
@@ -126,6 +162,10 @@ export function IntegrationStatusPill({
label = t("settings.integrationPill.parkedReconnect");
icon = <PauseCircle aria-hidden="true" className="h-3 w-3" />;
break;
case "pending-setup":
label = t("settings.integrationPill.pendingSetup");
icon = <Info aria-hidden="true" className="h-3 w-3" />;
break;
case "disconnected":
label = t("settings.integrationPill.notConnected");
icon = <CircleSlash aria-hidden="true" className="h-3 w-3" />;
@@ -134,7 +174,7 @@ export function IntegrationStatusPill({
const reference = now ?? new Date();
const relative =
state === "connected" && lastSyncAt
RELATIVE_STATES.has(state) && lastSyncAt
? formatRelative(
Math.max(0, reference.getTime() - toDate(lastSyncAt).getTime()),
t,
@@ -143,10 +183,10 @@ export function IntegrationStatusPill({
return (
<Badge
data-testid="integration-status-pill"
data-testid={testId ?? "integration-status-pill"}
data-state={state}
variant={
state === "connected" || state === "warning" || state === "parked"
state === "connected" || WARNING_STATES.has(state)
? undefined
: state === "error"
? "destructive"
@@ -156,8 +196,7 @@ export function IntegrationStatusPill({
className={cn(
"max-w-full whitespace-nowrap",
state === "connected" && connectedChipClass,
state === "warning" && warningChipClass,
state === "parked" && parkedChipClass,
WARNING_STATES.has(state) && warningChipClass,
className,
)}
>
@@ -10,7 +10,17 @@ import fr from "../../../../../messages/fr.json";
import itMessages from "../../../../../messages/it.json";
import pl from "../../../../../messages/pl.json";
let statusPayload: { lastSyncedAt: string | null } | undefined;
type Payload = {
lastSyncedAt: string | null;
syncHealth?: { verdict: string; since: string | null };
metricFreshness?: Array<{
type: string;
lastSeenAt: string;
stale: boolean;
}>;
};
let statusPayload: Payload | undefined;
let statusLoading = false;
let statusError = false;
@@ -40,8 +50,10 @@ describe("<AppleHealthCard>", () => {
});
it("explains that live sync uses the iOS app rather than OAuth", () => {
statusPayload = { lastSyncedAt: null };
statusLoading = false;
statusPayload = {
lastSyncedAt: null,
syncHealth: { verdict: "pending_first_sync", since: null },
};
const html = render();
@@ -53,35 +65,98 @@ describe("<AppleHealthCard>", () => {
expect(html).not.toContain(">Connected<");
});
it("keeps the status neutral when no Apple Health data has been observed", () => {
statusPayload = { lastSyncedAt: null };
statusLoading = false;
/**
* 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", () => {
const eightDaysAgo = new Date(
Date.now() - 8 * 24 * 60 * 60 * 1000,
).toISOString();
statusPayload = {
lastSyncedAt: eightDaysAgo,
syncHealth: { verdict: "stale", since: eightDaysAgo },
};
const html = render();
expect(html).toContain('data-testid="apple-health-status"');
expect(html).toContain('data-state="setup"');
expect(html).toContain("Set up in iOS app");
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"');
});
it("reports only observed data freshness without claiming a connection", () => {
statusPayload = { lastSyncedAt: "2026-07-20T10:30:00.000Z" };
statusLoading = false;
it("shows a recent delivery as connected with its relative time", () => {
const anHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
statusPayload = {
lastSyncedAt: anHourAgo,
syncHealth: { verdict: "fresh", since: null },
};
const html = render();
expect(html).toContain('data-state="recent-data"');
expect(html).toContain("Apple Health data received");
expect(html).toContain("Last Apple Health data:");
expect(html).toContain('dateTime="2026-07-20T10:30:00.000Z"');
expect(html).not.toContain(">Connected<");
expect(html).toContain('data-state="connected"');
expect(html).toMatch(/1\s+h\s+ago/);
expect(html).not.toContain('data-testid="apple-health-stale"');
});
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", () => {
statusPayload = {
lastSyncedAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
syncHealth: { verdict: "fresh", since: null },
metricFreshness: [
{
type: "RESPIRATORY_RATE",
lastSeenAt: new Date(
Date.now() - 30 * 24 * 60 * 60 * 1000,
).toISOString(),
stale: true,
},
{
type: "PULSE",
lastSeenAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
stale: false,
},
],
};
const html = render();
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 the shared card divider like every sibling", () => {
statusPayload = {
lastSyncedAt: null,
syncHealth: { verdict: "pending_first_sync", since: null },
};
expect(render()).toContain('data-testid="integration-card-divider"');
});
it("links to the existing one-shot Apple Health import fallback", () => {
statusPayload = { lastSyncedAt: null };
statusLoading = false;
statusPayload = {
lastSyncedAt: null,
syncHealth: { verdict: "pending_first_sync", since: null },
};
const html = render();
@@ -92,24 +167,24 @@ describe("<AppleHealthCard>", () => {
expect(html).toContain("Open one-shot import");
});
it("uses a non-committal checking state while status is loading", () => {
it("claims no status at all while the read is in flight", () => {
statusPayload = undefined;
statusLoading = true;
const html = render();
expect(html).toContain('data-state="checking"');
expect(html).toContain("Checking Apple Health data…");
expect(html).not.toContain('data-testid="apple-health-status"');
expect(html).not.toContain("Connected");
});
it("does not turn a failed status read into a setup or connection claim", () => {
it("treats a failed status read as an error line, not a status", () => {
statusError = true;
const html = render();
expect(html).toContain('data-state="unavailable"');
expect(html).toContain("Apple Health status unavailable");
expect(html).not.toContain('data-state="setup"');
expect(html).toContain('data-testid="integration-error-message"');
expect(html).not.toContain('data-testid="apple-health-status"');
expect(html).not.toContain("Connected");
});
@@ -117,12 +192,12 @@ describe("<AppleHealthCard>", () => {
const requiredKeys = [
"title",
"description",
"lastDataLabel",
"setupTitle",
"permissionStep",
"backgroundStep",
"importNote",
"importAction",
"staleNote",
] as const;
for (const catalog of [en, de, es, fr, itMessages, pl]) {
@@ -131,20 +206,19 @@ describe("<AppleHealthCard>", () => {
expect(appleHealth[key]).toBeTypeOf("string");
expect(appleHealth[key].length).toBeGreaterThan(0);
}
expect(Object.keys(appleHealth.status).sort()).toEqual([
"checking",
"dataReceived",
"setup",
"unavailable",
]);
// 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"]);
}
});
it.each(["en", "de", "es", "fr", "it", "pl"] as const)(
"renders localized Apple Health copy for %s without leaking keys",
(locale) => {
statusPayload = { lastSyncedAt: null };
statusLoading = false;
statusPayload = {
lastSyncedAt: null,
syncHealth: { verdict: "pending_first_sync", since: null },
};
const html = render(locale);
@@ -0,0 +1,127 @@
/**
* Nothing on the envelope may render nowhere.
*
* A provider whose Settings card was removed kept its cron, its ledger row and
* its block on the status envelope, and lost the only surface that could have
* shown it dying. It then stopped syncing for a month and no screen anywhere
* said so — every audit that went looking worked from the cards, so a provider
* without a card appeared in no cohort at all.
*
* The panel's unit of coverage is therefore the envelope entry, not the card.
* This is the acceptance test in executable form: render the panel with an
* envelope carrying every integration key, and require that each configured
* one surfaces either as a bespoke card or as a fallback row.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/settings/integrations",
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { id: "u1", role: "USER" },
isAuthenticated: true,
isLoading: false,
refetch: vi.fn(),
}),
}));
let envelope: unknown = null;
vi.mock("@tanstack/react-query", () => ({
useQuery: ({ queryKey }: { queryKey: unknown[] }) => {
const key = Array.isArray(queryKey) ? queryKey.join("/") : "";
if (key === "integrations/status") {
return { data: envelope, isLoading: false };
}
return { data: null, isLoading: false, isError: false, refetch: vi.fn() };
},
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { ConnectionsPanel } from "../connections-panel";
import { INTEGRATION_DISPLAY_NAMES } from "../integration-fallback-row";
const MONTH_AGO = new Date(Date.now() - 28 * 24 * 60 * 60 * 1000).toISOString();
/** Every key the server can ship, all configured, all a month silent. */
function fullEnvelope() {
return {
threshold: 3,
integrations: Object.keys(INTEGRATION_DISPLAY_NAMES).map((integration) => ({
integration,
state: "error_transient",
lastSuccessAt: MONTH_AGO,
lastAttemptAt: MONTH_AGO,
lastError: null,
configured: true,
connected: true,
available: true,
syncHealth: { verdict: "stalled", since: MONTH_AGO },
metricFreshness: [],
})),
};
}
function render() {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<ConnectionsPanel />
</I18nProvider>,
);
}
describe("ConnectionsPanel — every envelope entry surfaces", () => {
it("renders a card or a fallback row for every configured entry", () => {
envelope = fullEnvelope();
const html = render();
for (const integration of Object.keys(INTEGRATION_DISPLAY_NAMES)) {
const hasCard = html.includes(`data-testid="${integration}-card"`);
const hasFallback = html.includes(`data-integration="${integration}"`);
expect(
hasCard || hasFallback,
`${integration} renders nowhere on the connections panel`,
).toBe(true);
}
});
it("gives the card-less provider a fallback row with the stalled pill", () => {
// moodLog is the live case: no card since its removal, still on the wire,
// still running an hourly cron against an upstream that stopped answering.
envelope = fullEnvelope();
const html = render();
expect(html).toContain('data-testid="integration-fallback-row"');
expect(html).toContain('data-integration="moodlog"');
expect(html).toContain("moodLog");
expect(html).toContain('data-state="stalled"');
expect(html).toContain("Sync stopped");
expect(html).toContain("Last attempt");
});
it("renders no fallback row for a provider that is not configured", () => {
// Absence stays absence: an unconfigured provider is not a problem to
// report, so the panel says nothing about it.
envelope = {
threshold: 3,
integrations: [
{
integration: "moodlog",
state: "disconnected",
lastSuccessAt: null,
lastAttemptAt: null,
lastError: null,
configured: false,
syncHealth: { verdict: "disconnected", since: null },
metricFreshness: [],
},
],
};
expect(render()).not.toContain('data-testid="integration-fallback-row"');
});
});
@@ -1,12 +1,12 @@
/**
* v1.17.0 — interaction-parity + correctness checks for the Nightscout card.
* Nightscout parity + correctness.
*
* 1. A `parked` status renders the warning banner + reconnect button
* 1. A `parked` verdict renders the warning banner + reconnect button
* (matching the WHOOP card's parked treatment, byte-for-byte classes).
* 2. A `connected` status renders the shared TestConnectionButton AND the
* 2. A healthy connection renders the shared TestConnectionButton AND the
* connect→data link to /insights/blood-glucose.
* 3. The disconnect mutation invalidates `nightscoutStatus()` — the key the
* status query actually reads — so the pill refreshes after disconnect.
* 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.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
@@ -14,14 +14,12 @@ 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. Status is driven off a per-test payload.
let statusPayload: unknown = null;
// query keys it invalidates.
const invalidateSpy = vi.fn();
type OnSuccess = () => void;
const capturedDisconnectOnSuccess: { fn: OnSuccess | null } = { fn: null };
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({ data: statusPayload, isLoading: false }),
useMutation: ({ onSuccess }: { onSuccess?: () => void }) => {
// The Nightscout card declares exactly one mutation (disconnect).
capturedDisconnectOnSuccess.fn = onSuccess ?? null;
@@ -32,25 +30,38 @@ vi.mock("@tanstack/react-query", () => ({
import { I18nProvider } from "@/lib/i18n/context";
import { NightscoutCard } from "../nightscout-card";
import type { IntegrationStatusViewModel } from "../shared";
function render() {
function render(viewModel?: Partial<IntegrationStatusViewModel>) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<NightscoutCard />
<NightscoutCard
viewModel={
viewModel
? ({
integration: "nightscout",
state: "connected",
lastSuccessAt: null,
lastAttemptAt: null,
lastError: null,
...viewModel,
} as IntegrationStatusViewModel)
: undefined
}
/>
</I18nProvider>,
);
}
describe("NightscoutCard — parked + test + data-link + invalidation", () => {
it("renders the parked banner + reconnect button when state is parked", () => {
statusPayload = {
it("renders the parked banner + reconnect button when the verdict is parked", () => {
const html = render({
connected: true,
configured: true,
state: "parked",
lastSuccessAt: null,
lastError: "Nightscout unreachable",
};
const html = render();
syncHealth: { verdict: "parked", since: null },
});
expect(html).toContain('data-state="parked"');
expect(html).toContain('data-testid="nightscout-parked-banner"');
expect(html).toContain('data-testid="nightscout-resume-button"');
@@ -58,51 +69,78 @@ describe("NightscoutCard — parked + test + data-link + invalidation", () => {
});
it("renders the test-connection button + data link when connected", () => {
statusPayload = {
const html = render({
connected: true,
configured: true,
state: "connected",
lastSuccessAt: "2026-06-01T00:00:00.000Z",
lastError: null,
};
const html = render();
syncHealth: { verdict: "fresh", since: null },
});
expect(html).toContain("Test connection");
expect(html).toContain('data-testid="nightscout-data-link"');
expect(html).toContain('href="/insights/blood-glucose"');
});
it("disconnect invalidates the status key the query reads (nightscoutStatus)", () => {
statusPayload = { connected: true, configured: true, state: "connected" };
it("carries the shared card divider like every sibling", () => {
const html = render({
connected: true,
configured: true,
syncHealth: { verdict: "fresh", since: null },
});
expect(html).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 },
metricFreshness: [
{
type: "BLOOD_GLUCOSE",
lastSeenAt: "2026-06-01T00:00:00.000Z",
stale: true,
},
],
});
expect(html).toContain('data-slot="metric-freshness"');
expect(html).toContain("quiet");
});
it("disconnect invalidates the envelope the card actually reads", () => {
invalidateSpy.mockClear();
capturedDisconnectOnSuccess.fn = null;
render();
render({
connected: true,
configured: true,
syncHealth: { verdict: "fresh", since: null },
});
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.nightscoutStatus());
expect(invalidatedKeys).toContainEqual(queryKeys.integrationsStatus());
});
});
/**
* v1.32.28 — sync now, matching the treatment the OAuth cards get.
*/
describe("NightscoutCard — sync-now action", () => {
it("offers the sync action on a connected instance", () => {
statusPayload = {
const html = render({
connected: true,
configured: true,
state: "connected",
lastSuccessAt: null,
lastError: null,
};
const html = render();
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", () => {
statusPayload = { connected: false, configured: false };
expect(render()).not.toContain('data-testid="nightscout-sync"');
expect(
render({
connected: false,
configured: false,
state: "disconnected",
syncHealth: { verdict: "disconnected", since: null },
}),
).not.toContain('data-testid="nightscout-sync"');
});
});
@@ -12,10 +12,9 @@ import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { Watch } from "lucide-react";
// Drive the card's own `useQuery` status read off a per-test payload.
let statusPayload: unknown = null;
// 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", () => ({
useQuery: () => ({ data: statusPayload, isLoading: false }),
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
}));
@@ -23,12 +22,14 @@ vi.mock("@tanstack/react-query", () => ({
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?: Parameters<typeof OAuthProviderCard>[0]["viewModel"];
viewModel?: ViewModel;
} = {}) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
@@ -47,15 +48,17 @@ function render({
describe("OAuthProviderCard — parked + test + data-link parity", () => {
it("renders the parked banner + reconnect button when state is parked", () => {
statusPayload = {
connected: true,
configured: true,
available: true,
state: "parked",
lastSuccessAt: null,
lastError: "Polar grant expired",
};
const html = render();
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"');
@@ -65,27 +68,6 @@ describe("OAuthProviderCard — parked + test + data-link parity", () => {
});
it("renders the test-connection button + data link when connected", () => {
statusPayload = {
connected: true,
configured: true,
available: true,
state: "connected",
lastSuccessAt: "2026-06-01T00:00:00.000Z",
lastError: null,
};
const html = render();
// 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 instead of the per-card fetch (04-M2)", () => {
// The per-card useQuery mock returns null; the card must render the
// connected state from the supplied envelope view-model alone, proving the
// /api/<provider>/status round-trip is no longer the source.
statusPayload = null;
const html = render({
viewModel: {
connected: true,
@@ -94,6 +76,29 @@ describe("OAuthProviderCard — parked + test + data-link parity", () => {
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"');
@@ -101,12 +106,14 @@ describe("OAuthProviderCard — parked + test + data-link parity", () => {
});
it("does not render the data link or test button when disconnected", () => {
statusPayload = {
connected: false,
configured: false,
available: true,
};
const html = render();
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.
@@ -116,35 +123,39 @@ describe("OAuthProviderCard — parked + test + data-link parity", () => {
describe("OAuthProviderCard — per-user BYO credentials form (v1.17.1)", () => {
it("renders the credentials form only when the `credentials` prop is set", () => {
statusPayload = {
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 });
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();
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", () => {
statusPayload = {
connected: true,
configured: true,
available: true,
hasOwnCredentials: true,
state: "connected",
lastSuccessAt: null,
lastError: null,
};
const html = render({ credentials: true });
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");
});
});
@@ -152,29 +163,35 @@ describe("OAuthProviderCard — per-user BYO credentials form (v1.17.1)", () =>
describe("OAuthProviderCard — redirect-URI mini-guide (v1.29.x, UX audit H2)", () => {
it("shows the callback URL guide before the user has BYO credentials", () => {
process.env.NEXT_PUBLIC_APP_URL = "https://app.example";
statusPayload = {
connected: false,
configured: false,
available: true,
hasOwnCredentials: false,
};
const html = render({ credentials: true });
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", () => {
statusPayload = {
connected: true,
configured: true,
available: true,
hasOwnCredentials: true,
state: "connected",
lastSuccessAt: null,
lastError: null,
};
const html = render({ credentials: true });
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"');
});
});
@@ -187,25 +204,31 @@ describe("OAuthProviderCard — redirect-URI mini-guide (v1.29.x, UX audit H2)",
*/
describe("OAuthProviderCard — sync-now action", () => {
it("offers the sync action on a connected provider", () => {
statusPayload = {
connected: true,
configured: true,
available: true,
state: "connected",
lastSuccessAt: null,
lastError: null,
};
const html = render();
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", () => {
statusPayload = {
connected: false,
configured: true,
available: true,
};
expect(render()).not.toContain('data-testid="polar-sync"');
expect(
render({
viewModel: {
connected: false,
configured: true,
available: true,
syncHealth: { verdict: "disconnected", since: null },
},
}),
).not.toContain('data-testid="polar-sync"');
});
});
@@ -0,0 +1,82 @@
/**
* 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, sep } from "node:path";
import { globSync } from "node:fs";
import type { SyncVerdict } from "@/lib/integrations/sync-verdict";
import { 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("structural guard — no second pill dialect", () => {
const SRC = join(process.cwd(), "src");
function sourceFiles(): string[] {
return globSync("**/*.{ts,tsx}", { cwd: SRC })
.filter(
(p) => !p.startsWith(`generated${sep}`) && !p.startsWith("generated/"),
)
.filter((p) => !p.includes("__tests__"))
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
.map((p) => p.split(sep).join("/"))
.sort();
}
/**
* 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,24 +1,44 @@
"use client";
/**
* Apple Health — the one integration with no ledger row and no cron.
*
* The iOS app pushes; nothing here polls. The card used to derive its status
* from the mere presence of `lastSyncedAt`, so any timestamp at all painted the
* green "data received" chip — a pipe dead for three weeks looked exactly like
* one that delivered this morning. It now reads the server's verdict, which
* applies a seven-day threshold to the same timestamp, and renders the shared
* pill instead of a bespoke badge so the flagship integration speaks the same
* status language as its siblings.
*/
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { CheckCircle2, HeartPulse, Info, Upload } from "lucide-react";
import { HeartPulse, Upload } from "lucide-react";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { SettingsCard } from "@/components/settings/settings-card";
import { Badge } from "@/components/ui/badge";
import { IntegrationStatusPill } from "@/components/settings/integration-status-pill";
import { Button } from "@/components/ui/button";
import { apiGet } from "@/lib/api/api-fetch";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { useTranslations } from "@/lib/i18n/context";
import type {
MetricFreshnessEntry,
SyncHealth,
} from "@/lib/integrations/sync-verdict";
import { queryKeys } from "@/lib/query-keys";
import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure";
import { IntegrationErrorMessage, pillStateForVerdict } from "./shared";
interface HealthKitStatus {
lastSyncedAt: string | null;
syncHealth?: SyncHealth;
metricFreshness?: MetricFreshnessEntry[];
}
export function AppleHealthCard({ enabled }: { enabled: boolean }) {
const { t } = useTranslations();
const format = useFormatters();
const {
data: status,
isLoading,
@@ -30,28 +50,17 @@ export function AppleHealthCard({ enabled }: { enabled: boolean }) {
refetchOnWindowFocus: true,
});
const lastSyncedAt = status?.lastSyncedAt ?? null;
const statusState = isLoading
? "checking"
: isError
? "unavailable"
: lastSyncedAt
? "recent-data"
: "setup";
const statusLabel = isLoading
? t("settings.appleHealth.status.checking")
: isError
? t("settings.appleHealth.status.unavailable")
: lastSyncedAt
? t("settings.appleHealth.status.dataReceived")
: t("settings.appleHealth.status.setup");
const verdict = status?.syncHealth?.verdict;
const pillState = pillStateForVerdict(verdict);
// A failed status read is an error line, not a status state — the card says
// nothing about the pipe it could not ask about.
const showPill = !isLoading && !isError && !!status;
return (
<SettingsCard
as="section"
aria-labelledby="apple-health-card-title"
data-testid="apple-health-card"
className="space-y-4"
>
<SettingsCardHeader
icon={HeartPulse}
@@ -59,64 +68,68 @@ export function AppleHealthCard({ enabled }: { enabled: boolean }) {
title={t("settings.appleHealth.title")}
description={t("settings.appleHealth.description")}
status={
<Badge
variant={lastSyncedAt ? undefined : "outline"}
data-testid="apple-health-status"
data-state={statusState}
className={
lastSyncedAt
? "border-success/30 bg-success/15 text-success max-w-full whitespace-nowrap"
: "max-w-full whitespace-nowrap"
}
>
{lastSyncedAt ? (
<CheckCircle2 className="h-3 w-3" aria-hidden="true" />
) : (
<Info className="h-3 w-3" aria-hidden="true" />
)}
<span className="truncate">{statusLabel}</span>
</Badge>
showPill ? (
<IntegrationStatusPill
testId="apple-health-status"
state={pillState}
lastSyncAt={status?.lastSyncedAt ?? null}
/>
) : null
}
/>
{lastSyncedAt && (
<p
className="text-muted-foreground text-xs"
data-testid="apple-health-last-data"
>
{t("settings.appleHealth.lastDataLabel")}{" "}
<time dateTime={lastSyncedAt}>{format.dateTime(lastSyncedAt)}</time>
</p>
)}
<hr
data-testid="integration-card-divider"
className="border-border/60 mt-4"
/>
<div className="space-y-2">
<h3 className="text-sm font-semibold">
{t("settings.appleHealth.setupTitle")}
</h3>
<ol className="text-muted-foreground list-decimal space-y-2 pl-5 text-sm">
<li>{t("settings.appleHealth.permissionStep")}</li>
<li>{t("settings.appleHealth.backgroundStep")}</li>
</ol>
</div>
<div className="mt-4 space-y-4 pl-7">
{isError && (
<IntegrationErrorMessage
message={t("settings.appleHealth.status.unavailable")}
/>
)}
<div className="border-border bg-muted/30 flex flex-col gap-3 rounded-lg border p-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-muted-foreground text-xs sm:max-w-xl">
{t("settings.appleHealth.importNote")}
</p>
<Button
asChild
variant="outline"
size="sm"
className="min-h-11 shrink-0 sm:min-h-9"
>
<Link
href="/settings/export#settings-section-import-title"
data-testid="apple-health-import-link"
{verdict === "stale" && (
<p className="text-warning text-sm" data-testid="apple-health-stale">
{t("settings.appleHealth.staleNote")}
</p>
)}
<MetricFreshnessDisclosure
entries={status?.metricFreshness}
idPrefix="apple-health"
/>
<div className="space-y-2">
<h3 className="text-sm font-semibold">
{t("settings.appleHealth.setupTitle")}
</h3>
<ol className="text-muted-foreground list-decimal space-y-2 pl-5 text-sm">
<li>{t("settings.appleHealth.permissionStep")}</li>
<li>{t("settings.appleHealth.backgroundStep")}</li>
</ol>
</div>
<div className="border-border bg-muted/30 flex flex-col gap-3 rounded-lg border p-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-muted-foreground text-xs sm:max-w-xl">
{t("settings.appleHealth.importNote")}
</p>
<Button
asChild
variant="outline"
size="sm"
className="min-h-11 shrink-0 sm:min-h-9"
>
<Upload className="h-3.5 w-3.5" aria-hidden="true" />
{t("settings.appleHealth.importAction")}
</Link>
</Button>
<Link
href="/settings/export#settings-section-import-title"
data-testid="apple-health-import-link"
>
<Upload className="h-3.5 w-3.5" aria-hidden="true" />
{t("settings.appleHealth.importAction")}
</Link>
</Button>
</div>
</div>
</SettingsCard>
);
@@ -20,6 +20,7 @@ import { AppleHealthCard } from "@/components/settings/integrations/apple-health
import { FitbitCard } from "@/components/settings/integrations/fitbit-card";
import { GarminInfoNote } from "@/components/settings/integrations/garmin-info-note";
import { GoogleHealthCard } from "@/components/settings/integrations/google-health-card";
import { IntegrationFallbackRow } from "@/components/settings/integrations/integration-fallback-row";
import { NightscoutCard } from "@/components/settings/integrations/nightscout-card";
import type { OAuthProviderStatus } from "@/components/settings/integrations/oauth-provider-card";
import { OuraCard } from "@/components/settings/integrations/oura-card";
@@ -28,6 +29,7 @@ import { StravaCard } from "@/components/settings/integrations/strava-card";
import {
pickStatus,
useIntegrationStatuses,
type IntegrationKey,
type IntegrationStatusViewModel,
} from "@/components/settings/integrations/shared";
import { WhoopCard } from "@/components/settings/integrations/whoop-card";
@@ -164,9 +166,29 @@ function toOAuthStatus(
lastSuccessAt: vm.lastSuccessAt,
lastAttemptAt: vm.lastAttemptAt,
lastError: vm.lastError,
legacyLastSyncedAt: vm.legacyLastSyncedAt,
syncHealth: vm.syncHealth,
metricFreshness: vm.metricFreshness,
};
}
/**
* The keys a bespoke card on this panel renders. Every OTHER configured
* envelope entry falls through to `IntegrationFallbackRow` — the panel's unit
* of coverage is the envelope entry, not the card, so a provider can never
* again be shipped on the wire and rendered nowhere.
*/
const CARD_BACKED_INTEGRATIONS: ReadonlySet<IntegrationKey> = new Set([
"withings",
"whoop",
"fitbit",
"google-health",
"polar",
"oura",
"strava",
"nightscout",
]);
export function ConnectionsPanel() {
const { t } = useTranslations();
const { isAuthenticated } = useAuth();
@@ -269,6 +291,12 @@ export function ConnectionsPanel() {
const stravaViewModel = toOAuthStatus(
pickStatus(integrationStatus, "strava"),
);
const nightscoutViewModel = pickStatus(integrationStatus, "nightscout");
const unclaimedIntegrations = (integrationStatus?.integrations ?? []).filter(
(entry) =>
!CARD_BACKED_INTEGRATIONS.has(entry.integration) &&
(entry.configured || entry.connected),
);
return (
<div className="space-y-6" data-slot="connections-panel">
@@ -290,17 +318,30 @@ export function ConnectionsPanel() {
<AppleHealthCard enabled={isAuthenticated} />
</div>
<div id="polar" className="scroll-mt-28">
<PolarCard enabled={isAuthenticated} viewModel={polarViewModel} />
<PolarCard viewModel={polarViewModel} />
</div>
<div id="oura" className="scroll-mt-28">
<OuraCard enabled={isAuthenticated} viewModel={ouraViewModel} />
<OuraCard viewModel={ouraViewModel} />
</div>
<div id="strava" className="scroll-mt-28">
<StravaCard enabled={isAuthenticated} viewModel={stravaViewModel} />
<StravaCard viewModel={stravaViewModel} />
</div>
<div id="nightscout" className="scroll-mt-28">
<NightscoutCard enabled={isAuthenticated} />
<NightscoutCard viewModel={nightscoutViewModel} />
</div>
{/* Every configured envelope entry no card above claims. Normally this
renders nothing; on an account carrying a dropped-but-still-running
integration it renders the honest status that provider otherwise has
nowhere to appear. */}
{unclaimedIntegrations.map((entry) => (
<div
key={entry.integration}
id={entry.integration}
className="scroll-mt-28"
>
<IntegrationFallbackRow status={entry} />
</div>
))}
{/* Garmin: no direct connector (business-partner-only). A quiet
non-connector note pointing to the Apple Health / Health Connect
path — not an OAuthProviderCard (nothing to connect). */}
@@ -8,8 +8,10 @@
// the same view-model like WHOOP.
import { useState } from "react";
import Link from "next/link";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
ArrowRight,
HeartPulse,
Link2,
Loader2,
@@ -37,7 +39,6 @@ import { PasswordInput } from "@/components/ui/password-input";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { IntegrationStatusPill } from "@/components/settings/integration-status-pill";
import type { IntegrationPillState } from "@/components/settings/integration-status-pill";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
@@ -50,8 +51,10 @@ import {
import {
IntegrationErrorMessage,
pillStateFor,
pillTimestampFor,
type IntegrationStatusViewModel,
} from "./shared";
import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure";
import {
IntegrationCardDescription,
IntegrationRedirectGuide,
@@ -181,13 +184,16 @@ export function FitbitCard({
setCredsSaving(false);
}
const pillState: IntegrationPillState = status?.connected
? pillStateFor(viewModel)
: "disconnected";
const pillLastSyncAt =
status?.legacyLastSyncedAt ?? viewModel?.lastSuccessAt ?? null;
// The server resolves the verdict; the card only projects it. `connected`
// rides into that resolution, so a disconnected provider still lands on the
// "Not connected" pill without a second local rule here.
const pillState = pillStateFor(viewModel);
const pillLastSyncAt = pillTimestampFor(viewModel);
const errorMessage =
(pillState === "error" || pillState === "parked") && viewModel?.lastError
(pillState === "error" ||
pillState === "parked" ||
pillState === "warning") &&
viewModel?.lastError
? viewModel.lastError
: null;
@@ -272,6 +278,13 @@ export function FitbitCard({
{t("settings.integrationPill.resumeError")}
</p>
)}
{status?.connected && (
<MetricFreshnessDisclosure
entries={viewModel?.metricFreshness}
idPrefix="fitbit"
/>
)}
{resume.isSuccess && resume.data?.wasParked && (
<p
role="status"
@@ -468,6 +481,17 @@ export function FitbitCard({
{syncMsg}
</p>
)}
{/* connect→data loop: a discreet link to where this provider's
readings now surface — doubles as the "your data is richer"
cue. */}
<Link
href="/insights/sleep"
data-testid="fitbit-data-link"
className="text-primary inline-flex items-center gap-1 text-xs underline-offset-2 hover:underline"
>
{t("settings.fitbitViewData")}
<ArrowRight className="h-3 w-3" />
</Link>
</>
) : status?.configured ? (
<Button
@@ -16,8 +16,17 @@
// separate from the connect/disconnect state.
import { useState } from "react";
import Link from "next/link";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Link2, Loader2, RefreshCw, Save, Unlink, Watch } from "lucide-react";
import {
ArrowRight,
Link2,
Loader2,
RefreshCw,
Save,
Unlink,
Watch,
} from "lucide-react";
import {
AlertDialog,
@@ -38,7 +47,6 @@ import { PasswordInput } from "@/components/ui/password-input";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { IntegrationStatusPill } from "@/components/settings/integration-status-pill";
import type { IntegrationPillState } from "@/components/settings/integration-status-pill";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
@@ -51,8 +59,10 @@ import {
import {
IntegrationErrorMessage,
pillStateFor,
pillTimestampFor,
type IntegrationStatusViewModel,
} from "./shared";
import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure";
import { IntegrationCardDescription } from "./setup-guide-link";
export function GoogleHealthCard({
@@ -178,13 +188,16 @@ export function GoogleHealthCard({
setCredsSaving(false);
}
const pillState: IntegrationPillState = status?.connected
? pillStateFor(viewModel)
: "disconnected";
const pillLastSyncAt =
status?.legacyLastSyncedAt ?? viewModel?.lastSuccessAt ?? null;
// The server resolves the verdict; the card only projects it. `connected`
// rides into that resolution, so a disconnected provider still lands on the
// "Not connected" pill without a second local rule here.
const pillState = pillStateFor(viewModel);
const pillLastSyncAt = pillTimestampFor(viewModel);
const errorMessage =
(pillState === "error" || pillState === "parked") && viewModel?.lastError
(pillState === "error" ||
pillState === "parked" ||
pillState === "warning") &&
viewModel?.lastError
? viewModel.lastError
: null;
// The re-consent banner only makes sense while the connection still exists —
@@ -298,6 +311,13 @@ export function GoogleHealthCard({
{t("settings.integrationPill.resumeError")}
</p>
)}
{status?.connected && (
<MetricFreshnessDisclosure
entries={viewModel?.metricFreshness}
idPrefix="google-health"
/>
)}
{resume.isSuccess && resume.data?.wasParked && (
<p
role="status"
@@ -490,6 +510,17 @@ export function GoogleHealthCard({
{syncMsg}
</p>
)}
{/* connect→data loop: a discreet link to where this provider's
readings now surface — doubles as the "your data is richer"
cue. */}
<Link
href="/insights/steps"
data-testid="google-health-data-link"
className="text-primary inline-flex items-center gap-1 text-xs underline-offset-2 hover:underline"
>
{t("settings.googleHealthViewData")}
<ArrowRight className="h-3 w-3" />
</Link>
</>
) : status?.configured ? (
<Button
@@ -0,0 +1,100 @@
"use client";
/**
* The catch-all row — no envelope entry renders nowhere.
*
* A provider whose Settings card was removed kept its sync cron, its ledger
* row and its block on the status envelope, and lost the only surface that
* could have shown it dying. It then stopped syncing for a month and nothing,
* anywhere, said so.
*
* This row closes the class rather than the instance: the connections panel
* iterates the envelope and renders this for every configured entry no bespoke
* card claims. Adding a provider to the envelope without a card now produces a
* visible row instead of a silent JSON block, and a structural test pins that
* nothing can go unrendered.
*
* Read-only by design. A deliberately dropped integration does not get its
* management surface back; it gets an honest status.
*/
import { Plug } from "lucide-react";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import {
formatRelative,
IntegrationStatusPill,
} from "@/components/settings/integration-status-pill";
import { useTranslations } from "@/lib/i18n/context";
import {
pillStateFor,
pillTimestampFor,
type IntegrationKey,
type IntegrationStatusViewModel,
} from "./shared";
/**
* Display names, mirroring the server-side `INTEGRATION_LABELS`. Only the
* card-less keys can ever reach this row, but the map stays complete so a
* future drop needs no edit here.
*/
export const INTEGRATION_DISPLAY_NAMES: Record<IntegrationKey, string> = {
withings: "Withings",
whoop: "WHOOP",
fitbit: "Fitbit",
nightscout: "Nightscout",
polar: "Polar",
oura: "Oura",
"google-health": "Google Health",
strava: "Strava",
moodlog: "moodLog",
};
export function IntegrationFallbackRow({
status,
now,
}: {
status: IntegrationStatusViewModel;
/** Override "now" for deterministic testing. */
now?: Date;
}) {
const { t } = useTranslations();
const pillState = pillStateFor(status);
const reference = now ?? new Date();
const lastAttemptAt =
status.lastAttemptAt ?? status.syncHealth?.since ?? null;
const meta = lastAttemptAt
? t("settings.integrationFallback.lastAttempt", {
relative: formatRelative(
Math.max(0, reference.getTime() - new Date(lastAttemptAt).getTime()),
t,
),
})
: t("settings.integrationFallback.noAttempt");
return (
<SettingsCard
data-testid="integration-fallback-row"
data-integration={status.integration}
>
<SettingsCardHeader
icon={Plug}
title={
INTEGRATION_DISPLAY_NAMES[status.integration] ?? status.integration
}
description={t("settings.integrationFallback.description")}
status={
<IntegrationStatusPill
state={pillState}
lastSyncAt={pillTimestampFor(status)}
now={now}
/>
}
/>
<p className="text-muted-foreground mt-4 pl-7 text-xs">{meta}</p>
</SettingsCard>
);
}
@@ -0,0 +1,154 @@
"use client";
/**
* Per-metric freshness, rendered.
*
* The server has computed a last-seen timestamp per `(source, type)` on every
* status call since v1.27.30 and nothing rendered it. That is the one signal
* that separates "the connection is broken" from "one pipe inside a healthy
* connection is dead" — a revoked HealthKit permission, a per-collection 403,
* an upstream that quietly stopped returning one data type. The integration
* pill cannot express it, because the integration itself is fine.
*
* Collapsed by default: a Withings or Google Health user carries 15-25 types
* and an always-open list would bury the card's actions. The collapsed summary
* still names how many types have gone quiet, so the signal never hides behind
* a click.
*
* Honest absence is structural: the server only reports `(source, type)` pairs
* that have rows, so a type a provider never delivered simply is not here. No
* "expected but missing" row is invented.
*/
import { useState } from "react";
import { AlertTriangle, ChevronDown } from "lucide-react";
import { formatRelative } from "@/components/settings/integration-status-pill";
import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/components/measurements/measurement-list-meta";
import { useTranslations } from "@/lib/i18n/context";
import type { MetricFreshnessEntry } from "@/lib/integrations/sync-verdict";
import { cn } from "@/lib/utils";
/** The pseudo-type carrying a source's newest workout. */
const WORKOUT_TYPE = "WORKOUTS";
/**
* Turn an unmapped enum name into something readable. `MEASUREMENT_TYPE_LABEL_KEYS`
* is a plain record, not enum-complete, so a newly-added type must degrade to
* "Walking asymmetry" rather than leaking `WALKING_ASYMMETRY` at the user.
*/
function humanise(type: string): string {
const words = type.toLowerCase().split("_").join(" ");
return words.charAt(0).toUpperCase() + words.slice(1);
}
export function MetricFreshnessDisclosure({
entries,
idPrefix,
now,
}: {
entries: MetricFreshnessEntry[] | undefined;
/** Namespaces the disclosure's ids so several cards can coexist. */
idPrefix: string;
/** Override "now" for deterministic testing. */
now?: Date;
}) {
const { t } = useTranslations();
const [open, setOpen] = useState(false);
if (!entries?.length) return null;
const reference = now ?? new Date();
const label = (type: string): string => {
if (type === WORKOUT_TYPE)
return t("settings.integrationFreshness.workouts");
const key = MEASUREMENT_TYPE_LABEL_KEYS[type];
return key ? t(key) : humanise(type);
};
const relative = (lastSeenAt: string): string =>
formatRelative(
Math.max(0, reference.getTime() - new Date(lastSeenAt).getTime()),
t,
);
// Dead pipes surface without scrolling; everything else stays alphabetical.
const sorted = [...entries].sort(
(a, b) =>
Number(b.stale) - Number(a.stale) ||
label(a.type).localeCompare(label(b.type)),
);
const staleCount = entries.filter((entry) => entry.stale).length;
const newest = entries.reduce((max, entry) =>
entry.lastSeenAt > max.lastSeenAt ? entry : max,
);
const panelId = `${idPrefix}-metric-freshness-panel`;
return (
<div data-slot="metric-freshness" className="space-y-2">
<button
type="button"
onClick={() => setOpen((value) => !value)}
aria-expanded={open}
aria-controls={panelId}
data-slot="metric-freshness-toggle"
className="flex w-full items-center gap-2 text-left"
>
<ChevronDown
aria-hidden="true"
className={cn(
"text-muted-foreground size-3.5 shrink-0 transition-transform",
open && "rotate-180",
)}
/>
<span className="text-sm font-medium">
{t("settings.integrationFreshness.title")}
</span>
<span className="text-muted-foreground min-w-0 truncate text-xs">
{t("settings.integrationFreshness.summary", {
count: entries.length,
relative: relative(newest.lastSeenAt),
})}
{staleCount > 0 ? (
<span className="text-warning">
{" · "}
{t("settings.integrationFreshness.quiet", { count: staleCount })}
</span>
) : null}
</span>
</button>
{open && (
<ul id={panelId} className="space-y-1">
{sorted.map((entry) => (
<li
key={entry.type}
data-slot="metric-freshness-row"
data-metric={entry.type}
data-state={entry.stale ? "stale" : "fresh"}
className={cn(
"flex items-center justify-between gap-3 text-xs",
entry.stale ? "text-warning" : "text-muted-foreground",
)}
>
<span className="flex min-w-0 items-center gap-1.5">
{entry.stale ? (
<AlertTriangle
aria-hidden="true"
className="size-3 shrink-0"
/>
) : null}
<span className="truncate">{label(entry.type)}</span>
</span>
<time
dateTime={entry.lastSeenAt}
className="shrink-0 tabular-nums"
>
{relative(entry.lastSeenAt)}
</time>
</li>
))}
</ul>
)}
</div>
);
}
@@ -3,13 +3,14 @@
// v1.17.0 — Nightscout CGM integration card. Unlike WHOOP / Fitbit (OAuth,
// BYO-key), Nightscout is a URL + token the self-hoster pastes once: the user
// runs their own instance and HealthLog pulls continuous glucose off it. The
// card has its own status read (`/api/nightscout/status`) rather than the
// consolidated envelope. Warm copy, mobile-first, the private-network opt-in
// toggle maps to `nightscoutAllowPrivateHost`.
// card reads the consolidated `/api/integrations/status` envelope like every
// sibling — it was the last card firing its own status round-trip, and the
// last place a fourth status dialect could hide. Warm copy, mobile-first, the
// private-network opt-in toggle maps to `nightscoutAllowPrivateHost`.
import { useRef, useState } from "react";
import Link from "next/link";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
ArrowRight,
Droplet,
@@ -38,12 +39,9 @@ import { PasswordInput } from "@/components/ui/password-input";
import { Switch } from "@/components/ui/switch";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import {
IntegrationStatusPill,
type IntegrationPillState,
} from "@/components/settings/integration-status-pill";
import { IntegrationStatusPill } from "@/components/settings/integration-status-pill";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import { apiFetchRaw, apiGet, apiPost } from "@/lib/api/api-fetch";
import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import {
invalidateKeys,
@@ -51,43 +49,20 @@ import {
queryKeys,
} from "@/lib/query-keys";
import { IntegrationErrorMessage } from "./shared";
import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure";
import {
IntegrationErrorMessage,
pillStateForVerdict,
pillTimestampFor,
type IntegrationStatusViewModel,
} from "./shared";
import { IntegrationCardDescription } from "./setup-guide-link";
interface NightscoutStatus {
connected: boolean;
configured: boolean;
hasToken?: boolean;
allowPrivateHost?: boolean;
state?:
| "connected"
| "error_transient"
| "error_reauth"
| "disconnected"
| "parked";
lastSuccessAt?: string | null;
lastAttemptAt?: string | null;
lastError?: string | null;
}
/** Map the shared ledger state onto the pill's display state. */
function pillStateFor(
status: NightscoutStatus | undefined,
): IntegrationPillState {
if (!status?.connected) return "disconnected";
switch (status.state) {
case "parked":
return "parked";
case "error_reauth":
return "error";
case "error_transient":
return "warning";
default:
return "connected";
}
}
export function NightscoutCard({ enabled = true }: { enabled?: boolean }) {
export function NightscoutCard({
viewModel,
}: {
viewModel?: IntegrationStatusViewModel;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
@@ -103,12 +78,7 @@ export function NightscoutCard({ enabled = true }: { enabled?: boolean }) {
null,
);
const { data: status } = useQuery({
queryKey: queryKeys.nightscoutStatus(),
queryFn: async () => apiGet<NightscoutStatus>("/api/nightscout/status"),
enabled,
refetchOnWindowFocus: true,
});
const status = viewModel;
const formRef = useRef<HTMLFormElement | null>(null);
@@ -120,12 +90,8 @@ export function NightscoutCard({ enabled = true }: { enabled?: boolean }) {
setUrl("");
setToken("");
setAllowPrivateHost(false);
// The status read keys on `nightscoutStatus()`; invalidate it so the
// pill flips back after a disconnect (matching the Withings / WHOOP
// pattern), alongside the cross-integration envelope.
queryClient.invalidateQueries({
queryKey: queryKeys.nightscoutStatus(),
});
// The card reads the consolidated envelope, so that is the one key a
// disconnect has to invalidate for the pill to flip back.
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
@@ -152,9 +118,6 @@ export function NightscoutCard({ enabled = true }: { enabled?: boolean }) {
setMsgType("success");
setToken("");
void invalidateKeys(queryClient, measurementDependentKeys);
queryClient.invalidateQueries({
queryKey: queryKeys.nightscoutStatus(),
});
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
@@ -191,9 +154,6 @@ export function NightscoutCard({ enabled = true }: { enabled?: boolean }) {
);
setSyncMsgType("success");
void invalidateKeys(queryClient, measurementDependentKeys);
queryClient.invalidateQueries({
queryKey: queryKeys.nightscoutStatus(),
});
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
@@ -209,9 +169,15 @@ export function NightscoutCard({ enabled = true }: { enabled?: boolean }) {
}
}
const pillState = pillStateFor(status);
const pillState = pillStateForVerdict(status?.syncHealth?.verdict);
// `warning` joins the set now that a transient failure paints amber rather
// than red — the detail belongs on this line, not in a colour that tells the
// user to reconnect against something reconnecting cannot fix.
const errorMessage =
(pillState === "error" || pillState === "parked") && status?.lastError
(pillState === "error" ||
pillState === "parked" ||
pillState === "warning") &&
status?.lastError
? status.lastError
: null;
@@ -234,12 +200,15 @@ export function NightscoutCard({ enabled = true }: { enabled?: boolean }) {
status={
<IntegrationStatusPill
state={pillState}
lastSyncAt={status?.lastSuccessAt ?? null}
lastSyncAt={pillTimestampFor(status)}
/>
}
/>
<hr className="border-border/60 mt-4" />
<hr
data-testid="integration-card-divider"
className="border-border/60 mt-4"
/>
<div className="mt-4 space-y-4 pl-7">
{errorMessage && <IntegrationErrorMessage message={errorMessage} />}
@@ -279,6 +248,13 @@ export function NightscoutCard({ enabled = true }: { enabled?: boolean }) {
</div>
)}
{status?.connected && (
<MetricFreshnessDisclosure
entries={status.metricFreshness}
idPrefix="nightscout"
/>
)}
<form ref={formRef} onSubmit={handleConnect} className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="nightscout-url">
@@ -10,7 +10,7 @@
import { useState } from "react";
import Link from "next/link";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
ArrowRight,
Link2,
@@ -38,18 +38,21 @@ import { Label } from "@/components/ui/label";
import { PasswordInput } from "@/components/ui/password-input";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import {
IntegrationStatusPill,
type IntegrationPillState,
} from "@/components/settings/integration-status-pill";
import { IntegrationStatusPill } from "@/components/settings/integration-status-pill";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import { apiFetchRaw, apiGet, apiPost } from "@/lib/api/api-fetch";
import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import type {
MetricFreshnessEntry,
SyncHealth,
} from "@/lib/integrations/sync-verdict";
import {
invalidateKeys,
measurementDependentKeys,
queryKeys,
} from "@/lib/query-keys";
import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure";
import { IntegrationErrorMessage, pillStateForVerdict } from "./shared";
import {
IntegrationCardDescription,
IntegrationRedirectGuide,
@@ -71,28 +74,16 @@ export interface OAuthProviderStatus {
lastSuccessAt?: string | null;
lastAttemptAt?: string | null;
lastError?: string | null;
}
function pillStateFor(
status: OAuthProviderStatus | undefined,
): IntegrationPillState {
if (!status?.connected) return "disconnected";
switch (status.state) {
case "parked":
return "parked";
case "error_reauth":
return "error";
case "error_transient":
return "warning";
default:
return "connected";
}
legacyLastSyncedAt?: string | null;
/** The server-resolved liveness verdict; the pill is a projection of it. */
syncHealth?: SyncHealth;
metricFreshness?: MetricFreshnessEntry[];
}
export interface OAuthProviderCardProps {
/** Lower-case provider key, used for routes + query keys + testids. */
provider: "polar" | "oura" | "strava";
/** The query-key array shared by the status read + the invalidations. */
/** The provider's own query key, invalidated after connect / sync / save. */
statusQueryKey: readonly unknown[];
/** i18n key prefix (e.g. `settings.polar`). */
i18nPrefix: string;
@@ -103,12 +94,14 @@ export interface OAuthProviderCardProps {
/** When set, render a per-user BYO OAuth-credentials form above the connect
* button. The endpoint is the PUT target (e.g. `/api/polar/credentials`). */
credentials?: boolean;
enabled?: boolean;
/**
* When provided, the card reads its status from this view-model (sourced off
* the consolidated `/api/integrations/status` envelope) instead of firing its
* own `/api/<provider>/status` round-trip. v1.17.1 folds Polar/Oura onto the
* same envelope WHOOP/Fitbit already use, so the page reads from one source.
* The card's status, sourced off the consolidated
* `/api/integrations/status` envelope. The card used to fall back to its own
* `/api/<provider>/status` round-trip when this was absent; that fallback is
* gone. Every mount passes a view-model, the per-provider response carries no
* `syncHealth` so the fallback would have painted a wrong verdict, and during
* envelope load it fired three redundant requests for a caller that does not
* exist.
*/
viewModel?: OAuthProviderStatus;
}
@@ -120,7 +113,6 @@ export function OAuthProviderCard({
icon,
dataHref,
credentials = false,
enabled = true,
viewModel,
}: OAuthProviderCardProps) {
const { t } = useTranslations();
@@ -139,17 +131,7 @@ export function OAuthProviderCard({
null,
);
// Read off the consolidated envelope when a view-model is passed; otherwise
// fall back to the per-card status fetch (still used by any caller that has
// not been migrated onto the envelope). The fetch is disabled once a
// view-model is supplied so the page makes one request, not one-per-card.
const { data: fetchedStatus } = useQuery({
queryKey: statusQueryKey,
queryFn: async () => apiGet<OAuthProviderStatus>(`/api/${provider}/status`),
enabled: enabled && !viewModel,
refetchOnWindowFocus: true,
});
const status = viewModel ?? fetchedStatus;
const status = viewModel;
// Sync now. Same shape as the WHOOP card's incremental arm, minus the
// full-history dialog — none of these providers has a full-sync arm to call.
@@ -235,9 +217,16 @@ export function OAuthProviderCard({
},
});
const pillState = pillStateFor(status);
const pillState = pillStateForVerdict(status?.syncHealth?.verdict);
// The pill says what; this line says why. `warning` joins the set now that a
// transient failure no longer paints red — the detail moved here from a
// colour that told the user to reconnect against something reconnecting
// cannot fix.
const errorMessage =
(pillState === "error" || pillState === "parked") && status?.lastError
(pillState === "error" ||
pillState === "parked" ||
pillState === "warning") &&
status?.lastError
? status.lastError
: null;
const serverUnavailable = status && !status.available;
@@ -262,22 +251,25 @@ export function OAuthProviderCard({
status={
<IntegrationStatusPill
state={pillState}
lastSyncAt={status?.lastSuccessAt ?? null}
lastSyncAt={
pillState === "stale" || pillState === "stalled"
? (status?.syncHealth?.since ?? null)
: (status?.legacyLastSyncedAt ?? status?.lastSuccessAt ?? null)
}
/>
}
/>
<hr className="border-border/60 mt-4" />
<hr
data-testid="integration-card-divider"
className="border-border/60 mt-4"
/>
<div className="mt-4 space-y-4 pl-7">
{errorMessage && (
<p
role="alert"
data-testid={`${provider}-error`}
className="text-destructive text-sm break-words"
>
{errorMessage}
</p>
<div data-testid={`${provider}-error`}>
<IntegrationErrorMessage message={errorMessage} />
</div>
)}
{/* Parked-integration resume CTA. Surfaces only when the row state
@@ -310,6 +302,13 @@ export function OAuthProviderCard({
</div>
)}
{status?.connected && (
<MetricFreshnessDisclosure
entries={status.metricFreshness}
idPrefix={provider}
/>
)}
{credentials && (
<div className="space-y-3" data-testid={`${provider}-credentials`}>
<h3 className="text-sm font-semibold">
@@ -12,13 +12,7 @@ import {
} from "@/components/settings/integrations/oauth-provider-card";
import { queryKeys } from "@/lib/query-keys";
export function OuraCard({
enabled = true,
viewModel,
}: {
enabled?: boolean;
viewModel?: OAuthProviderStatus;
}) {
export function OuraCard({ viewModel }: { viewModel?: OAuthProviderStatus }) {
return (
<OAuthProviderCard
provider="oura"
@@ -27,7 +21,6 @@ export function OuraCard({
icon={CircleDot}
dataHref="/insights/sleep"
credentials
enabled={enabled}
viewModel={viewModel}
/>
);
@@ -12,13 +12,7 @@ import {
} from "@/components/settings/integrations/oauth-provider-card";
import { queryKeys } from "@/lib/query-keys";
export function PolarCard({
enabled = true,
viewModel,
}: {
enabled?: boolean;
viewModel?: OAuthProviderStatus;
}) {
export function PolarCard({ viewModel }: { viewModel?: OAuthProviderStatus }) {
return (
<OAuthProviderCard
provider="polar"
@@ -27,7 +21,6 @@ export function PolarCard({
icon={Watch}
dataHref="/insights/sleep"
credentials
enabled={enabled}
viewModel={viewModel}
/>
);
+68 -28
View File
@@ -12,6 +12,11 @@ import { AlertCircle } from "lucide-react";
import type { IntegrationPillState } from "@/components/settings/integration-status-pill";
import { apiGet } from "@/lib/api/api-fetch";
import type {
MetricFreshnessEntry,
SyncHealth,
SyncVerdict,
} from "@/lib/integrations/sync-verdict";
import { queryKeys } from "@/lib/query-keys";
// v1.4.15 Phase B2: shared status payload for both integration cards.
@@ -28,7 +33,10 @@ export type IntegrationKey =
// v1.27.0 — Google Health (Fitbit + Pixel Watch + Fitbit Air).
| "google-health"
// v1.28.x — Strava OAuth workout source.
| "strava";
| "strava"
// Nightscout folds onto the envelope so the card stops fetching its own
// status and inherits the shared verdict.
| "nightscout";
export type IntegrationState =
"connected" | "error_transient" | "error_reauth" | "disconnected" | "parked";
@@ -68,6 +76,16 @@ export interface IntegrationStatusViewModel {
// drives the saved-placeholder UI.
available?: boolean;
hasOwnCredentials?: boolean;
// Nightscout: a stored token, and the private-network opt-in.
hasToken?: boolean;
allowPrivateHost?: boolean;
/**
* The server-resolved liveness verdict. Present on every envelope entry;
* `undefined` only while the envelope is still loading.
*/
syncHealth?: SyncHealth;
/** Per-metric last-seen timestamps with the server-computed stale flag. */
metricFreshness?: MetricFreshnessEntry[];
}
export interface IntegrationStatusEnvelope {
@@ -100,44 +118,66 @@ export function pickStatus(
}
/**
* Collapse the API's five-state machine into the four states the
* pill UI cares about. `error_transient` and `error_reauth` both
* surface as the same "Error — reconnect" pill, the actionable
* difference (whether the user must reconnect vs wait for the next
* retry) is conveyed via the inline error text underneath. `parked`
* (v1.4.43 W14) is its own pill state — the integration has been
* disabled after 24h of persistent failures and needs an explicit
* "Wieder verbinden" click to resume.
* The one verdict→pill mapping in the tree.
*
* There used to be three of these, one per card family, and they disagreed:
* the same ledger state painted red on one card and orange on the next, on the
* same page. The server now resolves the verdict once and this projects it once
* — a fourth dialect cannot be minted without the structural guard noticing.
*
* `failing` deliberately lands on the warning tone, not the destructive one:
* **red is reserved for "your action fixes this"** (reconnect, resume). Telling
* a user to reconnect against a 503 sends them clicking at something they
* cannot repair; the streak detail survives in the inline error line and the
* "{n}/{threshold} consecutive failures" string the envelope single-sources.
*/
export function pillStateFor(
status: IntegrationStatusViewModel | undefined,
export function pillStateForVerdict(
verdict: SyncVerdict | undefined,
): IntegrationPillState {
if (!status) return "disconnected";
switch (status.state) {
case "connected":
switch (verdict) {
case "fresh":
return "connected";
case "error_transient":
// v1.4.43 W4 H3 — a `persistent` failure-kind streak (Withings
// rate-limit 601 / contract-mismatch 293/294) maps to the same
// `error_transient` DB state as a normal retryable failure but
// tells the user a different story: the access token still
// works, the upstream is responding with a non-recoverable
// status. Surfacing it as a "warning" pill (orange) instead of
// the red "Fehler — neu verbinden" stops the user from clicking
// reconnect ten times when reconnect can't fix it.
if ((status.consecutiveFailuresByKind?.persistent ?? 0) > 0) {
return "warning";
}
return "error";
case "error_reauth":
case "stale":
return "stale";
case "stalled":
return "stalled";
case "failing":
return "warning";
case "reauth_required":
return "error";
case "parked":
return "parked";
case "pending_first_sync":
return "pending-setup";
case "disconnected":
case undefined:
return "disconnected";
}
}
/** Project a view-model onto its pill state. Undefined = still loading. */
export function pillStateFor(
status: IntegrationStatusViewModel | undefined,
): IntegrationPillState {
return pillStateForVerdict(status?.syncHealth?.verdict);
}
/**
* The timestamp the pill shows inline. `connected` reads the last success (a
* connection row's own `lastSyncedAt` wins when it is newer, which is what
* repaints a fresh "just now" straight after a manual sync); the two aging
* states read the instant that triggered the verdict.
*/
export function pillTimestampFor(
status: IntegrationStatusViewModel | undefined,
): string | null {
const verdict = status?.syncHealth?.verdict;
if (verdict === "stale" || verdict === "stalled") {
return status?.syncHealth?.since ?? null;
}
return status?.legacyLastSyncedAt ?? status?.lastSuccessAt ?? null;
}
/**
* Inline actionable error message that surfaces under the pill when a
* sync attempt failed. The pill conveys "something is wrong"; this
@@ -13,13 +13,7 @@ import {
} from "@/components/settings/integrations/oauth-provider-card";
import { queryKeys } from "@/lib/query-keys";
export function StravaCard({
enabled = true,
viewModel,
}: {
enabled?: boolean;
viewModel?: OAuthProviderStatus;
}) {
export function StravaCard({ viewModel }: { viewModel?: OAuthProviderStatus }) {
return (
<OAuthProviderCard
provider="strava"
@@ -28,7 +22,6 @@ export function StravaCard({
icon={Activity}
dataHref="/insights/workouts"
credentials
enabled={enabled}
viewModel={viewModel}
/>
);
@@ -1,9 +1,11 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
Activity,
ArrowRight,
Link2,
Loader2,
RefreshCw,
@@ -29,7 +31,6 @@ import { PasswordInput } from "@/components/ui/password-input";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { IntegrationStatusPill } from "@/components/settings/integration-status-pill";
import type { IntegrationPillState } from "@/components/settings/integration-status-pill";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
@@ -42,8 +43,10 @@ import {
import {
IntegrationErrorMessage,
pillStateFor,
pillTimestampFor,
type IntegrationStatusViewModel,
} from "./shared";
import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure";
import {
IntegrationCardDescription,
IntegrationRedirectGuide,
@@ -175,18 +178,21 @@ export function WhoopCard({
setCredsSaving(false);
}
const pillState: IntegrationPillState = status?.connected
? pillStateFor(viewModel)
: "disconnected";
const pillLastSyncAt =
status?.legacyLastSyncedAt ?? viewModel?.lastSuccessAt ?? null;
// The server resolves the verdict; the card only projects it. `connected`
// rides into that resolution, so a disconnected provider still lands on the
// "Not connected" pill without a second local rule here.
const pillState = pillStateFor(viewModel);
const pillLastSyncAt = pillTimestampFor(viewModel);
const errorMessage =
(pillState === "error" || pillState === "parked") && viewModel?.lastError
(pillState === "error" ||
pillState === "parked" ||
pillState === "warning") &&
viewModel?.lastError
? viewModel.lastError
: null;
return (
<SettingsCard>
<SettingsCard data-testid="whoop-card">
<SettingsCardHeader
icon={Activity}
title={t("settings.whoop")}
@@ -253,6 +259,13 @@ export function WhoopCard({
{t("settings.integrationPill.resumeError")}
</p>
)}
{status?.connected && (
<MetricFreshnessDisclosure
entries={viewModel?.metricFreshness}
idPrefix="whoop"
/>
)}
{resume.isSuccess && resume.data?.wasParked && (
<p
role="status"
@@ -449,6 +462,17 @@ export function WhoopCard({
{syncMsg}
</p>
)}
{/* connect→data loop: a discreet link to where this provider's
readings now surface — doubles as the "your data is richer"
cue. */}
<Link
href="/insights/recovery"
data-testid="whoop-data-link"
className="text-primary inline-flex items-center gap-1 text-xs underline-offset-2 hover:underline"
>
{t("settings.whoopViewData")}
<ArrowRight className="h-3 w-3" />
</Link>
</>
) : status?.configured ? (
<Button
@@ -1,8 +1,16 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Link2, Loader2, RefreshCw, Save, Unlink } from "lucide-react";
import {
ArrowRight,
Link2,
Loader2,
RefreshCw,
Save,
Unlink,
} from "lucide-react";
import {
AlertDialog,
@@ -22,7 +30,6 @@ import { PasswordInput } from "@/components/ui/password-input";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { IntegrationStatusPill } from "@/components/settings/integration-status-pill";
import type { IntegrationPillState } from "@/components/settings/integration-status-pill";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
@@ -35,8 +42,10 @@ import {
import {
IntegrationErrorMessage,
pillStateFor,
pillTimestampFor,
type IntegrationStatusViewModel,
} from "./shared";
import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure";
import {
IntegrationCardDescription,
IntegrationRedirectGuide,
@@ -180,21 +189,24 @@ export function WithingsCard({
// still paints a fresh "X min ago" right after a manual sync once
// the envelope refetches. When the envelope hasn't answered yet we
// fall back to "disconnected" so the card never renders status-less.
const pillState: IntegrationPillState = status?.connected
? pillStateFor(viewModel)
: "disconnected";
const pillLastSyncAt =
status?.legacyLastSyncedAt ?? viewModel?.lastSuccessAt ?? null;
// The server resolves the verdict; the card only projects it. `connected`
// rides into that resolution, so a disconnected provider still lands on the
// "Not connected" pill without a second local rule here.
const pillState = pillStateFor(viewModel);
const pillLastSyncAt = pillTimestampFor(viewModel);
// v1.4.43 W14 — `parked` and `error` both want the underlying error
// message surfaced under the pill (the pill says "what" — the
// message says "why"). Other states leave the inline line off.
const errorMessage =
(pillState === "error" || pillState === "parked") && viewModel?.lastError
(pillState === "error" ||
pillState === "parked" ||
pillState === "warning") &&
viewModel?.lastError
? viewModel.lastError
: null;
return (
<SettingsCard>
<SettingsCard data-testid="withings-card">
<SettingsCardHeader
icon={Link2}
title={t("settings.withings")}
@@ -289,6 +301,13 @@ export function WithingsCard({
{t("settings.integrationPill.resumeError")}
</p>
)}
{status?.connected && (
<MetricFreshnessDisclosure
entries={viewModel?.metricFreshness}
idPrefix="withings"
/>
)}
{resume.isSuccess && resume.data?.wasParked && (
<p
role="status"
@@ -494,6 +513,17 @@ export function WithingsCard({
{syncMsg}
</p>
)}
{/* connect→data loop: a discreet link to where this provider's
readings now surface — doubles as the "your data is richer"
cue. */}
<Link
href="/measurements"
data-testid="withings-data-link"
className="text-primary inline-flex items-center gap-1 text-xs underline-offset-2 hover:underline"
>
{t("settings.withingsViewData")}
<ArrowRight className="h-3 w-3" />
</Link>
</>
) : status?.configured ? (
<Button
@@ -1,24 +1,37 @@
/**
* F-SYNC-1 — per-metric-type last-value freshness.
* Per-metric-type last-value freshness.
*
* Pins that the helper distinguishes a silently-dead metric (its newest reading
* frozen in the past) from a genuinely-current one, keyed by integration, from
* one grouped read over the live Measurement rows — the honest signal the
* per-integration "connected · 5 min ago" pill cannot carry.
* grouped reads over the live rows — the honest signal the per-integration
* "connected · 5 min ago" pill cannot carry.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const groupByMock = vi.hoisted(() => vi.fn());
const workoutGroupByMock = vi.hoisted(() => vi.fn());
vi.mock("@/lib/db", () => ({
prisma: { measurement: { groupBy: groupByMock } },
prisma: {
measurement: { groupBy: groupByMock },
workout: { groupBy: workoutGroupByMock },
},
}));
import {
getSourceFreshness,
getSourceMetricFreshness,
INTEGRATION_MEASUREMENT_SOURCE,
} from "../metric-freshness";
import {
classifyMetricFreshness,
METRIC_QUIET_AFTER_MS,
} from "../sync-verdict";
beforeEach(() => groupByMock.mockReset());
beforeEach(() => {
groupByMock.mockReset();
workoutGroupByMock.mockReset();
workoutGroupByMock.mockResolvedValue([]);
});
describe("getSourceMetricFreshness", () => {
it("maps grouped source/type rows to per-integration last-seen entries", async () => {
@@ -79,4 +92,119 @@ describe("getSourceMetricFreshness", () => {
groupByMock.mockResolvedValue([]);
expect(await getSourceMetricFreshness("u1")).toEqual({});
});
it("carries the workout leg for a source that writes no measurements", async () => {
// Strava writes Workout rows only; without this leg its freshness list is
// permanently empty and its one real pipe is invisible.
groupByMock.mockResolvedValue([]);
workoutGroupByMock.mockResolvedValue([
{
source: "STRAVA",
_max: { startedAt: new Date("2026-07-20T06:00:00.000Z") },
},
]);
const result = await getSourceMetricFreshness("u1");
expect(result.strava).toEqual([
{ type: "WORKOUTS", lastSeenAt: "2026-07-20T06:00:00.000Z" },
]);
});
it("appends the workout leg beside a source's own measurements", async () => {
groupByMock.mockResolvedValue([
{
source: "POLAR",
type: "SLEEP_DURATION",
_max: { measuredAt: new Date("2026-07-24T04:00:00.000Z") },
},
]);
workoutGroupByMock.mockResolvedValue([
{
source: "POLAR",
_max: { startedAt: new Date("2026-07-19T17:00:00.000Z") },
},
]);
expect((await getSourceMetricFreshness("u1")).polar).toEqual([
{ type: "SLEEP_DURATION", lastSeenAt: "2026-07-24T04:00:00.000Z" },
{ type: "WORKOUTS", lastSeenAt: "2026-07-19T17:00:00.000Z" },
]);
});
});
describe("getSourceFreshness — one source, for Apple Health", () => {
it("reads only the requested source", async () => {
groupByMock.mockResolvedValue([
{
source: "APPLE_HEALTH",
type: "RESPIRATORY_RATE",
_max: { measuredAt: new Date("2026-07-01T00:00:00.000Z") },
},
]);
expect(await getSourceFreshness("u1", "APPLE_HEALTH")).toEqual([
{ type: "RESPIRATORY_RATE", lastSeenAt: "2026-07-01T00:00:00.000Z" },
]);
expect(groupByMock.mock.calls[0][0].where.source.in).toEqual([
"APPLE_HEALTH",
]);
});
});
describe("classifyMetricFreshness — the stale flag", () => {
const NOW = new Date("2026-07-25T12:00:00.000Z");
const ago = (ms: number) => new Date(NOW.getTime() - ms).toISOString();
const DAY = 24 * 60 * 60 * 1000;
it("flags a quiet metric only while the provider itself reads healthy", () => {
const samples = [{ type: "WEIGHT", lastSeenAt: ago(20 * DAY) }];
// Healthy pipe, one silent metric: the dead-pipe signature.
expect(classifyMetricFreshness(samples, "fresh", NOW)[0].stale).toBe(true);
// The whole connection has stopped: every metric ages together, so tinting
// them would only restate what the pill already says.
expect(classifyMetricFreshness(samples, "stalled", NOW)[0].stale).toBe(
false,
);
expect(classifyMetricFreshness(samples, "failing", NOW)[0].stale).toBe(
false,
);
expect(classifyMetricFreshness(samples, "stale", NOW)[0].stale).toBe(false);
});
it("exempts the types that are irregular by nature", () => {
// A VO2 max estimate every few weeks is normal; a workout gap is a rest
// week, not a broken pipe.
for (const type of [
"VO2_MAX",
"IRREGULAR_RHYTHM_NOTIFICATION",
"WORKOUTS",
]) {
const [entry] = classifyMetricFreshness(
[{ type, lastSeenAt: ago(60 * DAY) }],
"fresh",
NOW,
);
expect(entry.stale, type).toBe(false);
// The honest timestamp still renders — exempt means untinted, not hidden.
expect(entry.lastSeenAt).toBe(ago(60 * DAY));
}
});
it("draws the quiet line at fourteen days", () => {
// Deliberately generous: a weekly weigher must not be warned after a
// holiday, and a false "this is broken" costs more trust than a few days
// of latency on a real one.
expect(METRIC_QUIET_AFTER_MS).toBe(14 * DAY);
const at = (ms: number) =>
classifyMetricFreshness(
[{ type: "PULSE", lastSeenAt: ago(ms) }],
"fresh",
NOW,
)[0].stale;
expect(at(13 * DAY)).toBe(false);
expect(at(15 * DAY)).toBe(true);
});
});
@@ -0,0 +1,207 @@
/**
* The sync-health ladder.
*
* The load-bearing row is `stalled`. A ledger frozen at `error_transient` with
* a month-old `lastAttemptAt` used to be indistinguishable from one retrying
* every hour, because nothing anywhere read the age of that timestamp. The
* fixture below is the live incident that exposed it: an integration that had
* not even TRIED for four weeks while claiming, in its own documented contract,
* that it "still attempts on the next run".
*/
import { describe, expect, it } from "vitest";
import {
APPLE_HEALTH_DATA_STALE_AFTER_MS,
ATTEMPT_STALE_AFTER_MS,
INTEGRATION_CADENCE,
POLLED_CADENCE,
PUSH_CADENCE,
resolveSyncVerdict,
} from "../sync-verdict";
const NOW = new Date("2026-07-25T12:00:00.000Z");
const nowMs = NOW.getTime();
const ago = (ms: number) => new Date(nowMs - ms).toISOString();
const HOUR = 60 * 60 * 1000;
const DAY = 24 * HOUR;
describe("resolveSyncVerdict — the ladder", () => {
it("calls a month-silent polled integration stalled, not failing", () => {
// The live incident: state frozen at error_transient since the last write,
// no attempt for four weeks, hourly cron cohort.
const lastAttemptAt = ago(28 * DAY);
const result = resolveSyncVerdict({
configured: true,
state: "error_transient",
lastSuccessAt: ago(55 * DAY),
lastAttemptAt,
cadence: INTEGRATION_CADENCE.moodlog,
now: NOW,
});
expect(result.verdict).toBe("stalled");
expect(result.since).toBe(lastAttemptAt);
});
it("calls a recently-attempted failing integration failing", () => {
const lastSuccessAt = ago(3 * DAY);
expect(
resolveSyncVerdict({
connected: true,
state: "error_transient",
lastSuccessAt,
lastAttemptAt: ago(2 * HOUR),
cadence: POLLED_CADENCE,
now: NOW,
}),
).toEqual({ verdict: "failing", since: lastSuccessAt });
});
it("draws the stalled line at 24 hours", () => {
// The threshold is a decision, not an implementation detail: ~24 missed
// hourly ticks, tolerant of a maintenance window, short enough that a
// stopped sync surfaces the next day.
expect(ATTEMPT_STALE_AFTER_MS).toBe(24 * HOUR);
const base = {
connected: true,
state: "connected",
lastSuccessAt: null,
cadence: POLLED_CADENCE,
now: NOW,
};
expect(
resolveSyncVerdict({ ...base, lastAttemptAt: ago(23 * HOUR) }).verdict,
).toBe("fresh");
expect(
resolveSyncVerdict({ ...base, lastAttemptAt: ago(25 * HOUR) }).verdict,
).toBe("stalled");
});
it("never calls a push source stalled — it has no attempt concept", () => {
// Same 28-day silence, push cadence: the honest reading is 'no data', not
// 'stopped trying', because nothing was ever going to try.
const lastSyncedAt = ago(28 * DAY);
expect(
resolveSyncVerdict({
configured: true,
lastDataAt: lastSyncedAt,
legacyLastSyncedAt: lastSyncedAt,
cadence: PUSH_CADENCE,
now: NOW,
}),
).toEqual({ verdict: "stale", since: lastSyncedAt });
});
it("draws the Apple Health line at seven days", () => {
// Long enough not to false-alarm on a normal iOS background-delivery gap,
// short enough that a genuinely dead pipe is named within a week.
expect(APPLE_HEALTH_DATA_STALE_AFTER_MS).toBe(7 * DAY);
const base = { configured: true, cadence: PUSH_CADENCE, now: NOW };
expect(
resolveSyncVerdict({ ...base, lastDataAt: ago(6 * DAY) }).verdict,
).toBe("fresh");
expect(
resolveSyncVerdict({ ...base, lastDataAt: ago(8 * DAY) }).verdict,
).toBe("stale");
});
it("reports a never-attempted connection as pending, not connected", () => {
expect(
resolveSyncVerdict({
connected: true,
configured: true,
// The synthetic "connected, never attempted" ledger snapshot.
state: "connected",
lastSuccessAt: null,
lastAttemptAt: null,
cadence: POLLED_CADENCE,
now: NOW,
}),
).toEqual({ verdict: "pending_first_sync", since: null });
});
it("puts parked and reauth ahead of every age-based arm", () => {
const lastAttemptAt = ago(40 * DAY);
expect(
resolveSyncVerdict({
connected: true,
state: "parked",
lastAttemptAt,
lastSuccessAt: ago(60 * DAY),
cadence: POLLED_CADENCE,
now: NOW,
}),
).toEqual({ verdict: "parked", since: lastAttemptAt });
expect(
resolveSyncVerdict({
connected: true,
state: "error_reauth",
lastAttemptAt,
lastSuccessAt: ago(60 * DAY),
cadence: POLLED_CADENCE,
now: NOW,
}),
).toEqual({ verdict: "reauth_required", since: lastAttemptAt });
});
it("keeps a deliberate disconnect out of the aging arms", () => {
// Credentials survive a disconnect. Without this arm the row would age into
// 'stalled' and tell the user their sync had stopped, when they stopped it.
expect(
resolveSyncVerdict({
connected: false,
configured: true,
state: "disconnected",
lastSuccessAt: ago(90 * DAY),
lastAttemptAt: ago(90 * DAY),
cadence: POLLED_CADENCE,
now: NOW,
}),
).toEqual({ verdict: "disconnected", since: null });
});
it("reports an unconfigured, unconnected provider as disconnected", () => {
expect(
resolveSyncVerdict({
configured: false,
cadence: POLLED_CADENCE,
now: NOW,
}),
).toEqual({ verdict: "disconnected", since: null });
});
it("falls back to the data-age arm for a polled source that keeps trying", () => {
// Defensive arm: attempts are current, but nothing new has landed.
const lastSuccessAt = ago(5 * DAY);
expect(
resolveSyncVerdict({
connected: true,
state: "connected",
lastSuccessAt,
lastAttemptAt: ago(30 * 60 * 1000),
cadence: POLLED_CADENCE,
now: NOW,
}),
).toEqual({ verdict: "stale", since: lastSuccessAt });
});
it("calls a healthy hourly integration fresh", () => {
expect(
resolveSyncVerdict({
connected: true,
state: "connected",
lastSuccessAt: ago(20 * 60 * 1000),
lastAttemptAt: ago(20 * 60 * 1000),
cadence: POLLED_CADENCE,
now: NOW,
}),
).toEqual({ verdict: "fresh", since: null });
});
it("puts every ledger-backed integration on the polled cadence", () => {
for (const cadence of Object.values(INTEGRATION_CADENCE)) {
expect(cadence.attemptStaleAfterMs).toBe(24 * HOUR);
}
});
});
+141 -39
View File
@@ -1,5 +1,5 @@
/**
* F-SYNC-1 — per-metric-type freshness (the smallest honest signal).
* Per-metric-type freshness (the smallest honest signal).
*
* Freshness was tracked only per-integration (`IntegrationStatus.lastSuccessAt`
* / a connection's `lastSyncedAt`). A provider that returns HTTP 200 with an
@@ -12,19 +12,33 @@
* This computes the last-value timestamp per `(source, type)` straight from the
* `Measurement` table (no schema change, no migration) so a caller can surface
* per-metric liveness honestly: a metric whose newest reading is frozen weeks in
* the past is visibly distinct from one that is genuinely current. The coarser
* "is this stale for THIS metric's expected cadence" classification (per-type
* thresholds / an expected-cadence probe cron) is the deferred fuller model;
* this exposes the raw honest timestamp the UI renders as "last seen …".
* the past is visibly distinct from one that is genuinely current. The
* classification of "quiet" vs "current" is a pure function next to the verdict
* (`classifyMetricFreshness` in `./sync-verdict`); this module only reads.
*
* Two sources write no `Measurement` rows for part of what they deliver:
* Strava writes only `Workout` rows, and Polar's workout leg is one of its two
* cron legs. A second grouped read over `Workout` appends a `WORKOUTS`
* pseudo-entry so those pipes are visible too.
*/
import { prisma } from "@/lib/db";
import type { MeasurementSource } from "@/generated/prisma/client";
import type { IntegrationKey } from "./status";
import {
WORKOUT_FRESHNESS_TYPE,
type MetricFreshnessSample,
} from "./sync-verdict";
export type {
MetricFreshnessEntry,
MetricFreshnessSample,
} from "./sync-verdict";
/**
* The `MeasurementSource` each sync integration's rows carry. `moodlog` is
* absent — it writes `MoodEntry` rows, not `Measurement` rows, so it has no
* per-metric measurement freshness.
* per-metric measurement freshness. `strava` is absent from the measurement
* map for the same reason and picks up its `WORKOUTS` entry below.
*/
export const INTEGRATION_MEASUREMENT_SOURCE: Partial<
Record<IntegrationKey, MeasurementSource>
@@ -38,53 +52,141 @@ export const INTEGRATION_MEASUREMENT_SOURCE: Partial<
"google-health": "GOOGLE_HEALTH",
};
/** Newest recorded reading for one `(source, type)` pair. */
export interface MetricFreshnessEntry {
/** The `MeasurementType` (e.g. "RESPIRATORY_RATE"). */
type: string;
/** ISO timestamp of the newest (non-deleted) reading for this metric. */
lastSeenAt: string;
/**
* The `MeasurementSource` each workout-writing integration's `Workout` rows
* carry. Strava is workouts-only; Polar syncs workouts alongside its
* measurements.
*/
export const INTEGRATION_WORKOUT_SOURCE: Partial<
Record<IntegrationKey, MeasurementSource>
> = {
strava: "STRAVA",
polar: "POLAR",
};
/**
* Newest measurement per `(source, type)` for the given sources. One grouped
* query; a `(source, type)` pair with no rows simply has no entry, which is how
* honest absence stays structural — a metric a provider never delivered is
* never invented.
*/
export async function getMeasurementFreshnessBySource(
userId: string,
sources: readonly MeasurementSource[],
): Promise<Map<MeasurementSource, MetricFreshnessSample[]>> {
const out = new Map<MeasurementSource, MetricFreshnessSample[]>();
if (sources.length === 0) return out;
const grouped = await prisma.measurement.groupBy({
by: ["source", "type"],
where: { userId, deletedAt: null, source: { in: [...sources] } },
_max: { measuredAt: true },
});
for (const row of grouped) {
const lastSeen = row._max.measuredAt;
if (!lastSeen) continue;
const list = out.get(row.source) ?? [];
list.push({ type: row.type, lastSeenAt: lastSeen.toISOString() });
out.set(row.source, list);
}
return out;
}
/**
* Newest workout per source. `Workout` rows are hard-deleted, so there is no
* tombstone predicate to apply.
*/
export async function getWorkoutFreshnessBySource(
userId: string,
sources: readonly MeasurementSource[],
): Promise<Map<MeasurementSource, string>> {
const out = new Map<MeasurementSource, string>();
if (sources.length === 0) return out;
const grouped = await prisma.workout.groupBy({
by: ["source"],
where: { userId, source: { in: [...sources] } },
_max: { startedAt: true },
});
for (const row of grouped) {
const lastSeen = row._max.startedAt;
if (!lastSeen) continue;
out.set(row.source, lastSeen.toISOString());
}
return out;
}
/**
* Per-metric last-value timestamps for one measurement source, sorted
* alphabetically for a stable wire shape. Used by the Apple Health route, whose
* transport sits outside the integration envelope.
*/
export async function getSourceFreshness(
userId: string,
source: MeasurementSource,
): Promise<MetricFreshnessSample[]> {
const [measurements, workouts] = await Promise.all([
getMeasurementFreshnessBySource(userId, [source]),
getWorkoutFreshnessBySource(userId, [source]),
]);
const entries = measurements.get(source) ?? [];
const workoutSeenAt = workouts.get(source);
if (workoutSeenAt) {
entries.push({
type: WORKOUT_FRESHNESS_TYPE,
lastSeenAt: workoutSeenAt,
});
}
return sortByType(entries);
}
/**
* Compute per-`(integration, type)` last-value timestamps for the sync
* integrations, keyed by `IntegrationKey`. One grouped query over the live
* `Measurement` rows; a source with no rows simply has no entry. Types are
* sorted alphabetically for a stable wire shape.
* integrations, keyed by `IntegrationKey`. Two grouped queries (measurements +
* workouts); a source with no rows simply has no entry.
*/
export async function getSourceMetricFreshness(
userId: string,
): Promise<Partial<Record<IntegrationKey, MetricFreshnessEntry[]>>> {
const sources = Object.values(INTEGRATION_MEASUREMENT_SOURCE);
const grouped = await prisma.measurement.groupBy({
by: ["source", "type"],
where: { userId, deletedAt: null, source: { in: sources } },
_max: { measuredAt: true },
});
): Promise<Partial<Record<IntegrationKey, MetricFreshnessSample[]>>> {
const [measurements, workouts] = await Promise.all([
getMeasurementFreshnessBySource(
userId,
Object.values(INTEGRATION_MEASUREMENT_SOURCE),
),
getWorkoutFreshnessBySource(
userId,
Object.values(INTEGRATION_WORKOUT_SOURCE),
),
]);
// Invert the source→key map once so each grouped row resolves its integration.
const sourceToKey = new Map<MeasurementSource, IntegrationKey>(
(
Object.entries(INTEGRATION_MEASUREMENT_SOURCE) as Array<
[IntegrationKey, MeasurementSource]
>
).map(([key, source]) => [source, key]),
);
const out: Partial<Record<IntegrationKey, MetricFreshnessSample[]>> = {};
const out: Partial<Record<IntegrationKey, MetricFreshnessEntry[]>> = {};
for (const row of grouped) {
const lastSeen = row._max.measuredAt;
if (!lastSeen) continue;
const key = sourceToKey.get(row.source);
if (!key) continue;
for (const [key, source] of Object.entries(
INTEGRATION_MEASUREMENT_SOURCE,
) as Array<[IntegrationKey, MeasurementSource]>) {
const entries = measurements.get(source);
if (entries?.length) out[key] = entries;
}
for (const [key, source] of Object.entries(
INTEGRATION_WORKOUT_SOURCE,
) as Array<[IntegrationKey, MeasurementSource]>) {
const lastSeenAt = workouts.get(source);
if (!lastSeenAt) continue;
(out[key] ??= []).push({
type: row.type,
lastSeenAt: lastSeen.toISOString(),
type: WORKOUT_FRESHNESS_TYPE,
lastSeenAt,
});
}
for (const key of Object.keys(out) as IntegrationKey[]) {
out[key]!.sort((a, b) => a.type.localeCompare(b.type));
out[key] = sortByType(out[key]!);
}
return out;
}
function sortByType(entries: MetricFreshnessSample[]): MetricFreshnessSample[] {
return [...entries].sort((a, b) => a.type.localeCompare(b.type));
}
+309
View File
@@ -0,0 +1,309 @@
/**
* Server-side sync-health verdict — the one place liveness is decided.
*
* The ledger (`src/lib/integrations/status.ts`) records what the last sync
* attempt DID. It has no way to express what an attempt is no longer doing:
* `error_transient` documents itself as "the sync entry-point still attempts on
* the next run", yet a row can sit at that value for a month while nothing runs
* at all — and nothing anywhere read the AGE of `lastAttemptAt`, so the state
* was unrepresentable. A provider that had stopped even trying looked exactly
* like one that was retrying every hour.
*
* This module derives the missing verdict as a read-side projection. It writes
* nothing, adds no cron, and cannot desynchronise from the row it reads. The
* status envelope computes it per entry from data it already has in hand (zero
* additional queries) and every consumer — the Settings cards, the fallback
* row, the MCP status tool — renders the same answer.
*
* The module is deliberately pure: no Prisma import, no I/O, `import type` only.
* That keeps it importable from client components (the pill mapper reads the
* verdict union) without dragging the database client into the browser bundle.
*/
import type { IntegrationKey } from "./status";
/**
* The eight verdicts, from healthiest to least.
*
* fresh → syncing, data is current.
* stale → syncing (or push-fed) but no new data for a while.
* The primary arm for push-based Apple Health.
* stalled → no sync ATTEMPT for longer than the cadence allows.
* Not "failing" — not being tried at all.
* failing → attempting and erroring (transient or persistent).
* reauth_required → the grant is gone; the user must reconnect.
* parked → paused after a persistent failure streak; needs a
* manual resume.
* pending_first_sync → connected/configured but no attempt has been made yet.
* disconnected → no live connection.
*/
export type SyncVerdict =
| "fresh"
| "stale"
| "stalled"
| "failing"
| "reauth_required"
| "parked"
| "pending_first_sync"
| "disconnected";
/** The verdict plus the instant that triggered it (null when nothing did). */
export interface SyncHealth {
verdict: SyncVerdict;
since: string | null;
}
/**
* How long a polled integration may go without a sync ATTEMPT before it counts
* as stalled. 24 h is roughly 24 missed hourly ticks — unambiguous, and
* tolerant of a maintenance window or a slow redeploy.
*/
export const ATTEMPT_STALE_AFTER_MS = 24 * 60 * 60 * 1000;
/**
* How long a polled integration may go without new DATA before it counts as
* stale. Near-unreachable for the hourly cohort by construction (every tick
* either stamps a success or flips the row to an error state), so it stands as
* defence rather than as the primary signal.
*/
export const DATA_STALE_AFTER_MS = 48 * 60 * 60 * 1000;
/**
* Apple Health is push-based: the iOS app delivers, nothing here polls. Seven
* days is the "this pipe is not delivering" line. Shorter windows false-alarm
* on normal iOS background-delivery gaps (low storage, app unopened, budget
* exhausted), and a wrong warning on the flagship integration costs more trust
* than a few days of latency on a genuinely dead one.
*/
export const APPLE_HEALTH_DATA_STALE_AFTER_MS = 7 * 24 * 60 * 60 * 1000;
/**
* A metric type that has not been seen for this long, while the integration
* around it reads healthy, is the dead-pipe signature. Deliberately generous:
* a weekly weigher must not be warned after a holiday.
*/
export const METRIC_QUIET_AFTER_MS = 14 * 24 * 60 * 60 * 1000;
/** Per-cohort expectation. `attemptStaleAfterMs: null` = no attempt concept. */
export interface SyncCadence {
attemptStaleAfterMs: number | null;
dataStaleAfterMs: number;
}
/** Hourly cron cohort — every integration with a ledger row. */
export const POLLED_CADENCE: SyncCadence = {
attemptStaleAfterMs: ATTEMPT_STALE_AFTER_MS,
dataStaleAfterMs: DATA_STALE_AFTER_MS,
};
/** Apple Health — the iOS app pushes; there is nothing to "attempt". */
export const PUSH_CADENCE: SyncCadence = {
attemptStaleAfterMs: null,
dataStaleAfterMs: APPLE_HEALTH_DATA_STALE_AFTER_MS,
};
/**
* Every `IntegrationKey` is polled by an hourly cron registered in
* `src/lib/jobs/reminder/register-integration-sync.ts`. Apple Health is not an
* `IntegrationKey` — it takes `PUSH_CADENCE` directly.
*/
export const INTEGRATION_CADENCE: Record<IntegrationKey, SyncCadence> = {
withings: POLLED_CADENCE,
whoop: POLLED_CADENCE,
fitbit: POLLED_CADENCE,
nightscout: POLLED_CADENCE,
polar: POLLED_CADENCE,
oura: POLLED_CADENCE,
"google-health": POLLED_CADENCE,
strava: POLLED_CADENCE,
moodlog: POLLED_CADENCE,
};
export interface SyncVerdictInput {
/**
* Whether a live connection exists (an OAuth token, a connection row). Pass
* `undefined` for providers that carry no connection concept — moodLog is
* configured by URL alone — so the ladder falls through to `configured`.
*/
connected?: boolean;
/** Whether credentials / a URL are stored for this provider. */
configured?: boolean;
/** The ledger `state` string. */
state?: string | null;
lastSuccessAt?: string | Date | null;
lastAttemptAt?: string | Date | null;
/** A connection row's own `lastSyncedAt`, for rows predating the ledger. */
legacyLastSyncedAt?: string | Date | null;
/** Newest observed data instant, when the caller knows it (Apple Health). */
lastDataAt?: string | Date | null;
cadence: SyncCadence;
now?: Date | number;
}
function toMillis(value: string | Date | null | undefined): number | null {
if (value == null) return null;
const ms = value instanceof Date ? value.getTime() : Date.parse(value);
return Number.isFinite(ms) ? ms : null;
}
/** Newest of the supplied instants, or null when they are all absent. */
function newest(...values: Array<number | null>): number | null {
let max: number | null = null;
for (const value of values) {
if (value == null) continue;
if (max == null || value > max) max = value;
}
return max;
}
function iso(ms: number | null): string | null {
return ms == null ? null : new Date(ms).toISOString();
}
/**
* Resolve the sync-health verdict. First match wins.
*
* The `stalled` arm deliberately outranks `failing`: a row frozen at
* `error_transient` for a month is not failing, it is not being tried — the
* exact contradiction of the state's own documented contract.
*
* Two arms are refinements over the pure-ledger ladder, and they preserve what
* the cards already rendered: an explicit `connected === false` and an explicit
* `state === "disconnected"` both short-circuit to `disconnected`. Without them
* a provider whose credentials are still stored after a disconnect would age
* into `stalled` and claim its sync had stopped, when the user stopped it.
*/
export function resolveSyncVerdict(input: SyncVerdictInput): SyncHealth {
const nowMs =
input.now == null
? Date.now()
: input.now instanceof Date
? input.now.getTime()
: input.now;
const lastSuccess = toMillis(input.lastSuccessAt);
const lastAttempt = toMillis(input.lastAttemptAt);
const legacy = toMillis(input.legacyLastSyncedAt);
const lastData = toMillis(input.lastDataAt);
// 1. No live connection, or one the user explicitly ended.
if (input.connected === false)
return { verdict: "disconnected", since: null };
if (!input.connected && !input.configured) {
return { verdict: "disconnected", since: null };
}
if (input.state === "disconnected") {
return { verdict: "disconnected", since: null };
}
// 2. States the user has to act on, in order of specificity.
if (input.state === "parked") {
return { verdict: "parked", since: iso(lastAttempt) };
}
if (input.state === "error_reauth") {
return { verdict: "reauth_required", since: iso(lastAttempt) };
}
// 3. Nothing has ever run and nothing has ever arrived — the honest reading
// of the "connected, never attempted" synthetic ledger snapshot, and of a
// push source that has yet to deliver its first sample.
if (newest(lastAttempt, lastSuccess, legacy, lastData) == null) {
return { verdict: "pending_first_sync", since: null };
}
// 4. Polled cohort only: has it even tried lately?
const attemptWindow = input.cadence.attemptStaleAfterMs;
if (attemptWindow != null) {
const lastTouch = newest(lastAttempt, lastSuccess);
if (lastTouch != null && nowMs - lastTouch > attemptWindow) {
return { verdict: "stalled", since: iso(lastTouch) };
}
}
// 5. Trying and erroring.
if (input.state === "error_transient") {
return { verdict: "failing", since: iso(lastSuccess) };
}
// 6. Running, but nothing new is arriving. The primary arm for push sources.
const lastFresh = newest(lastSuccess, legacy, lastData);
if (lastFresh != null && nowMs - lastFresh > input.cadence.dataStaleAfterMs) {
return { verdict: "stale", since: iso(lastFresh) };
}
return { verdict: "fresh", since: null };
}
/**
* Metric types that are irregular by nature. A fixed quiet-window would
* false-positive on every one of them, so they render their honest timestamp
* and never tint. Starting list, tunable — the fuller per-type cadence model
* stays the deferred follow-up.
*/
export const SPARSE_METRIC_TYPES: ReadonlySet<string> = new Set([
// Measured only when the user steps on a body-composition scale that
// supports them, or during an occasional fitness test.
"VO2_MAX",
"VASCULAR_AGE",
"PULSE_WAVE_VELOCITY",
"SIX_MINUTE_WALK_DISTANCE",
"FALL_COUNT",
// Event types: fired by the device only when something happens. Silence is
// the healthy reading.
"IRREGULAR_RHYTHM_NOTIFICATION",
"HIGH_HEART_RATE_EVENT",
"LOW_HEART_RATE_EVENT",
"WALKING_STEADINESS_EVENT",
"BREATHING_DISTURBANCE_EVENT",
"AUDIO_EXPOSURE_EVENT",
// People train irregularly; a quiet training week is not a broken pipe.
"WORKOUTS",
]);
/** The pseudo-type carrying a source's newest workout, next to its metrics. */
export const WORKOUT_FRESHNESS_TYPE = "WORKOUTS";
/** Newest recorded reading for one `(source, type)` pair, as queried. */
export interface MetricFreshnessSample {
/** The `MeasurementType` (e.g. "RESPIRATORY_RATE"), or `WORKOUTS`. */
type: string;
/** ISO timestamp of the newest (non-deleted) reading for this metric. */
lastSeenAt: string;
}
/** A sample plus the server's staleness verdict for it. */
export interface MetricFreshnessEntry extends MetricFreshnessSample {
/**
* The metric has gone quiet while the integration around it reads healthy.
* Only ever true when the provider verdict is `fresh` — see
* `classifyMetricFreshness`.
*/
stale: boolean;
}
/**
* Attach the per-metric staleness flag.
*
* A metric silent for weeks WHILE the pipe is otherwise green is the dead-pipe
* signature: a per-collection 403, an upstream envelope drift, a revoked
* per-type permission. When the provider itself is stalled / failing / stale
* every metric ages together, so tinting them would only restate what the pill
* already says — the entries stay neutral and the provider verdict carries the
* story.
*/
export function classifyMetricFreshness(
samples: readonly MetricFreshnessSample[],
verdict: SyncVerdict,
now: Date | number = Date.now(),
): MetricFreshnessEntry[] {
const nowMs = now instanceof Date ? now.getTime() : now;
return samples.map((sample) => {
if (verdict !== "fresh" || SPARSE_METRIC_TYPES.has(sample.type)) {
return { ...sample, stale: false };
}
const seen = toMillis(sample.lastSeenAt);
return {
...sample,
stale: seen != null && nowMs - seen > METRIC_QUIET_AFTER_MS,
};
});
}
+36
View File
@@ -592,6 +592,42 @@ describe("get_integration_status", () => {
expect(JSON.stringify(p)).not.toContain("token revoked");
});
it("reports a month-dead pull as stalled while `connected` still reads true", () => {
// `connected` is `state !== "disconnected"` and carries no liveness at all.
// Kept for compatibility; `verdict` is what an assistant should answer
// "why is my data stale?" from.
const lastAttemptAt = new Date(
Date.now() - 28 * 24 * 60 * 60 * 1000,
).toISOString();
vi.mocked(prisma.integrationStatus.findMany).mockResolvedValue([
{ integration: "moodlog" },
] as never);
vi.mocked(getIntegrationStatus).mockResolvedValue({
integration: "moodlog",
state: "error_transient",
lastSuccessAt: new Date(
Date.now() - 55 * 24 * 60 * 60 * 1000,
).toISOString(),
lastAttemptAt,
lastError: null,
consecutiveFailuresByKind: null,
});
return tool("get_integration_status")
.run(CTX, {})
.then((raw) => {
const result = raw as {
providers: Array<Record<string, unknown>>;
};
expect(result.providers[0]).toMatchObject({
provider: "moodlog",
connected: true,
verdict: "stalled",
since: lastAttemptAt,
});
});
});
it("returns { present: false } when nothing has ever synced", async () => {
vi.mocked(prisma.integrationStatus.findMany).mockResolvedValue([] as never);
const result = (await tool("get_integration_status").run(CTX, {})) as {
+20 -1
View File
@@ -62,6 +62,10 @@ import {
getIntegrationStatus,
type IntegrationKey,
} from "@/lib/integrations/status";
import {
INTEGRATION_CADENCE,
resolveSyncVerdict,
} from "@/lib/integrations/sync-verdict";
import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto";
import { fenceUserText, scrubFenceMarkers } from "@/lib/ai/coach/data-fence";
import type { McpAuthContext } from "./auth";
@@ -860,6 +864,8 @@ const getIntegrationStatusOutput: z.ZodRawShape = {
reauthRequired: z.boolean(),
lastSuccessAt: z.string().nullable(),
lastAttemptAt: z.string().nullable(),
verdict: z.string(),
since: z.string().nullable(),
}),
)
.optional(),
@@ -1478,7 +1484,7 @@ export const MCP_TOOLS: McpToolDefinition[] = [
name: "get_integration_status",
title: "Get device & service sync status",
description:
"Fetch the sync health of the user's connected devices and services (Withings, WHOOP, Fitbit, Nightscout, Polar, Oura, moodLog) — which are connected, when each last synced, and whether one needs reconnecting or is failing. Answers 'why is my data stale?'. Reuses the integration-status ledger; carries no secrets or tokens. Returns { present: false } when no integration has ever attempted a sync.",
"Fetch the sync health of the user's connected devices and services (Withings, WHOOP, Fitbit, Nightscout, Polar, Oura, moodLog) — which are connected, when each last synced, and whether one needs reconnecting or is failing. Answers 'why is my data stale?'. Reuses the integration-status ledger; carries no secrets or tokens. `connected` means only 'not explicitly disconnected'; `verdict` is the liveness truth (fresh / stale / stalled / failing / reauth_required / parked / pending_first_sync / disconnected), where `stalled` means the sync has stopped even attempting. Returns { present: false } when no integration has ever attempted a sync.",
inputShape: {},
annotations: READ_ONLY_ANNOTATIONS,
outputShape: getIntegrationStatusOutput,
@@ -1503,6 +1509,17 @@ export const MCP_TOOLS: McpToolDefinition[] = [
ctx.userId,
r.integration as IntegrationKey,
);
// `connected` means "not explicitly disconnected" and says nothing
// about liveness — a month-dead pull still reads true. `verdict` is
// the liveness truth, resolved by the same server-side rule the
// Settings page renders.
const health = resolveSyncVerdict({
configured: true,
state: s.state,
lastSuccessAt: s.lastSuccessAt,
lastAttemptAt: s.lastAttemptAt,
cadence: INTEGRATION_CADENCE[s.integration],
});
return {
provider: s.integration,
state: s.state,
@@ -1510,6 +1527,8 @@ export const MCP_TOOLS: McpToolDefinition[] = [
reauthRequired: s.state === "error_reauth" || s.state === "parked",
lastSuccessAt: s.lastSuccessAt,
lastAttemptAt: s.lastAttemptAt,
verdict: health.verdict,
since: health.since,
};
}),
);
+61 -1
View File
@@ -29,6 +29,57 @@ const healthKitEntry = z
"One resolved HealthKit metric mapping (defaults merged with the user's stored overrides).",
});
// Mirrors `src/lib/integrations/sync-verdict.ts` — the server-resolved liveness
// verdict. Apple Health is push-based, so only the data-age arms apply.
const syncHealth = z
.object({
verdict: z
.enum([
"fresh",
"stale",
"stalled",
"failing",
"reauth_required",
"parked",
"pending_first_sync",
"disconnected",
])
.describe(
"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: z.iso
.datetime({ offset: true })
.nullable()
.describe("The instant that triggered the verdict; null when none did."),
})
.meta({
id: "SyncHealth",
description:
"Server-resolved sync-health verdict. The single source of liveness truth — `lastSyncedAt` alone cannot express 'this pipe stopped delivering'.",
});
const metricFreshnessEntry = z
.object({
type: z
.string()
.describe(
"The measurement type (e.g. `RESPIRATORY_RATE`), or `WORKOUTS` for the workout leg.",
),
lastSeenAt: z.iso
.datetime({ offset: true })
.describe("Newest recorded reading for this type from this source."),
stale: z
.boolean()
.describe(
"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`.",
),
})
.meta({
id: "MetricFreshnessEntry",
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.",
});
const healthKitConfigResponse = z
.object({
entries: z.array(healthKitEntry),
@@ -38,11 +89,20 @@ const healthKitConfigResponse = z
.describe(
"When HealthKit last synced for this user; null when never (and always null on the PATCH echo).",
),
syncHealth: syncHealth
.optional()
.describe("Present on the GET read; omitted from the PATCH echo."),
metricFreshness: z
.array(metricFreshnessEntry)
.optional()
.describe(
"Per-metric-type freshness for the `APPLE_HEALTH` source. Present on the GET read; omitted from the PATCH echo.",
),
})
.meta({
id: "HealthKitConfigResponse",
description:
"The resolved HealthKit integration config: the default metric set merged with the user's stored per-metric overrides.",
"The resolved HealthKit integration config: the default metric set merged with the user's stored per-metric overrides, plus the sync-health verdict and per-metric freshness.",
});
// Mirror the route's `patchSchema` — merge-by-id; unknown ids are ignored.
+3 -4
View File
@@ -43,11 +43,10 @@ export const integrationKeys = {
// never collides with the classic `fitbit` key.
googleHealth: () => ["google-health"] as const,
// v1.17.0 — Nightscout CGM integration card. Self-contained status read
// (not the consolidated /api/integrations/status envelope), so the
// connect/disconnect mutations invalidate this single key.
// v1.17.0 — Nightscout CGM integration card. Reads status off the
// consolidated /api/integrations/status envelope (like Fitbit/WHOOP); this
// key stays for the card's other reads and invalidations.
nightscout: () => ["nightscout"] as const,
nightscoutStatus: () => ["nightscout", "status"] as const,
// v1.17.0 (F4) — Polar + Oura OAuth integration cards. Self-contained status
// reads (not the consolidated /api/integrations/status envelope), so the