From da58d1664239fc7c4adf3fa221cdb71dfc8f7f78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 11 Sep 2026 09:15:32 +0200 Subject: [PATCH] fix(onboarding): the connect step reads the sync verdict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The step decided "connected" from the ledger's `state` and had one piece of copy for every outcome: "the first sync is in progress". That sentence was false for a connection delivering for months, and worse for one the person has to repair — the screen said everything was fine while the grant was gone. `connectSourceView` resolves the envelope entry into one of four tiles and says whether the flow may record its result. Only `pending_first_sync` and `fresh` may; `stale` is acknowledged without being claimed as new; `stalled`, `failing`, `reauth_required` and `parked` name the state and link at the page that can change it. An unresolved envelope paints no tile at all. A source the instance cannot connect — a BYO provider with no client pair, a shared-app provider with no app — now points at the credentials instead of offering a "Connect" button onto a card that has none. --- messages/de.json | 40 +++- messages/en.json | 40 +++- messages/es.json | 40 +++- messages/fr.json | 40 +++- messages/it.json | 40 +++- messages/ko.json | 40 +++- messages/pl.json | 40 +++- .../__tests__/first-result-screen.test.tsx | 194 ++++++++++++++++++ .../onboarding/first-result-screen.tsx | 144 +++++++++++-- .../__tests__/first-result-config.test.ts | 181 ++++++++++++++++ src/lib/onboarding/first-result-config.ts | 129 +++++++++++- 11 files changed, 883 insertions(+), 45 deletions(-) create mode 100644 src/components/onboarding/__tests__/first-result-screen.test.tsx diff --git a/messages/de.json b/messages/de.json index bc23d2f08..a5635870d 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4677,12 +4677,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 dc66aa61b..f176687e3 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4677,12 +4677,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 e5fddd66f..7a5cb4ca3 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4677,12 +4677,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 1640aa7e9..aece35130 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4677,12 +4677,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 d965b3827..0f61259f0 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4677,12 +4677,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 4dabc847b..e2b1833e3 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -4677,12 +4677,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 9769c0838..5a2085acc 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4677,12 +4677,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/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..af21c21d9 --- /dev/null +++ b/src/components/onboarding/__tests__/first-result-screen.test.tsx @@ -0,0 +1,194 @@ +/** + * 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 { 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(): OnboardingStateDto { + return { + steps: defaultOnboardingSteps(), + needs: { + ...emptyOnboardingNeeds(), + recordTarget: "me", + sources: ["whoop"], + }, + completedAt: null, + firstResult: null, + }; +} + +function render(overrides: StatusOverrides | null) { + 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"'); + }); +}); diff --git a/src/components/onboarding/first-result-screen.tsx b/src/components/onboarding/first-result-screen.tsx index d77894ab6..d203b0dcf 100644 --- a/src/components/onboarding/first-result-screen.tsx +++ b/src/components/onboarding/first-result-screen.tsx @@ -33,7 +33,9 @@ import type { OnboardingAreaKey } from "@/lib/modules/registry"; import { AREA_PAGE_HREF, AREA_READING_TARGETS, + connectSourceView, SOURCE_INTEGRATION, + type ConnectSourceSlot, } from "@/lib/onboarding/first-result-config"; import type { OnboardingStateDto } from "@/lib/onboarding/needs"; import { questionOptionLabelKey } from "@/lib/onboarding/question-config"; @@ -193,32 +195,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 +289,8 @@ function ConnectSourceTask({

{t("onboarding.flow.first-result.connect-source.howTo", { @@ -234,21 +298,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 +609,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 (
{ } }); }); + +/** + * 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(); + }, + ); +}); diff --git a/src/lib/onboarding/first-result-config.ts b/src/lib/onboarding/first-result-config.ts index 5b10a99bc..d320e7c6d 100644 --- a/src/lib/onboarding/first-result-config.ts +++ b/src/lib/onboarding/first-result-config.ts @@ -7,6 +7,7 @@ * 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"; @@ -47,19 +48,131 @@ 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; + } +}