diff --git a/messages/de.json b/messages/de.json index bb63290f5..916b8b9e4 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4666,6 +4666,7 @@ "alwaysOn": "Immer an: {modules} und die Kernwerte (Gewicht, Blutdruck, Puls).", "everythingElse": "Alles andere bleibt einen Klick entfernt unter", "modulesLink": "Einstellungen, Module", + "switchesOff": "Nicht mehr in der Navigation: {modules}. Jederzeit unter Einstellungen, Module wieder einschaltbar.", "glucoseUnit": "Blutzucker", "unitPreference": "Einheiten", "language": "Sprache", diff --git a/messages/en.json b/messages/en.json index 2ecd85fdf..7b6af9ac1 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4666,6 +4666,7 @@ "alwaysOn": "Always on: {modules}, and the core readings (weight, blood pressure, pulse).", "everythingElse": "Everything else stays one click away under", "modulesLink": "Settings, Modules", + "switchesOff": "Out of the navigation: {modules}. You can switch any of them back on under Settings, Modules.", "glucoseUnit": "Blood glucose", "unitPreference": "Units", "language": "Language", diff --git a/messages/es.json b/messages/es.json index 527610e38..8325c73db 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4666,6 +4666,7 @@ "alwaysOn": "Siempre activos: {modules} y las mediciones básicas (peso, presión arterial, pulso).", "everythingElse": "Todo lo demás queda a un clic en", "modulesLink": "Ajustes, Módulos", + "switchesOff": "Fuera de la navegación: {modules}. Puedes volver a activarlos cuando quieras en Ajustes, Módulos.", "glucoseUnit": "Glucosa en sangre", "unitPreference": "Unidades", "language": "Idioma", diff --git a/messages/fr.json b/messages/fr.json index b8c3b73d3..64da413da 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4666,6 +4666,7 @@ "alwaysOn": "Toujours actifs : {modules}, et les mesures de base (poids, tension, pouls).", "everythingElse": "Tout le reste reste à un clic dans", "modulesLink": "Réglages, Modules", + "switchesOff": "Hors de la navigation : {modules}. Tu peux les réactiver à tout moment dans Réglages, Modules.", "glucoseUnit": "Glycémie", "unitPreference": "Unités", "language": "Langue", diff --git a/messages/it.json b/messages/it.json index a1ceb9da9..c5a336421 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4666,6 +4666,7 @@ "alwaysOn": "Sempre attivi: {modules} e le misurazioni di base (peso, pressione, polso).", "everythingElse": "Tutto il resto resta a un clic in", "modulesLink": "Impostazioni, Moduli", + "switchesOff": "Fuori dalla navigazione: {modules}. Puoi riattivarli quando vuoi in Impostazioni, Moduli.", "glucoseUnit": "Glicemia", "unitPreference": "Unità", "language": "Lingua", diff --git a/messages/ko.json b/messages/ko.json index 7772b8576..727b99b4a 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -4666,6 +4666,7 @@ "alwaysOn": "항상 켜짐: {modules}, 그리고 기본 측정값(체중, 혈압, 맥박).", "everythingElse": "나머지는 한 번의 클릭으로 열 수 있습니다:", "modulesLink": "설정, 모듈", + "switchesOff": "탐색에서 빠집니다: {modules}. 설정, 모듈에서 언제든 다시 켤 수 있습니다.", "glucoseUnit": "혈당", "unitPreference": "단위", "language": "언어", diff --git a/messages/pl.json b/messages/pl.json index cff8897c2..ee155e164 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4666,6 +4666,7 @@ "alwaysOn": "Zawsze włączone: {modules} oraz podstawowe pomiary (waga, ciśnienie, tętno).", "everythingElse": "Cała reszta jest o jedno kliknięcie w", "modulesLink": "Ustawienia, Moduły", + "switchesOff": "Znika z nawigacji: {modules}. W każdej chwili możesz je włączyć w Ustawienia, Moduły.", "glucoseUnit": "Glukoza we krwi", "unitPreference": "Jednostki", "language": "Język", diff --git a/src/__tests__/profile-rejection-surface-guard.test.ts b/src/__tests__/profile-rejection-surface-guard.test.ts index 7bed465fd..e18fec0d0 100644 --- a/src/__tests__/profile-rejection-surface-guard.test.ts +++ b/src/__tests__/profile-rejection-surface-guard.test.ts @@ -31,12 +31,19 @@ const SRC = join(process.cwd(), "src"); const PROFILE_ROUTES = ["/api/auth/profile", "/api/user/profile"]; function sourceFiles(): string[] { - return walkSourceFiles(SRC, { floor: 3000 }) - .filter((p) => !p.startsWith("generated/")) - .filter((p) => !p.startsWith("app/api/")) - .filter((p) => !p.includes("__tests__")) - .filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx")) - .sort(); + return ( + walkSourceFiles(SRC, { floor: 3000 }) + .filter((p) => !p.startsWith("generated/")) + .filter((p) => !p.startsWith("app/api/")) + // v1.39 (Wave C, C6) — the edge proxy names routes and methods as DATA + // (the demo-mode mutation allowlist is a list of path/method pairs), and + // a pair reads to the matcher below exactly like a request expression. It + // submits nothing and has no answer to read. + .filter((p) => p !== "proxy.ts") + .filter((p) => !p.includes("__tests__")) + .filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx")) + .sort() + ); } function read(rel: string): string { diff --git a/src/__tests__/proxy-demo-mode-allowlist.test.ts b/src/__tests__/proxy-demo-mode-allowlist.test.ts index d51c61082..0f7a6af0d 100644 --- a/src/__tests__/proxy-demo-mode-allowlist.test.ts +++ b/src/__tests__/proxy-demo-mode-allowlist.test.ts @@ -106,6 +106,36 @@ describe("proxy.ts DEMO_MODE mutation allowlist", () => { expect(missing, "onboarding writes the demo cannot make").toEqual([]); }); + it("permits the baseline writes the confirm screen makes (M-m)", () => { + // v1.39 (Wave C, C6) — a demo visitor who typed a height got a 403 and a + // generic toast, and "someone I look after" could only take "Finish + // without the profile": the three writes the confirm screen makes were + // not on the list. None of them is health data — the account's own + // profile fields, the encrypted self-context the anamnesis card writes, + // and the managed record the flow's guardian arm creates. + for (const [path, method] of [ + ["/api/auth/profile", "PUT"], + ["/api/coach/about-me", "PUT"], + ["/api/managed-profiles", "POST"], + ] as const) { + expect( + proxy(makeRequest(path, method)).status, + `${method} ${path} is blocked in the demo`, + ).not.toBe(403); + } + }); + + it("does not drag in a sibling verb on the baseline paths", () => { + expect(proxy(makeRequest("/api/auth/profile", "DELETE")).status).toBe(403); + expect(proxy(makeRequest("/api/managed-profiles", "DELETE")).status).toBe( + 403, + ); + // The per-profile routes are a different path and stay closed. + expect( + proxy(makeRequest("/api/managed-profiles/abc", "DELETE")).status, + ).toBe(403); + }); + it("still blocks a health-data mutation (POST /api/measurements)", () => { const res = proxy(makeRequest("/api/measurements", "POST")); expect(res.status).toBe(403); diff --git a/src/components/onboarding/__tests__/confirm-failure-recovery.test.ts b/src/components/onboarding/__tests__/confirm-failure-recovery.test.ts new file mode 100644 index 000000000..0fae32763 --- /dev/null +++ b/src/components/onboarding/__tests__/confirm-failure-recovery.test.ts @@ -0,0 +1,155 @@ +/** + * v1.39 (Wave C, C2) — a failed completion leaves the confirm screen usable. + * + * Two defects, one symptom. `advance()` in the baseline form set its pending + * flag and never reset it on the success path, and `finish()` on the confirm + * screen caught its own error and returned normally — so a failed + * `POST /api/onboarding/complete` (or a 429 on the answer PATCHes before it) + * left "Überspringen" and "Bestätigen und weiter" disabled for the rest of the + * page's life and only a reload recovered (research I10). + * + * The second defect is the retry itself: `finish()` fires one PATCH per + * question the flow never showed, and a failure midway left a partly-passed + * ledger that the next attempt walked from the top again (M-j). + * + * Both are pinned at the seam the component drives, because this repo's + * component tests render server-side and cannot click. + */ +import { describe, expect, it, vi } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { + defaultOnboardingSteps, + emptyOnboardingNeeds, + type OnboardingStateDto, +} from "@/lib/onboarding/needs"; + +import { runBaselineAttempt } from "../baseline-form"; +import { runConfirmFinish } from "../confirm-screen"; + +type PassableStep = Parameters< + Parameters[0]["passStep"] +>[0]; + +/** + * A flow with no unit-bearing area: Q6 is the question the machine never + * showed, so the confirm screen has to pass it on the ledger before the + * completion. + */ +function state( + overrides: Partial = {}, +): OnboardingStateDto { + return { + steps: defaultOnboardingSteps().map((step) => + step.id === "who" ? { ...step, status: "done" as const } : step, + ), + needs: { ...emptyOnboardingNeeds(), recordTarget: "me" }, + completedAt: null, + firstResult: null, + ...overrides, + }; +} + +/** The ledger as the server would answer it after one step was passed. */ +function withPassed( + base: OnboardingStateDto, + ids: readonly string[], +): OnboardingStateDto { + return { + ...base, + steps: base.steps.map((step) => + ids.includes(step.id) ? { ...step, status: "skipped" as const } : step, + ), + }; +} + +describe("runBaselineAttempt", () => { + it("hands the buttons back when the completion rejects", async () => { + const pending: boolean[] = []; + const onError = vi.fn(); + await runBaselineAttempt( + (value) => pending.push(value), + async () => { + throw new Error("complete failed"); + }, + onError, + ); + expect(pending).toEqual([true, false]); + expect(onError).toHaveBeenCalledTimes(1); + }); + + it("hands the buttons back on the success path too", async () => { + const pending: boolean[] = []; + await runBaselineAttempt( + (value) => pending.push(value), + async () => {}, + vi.fn(), + ); + expect(pending).toEqual([true, false]); + }); + + it("is the form's only way to move the pending flag", () => { + // A second, inline `setSaving(false)` is how the flag stopped being + // reset on one of the three exits in the first place. + const source = readFileSync( + join(process.cwd(), "src/components/onboarding/baseline-form.tsx"), + "utf8", + ); + expect(source).not.toContain("setSaving(false)"); + expect(source).not.toContain("setSaving(true)"); + }); +}); + +describe("runConfirmFinish", () => { + it("rethrows a failed completion instead of returning normally", async () => { + const ledger = { current: state() }; + const passed: PassableStep[] = []; + const navigate = vi.fn(); + await expect( + runConfirmFinish({ + ledger, + // The server answers the written ledger; only the runner may put it + // back, which is the whole point of the ref. + passStep: async (step) => { + passed.push(step); + return withPassed(ledger.current, [step]); + }, + complete: async () => { + throw new Error("429"); + }, + navigate, + }), + ).rejects.toThrow("429"); + // Q6 was never shown for a flow with no unit-bearing area, so it is + // passed on the ledger before the completion — and that write survived. + expect(passed).toEqual(["units"]); + expect(navigate).not.toHaveBeenCalled(); + }); + + it("does not pass a step the failed attempt already passed (M-j)", async () => { + const ledger = { current: state() }; + const passed: PassableStep[] = []; + const passStep = async (step: PassableStep) => { + passed.push(step); + return withPassed(ledger.current, [step]); + }; + let attempt = 0; + const complete = async () => { + attempt += 1; + if (attempt === 1) throw new Error("429"); + return { onboarding: ledger.current }; + }; + const navigate = vi.fn(); + + await expect( + runConfirmFinish({ ledger, passStep, complete, navigate }), + ).rejects.toThrow("429"); + await runConfirmFinish({ ledger, passStep, complete, navigate }); + + // "units" exactly once across both attempts; "first-result" is the + // second attempt's own pass, because these answers offer no task. + expect(passed).toEqual(["units", "first-result"]); + expect(navigate).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/onboarding/__tests__/confirm-screen.test.tsx b/src/components/onboarding/__tests__/confirm-screen.test.tsx new file mode 100644 index 000000000..274a05b74 --- /dev/null +++ b/src/components/onboarding/__tests__/confirm-screen.test.tsx @@ -0,0 +1,109 @@ +/** + * v1.39 (Wave C) — what the confirm screen is allowed to claim. + * + * C5 (research M-l): both of the screen's own Settings links are page routes, + * and `hl_onboarding=pending` is still set while this screen is on — the + * completion is what clears it, and this screen is what calls the completion. + * The proxy sends both straight back to the flow, so on a first run they are + * dead links under a sentence that is not yet true. + * + * C4 (research I6): the screen listed what the answers switch ON and said + * everything else "stays one click away under Settings, Modules" — true of + * reachability, false of the navigation entry. A module the answers do not + * ask for leaves the navigation, and nothing said so. + * + * Rendered server-side, this repo's component-test convention. + */ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; +import type { AuthUser } from "@/hooks/use-auth"; +import { + defaultOnboardingSteps, + emptyOnboardingNeeds, + type OnboardingStateDto, +} from "@/lib/onboarding/needs"; + +let currentUser: AuthUser | null = null; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ user: currentUser }), + useAccountOnceMounted: () => currentUser, +})); + +const { ConfirmScreen } = await import("../confirm-screen"); + +function state( + overrides: Partial = {}, + needs: Partial = {}, +): OnboardingStateDto { + return { + steps: defaultOnboardingSteps(), + needs: { ...emptyOnboardingNeeds(), recordTarget: "me", ...needs }, + completedAt: null, + firstResult: null, + ...overrides, + }; +} + +function render( + flow: OnboardingStateDto, + modules: Partial> = {}, +): string { + currentUser = { + id: "u1", + heightCm: null, + dateOfBirth: null, + gender: null, + timezone: "Europe/Berlin", + unitPreference: "metric", + modules, + } as unknown as AuthUser; + return renderToStaticMarkup( + + + + + , + ); +} + +describe(" — modules leaving the navigation (C4)", () => { + it("names them when the answers do not ask for what the record shows", () => { + const html = render(state({}, { areas: ["sleep"], medication: "no" }), { + medications: true, + }); + expect(html).toContain('data-slot="onboarding-confirm-modules-off"'); + expect(html).toContain("Medications"); + }); + + it("says nothing when the answers take nothing away", () => { + const html = render(state({}, { areas: ["sleep"], medication: "yes" }), { + medications: true, + }); + expect(html).not.toContain('data-slot="onboarding-confirm-modules-off"'); + }); +}); + +describe(" — Settings links on a first run (C5)", () => { + it("renders both as plain text while the completion has not run", () => { + const html = render(state()); + expect(html).not.toContain('href="/settings/modules"'); + expect(html).not.toContain('href="/settings/account"'); + // The words stay: they say where the thing lives, which is true. + expect(html).toContain("Settings, Modules"); + expect(html).toContain("Settings, Account"); + }); + + it("renders them as links once the flow has been completed once", () => { + const html = render(state({ completedAt: "2026-09-10T08:00:00.000Z" }), {}); + expect(html).toContain('href="/settings/modules"'); + expect(html).toContain('href="/settings/account"'); + }); +}); diff --git a/src/components/onboarding/__tests__/getting-started-checklist.test.tsx b/src/components/onboarding/__tests__/getting-started-checklist.test.tsx new file mode 100644 index 000000000..cfe0c20e9 --- /dev/null +++ b/src/components/onboarding/__tests__/getting-started-checklist.test.tsx @@ -0,0 +1,139 @@ +/** + * v1.39 (Wave C, C1) — the checklist's data queries follow the same rule as + * its visibility. + * + * `shouldShowChecklist` gained `hasEnteredOnboardingFlow` as a third + * disjunct; `checklistRelevant` in the component kept restating the older + * half of the rule, so a wizard-run account past five readings still saw the + * card while every supporting query was `enabled: false` — the medication, + * data-source, notification and insights rows read "not done" for things the + * account had already done (research I9). + * + * Rendered SSR-only, per this repo's component-test convention. `useQuery` is + * mocked to honour `enabled` exactly the way tanstack does — a disabled query + * has no data — so what the rows say here is what the dashboard says. + */ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; + +import { I18nProvider } from "@/lib/i18n/context"; +import type { AuthUser } from "@/hooks/use-auth"; +import { + defaultOnboardingSteps, + emptyOnboardingNeeds, + type OnboardingStateDto, +} from "@/lib/onboarding/needs"; + +let currentUser: AuthUser | null = null; +/** Every `enabled` flag the component asked for, keyed by its query key. */ +const enabledByKey = new Map(); + +vi.mock("@/hooks/use-auth", () => ({ + useAccountOnceMounted: () => currentUser, +})); + +vi.mock("@/lib/queries/use-dashboard-snapshot", () => ({ + useDashboardSnapshot: () => ({ + data: { tiles: { summaries: { weight: { count: 12 } } } }, + }), +})); + +const RESPONSES: Record = { + '["medications"]': [{ id: "m1" }, { id: "m2" }, { id: "m3" }], + '["user","ai-provider"]': { aiAvailable: true }, + '["integrations","status"]': { + integrations: [{ integration: "whoop", connected: true }], + }, + // Deliberately NOT configured: one open row keeps the card on screen, so + // the rows this test is about are actually rendered. + '["notifications","preferences"]': { channels: [{ enabled: false }] }, +}; + +vi.mock("@tanstack/react-query", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useQuery: (options: { + queryKey: readonly unknown[]; + enabled?: boolean; + }) => { + const key = JSON.stringify(options.queryKey); + const enabled = options.enabled !== false; + enabledByKey.set(key, enabled); + return { data: enabled ? RESPONSES[key] : undefined }; + }, + }; +}); + +const { GettingStartedChecklist } = + await import("../getting-started-checklist"); + +/** A record the flow really finished: Q1 answered, confirmed, task settled. */ +function settledOnboarding(): OnboardingStateDto { + return { + steps: defaultOnboardingSteps().map((step) => ({ + ...step, + status: "done" as const, + })), + needs: { ...emptyOnboardingNeeds(), recordTarget: "me" }, + completedAt: "2026-09-10T08:00:00.000Z", + firstResult: { + task: "log-reading", + target: null, + completedAt: "2026-09-10T08:05:00.000Z", + }, + } as OnboardingStateDto; +} + +function user(overrides: Partial = {}): AuthUser { + return { + id: "u1", + email: "a@b.c", + username: "a", + heightCm: 175, + dateOfBirth: "1990-01-01T00:00:00.000Z", + gender: "MALE", + onboardingCompletedAt: "2026-09-10T08:00:00.000Z", + onboarding: settledOnboarding(), + ...overrides, + } as AuthUser; +} + +function render(): string { + enabledByKey.clear(); + return renderToStaticMarkup( + + + , + ); +} + +describe(" — rows for an established account", () => { + it("reads the real medication, data-source and insights state once the flow is behind the account", () => { + currentUser = user(); + const html = render(); + + // The card is on screen: the notifications row is genuinely open. The + // list itself is collapsed on a server paint, so the progress meter is + // what the rows add up to — profile, measurement, medication, data + // source and insights done, notifications not. + expect(html).toContain('data-testid="onboarding-card"'); + expect(html).toContain("5 of 6 done"); + }); + + it("keeps the supporting queries enabled while the card can render", () => { + currentUser = user(); + render(); + expect(enabledByKey.get('["medications"]')).toBe(true); + expect(enabledByKey.get('["integrations","status"]')).toBe(true); + expect(enabledByKey.get('["notifications","preferences"]')).toBe(true); + expect(enabledByKey.get('["user","ai-provider"]')).toBe(true); + }); + + it("still fetches nothing for a record that never entered the flow and is long past five readings", () => { + currentUser = user({ onboarding: null }); + render(); + expect(enabledByKey.get('["medications"]')).toBe(false); + expect(enabledByKey.get('["integrations","status"]')).toBe(false); + }); +}); diff --git a/src/components/onboarding/__tests__/units-screen.test.tsx b/src/components/onboarding/__tests__/units-screen.test.tsx new file mode 100644 index 000000000..9dfeb5de5 --- /dev/null +++ b/src/components/onboarding/__tests__/units-screen.test.tsx @@ -0,0 +1,82 @@ +/** + * v1.39 (Wave C, C3) — Q6 cannot be answered with nothing chosen (M-k). + * + * "Weiter" used to be live on an untouched units screen: it wrote + * `{ units: {} }`, which marked the step DONE without recording a preference, + * so the account carried "asked and answered" for a question nobody answered. + * Skip is the way past a question with no opinion, and Skip says so on the + * ledger. + * + * Rendered server-side, this repo's component-test convention. + */ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { + defaultOnboardingSteps, + emptyOnboardingNeeds, + type OnboardingStateDto, +} from "@/lib/onboarding/needs"; + +import { UnitsScreen } from "../units-screen"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ user: null }), +})); + +function state( + needs: Partial, +): OnboardingStateDto { + return { + steps: defaultOnboardingSteps(), + needs: { ...emptyOnboardingNeeds(), recordTarget: "me", ...needs }, + completedAt: null, + firstResult: null, + }; +} + +function render(needs: Partial): string { + return renderToStaticMarkup( + + + + + , + ); +} + +/** + * Is the primary button's own tag disabled? Read off the tag rather than the + * whole page, and off the attribute rather than the string "disabled", which + * also occurs in the button's `disabled:` utility classes. + */ +function nextIsDisabled(html: string): boolean { + const at = html.indexOf('data-slot="onboarding-next"'); + expect(at).toBeGreaterThan(-1); + const open = html.lastIndexOf("", at) + 1).includes('disabled=""'); +} + +describe("", () => { + it("does not let the person continue without choosing a unit", () => { + const html = render({ areas: ["glucose"] }); + expect(nextIsDisabled(html)).toBe(true); + // Skip is still there: it is the honest way past a question with no + // opinion, and it says "skipped" on the ledger. + expect(html).toContain('data-slot="onboarding-skip"'); + }); + + it("lets the person continue once a unit is chosen", () => { + const html = render({ + areas: ["glucose"], + units: { glucoseUnit: "mg/dL", unitPreference: null }, + }); + expect(nextIsDisabled(html)).toBe(false); + }); +}); diff --git a/src/components/onboarding/baseline-form.tsx b/src/components/onboarding/baseline-form.tsx index 3f0cdc266..5ff30b80f 100644 --- a/src/components/onboarding/baseline-form.tsx +++ b/src/components/onboarding/baseline-form.tsx @@ -71,6 +71,37 @@ interface BaselineFormState { gender: string; } +/** + * v1.39 (Wave C, C2) — one attempt at confirming, with the buttons always + * handed back. + * + * `advance()` used to raise its own pending flag and lower it again on two of + * the three exits: the refused-field return and its catch. The third exit — + * the success path, `await onConfirmed()` — never lowered it, and + * `onConfirmed` is the confirm screen's completion, which caught its own + * error and returned normally. So a failed completion disabled "Skip" and + * "Confirm and continue" for the rest of the page's life and only a reload + * recovered (research I10). + * + * The reset lives in a `finally` here, which is the one place it cannot be + * forgotten on a new exit, and the component has no other way to move the + * flag. + */ +export async function runBaselineAttempt( + setPending: (pending: boolean) => void, + attempt: () => Promise, + onError: (err: unknown) => void, +): Promise { + setPending(true); + try { + await attempt(); + } catch (err) { + onError(err); + } finally { + setPending(false); + } +} + export function BaselineForm({ initial, backHref, @@ -152,50 +183,50 @@ export function BaselineForm({ async function advance(opts: { saveProfile: boolean }) { if (saving || confirming) return; - setSaving(true); setFieldErrors({}); - try { - if (opts.saveProfile) { - const profileBody = buildBaselineProfileBody( - form, - heightAdapter.toCanonicalCm(form.height), - ); - if (Object.keys(profileBody).length > 0) { - const outcome = describeBaselineSaveOutcome( - await putBaselineProfile(profileBody), - t, - baselineFieldLabelKeys(heightAdapter.usesFeetInches), + await runBaselineAttempt( + setSaving, + async () => { + if (opts.saveProfile) { + const profileBody = buildBaselineProfileBody( + form, + heightAdapter.toCanonicalCm(form.height), ); - if (outcome.notice) { - const show = - outcome.notice.tone === "warning" ? toast.warning : toast.error; - show(outcome.notice.message); + if (Object.keys(profileBody).length > 0) { + const outcome = describeBaselineSaveOutcome( + await putBaselineProfile(profileBody), + t, + baselineFieldLabelKeys(heightAdapter.usesFeetInches), + ); + if (outcome.notice) { + const show = + outcome.notice.tone === "warning" ? toast.warning : toast.error; + show(outcome.notice.message); + } + if (!outcome.advance) { + // A field was refused. Staying on the step is the point — + // each refused input now says why under itself, the values + // the person typed are still in front of them, and the + // account is not stamped as set up over a value that never + // landed. + setFieldErrors(outcome.fieldErrors); + return; + } } - if (!outcome.advance) { - // A field was refused. Staying on the step is the point — - // each refused input now says why under itself, the values - // the person typed are still in front of them, and the - // account is not stamped as set up over a value that never - // landed. - setFieldErrors(outcome.fieldErrors); - setSaving(false); - return; - } - } - // Anamnesis — only write when the user actually filled a field - // (the helper returns null for an untouched card, so a - // collapsed card never round-trips). - const aboutMeBody = buildAnamnesisAboutMeBody(baseAboutMe, anamnesis); - if (aboutMeBody) { - await apiPut("/api/coach/about-me", aboutMeBody); + // Anamnesis — only write when the user actually filled a field + // (the helper returns null for an untouched card, so a + // collapsed card never round-trips). + const aboutMeBody = buildAnamnesisAboutMeBody(baseAboutMe, anamnesis); + if (aboutMeBody) { + await apiPut("/api/coach/about-me", aboutMeBody); + } } - } - await onConfirmed(); - } catch (err) { - toast.error(localizedApiError(err, t, "onboarding.errorGeneric")); - setSaving(false); - } + await onConfirmed(); + }, + (err) => + toast.error(localizedApiError(err, t, "onboarding.errorGeneric")), + ); } return ( diff --git a/src/components/onboarding/confirm-screen.tsx b/src/components/onboarding/confirm-screen.tsx index 9a0b053ca..dae55b0c8 100644 --- a/src/components/onboarding/confirm-screen.tsx +++ b/src/components/onboarding/confirm-screen.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useRef, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { toast } from "sonner"; @@ -20,7 +20,14 @@ import { localeLabels } from "@/lib/i18n/config"; import { useTranslations } from "@/lib/i18n/context"; import { MODULE_REGISTRY } from "@/lib/modules/registry"; import { confirmedModules } from "@/lib/onboarding/confirm-summary"; -import type { OnboardingStateDto } from "@/lib/onboarding/needs"; +import { + ONBOARDING_SKIPPABLE_STEP_IDS, + type OnboardingStateDto, +} from "@/lib/onboarding/needs"; + +/** A step the confirm screen may pass on the ledger without an answer. */ +type PassableStepId = (typeof ONBOARDING_SKIPPABLE_STEP_IDS)[number]; +import { isOnboardingSettled } from "@/lib/onboarding/needs"; import { firstResultApplies, nextScreen, @@ -51,6 +58,40 @@ import { * offer, otherwise done. When there is no task, the first-result step is * passed explicitly so the ledger reads as settled rather than owed. */ +/** + * v1.39 (Wave C, C2) — the completion sequence, apart from the component so + * a failure partway through it can be pinned without a browser. + * + * Passes every question the flow never showed (the route derives only once + * EVERY question is answered or passed, and a step nobody was asked cannot be + * answered), completes, passes the first-result step when there is no task to + * offer, and navigates. It reports a failure by THROWING — the caller owns the + * buttons and the toast. + */ +export async function runConfirmFinish(args: { + /** The ledger as the last write left it; updated in place as steps pass. */ + ledger: { current: OnboardingStateDto }; + managedRecordId?: string; + passStep: (step: PassableStepId) => Promise; + complete: ( + managedRecordId?: string, + ) => Promise<{ onboarding?: OnboardingStateDto | null }>; + navigate: (href: string) => void; +}): Promise { + for (const id of questionsToPassBeforeConfirm(args.ledger.current)) { + args.ledger.current = await args.passStep(id); + } + const { onboarding } = await args.complete(args.managedRecordId); + let written = onboarding ?? args.ledger.current; + if (!firstResultApplies(written)) { + // Nothing to offer, so the step is passed rather than left owed: a + // pending step would keep the setup reading as unfinished forever. + written = await args.passStep("first-result"); + } + args.ledger.current = written; + args.navigate(screenHref(nextScreen(written, "confirm") ?? "done")); +} + export function ConfirmScreen({ state }: { state: OnboardingStateDto }) { const { t, locale } = useTranslations(); const router = useRouter(); @@ -58,10 +99,37 @@ export function ConfirmScreen({ state }: { state: OnboardingStateDto }) { const complete = useOnboardingComplete(); const answer = useOnboardingAnswer(); const [finishing, setFinishing] = useState(false); + /** + * v1.39 (Wave C, C2) — the ledger as the last write left it (M-j). + * `finish()` sends one PATCH per question the flow never showed; a failure + * midway used to leave a partly-passed ledger that the next attempt walked + * from the top again, because the `state` prop it read was the one this + * screen was rendered with. Every mutation answers the written state, so + * the ref carries the progress across attempts and + * `questionsToPassBeforeConfirm` — which lists pending steps only — then + * skips what the failed attempt already passed. + */ + const ledger = useRef(state); const needs = state.needs; + /** + * v1.39 (Wave C, C5) — is `hl_onboarding=pending` still set? (research M-l) + * + * Both of this screen's Settings links are page routes, and the cookie is + * cleared by the completion — which is what THIS screen calls. So on a + * first run the proxy 307s both straight back into the flow: two links + * that cannot go anywhere, under a sentence saying where things live. The + * words are true, the links are not, so only the links go. + */ + const settingsReachable = + isOnboardingSettled(state) || state.completedAt !== null; const forSomeoneElse = needs.recordTarget === "someone-else"; - const { chosen, alwaysOn } = confirmedModules(needs); + // v1.39 (Wave C, C4) — `user.modules` is the map the navigation reads, so + // the screen can only name a module the person actually has today. + const { chosen, alwaysOn, wouldSwitchOff } = confirmedModules( + needs, + user?.modules ?? {}, + ); const back = previousScreen(state, "confirm"); const glucoseUnit = needs.units.glucoseUnit ?? user?.glucoseUnit ?? null; @@ -73,37 +141,39 @@ export function ConfirmScreen({ state }: { state: OnboardingStateDto }) { * after" just created: the route then applies the derivation to THAT * record and stamps this one complete without deriving, so the guardian's * own modules are never re-ordered around the child's answers. + * + * v1.39 (Wave C, C2) — this RETHROWS. `` owns the two + * buttons and releases them in its own `finally`, and it can only do that + * for a failure it is told about: the previous version caught, toasted and + * returned normally, which left both buttons disabled until a reload + * (research I10). The two managed-arm callers, which have no form around + * them, go through `finishReporting` below. */ async function finish(managedRecordId?: string) { if (finishing) return; setFinishing(true); try { - // A question this flow never showed — Q6 for somebody with no - // unit-bearing area — is passed on the ledger before the completion, - // because the route derives only once EVERY question is answered or - // passed, and a step nobody was asked cannot be answered. - for (const id of questionsToPassBeforeConfirm(state)) { - await answer.mutateAsync({ step: id, status: "skipped" }); - } - const { onboarding } = await complete.mutateAsync( - managedRecordId ? { managedRecordId } : undefined, - ); - let written = onboarding ?? state; - if (!firstResultApplies(written)) { - // Nothing to offer, so the step is passed rather than left owed: - // a pending step would keep the setup reading as unfinished forever. - written = await answer.mutateAsync({ - step: "first-result", - status: "skipped", - }); - } - router.push(screenHref(nextScreen(written, "confirm") ?? "done")); + await runConfirmFinish({ + ledger, + managedRecordId, + passStep: (step) => answer.mutateAsync({ step, status: "skipped" }), + complete: (id) => + complete.mutateAsync(id ? { managedRecordId: id } : undefined), + navigate: (href) => router.push(href), + }); } catch (err) { - toast.error(localizedApiError(err, t, "onboarding.errorGeneric")); setFinishing(false); + throw err; } } + /** The managed arm's two buttons: nothing above them reports a failure. */ + function finishReporting(managedRecordId?: string) { + void finish(managedRecordId).catch((err) => { + toast.error(localizedApiError(err, t, "onboarding.errorGeneric")); + }); + } + const moduleNames = (keys: readonly (keyof typeof MODULE_REGISTRY)[]) => keys.map((key) => t(MODULE_REGISTRY[key].labelKey)).join(", "); @@ -129,6 +199,13 @@ export function ConfirmScreen({ state }: { state: OnboardingStateDto }) { }) : t("onboarding.flow.confirm.nothingExtra")}

+ {wouldSwitchOff.length > 0 ? ( +

+ {t("onboarding.flow.confirm.switchesOff", { + modules: moduleNames(wouldSwitchOff), + })} +

+ ) : null}

{t("onboarding.flow.confirm.alwaysOn", { modules: moduleNames(alwaysOn), @@ -136,12 +213,18 @@ export function ConfirmScreen({ state }: { state: OnboardingStateDto }) {

{t("onboarding.flow.confirm.everythingElse")}{" "} - - {t("onboarding.flow.confirm.modulesLink")} - + {settingsReachable ? ( + + {t("onboarding.flow.confirm.modulesLink")} + + ) : ( + + {t("onboarding.flow.confirm.modulesLink")} + + )}

@@ -176,12 +259,18 @@ export function ConfirmScreen({ state }: { state: OnboardingStateDto }) { ) : null}

- - {t("onboarding.flow.confirm.changeInSettings")} - + {settingsReachable ? ( + + {t("onboarding.flow.confirm.changeInSettings")} + + ) : ( + + {t("onboarding.flow.confirm.changeInSettings")} + + )}

{forSomeoneElse ? ( @@ -196,7 +285,7 @@ export function ConfirmScreen({ state }: { state: OnboardingStateDto }) { void finish(profile.id)} + onCreated={(profile) => finishReporting(profile.id)} />

{t("onboarding.flow.confirm.managedLater")}

@@ -212,7 +301,7 @@ export function ConfirmScreen({ state }: { state: OnboardingStateDto }) {