diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 36f4dfe69..e23ee588d 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -64323,9 +64323,12 @@ components: - error_reauth - disconnected - parked - description: Last recorded ledger state for this provider. `connected` is also the value a user who has never synced - sees — the ledger row is created on the first attempt, and its absence reads as `connected`, not as an - error. + - unknown + description: "Last recorded ledger state for this provider. `unknown` (added v1.38.19) is what a user who has never + synced sees: the ledger row is created on the first attempt, and its absence is no claim about the + connection in either direction — it used to read as `connected`, which is why a client must never treat this + field as liveness. Read `syncHealth.verdict` for that, and treat `unknown` as `disconnected` anywhere a + ledger state is painted." lastSuccessAt: anyOf: - type: string @@ -65318,9 +65321,12 @@ components: - error_reauth - disconnected - parked - description: Last recorded ledger state for this provider. `connected` is also the value a user who has never synced - sees — the ledger row is created on the first attempt, and its absence reads as `connected`, not as an - error. + - unknown + description: "Last recorded ledger state for this provider. `unknown` (added v1.38.19) is what a user who has never + synced sees: the ledger row is created on the first attempt, and its absence is no claim about the + connection in either direction — it used to read as `connected`, which is why a client must never treat this + field as liveness. Read `syncHealth.verdict` for that, and treat `unknown` as `disconnected` anywhere a + ledger state is painted." lastSuccessAt: anyOf: - type: string @@ -65724,9 +65730,12 @@ components: - error_reauth - disconnected - parked - description: Last recorded ledger state for this provider. `connected` is also the value a user who has never synced - sees — the ledger row is created on the first attempt, and its absence reads as `connected`, not as an - error. + - unknown + description: "Last recorded ledger state for this provider. `unknown` (added v1.38.19) is what a user who has never + synced sees: the ledger row is created on the first attempt, and its absence is no claim about the + connection in either direction — it used to read as `connected`, which is why a client must never treat this + field as liveness. Read `syncHealth.verdict` for that, and treat `unknown` as `disconnected` anywhere a + ledger state is painted." lastSuccessAt: anyOf: - type: string diff --git a/e2e/setup-flow-a11y.spec.ts b/e2e/setup-flow-a11y.spec.ts index 3891eb7c4..688fe7665 100644 --- a/e2e/setup-flow-a11y.spec.ts +++ b/e2e/setup-flow-a11y.spec.ts @@ -118,7 +118,14 @@ const SCREENS: ScreenCase[] = [ { screen: "first-result", path: "/onboarding/first-result", - painted: (page) => page.locator('[data-slot="onboarding-task-connect"]'), + // v1.38.19 (wave B) — wait for the tile the screen SETTLED on, not for + // the connect card. This sweep used to name the connect card, which is + // painted for the few hundred milliseconds before the status envelope + // resolves whatever the account's real state is — so it scanned a frame + // the person may never see, and passed straight through the defect that + // frame was hiding. `data-connect-slot` appears only once the screen has + // decided, and it is on every one of the four tiles. + painted: (page) => page.locator("[data-connect-slot]"), prepare: (page) => complete(page), }, { diff --git a/e2e/setup-flow-connect.spec.ts b/e2e/setup-flow-connect.spec.ts new file mode 100644 index 000000000..4054a5265 --- /dev/null +++ b/e2e/setup-flow-connect.spec.ts @@ -0,0 +1,212 @@ +/** + * v1.38.19 (wave B / C1, C2) — the setup flow, path 5: a wearable on Q4. + * + * The connect arm is the only place in the flow that renders a claim about + * real account state, and until this file no journey reached it: bp answers + * `manual`, visit answers `file`, and the child path ends at confirm. The + * a11y sweep does open the screen, but asserted the connect card was visible + * — which it was, for the few hundred milliseconds before the status query + * resolved. So the sweep passed THROUGH the defect it should have caught. + * + * Both cases here assert AFTER `GET /api/integrations/status` has settled, + * which is the only moment at which the screen's claim means anything. + * + * Runs in one project (see `playwright.config.ts`): it resets and mutates its + * own account. + */ +import pg from "pg"; + +import { + E2E_SETUP_CONNECT, + SETUP_CONNECT_STORAGE_STATE_PATH, +} from "./setup/global-setup"; +import { resetSetupFlow } from "./setup/setup-flow-fixture"; +import type { Page, Response } from "@playwright/test"; + +import { expect, test } from "./setup/test"; +import { answer, expectScreen, readMe } from "./setup-flow-helpers"; + +function pool(): pg.Pool { + const url = process.env.DATABASE_URL; + if (!url) throw new Error("[setup-flow-connect] DATABASE_URL is not set"); + return new pg.Pool({ connectionString: url }); +} + +/** + * Put the account's Oura back to "never connected". The journey's second case + * writes a token column and a ledger row, neither of which `resetSetupFlow` + * knows about — it resets the FLOW, and a leftover connection would make the + * first case assert against the second case's state. + */ +async function clearOura(): Promise { + const db = pool(); + try { + const { rows } = await db.query<{ id: string }>( + `SELECT id FROM users WHERE username = $1`, + [E2E_SETUP_CONNECT.username], + ); + const id = rows[0]?.id; + if (!id) throw new Error("[setup-flow-connect] no account"); + await db.query( + `UPDATE users SET oura_access_token_encrypted = NULL WHERE id = $1`, + [id], + ); + await db.query( + `DELETE FROM integration_statuses WHERE user_id = $1 AND integration = 'oura'`, + [id], + ); + } finally { + await db.end(); + } +} + +/** + * A connection that has been delivering for an hour: a stored token (the + * envelope reads `connected` off the column's presence alone) and a ledger + * row whose last success is recent enough for the `fresh` verdict. + */ +async function seedDeliveringOura(): Promise { + const db = pool(); + try { + const { rows } = await db.query<{ id: string }>( + `SELECT id FROM users WHERE username = $1`, + [E2E_SETUP_CONNECT.username], + ); + const id = rows[0]?.id; + if (!id) throw new Error("[setup-flow-connect] no account"); + await db.query( + `UPDATE users SET oura_access_token_encrypted = 'e2e-placeholder' WHERE id = $1`, + [id], + ); + await db.query( + `INSERT INTO integration_statuses + (id, user_id, integration, state, last_success_at, last_attempt_at, created_at, updated_at) + VALUES ($1, $2, 'oura', 'connected', NOW(), NOW(), NOW(), NOW()) + ON CONFLICT (user_id, integration) DO UPDATE + SET state = 'connected', + last_success_at = NOW(), + last_attempt_at = NOW(), + updated_at = NOW()`, + [`e2e-connect-oura-${Date.now()}`, id], + ); + } finally { + await db.end(); + } +} + +/** + * Walk the questions through the API, then open the screen under test. + * + * `areas` decides whether the priority has anywhere to fall to when the Q4 + * source turns out to be connected already: a Q2 area is a `log-reading` task, + * an empty Q2 is nothing. + */ +async function reachFirstResult( + page: Page, + areas: readonly string[], +): Promise { + await answer(page, { step: "who", recordTarget: "me" }); + await answer(page, { + step: "areas", + ...(areas.length > 0 ? { areas } : { status: "skipped" }), + }); + await answer(page, { step: "medication", medication: "no" }); + await answer(page, { step: "sources", sources: ["oura"] }); + await answer(page, { step: "visit", visit: "no" }); + const settled = page.waitForResponse( + (res: Response) => + res.url().includes("/api/integrations/status") && res.status() === 200, + ); + await page.goto("/onboarding/confirm"); + await expectScreen(page, "confirm"); + await page.locator('[data-slot="onboarding-next"]').click(); + await expectScreen(page, "first-result"); + // Every assertion below is about what the screen says once it KNOWS. + await settled; +} + +// The three cases drive the one account, each setting up a different +// connection state. Run in parallel they would each walk the others' state. +test.describe.configure({ mode: "serial" }); + +test.describe("setup flow — a wearable on Q4", () => { + test.use({ storageState: SETUP_CONNECT_STORAGE_STATE_PATH }); + + test.beforeEach(async () => { + await resetSetupFlow(E2E_SETUP_CONNECT.username); + await clearOura(); + }); + + test.afterAll(async () => { + await clearOura(); + }); + + test("never claims a connection the account does not have", async ({ + page, + }) => { + test.setTimeout(90_000); + await page.goto("/onboarding"); + await reachFirstResult(page, ["sleep"]); + + // The tile the screen settled on: either the connect card, or — on an + // instance with no Oura app configured — the credentials note. Never a + // result: nothing was connected. + const tile = page.locator("[data-connect-slot]"); + await expect(tile).toBeVisible({ timeout: 15_000 }); + const slot = await tile.getAttribute("data-connect-slot"); + expect(["connect", "credentials"]).toContain(slot); + await expect(tile).toHaveAttribute("data-connect-state", "disconnected"); + await expect( + page.locator('[data-slot="onboarding-first-result-done"]'), + ).toHaveCount(0); + + // And the ledger was not stamped on the person's behalf. + const me = await readMe(page); + expect(me.onboarding.firstResult?.completedAt ?? null).toBeNull(); + }); + + test("offers the next task instead of a connection that already works", async ({ + page, + }) => { + test.setTimeout(90_000); + await seedDeliveringOura(); + await page.goto("/onboarding"); + await reachFirstResult(page, ["sleep"]); + + // The answers name one area as well, so the priority has somewhere to + // fall to and the flow ends on something the person can actually do. + await expect(page.locator("section[data-task]")).toHaveAttribute( + "data-task", + "log-reading", + ); + await expect( + page.locator('[data-slot="onboarding-task-connect"]'), + ).toHaveCount(0); + }); + + test("acknowledges a connection that is already delivering", async ({ + page, + }) => { + test.setTimeout(90_000); + await seedDeliveringOura(); + await page.goto("/onboarding"); + // Q2 passed over, so the connection is the only task the answers name. + await reachFirstResult(page, []); + + const tile = page.locator('[data-connect-slot="result"]'); + await expect(tile).toBeVisible({ timeout: 15_000 }); + await expect(tile).toHaveAttribute("data-connect-state", "fresh"); + + // The acknowledgment replaces the offer: the connect CTA is gone. + await expect( + page.locator('[data-slot="onboarding-task-connect"]'), + ).toHaveCount(0); + + // A working connection is a result the flow may record. + await expect + .poll(async () => (await readMe(page)).onboarding.firstResult?.task, { + timeout: 15_000, + }) + .toBe("connect-source"); + }); +}); diff --git a/e2e/setup/global-setup.ts b/e2e/setup/global-setup.ts index 13bfccc33..7dc203437 100644 --- a/e2e/setup/global-setup.ts +++ b/e2e/setup/global-setup.ts @@ -480,6 +480,21 @@ export const E2E_SETUP_A11Y = { role: "USER", } as const; +/** + * v1.38.19 (wave B) — the connect-source journey's account. + * + * The one arm of the flow that renders a claim about real account state had + * no journey that reached it: bp answers `manual`, visit answers `file`, and + * the child path ends at confirm. This account answers Q4 with a wearable, so + * `/onboarding/first-result` really does render the connect step. + */ +export const E2E_SETUP_CONNECT = { + email: "e2e-setup-connect@healthlog.test", + username: "e2e-setup-connect", + password: "Cn6!Zx4tB9wQm3Jf", + role: "USER", +} as const; + export const E2E_SETUP_LOCALE = { email: "e2e-setup-locale@healthlog.test", username: "e2e-setup-locale", @@ -507,6 +522,10 @@ export const SETUP_A11Y_STORAGE_STATE_PATH = resolve( process.cwd(), "e2e/setup/storageStateSetupA11y.json", ); +export const SETUP_CONNECT_STORAGE_STATE_PATH = resolve( + process.cwd(), + "e2e/setup/storageStateSetupConnect.json", +); export const SETUP_LOCALE_STORAGE_STATE_PATH = resolve( process.cwd(), "e2e/setup/storageStateSetupLocale.json", @@ -519,6 +538,7 @@ const SETUP_FLOW_ACCOUNTS = [ E2E_SETUP_CHILD, E2E_SETUP_A11Y, E2E_SETUP_LOCALE, + E2E_SETUP_CONNECT, ] as const; async function hashPassword(password: string): Promise { @@ -1261,6 +1281,7 @@ export default async function globalSetup(config: FullConfig): Promise { await capture(E2E_SETUP_VISIT, SETUP_VISIT_STORAGE_STATE_PATH); await capture(E2E_SETUP_A11Y, SETUP_A11Y_STORAGE_STATE_PATH); await capture(E2E_SETUP_LOCALE, SETUP_LOCALE_STORAGE_STATE_PATH); + await capture(E2E_SETUP_CONNECT, SETUP_CONNECT_STORAGE_STATE_PATH); await capture(E2E_SETUP_CHILD, SETUP_CHILD_STORAGE_STATE_PATH); await pool.query( `UPDATE users SET totp_confirmed_at = $2 WHERE username = $1`, diff --git a/messages/de.json b/messages/de.json index 916b8b9e4..f59632503 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4683,12 +4683,46 @@ "connect-source": { "title": "{target} verbinden", "body": "Eine Verbindung, aus deinen Antworten gewählt. Danach kommen die Werte von selbst.", - "howTo": "Die Verbindung wird auf der Integrationsseite hergestellt. Komm danach hierher zurück; dieser Bildschirm merkt, wenn sie steht.", + "howTo": "Die Verbindung zu {target} wird auf der Integrationsseite hergestellt. Komm danach hierher zurück; dieser Bildschirm merkt, wenn sie steht.", "cta": "{target} verbinden", "checking": "Verbindung wird geprüft.", "waiting": "Noch nicht verbunden.", - "done": "{target} ist verbunden.", - "syncing": "Die erste Synchronisierung läuft. Die Werte erscheinen auf dem Dashboard, sobald sie ankommen." + "openSettings": "Einstellungen → Integrationen öffnen", + "noDate": "unbekannt", + "needsCredentials": { + "title": "Diese Instanz hat noch keine {target}-App hinterlegt.", + "detail": "Trage deine eigenen Zugangsdaten unter Einstellungen → Integrationen ein, dann kann die Verbindung starten." + }, + "state": { + "pending_first_sync": { + "title": "{target} ist verbunden.", + "detail": "Die erste Synchronisierung läuft. Die Werte erscheinen auf dem Dashboard, sobald sie ankommen." + }, + "fresh": { + "title": "{target} ist schon verbunden.", + "detail": "Letzter Wert {when}. Hier gibt es nichts mehr zu tun." + }, + "stale": { + "title": "{target} ist verbunden, aber es kommt gerade nichts an.", + "detail": "Letzter Wert {when}. Die Verbindung selbst ist in Ordnung." + }, + "stalled": { + "title": "{target} ist verbunden, aber es wird nicht mehr synchronisiert.", + "detail": "Letzter Versuch {when}. Prüfe die Verbindung unter Einstellungen → Integrationen." + }, + "failing": { + "title": "{target} ist verbunden, aber die Synchronisierung schlägt fehl.", + "detail": "Ohne Erfolg seit {when}. Prüfe die Verbindung unter Einstellungen → Integrationen." + }, + "reauth_required": { + "title": "Die Verbindung zu {target} muss erneuert werden.", + "detail": "Einmal neu anmelden, dann laufen die Werte weiter." + }, + "parked": { + "title": "Die Verbindung zu {target} ist pausiert.", + "detail": "Prüfe die Verbindung unter Einstellungen → Integrationen." + } + } }, "add-medication": { "title": "Dein erstes Medikament hinzufügen", diff --git a/messages/en.json b/messages/en.json index 7b6af9ac1..b8f2dd281 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4683,12 +4683,46 @@ "connect-source": { "title": "Connect {target}", "body": "One connection, chosen from your answers. Readings then arrive on their own.", - "howTo": "The connection is made on the integrations page. Come back here afterwards; this screen notices when it is done.", + "howTo": "The connection to {target} is made on the integrations page. Come back here afterwards; this screen notices when it is done.", "cta": "Connect {target}", "checking": "Checking the connection.", "waiting": "Not connected yet.", - "done": "{target} is connected.", - "syncing": "The first sync is in progress. Readings appear on the dashboard as they arrive." + "openSettings": "Open Settings → Integrations", + "noDate": "unknown", + "needsCredentials": { + "title": "This instance has no {target} app on file yet.", + "detail": "Enter your own credentials under Settings → Integrations, then the connection can start." + }, + "state": { + "pending_first_sync": { + "title": "{target} is connected.", + "detail": "The first sync is in progress. Readings appear on the dashboard as they arrive." + }, + "fresh": { + "title": "{target} is already connected.", + "detail": "Last reading {when}. Nothing left to do here." + }, + "stale": { + "title": "{target} is connected, but nothing is arriving right now.", + "detail": "Last reading {when}. The connection itself is fine." + }, + "stalled": { + "title": "{target} is connected, but it has stopped syncing.", + "detail": "Last attempt {when}. Check the connection under Settings → Integrations." + }, + "failing": { + "title": "{target} is connected, but the sync keeps failing.", + "detail": "Nothing has come through since {when}. Check the connection under Settings → Integrations." + }, + "reauth_required": { + "title": "The connection to {target} needs renewing.", + "detail": "Sign in once more and the readings carry on." + }, + "parked": { + "title": "The connection to {target} is paused.", + "detail": "Check the connection under Settings → Integrations." + } + } }, "add-medication": { "title": "Add your first medication", diff --git a/messages/es.json b/messages/es.json index 8325c73db..0edd77a85 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4683,12 +4683,46 @@ "connect-source": { "title": "Conectar {target}", "body": "Una conexión, elegida según tus respuestas. Después las mediciones llegan solas.", - "howTo": "La conexión se hace en la página de integraciones. Vuelve aquí después; esta pantalla detecta cuándo está lista.", + "howTo": "La conexión con {target} se hace en la página de integraciones. Vuelve aquí después; esta pantalla detecta cuándo está lista.", "cta": "Conectar {target}", "checking": "Comprobando la conexión.", "waiting": "Aún no conectado.", - "done": "{target} está conectado.", - "syncing": "La primera sincronización está en marcha. Las mediciones aparecen en el panel a medida que llegan." + "openSettings": "Abrir Ajustes → Integraciones", + "noDate": "desconocido", + "needsCredentials": { + "title": "Esta instancia aún no tiene una aplicación de {target} registrada.", + "detail": "Introduce tus propias credenciales en Ajustes → Integraciones y la conexión podrá empezar." + }, + "state": { + "pending_first_sync": { + "title": "{target} está conectado.", + "detail": "La primera sincronización está en marcha. Los valores aparecerán en el panel en cuanto lleguen." + }, + "fresh": { + "title": "{target} ya está conectado.", + "detail": "Último valor {when}. Aquí no queda nada por hacer." + }, + "stale": { + "title": "{target} está conectado, pero ahora mismo no llega nada.", + "detail": "Último valor {when}. La conexión en sí está bien." + }, + "stalled": { + "title": "{target} está conectado, pero ha dejado de sincronizar.", + "detail": "Último intento {when}. Revisa la conexión en Ajustes → Integraciones." + }, + "failing": { + "title": "{target} está conectado, pero la sincronización falla.", + "detail": "No ha llegado nada desde {when}. Revisa la conexión en Ajustes → Integraciones." + }, + "reauth_required": { + "title": "Hay que renovar la conexión con {target}.", + "detail": "Inicia sesión una vez más y los valores seguirán llegando." + }, + "parked": { + "title": "La conexión con {target} está en pausa.", + "detail": "Revisa la conexión en Ajustes → Integraciones." + } + } }, "add-medication": { "title": "Añade tu primer medicamento", diff --git a/messages/fr.json b/messages/fr.json index 64da413da..95a5ed947 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4683,12 +4683,46 @@ "connect-source": { "title": "Connecter {target}", "body": "Une seule connexion, choisie d'après vos réponses. Les mesures arrivent ensuite d'elles-mêmes.", - "howTo": "La connexion se fait sur la page des intégrations. Revenez ici ensuite ; cet écran détecte quand elle est établie.", + "howTo": "La connexion à {target} se fait sur la page des intégrations. Revenez ici ensuite ; cet écran détecte quand elle est établie.", "cta": "Connecter {target}", "checking": "Vérification de la connexion.", "waiting": "Pas encore connecté.", - "done": "{target} est connecté.", - "syncing": "La première synchronisation est en cours. Les mesures apparaissent sur le tableau de bord au fur et à mesure." + "openSettings": "Ouvrir Paramètres → Intégrations", + "noDate": "inconnue", + "needsCredentials": { + "title": "Cette instance n’a pas encore d’application {target} enregistrée.", + "detail": "Saisissez vos propres identifiants dans Paramètres → Intégrations, la connexion pourra alors démarrer." + }, + "state": { + "pending_first_sync": { + "title": "{target} est connecté.", + "detail": "La première synchronisation est en cours. Les valeurs apparaîtront sur le tableau de bord dès leur arrivée." + }, + "fresh": { + "title": "{target} est déjà connecté.", + "detail": "Dernière valeur {when}. Il n’y a plus rien à faire ici." + }, + "stale": { + "title": "{target} est connecté, mais rien n’arrive en ce moment.", + "detail": "Dernière valeur {when}. La connexion elle-même est en ordre." + }, + "stalled": { + "title": "{target} est connecté, mais la synchronisation ne tourne plus.", + "detail": "Dernière tentative {when}. Vérifiez la connexion dans Paramètres → Intégrations." + }, + "failing": { + "title": "{target} est connecté, mais la synchronisation échoue.", + "detail": "Plus rien n’est arrivé depuis {when}. Vérifiez la connexion dans Paramètres → Intégrations." + }, + "reauth_required": { + "title": "La connexion à {target} doit être renouvelée.", + "detail": "Reconnectez-vous une fois et les valeurs reprendront." + }, + "parked": { + "title": "La connexion à {target} est en pause.", + "detail": "Vérifiez la connexion dans Paramètres → Intégrations." + } + } }, "add-medication": { "title": "Ajoutez votre premier médicament", diff --git a/messages/it.json b/messages/it.json index c5a336421..8713afca9 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4683,12 +4683,46 @@ "connect-source": { "title": "Collega {target}", "body": "Una sola connessione, scelta in base alle tue risposte. Poi le misurazioni arrivano da sole.", - "howTo": "La connessione si fa nella pagina delle integrazioni. Poi torna qui; questa schermata si accorge quando è pronta.", + "howTo": "La connessione a {target} si fa nella pagina delle integrazioni. Poi torna qui; questa schermata si accorge quando è pronta.", "cta": "Collega {target}", "checking": "Verifica della connessione in corso.", "waiting": "Non ancora collegato.", - "done": "{target} è collegato.", - "syncing": "La prima sincronizzazione è in corso. Le misurazioni compaiono nella dashboard man mano che arrivano." + "openSettings": "Apri Impostazioni → Integrazioni", + "noDate": "sconosciuto", + "needsCredentials": { + "title": "Questa istanza non ha ancora un’app {target} registrata.", + "detail": "Inserisci le tue credenziali in Impostazioni → Integrazioni e la connessione potrà partire." + }, + "state": { + "pending_first_sync": { + "title": "{target} è collegato.", + "detail": "La prima sincronizzazione è in corso. I valori compaiono sulla dashboard appena arrivano." + }, + "fresh": { + "title": "{target} è già collegato.", + "detail": "Ultimo valore {when}. Qui non resta nulla da fare." + }, + "stale": { + "title": "{target} è collegato, ma al momento non arriva nulla.", + "detail": "Ultimo valore {when}. La connessione in sé è a posto." + }, + "stalled": { + "title": "{target} è collegato, ma non sincronizza più.", + "detail": "Ultimo tentativo {when}. Controlla la connessione in Impostazioni → Integrazioni." + }, + "failing": { + "title": "{target} è collegato, ma la sincronizzazione non riesce.", + "detail": "Non arriva nulla da {when}. Controlla la connessione in Impostazioni → Integrazioni." + }, + "reauth_required": { + "title": "La connessione a {target} va rinnovata.", + "detail": "Accedi ancora una volta e i valori riprendono ad arrivare." + }, + "parked": { + "title": "La connessione a {target} è in pausa.", + "detail": "Controlla la connessione in Impostazioni → Integrazioni." + } + } }, "add-medication": { "title": "Aggiungi il tuo primo farmaco", diff --git a/messages/ko.json b/messages/ko.json index 727b99b4a..6626ae5a4 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -4683,12 +4683,46 @@ "connect-source": { "title": "{target} 연결", "body": "답변을 바탕으로 고른 연결 하나입니다. 이후 측정값은 저절로 들어옵니다.", - "howTo": "연결은 연동 페이지에서 합니다. 마친 뒤 이곳으로 돌아오면 이 화면이 연결을 감지합니다.", + "howTo": "{target} 연결은 연동 페이지에서 합니다. 마친 뒤 이곳으로 돌아오면 이 화면이 연결을 감지합니다.", "cta": "{target} 연결", "checking": "연결을 확인하는 중입니다.", "waiting": "아직 연결되지 않았습니다.", - "done": "{target}이(가) 연결되었습니다.", - "syncing": "첫 동기화가 진행 중입니다. 측정값이 도착하는 대로 대시보드에 나타납니다." + "openSettings": "설정 → 연동 열기", + "noDate": "알 수 없음", + "needsCredentials": { + "title": "이 인스턴스에는 아직 {target} 앱이 등록되어 있지 않습니다.", + "detail": "설정 → 연동에서 직접 발급받은 인증 정보를 입력하면 연결을 시작할 수 있습니다." + }, + "state": { + "pending_first_sync": { + "title": "{target}이(가) 연결되었습니다.", + "detail": "첫 동기화가 진행 중입니다. 값이 도착하는 대로 대시보드에 표시됩니다." + }, + "fresh": { + "title": "{target}은(는) 이미 연결되어 있습니다.", + "detail": "마지막 값 {when}. 여기서 더 할 일은 없습니다." + }, + "stale": { + "title": "{target}은(는) 연결되어 있지만 지금은 값이 들어오지 않습니다.", + "detail": "마지막 값 {when}. 연결 자체에는 문제가 없습니다." + }, + "stalled": { + "title": "{target}은(는) 연결되어 있지만 동기화가 멈췄습니다.", + "detail": "마지막 시도 {when}. 설정 → 연동에서 연결을 확인하세요." + }, + "failing": { + "title": "{target}은(는) 연결되어 있지만 동기화가 계속 실패합니다.", + "detail": "{when} 이후로 아무것도 들어오지 않았습니다. 설정 → 연동에서 연결을 확인하세요." + }, + "reauth_required": { + "title": "{target} 연결을 갱신해야 합니다.", + "detail": "한 번 다시 로그인하면 값이 계속 들어옵니다." + }, + "parked": { + "title": "{target} 연결이 일시 중지되었습니다.", + "detail": "설정 → 연동에서 연결을 확인하세요." + } + } }, "add-medication": { "title": "첫 약 추가하기", diff --git a/messages/pl.json b/messages/pl.json index ee155e164..21e52cc8f 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4683,12 +4683,46 @@ "connect-source": { "title": "Połącz {target}", "body": "Jedno połączenie, wybrane na podstawie Twoich odpowiedzi. Potem pomiary przychodzą same.", - "howTo": "Połączenie nawiązuje się na stronie integracji. Wróć tutaj później; ten ekran zauważy, kiedy będzie gotowe.", + "howTo": "Połączenie z {target} nawiązuje się na stronie integracji. Wróć tutaj później; ten ekran zauważy, kiedy będzie gotowe.", "cta": "Połącz {target}", "checking": "Sprawdzanie połączenia.", "waiting": "Jeszcze nie połączono.", - "done": "{target} jest połączone.", - "syncing": "Trwa pierwsza synchronizacja. Pomiary pojawią się na panelu, gdy tylko nadejdą." + "openSettings": "Otwórz Ustawienia → Integracje", + "noDate": "nieznany", + "needsCredentials": { + "title": "Ta instancja nie ma jeszcze zapisanej aplikacji {target}.", + "detail": "Wpisz własne dane dostępowe w Ustawieniach → Integracje, wtedy połączenie będzie mogło ruszyć." + }, + "state": { + "pending_first_sync": { + "title": "{target} jest połączony.", + "detail": "Trwa pierwsza synchronizacja. Wartości pojawią się na pulpicie, gdy tylko dotrą." + }, + "fresh": { + "title": "{target} jest już połączony.", + "detail": "Ostatnia wartość {when}. Tutaj nie ma już nic do zrobienia." + }, + "stale": { + "title": "{target} jest połączony, ale w tej chwili nic nie przychodzi.", + "detail": "Ostatnia wartość {when}. Samo połączenie jest w porządku." + }, + "stalled": { + "title": "{target} jest połączony, ale synchronizacja się zatrzymała.", + "detail": "Ostatnia próba {when}. Sprawdź połączenie w Ustawieniach → Integracje." + }, + "failing": { + "title": "{target} jest połączony, ale synchronizacja się nie udaje.", + "detail": "Od {when} nic nie dotarło. Sprawdź połączenie w Ustawieniach → Integracje." + }, + "reauth_required": { + "title": "Połączenie z {target} wymaga odnowienia.", + "detail": "Zaloguj się jeszcze raz, a wartości popłyną dalej." + }, + "parked": { + "title": "Połączenie z {target} jest wstrzymane.", + "detail": "Sprawdź połączenie w Ustawieniach → Integracje." + } + } }, "add-medication": { "title": "Dodaj swój pierwszy lek", diff --git a/playwright.config.ts b/playwright.config.ts index a6231feed..c958c8d77 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -232,6 +232,7 @@ export default defineConfig({ "setup-flow-visit.spec.ts", "setup-flow-child.spec.ts", "setup-flow-a11y.spec.ts", + "setup-flow-connect.spec.ts", "setup-flow-locale-overflow.spec.ts", ], use: { diff --git a/src/app/api/integrations/status/__tests__/route.test.ts b/src/app/api/integrations/status/__tests__/route.test.ts index 31f546594..75c79e084 100644 --- a/src/app/api/integrations/status/__tests__/route.test.ts +++ b/src/app/api/integrations/status/__tests__/route.test.ts @@ -60,6 +60,7 @@ const whoopFind = prisma.whoopConnection.findUnique as ReturnType; type Entry = { integration: string; + state?: string; connected?: boolean; configured?: boolean; available?: boolean; @@ -344,3 +345,41 @@ describe("/api/integrations/status — the freshness read failed", () => { } }); }); + +/** + * v1.38.19 (wave B / I5) — the never-connected provider on the wire. + * + * The ledger's synthetic "no row" snapshot reads `unknown`, and the envelope + * publishes it verbatim. A client that reads `state` must see a value that + * cannot be mistaken for a live connection, while `syncHealth.verdict` keeps + * saying `disconnected` for the same entry. + */ +describe("/api/integrations/status — a provider with no ledger row", () => { + beforeEach(() => { + vi.clearAllMocks(); + for (const key of Object.keys(perIntegrationLedger)) { + delete perIntegrationLedger[key]; + } + polarAvailable.mockResolvedValue(true); + ouraAvailable.mockResolvedValue(false); + }); + + it("publishes state unknown beside connected false and a disconnected verdict", async () => { + perIntegrationLedger.whoop = { + state: "unknown", + lastSuccessAt: null, + lastAttemptAt: null, + lastError: null, + failingSince: null, + }; + userFind.mockResolvedValue({}); + whoopFind.mockResolvedValue(null); + + const whoop = (await fetchEntries()).find( + (e) => e.integration === "whoop", + )!; + expect(whoop.state).toBe("unknown"); + expect(whoop.connected).toBe(false); + expect(whoop.syncHealth?.verdict).toBe("disconnected"); + }); +}); diff --git a/src/components/onboarding/__tests__/first-result-screen.test.tsx b/src/components/onboarding/__tests__/first-result-screen.test.tsx new file mode 100644 index 000000000..3fead3a5b --- /dev/null +++ b/src/components/onboarding/__tests__/first-result-screen.test.tsx @@ -0,0 +1,243 @@ +/** + * v1.38.19 (wave B / C1, C2, I1, I2, I3) — the connect arm of the one task + * the flow ends on. + * + * This arm is the only place in the flow that renders a claim about real + * account state, and it shipped without a test. It decided "connected" from + * the ledger's `state` field, whose "no row" default was `connected` — so a + * brand-new account that ticked WHOOP on Q4 was told WHOOP was connected and + * had the flow's one result stamped as achieved on its behalf. It also had a + * single piece of copy, "the first sync is in progress", for every outcome, + * including the two that need the person to act. + * + * Vitest renders without a DOM here (project convention: SSR only), so what + * is pinned is the first paint — which tile the screen paints for each of the + * eight verdicts and what it says. Whether the result is RECORDED is decided + * by `connectSourceView(...).settled` and pinned in + * `src/lib/onboarding/__tests__/first-result-config.test.ts`, where the eight + * verdicts are enumerated against the decision itself rather than its markup. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; +import type { SyncVerdict } from "@/lib/integrations/sync-verdict"; +import { + defaultOnboardingSteps, + emptyOnboardingNeeds, + type OnboardingStateDto, +} from "@/lib/onboarding/needs"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), +})); + +vi.mock("../use-onboarding-flow", async () => { + const actual = await vi.importActual( + "../use-onboarding-flow", + ); + return { + ...actual, + useOnboardingAnswer: () => ({ + mutateAsync: vi.fn().mockResolvedValue(undefined), + isPending: false, + }), + }; +}); + +const envelope = vi.hoisted(() => ({ + value: undefined as { isLoading: boolean; data: unknown } | undefined, +})); + +vi.mock("@/components/settings/integrations/shared", async () => { + const actual = await vi.importActual< + typeof import("@/components/settings/integrations/shared") + >("@/components/settings/integrations/shared"); + return { + ...actual, + useIntegrationStatuses: () => envelope.value, + }; +}); + +import { FirstResultScreen } from "../first-result-screen"; + +interface StatusOverrides { + connected?: boolean; + configured?: boolean; + available?: boolean; + state?: string; + verdict?: SyncVerdict; + since?: string | null; + lastSuccessAt?: string | null; +} + +function statuses(overrides: StatusOverrides | null) { + envelope.value = + overrides === null + ? { isLoading: true, data: undefined } + : { + isLoading: false, + data: { + threshold: 3, + integrations: [ + { + integration: "whoop", + connected: overrides.connected ?? false, + configured: overrides.configured ?? true, + available: overrides.available, + state: overrides.state ?? "unknown", + lastSuccessAt: overrides.lastSuccessAt ?? null, + lastAttemptAt: null, + lastError: null, + consecutiveFailuresByKind: null, + syncHealth: { + verdict: overrides.verdict ?? "disconnected", + since: overrides.since ?? null, + }, + metricFreshness: [], + }, + ], + }, + }; +} + +function state( + needs: Partial = {}, +): OnboardingStateDto { + return { + steps: defaultOnboardingSteps(), + needs: { + ...emptyOnboardingNeeds(), + recordTarget: "me", + sources: ["whoop"], + ...needs, + }, + completedAt: null, + firstResult: null, + }; +} + +function render( + overrides: StatusOverrides | null, + needs: Partial = {}, +) { + statuses(overrides); + return renderToStaticMarkup( + + + + + , + ); +} + +const VERDICT_SLOT = [ + ["disconnected", "connect"], + ["pending_first_sync", "result"], + ["fresh", "result"], + ["stale", "result"], + ["stalled", "attention"], + ["failing", "attention"], + ["reauth_required", "attention"], + ["parked", "attention"], +] as const; + +describe(" — the connect arm", () => { + it.each(VERDICT_SLOT)( + "paints the %s verdict on the %s tile", + (verdict, slot) => { + const html = render({ + connected: verdict !== "disconnected", + verdict, + since: "2026-09-01T00:00:00.000Z", + lastSuccessAt: "2026-09-01T00:00:00.000Z", + }); + expect(html).toContain(`data-connect-slot="${slot}"`); + }, + ); + + it("never claims a connection for a fresh account's empty ledger", () => { + // The exact envelope a brand-new account publishes: no row for the + // provider, so the ledger state is the synthetic `unknown`. + const html = render({ connected: false, state: "unknown" }); + expect(html).toContain('data-connect-slot="connect"'); + expect(html).not.toContain("is connected"); + expect(html).not.toContain("The first sync is in progress"); + }); + + it("says the first sync is running only while it actually is", () => { + expect( + render({ connected: true, verdict: "pending_first_sync" }), + ).toContain("The first sync is in progress"); + expect( + render({ + connected: true, + verdict: "fresh", + lastSuccessAt: "2026-09-01T00:00:00.000Z", + }), + ).not.toContain("The first sync is in progress"); + }); + + it("names the state a connection that needs repairing is in", () => { + const html = render({ + connected: true, + verdict: "reauth_required", + since: "2026-09-01T00:00:00.000Z", + }); + expect(html).toContain("needs renewing"); + expect(html).toContain("/settings/integrations#whoop"); + }); + + it("points a source the instance cannot connect at the credentials", () => { + const html = render({ connected: false, configured: false }); + expect(html).toContain('data-connect-slot="credentials"'); + expect(html).toContain("no WHOOP app on file yet"); + }); + + it("decides nothing while the status envelope is still in flight", () => { + const html = render(null); + expect(html).not.toContain("data-connect-slot="); + expect(html).toContain('data-slot="onboarding-task-checking"'); + }); + + /** + * v1.38.19 (wave B / I1) — the heading is chosen from the answers alone on + * the server, because no status is in hand there. Once the envelope has + * resolved the screen re-picks: a source that is already delivering is not + * a task, and "Connect WHOOP" over a WHOOP that has been feeding the + * dashboard for months is the flow talking past the account it can see. + */ + it("stops offering a source that is already delivering", () => { + const html = render( + { + connected: true, + verdict: "fresh", + lastSuccessAt: "2026-09-10T06:35:00.000Z", + }, + { medication: "yes" }, + ); + expect(html).toContain('data-task="add-medication"'); + expect(html).not.toContain("Connect WHOOP"); + }); + + it("acknowledges the connection when there is nothing to fall through to", () => { + // The whole answer is one already-connected source. Dropping the step + // would end the flow on nothing at all, so the screen keeps it and says + // what it found — and that is the result it records. + const html = render({ + connected: true, + verdict: "fresh", + lastSuccessAt: "2026-09-10T06:35:00.000Z", + }); + expect(html).toContain('data-task="connect-source"'); + expect(html).toContain('data-connect-state="fresh"'); + expect(html).toContain("WHOOP is already connected"); + }); + + it("keeps the answer's heading while the envelope is in flight", () => { + const html = render(null, { medication: "yes" }); + expect(html).toContain('data-task="connect-source"'); + expect(html).toContain("Connect WHOOP"); + }); +}); diff --git a/src/components/onboarding/first-result-screen.tsx b/src/components/onboarding/first-result-screen.tsx index d77894ab6..a3bd92ff8 100644 --- a/src/components/onboarding/first-result-screen.tsx +++ b/src/components/onboarding/first-result-screen.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useQuery } from "@tanstack/react-query"; @@ -33,7 +33,10 @@ import type { OnboardingAreaKey } from "@/lib/modules/registry"; import { AREA_PAGE_HREF, AREA_READING_TARGETS, + connectSourceView, + deliveringSources, SOURCE_INTEGRATION, + type ConnectSourceSlot, } from "@/lib/onboarding/first-result-config"; import type { OnboardingStateDto } from "@/lib/onboarding/needs"; import { questionOptionLabelKey } from "@/lib/onboarding/question-config"; @@ -68,7 +71,23 @@ export function FirstResultScreen({ state }: { state: OnboardingStateDto }) { const { t } = useTranslations(); const router = useRouter(); const answer = useOnboardingAnswer(); - const offer = chooseFirstResultTask(state.needs); + // The server picked the order from the answers alone — it had no status in + // hand. Re-pick here once the envelope resolves: a source that is already + // delivering is not a task, and offering to connect it is the flow talking + // past an account it can see. The same query key the task body reads, so + // this costs no second request. + const statuses = useIntegrationStatuses(true); + const delivering = useMemo( + () => deliveringSources(statuses.data?.integrations), + [statuses.data], + ); + // …but only where there is somewhere to fall to. A person whose one answer + // is a source that is already delivering still gets an end to the flow: the + // screen acknowledges the connection it found rather than dropping the step + // and ending on nothing. + const offer = + chooseFirstResultTask(state.needs, delivering) ?? + chooseFirstResultTask(state.needs); const back = previousScreen(state, "first-result"); // Completed only when the LEDGER says so as well: a restart puts the step @@ -193,32 +212,92 @@ function ConnectSourceTask({ onComplete: (target: string | null) => Promise; }) { const { t } = useTranslations(); + const fmt = useFormatters(); const { anchor, statusKey } = SOURCE_INTEGRATION[source]; const label = t(questionOptionLabelKey("sources", source)); // Refetches on window focus, so coming back from the integrations tab // after the handshake picks the fresh verdict up on its own. const statuses = useIntegrationStatuses(true); const status = pickStatus(statuses.data, statusKey as IntegrationKey); - const connected = status?.connected === true || status?.state === "connected"; + // The liveness truth is the server-resolved verdict and nothing else. The + // ledger's `state` is what the last ATTEMPT did, and its "no row" default + // read as `connected` until v1.38.19 — which is how a brand-new account was + // told its wearable was connected, and had this step stamped as achieved on + // its behalf. `null` means the envelope has not resolved: no claim, no tile. + const view = connectSourceView(source, status); + const settled = view?.settled ?? false; - // Record the result once the connection is there — once. `completed` flips - // only after the write resolves, and the status envelope can re-render this - // in between, so a ref latches the first call. + // Record the result once the connection is really there — once. `completed` + // flips only after the write resolves, and the status envelope can re-render + // this in between, so a ref latches the first call. const recorded = useRef(completed); useEffect(() => { - if (connected && !recorded.current) { + if (settled && !recorded.current) { recorded.current = true; void onComplete(source); } - }, [connected, onComplete, source]); + }, [settled, onComplete, source]); - if (completed || connected) { + const settingsHref = `/settings/integrations#${anchor}`; + + if (!view) { + // Nothing is known yet. The heading is the person's own answer, so it + // stays; the body says only that the check is running. + return ( +

+ {t("onboarding.flow.first-result.connect-source.checking")} +

+ ); + } + + const key = `onboarding.flow.first-result.connect-source.state.${view.verdict}`; + const when = view.when + ? fmt.dateTime(view.when) + : t("onboarding.flow.first-result.connect-source.noDate"); + + if (view.slot === "result") { return ( + ); + } + + if (view.slot === "attention") { + return ( + + ); + } + + if (view.slot === "credentials") { + return ( + ); } @@ -227,6 +306,8 @@ function ConnectSourceTask({

{t("onboarding.flow.first-result.connect-source.howTo", { @@ -234,21 +315,58 @@ function ConnectSourceTask({ })}

- {statuses.isLoading - ? t("onboarding.flow.first-result.connect-source.checking") - : t("onboarding.flow.first-result.connect-source.waiting")} + {t("onboarding.flow.first-result.connect-source.waiting")}

); } +/** + * A connection the flow cannot treat as its result: one that needs repairing + * (`attention`) or one the instance cannot start at all (`credentials`). Both + * say which state it is in and link at the one page that can change it — + * never a "Connect" button onto a card that has none. + */ +function ConnectNoticeTile({ + slot, + state, + testSlot, + title, + detail, + href, + cta, +}: { + slot: ConnectSourceSlot; + state: string; + testSlot: string; + title: string; + detail: string; + href: string; + cta: string; +}) { + return ( +
+

{title}

+

{detail}

+ +
+ ); +} + /* ── add a medication ──────────────────────────────────────────────────── */ interface MedicationDetail { @@ -508,11 +626,22 @@ function LatestReadingTile({ * itself sits under it in content colour, because it is the reading, the * medication or the connection the person came for. */ -function ResultTile({ title, detail }: { title: string; detail?: string }) { +function ResultTile({ + title, + detail, + connect, +}: { + title: string; + detail?: string; + /** Set by the connect arm so a journey can read which verdict it painted. */ + connect?: { slot: ConnectSourceSlot; state: string }; +}) { return (
({ })); import { + getIntegrationStatus, recordSyncFailure, recordSyncSuccess, markReauthRequired, @@ -1368,3 +1369,25 @@ describe("recordSyncFailure — leg set", () => { expect(writtenUpdate().failingLegs).toEqual(["sleep"]); }); }); + +/** + * v1.38.19 (wave B / I5) — the synthetic "no row" snapshot. + * + * `state: "connected"` used to be the value an account with no sync history + * read back, which made the ledger field's default mean the opposite of its + * name: a provider nobody had ever connected published "connected" on the + * envelope, and the setup flow's connect step believed it. The honest value + * for "there is no history here" is `unknown`; `syncHealth.verdict` stays the + * only liveness truth. + */ +describe("getIntegrationStatus with no ledger row", () => { + it("reads as unknown, not as connected", async () => { + vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce( + null as never, + ); + const snapshot = await getIntegrationStatus("u1", "whoop"); + expect(snapshot.state).toBe("unknown"); + expect(snapshot.lastSuccessAt).toBeNull(); + expect(snapshot.lastAttemptAt).toBeNull(); + }); +}); diff --git a/src/lib/integrations/status.ts b/src/lib/integrations/status.ts index 2fa914b4f..96e730cb5 100644 --- a/src/lib/integrations/status.ts +++ b/src/lib/integrations/status.ts @@ -159,7 +159,22 @@ export function isSyncFailureRecorded(err: unknown): boolean { * trail. */ export type IntegrationState = - "connected" | "error_transient" | "error_reauth" | "disconnected" | "parked"; + | "connected" + | "error_transient" + | "error_reauth" + | "disconnected" + | "parked" + /** + * v1.38.19 — no ledger row exists for this (user, provider) pair, so the + * ledger has nothing to say. It is NOT a claim about the connection either + * way: `connected` used to be the synthetic default here, which made the + * field's default read as the opposite of the truth for every account that + * had never connected anything — the setup flow's connect step believed it + * and told a brand-new account its wearable was connected. Consumers treat + * `unknown` the way they treat `disconnected`; `syncHealth.verdict` stays + * the only liveness truth. + */ + | "unknown"; /** * The ladder at which a streak of failures escalates from "user-visible @@ -323,9 +338,11 @@ function zeroBuckets(): ConsecutiveFailuresByKind { } /** - * Read the current snapshot. Returns a synthetic "connected, never - * attempted" record when no row exists yet — the UI treats this as - * "no sync history" without a special case. + * Read the current snapshot. Returns a synthetic "unknown, never attempted" + * record when no row exists yet — the ledger holds no history, and says so + * rather than guessing a state. `resolveSyncVerdict` reads the same absence + * off the null timestamps and answers `pending_first_sync` for a live + * connection, `disconnected` for one that is not there. */ export async function getIntegrationStatus( userId: string, @@ -337,7 +354,7 @@ export async function getIntegrationStatus( if (!row) { return { integration, - state: "connected", + state: "unknown", lastSuccessAt: null, lastAttemptAt: null, lastError: null, diff --git a/src/lib/onboarding/__tests__/first-result-config.test.ts b/src/lib/onboarding/__tests__/first-result-config.test.ts index 40e8aa56f..ca6160843 100644 --- a/src/lib/onboarding/__tests__/first-result-config.test.ts +++ b/src/lib/onboarding/__tests__/first-result-config.test.ts @@ -3,6 +3,9 @@ * on a surface that exists, so the first-result screen can never be handed * a task it cannot render. */ +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + import { describe, expect, it } from "vitest"; import { ONBOARDING_AREA_KEYS } from "@/lib/modules/registry"; @@ -11,6 +14,8 @@ import { NAV_DESTINATIONS } from "@/components/layout/nav-model"; import { AREA_PAGE_HREF, AREA_READING_TARGETS, + connectSourceView, + deliveringSources, SOURCE_INTEGRATION, } from "../first-result-config"; import { BROWSER_CONNECTABLE_SOURCES } from "../wizard-steps"; @@ -38,3 +43,216 @@ describe("first-result targets", () => { } }); }); + +/** + * v1.38.19 (wave B / C1, I2, I3) — what the connect step is allowed to say. + * + * The screen used to decide "connected" from the ledger's `state` field, whose + * default meant the opposite of its name, and had one piece of copy for every + * outcome. The decision is a pure function over the envelope entry now, so the + * eight verdicts can be enumerated here rather than inferred from markup. + */ +describe("connectSourceView", () => { + const cases = [ + ["disconnected", "connect", false], + ["pending_first_sync", "result", true], + ["fresh", "result", true], + ["stale", "result", false], + ["stalled", "attention", false], + ["failing", "attention", false], + ["reauth_required", "attention", false], + ["parked", "attention", false], + ] as const; + + it.each(cases)( + "verdict %s renders the %s tile and settles=%s", + (verdict, slot, settled) => { + const view = connectSourceView("whoop", { + connected: verdict !== "disconnected", + configured: true, + syncHealth: { verdict, since: "2026-09-01T00:00:00.000Z" }, + lastSuccessAt: "2026-09-01T00:00:00.000Z", + }); + expect(view?.slot).toBe(slot); + expect(view?.settled).toBe(settled); + expect(view?.verdict).toBe(verdict); + }, + ); + + it("says nothing at all until the envelope has resolved", () => { + expect(connectSourceView("whoop", undefined)).toBeNull(); + }); + + it("does not read a fresh account's empty ledger as a connection", () => { + // What a brand-new account publishes: no row anywhere, so `state` is the + // synthetic `unknown` and the verdict is `disconnected`. + const view = connectSourceView("whoop", { + connected: false, + configured: true, + state: "unknown", + syncHealth: { verdict: "disconnected", since: null }, + lastSuccessAt: null, + }); + expect(view?.slot).toBe("connect"); + expect(view?.settled).toBe(false); + }); + + it("points a BYO provider with no credentials at the credentials", () => { + const view = connectSourceView("whoop", { + connected: false, + configured: false, + syncHealth: { verdict: "disconnected", since: null }, + lastSuccessAt: null, + }); + expect(view?.slot).toBe("credentials"); + }); + + it("points a shared-app provider with no usable app at the credentials", () => { + const view = connectSourceView("oura", { + connected: false, + configured: false, + available: false, + syncHealth: { verdict: "disconnected", since: null }, + lastSuccessAt: null, + }); + expect(view?.slot).toBe("credentials"); + }); + + it("offers the connect card when the instance's shared app resolves", () => { + const view = connectSourceView("oura", { + connected: false, + configured: false, + available: true, + syncHealth: { verdict: "disconnected", since: null }, + lastSuccessAt: null, + }); + expect(view?.slot).toBe("connect"); + }); + + it("never withholds the connect card from a self-hosted URL source", () => { + const view = connectSourceView("nightscout", { + connected: false, + configured: false, + syncHealth: { verdict: "disconnected", since: null }, + lastSuccessAt: null, + }); + expect(view?.slot).toBe("connect"); + }); + + it("dates a stale tile from the last value and a failing one from the streak", () => { + expect( + connectSourceView("whoop", { + connected: true, + configured: true, + syncHealth: { verdict: "stale", since: "2026-08-01T00:00:00.000Z" }, + lastSuccessAt: "2026-08-20T00:00:00.000Z", + })?.when, + ).toBe("2026-08-20T00:00:00.000Z"); + expect( + connectSourceView("whoop", { + connected: true, + configured: true, + syncHealth: { verdict: "failing", since: "2026-08-01T00:00:00.000Z" }, + lastSuccessAt: null, + })?.when, + ).toBe("2026-08-01T00:00:00.000Z"); + }); +}); + +/** + * The connect step's copy is chosen by a key built at runtime from the + * verdict, which the repo's three i18n guards cannot see into: a template + * `ns.${x}` counts as covering its whole subtree, so a MEMBER missing from + * every bundle is invisible to them. Enumerate the verdicts here instead — + * the eight the server can resolve, in all seven locales. + */ +describe("connect-source state copy", () => { + const VERDICTS = [ + "fresh", + "stale", + "stalled", + "failing", + "reauth_required", + "parked", + "pending_first_sync", + ] as const; + + const LOCALES = readdirSync(join(__dirname, "../../../../messages")) + .filter((file) => file.endsWith(".json")) + .map((file) => ({ + locale: file.replace(/\.json$/, ""), + copy: JSON.parse( + readFileSync(join(__dirname, "../../../../messages", file), "utf8"), + ) as Record, + })); + + it("covers all seven locales", () => { + expect(LOCALES.length).toBe(7); + }); + + it.each(LOCALES)( + "names every verdict in $locale, with no English fallback", + ({ locale, copy }) => { + const block = ( + copy as unknown as { + onboarding: { + flow: { + "first-result": { + "connect-source": Record>; + }; + }; + }; + } + ).onboarding.flow["first-result"]["connect-source"]; + for (const verdict of VERDICTS) { + const entry = block.state[verdict] as + { title?: string; detail?: string } | undefined; + expect(entry?.title, `${locale}/${verdict}.title`).toBeTruthy(); + expect(entry?.detail, `${locale}/${verdict}.detail`).toBeTruthy(); + } + // `disconnected` is the one verdict with no state copy: it renders the + // connect card or the credentials note, neither of which is a claim + // about a connection that exists. + expect(block.state.disconnected).toBeUndefined(); + expect(block.needsCredentials.title).toBeTruthy(); + expect(block.openSettings).toBeTruthy(); + expect(block.noDate).toBeTruthy(); + }, + ); +}); + +/** + * v1.38.19 (wave B / I1) — which sources the flow must stop offering. + * + * Only a connection that is actually delivering, or one whose data has merely + * gone quiet: both are real connections the person made, and offering to make + * them again is the wizard talking past the account it can see. Everything + * else — never connected, first sync running, needs repairing — stays in the + * priority, because there is still something to do or something to say. + */ +describe("deliveringSources", () => { + function entry(integration: string, verdict: string) { + return { + integration, + connected: verdict !== "disconnected", + configured: true, + syncHealth: { verdict, since: null }, + lastSuccessAt: null, + }; + } + + it("names the sources that are delivering or merely quiet", () => { + const set = deliveringSources([ + entry("whoop", "fresh"), + entry("oura", "stale"), + entry("polar", "pending_first_sync"), + entry("fitbit", "reauth_required"), + entry("withings", "disconnected"), + ] as never); + expect([...set].sort()).toEqual(["oura", "whoop"]); + }); + + it("names nothing while the envelope has not resolved", () => { + expect(deliveringSources(undefined).size).toBe(0); + }); +}); diff --git a/src/lib/onboarding/__tests__/wizard-steps.test.ts b/src/lib/onboarding/__tests__/wizard-steps.test.ts index bbb82e687..740852a6f 100644 --- a/src/lib/onboarding/__tests__/wizard-steps.test.ts +++ b/src/lib/onboarding/__tests__/wizard-steps.test.ts @@ -167,6 +167,46 @@ describe("chooseFirstResultTask", () => { ).toEqual({ task: "log-reading", target: "weight-body" }); expect(chooseFirstResultTask(emptyOnboardingNeeds())).toBeNull(); }); + + /** + * v1.38.19 (wave B / I1) — the choice used to read the answers alone, so an + * account whose WHOOP had been delivering for months was told to go and + * connect WHOOP. A source that is already delivering is not a task; the + * priority falls through to the next one the person's answers name. + */ + it("passes over a source that is already delivering", () => { + const needs: OnboardingNeeds = { + ...emptyOnboardingNeeds(), + sources: ["whoop", "oura"], + medication: "yes", + areas: ["sleep"], + }; + expect(chooseFirstResultTask(needs, new Set(["whoop"]))).toEqual({ + task: "connect-source", + target: "oura", + }); + expect(chooseFirstResultTask(needs, new Set(["whoop", "oura"]))).toEqual({ + task: "add-medication", + target: null, + }); + expect( + chooseFirstResultTask( + { ...needs, medication: "no" }, + new Set(["whoop", "oura"]), + ), + ).toEqual({ task: "log-reading", target: "sleep" }); + }); + + it("is unchanged when nothing is known about the connections", () => { + const needs: OnboardingNeeds = { + ...emptyOnboardingNeeds(), + sources: ["whoop"], + medication: "yes", + }; + expect(chooseFirstResultTask(needs)).toEqual( + chooseFirstResultTask(needs, new Set()), + ); + }); }); describe("firstResultApplies", () => { diff --git a/src/lib/onboarding/first-result-config.ts b/src/lib/onboarding/first-result-config.ts index 5b10a99bc..0f2eb588b 100644 --- a/src/lib/onboarding/first-result-config.ts +++ b/src/lib/onboarding/first-result-config.ts @@ -7,9 +7,13 @@ * which integration a Q4 source is, and which page carries an area the * measurement form cannot log. */ +import type { SyncVerdict } from "@/lib/integrations/sync-verdict"; import type { OnboardingAreaKey } from "@/lib/modules/registry"; -import type { BrowserConnectableSource } from "./wizard-steps"; +import { + BROWSER_CONNECTABLE_SOURCES, + type BrowserConnectableSource, +} from "./wizard-steps"; export interface AreaReadingTarget { /** The `defaultType` the measurement form opens on. */ @@ -47,19 +51,158 @@ export const AREA_PAGE_HREF: Readonly< illness: "/illness", }); +/** + * How a source's connection is authorised on this instance, which decides + * what "cannot be connected yet" means for it: + * + * - `byo` — the OAuth app is per-user only (`configured` is the stored client + * id/secret pair). With none, the provider card renders a credentials note + * and no connect button at all, so offering "Connect" walks the person into + * a dead end. + * - `shared` — an operator env app is the fallback (`available` says whether + * any usable credentials resolve, own or shared). + * - `self-hosted` — a URL the person pastes; nothing can be missing up front. + */ +export type SourceCredentialModel = "byo" | "shared" | "self-hosted"; + /** * The connections panel's anchor for each connectable source — the `id` of * the provider card on Settings → Integrations, and the key its status is * published under on `/api/integrations/status`. */ export const SOURCE_INTEGRATION: Readonly< - Record + Record< + BrowserConnectableSource, + { anchor: string; statusKey: string; credentials: SourceCredentialModel } + > > = Object.freeze({ - withings: { anchor: "withings", statusKey: "withings" }, - oura: { anchor: "oura", statusKey: "oura" }, - whoop: { anchor: "whoop", statusKey: "whoop" }, - polar: { anchor: "polar", statusKey: "polar" }, - fitbit: { anchor: "fitbit", statusKey: "fitbit" }, - strava: { anchor: "strava", statusKey: "strava" }, - nightscout: { anchor: "nightscout", statusKey: "nightscout" }, + withings: { anchor: "withings", statusKey: "withings", credentials: "byo" }, + oura: { anchor: "oura", statusKey: "oura", credentials: "shared" }, + whoop: { anchor: "whoop", statusKey: "whoop", credentials: "byo" }, + polar: { anchor: "polar", statusKey: "polar", credentials: "shared" }, + fitbit: { anchor: "fitbit", statusKey: "fitbit", credentials: "byo" }, + strava: { anchor: "strava", statusKey: "strava", credentials: "shared" }, + nightscout: { + anchor: "nightscout", + statusKey: "nightscout", + credentials: "self-hosted", + }, }); + +/** Which tile the connect step renders. */ +export type ConnectSourceSlot = + /** Nothing is connected and something can be: today's connect card. */ + | "connect" + /** Nothing is connected and nothing CAN be: point at the credentials. */ + | "credentials" + /** A working connection — the flow's result, whether or not it is new. */ + | "result" + /** A connection the person has to repair, named rather than painted green. */ + | "attention"; + +/** The fields of an `/api/integrations/status` entry this decision reads. */ +export interface ConnectSourceStatus { + connected?: boolean; + configured?: boolean; + available?: boolean; + state?: string; + syncHealth?: { verdict: SyncVerdict; since: string | null }; + lastSuccessAt?: string | null; +} + +export interface ConnectSourceView { + slot: ConnectSourceSlot; + verdict: SyncVerdict; + /** + * Whether the flow may record this as the completed first result. Only a + * connection that is delivering, or one whose first sync is under way — + * never a connection that needs repairing, and never the mere absence of a + * ledger row. + */ + settled: boolean; + /** The instant the copy names, or null when there is none to name. */ + when: string | null; +} + +/** + * What the connect step may honestly say about one source. + * + * The liveness truth is `syncHealth.verdict` and nothing else — the ledger's + * `state` is what the last ATTEMPT did, and its "no row" default used to read + * as `connected`, which is how a brand-new account was told its wearable was + * connected and had the flow's one result stamped as achieved on its behalf. + * + * Returns `null` while the envelope has not resolved: an unloaded status is + * not evidence of anything, and the screen says nothing rather than guessing. + */ +export function connectSourceView( + source: BrowserConnectableSource, + status: ConnectSourceStatus | undefined, +): ConnectSourceView | null { + if (!status) return null; + const verdict = status.syncHealth?.verdict ?? "disconnected"; + const settled = verdict === "pending_first_sync" || verdict === "fresh"; + const when = + verdict === "fresh" || verdict === "stale" + ? (status.lastSuccessAt ?? status.syncHealth?.since ?? null) + : (status.syncHealth?.since ?? status.lastSuccessAt ?? null); + + if (settled || verdict === "stale") { + return { slot: "result", verdict, settled, when }; + } + if (verdict !== "disconnected") { + return { slot: "attention", verdict, settled, when }; + } + return { + slot: canStartConnection(source, status) ? "connect" : "credentials", + verdict, + settled, + when, + }; +} + +/** + * Whether the connect CTA leads anywhere. A `byo` provider without its client + * pair and a `shared` provider without any resolvable app both land on a card + * that has no connect button — the dead end the flow used to walk into. + */ +function canStartConnection( + source: BrowserConnectableSource, + status: ConnectSourceStatus, +): boolean { + switch (SOURCE_INTEGRATION[source].credentials) { + case "byo": + return status.configured !== false; + case "shared": + return status.available !== false; + case "self-hosted": + return true; + } +} + +/** + * The sources the flow must stop offering to connect: the ones whose + * connection is already delivering (`fresh`), and the ones whose data has + * merely gone quiet (`stale`) — both are connections the person made, and + * both would be "connect it" a second time. + * + * `pending_first_sync` is deliberately NOT in the set: that is a connection + * made moments ago, and it is exactly the result this step exists to show. + * Neither is anything on the attention arm — there is still something to say. + */ +export function deliveringSources( + entries: + ReadonlyArray | undefined, +): ReadonlySet { + const delivering = new Set(); + if (!entries) return delivering; + for (const source of BROWSER_CONNECTABLE_SOURCES) { + const entry = entries.find( + (candidate) => + candidate.integration === SOURCE_INTEGRATION[source].statusKey, + ); + const verdict = connectSourceView(source, entry)?.verdict; + if (verdict === "fresh" || verdict === "stale") delivering.add(source); + } + return delivering; +} diff --git a/src/lib/onboarding/wizard-steps.ts b/src/lib/onboarding/wizard-steps.ts index 71c5912a7..b5055dccc 100644 --- a/src/lib/onboarding/wizard-steps.ts +++ b/src/lib/onboarding/wizard-steps.ts @@ -93,6 +93,8 @@ export function questionScreens(state: FlowState): OnboardingStepId[] { : [...FIXED_QUESTIONS]; } +const EMPTY_CONNECTED: ReadonlySet = new Set(); + export interface FirstResultOffer { task: OnboardingFirstResultTask; /** A source key for a connection, an area key for a reading, else null. */ @@ -104,11 +106,21 @@ export interface FirstResultOffer { * the browser can complete → the first medication with a reminder, if the * answer to Q3 was a daily schedule → one reading for the first Q2 area. * Everything the priority passes over goes to the checklist instead. + * + * `connected` names the sources whose connection is already delivering. It is + * optional because the server picks the order from the answers alone, before + * any status is in hand; the screen re-picks once the envelope resolves. A + * source in the set is not a task — an account whose WHOOP had been feeding + * the dashboard for months was told to go and connect WHOOP, which is the + * wizard talking past the account it can already see. */ export function chooseFirstResultTask( needs: OnboardingNeeds, + connected: ReadonlySet = EMPTY_CONNECTED, ): FirstResultOffer | null { - const connectable = needs.sources.find(isBrowserConnectableSource); + const connectable = needs.sources.find( + (source) => isBrowserConnectableSource(source) && !connected.has(source), + ); if (connectable) return { task: "connect-source", target: connectable }; if (needs.medication === "yes") return { task: "add-medication", target: null }; diff --git a/src/lib/openapi/routes/integrations.ts b/src/lib/openapi/routes/integrations.ts index 13fcf3981..020549058 100644 --- a/src/lib/openapi/routes/integrations.ts +++ b/src/lib/openapi/routes/integrations.ts @@ -326,9 +326,10 @@ const integrationLedgerState = z "error_reauth", "disconnected", "parked", + "unknown", ]) .describe( - "Last recorded ledger state for this provider. `connected` is also the value a user who has never synced sees — the ledger row is created on the first attempt, and its absence reads as `connected`, not as an error.", + "Last recorded ledger state for this provider. `unknown` (added v1.38.19) is what a user who has never synced sees: the ledger row is created on the first attempt, and its absence is no claim about the connection in either direction — it used to read as `connected`, which is why a client must never treat this field as liveness. Read `syncHealth.verdict` for that, and treat `unknown` as `disconnected` anywhere a ledger state is painted.", ); const nightscoutConnectRequest = nightscoutConnectSchema.meta({ diff --git a/src/lib/record-settings/integrations.ts b/src/lib/record-settings/integrations.ts index 192dd7e7c..cadea4616 100644 --- a/src/lib/record-settings/integrations.ts +++ b/src/lib/record-settings/integrations.ts @@ -14,5 +14,10 @@ export function resolveManagedIntegrationState( connected: Record, integration: IntegrationKey, ): IntegrationState { - return connected[integration] ? state : "disconnected"; + if (!connected[integration]) return "disconnected"; + // v1.38.19 — `unknown` is the ledger saying it holds no row. The managed + // projection publishes a closed vocabulary its card paints from, and + // "no history" is not one of its states, so it reads as `disconnected` + // here exactly as it does everywhere else. + return state === "unknown" ? "disconnected" : state; }