mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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:
45 files changed
+2973
-621
No files matched your search
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user