From d91a8dfebe286d0cd45d34e09733b208e313883c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 16:38:47 +0200 Subject: [PATCH] Make the setup flow pass its own journeys and axe sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run against the standalone build, the four journeys and the axe sweep surfaced five things the unit tier could not see. The completion route derives only once every question is settled, and Q6 stays pending for somebody with no unit-bearing area — the confirm screen now passes the questions the flow never showed before it completes. The dashboard seed matched a JSON null where the unset column is a SQL NULL, so it never landed; the retired step route carried the same filter. The auth shell's onboarding branch matched only the front door, so every question screen rendered inside the full app chrome with the sidebar. Inline links relied on colour alone, the step list sat outside any landmark, and the baseline form brought a second h1 onto the confirm screen. The two sweeps run serially, since each drives one account through the API. The integration file gains the seed case that would have caught the null filter. --- e2e/setup-flow-a11y.spec.ts | 4 ++ e2e/setup-flow-bp.spec.ts | 15 ++--- e2e/setup-flow-locale-overflow.spec.ts | 4 ++ src/app/api/onboarding/complete/route.ts | 5 +- src/components/layout/auth-shell.tsx | 6 +- src/components/onboarding/baseline-form.tsx | 14 ++--- src/components/onboarding/confirm-screen.tsx | 23 +++++++- src/components/onboarding/done-screen.tsx | 6 +- .../onboarding/onboarding-shell.tsx | 49 +++++++++------- src/components/onboarding/welcome-screen.tsx | 2 +- tests/integration/onboarding-needs.test.ts | 58 +++++++++++++++++++ 11 files changed, 137 insertions(+), 49 deletions(-) diff --git a/e2e/setup-flow-a11y.spec.ts b/e2e/setup-flow-a11y.spec.ts index b1a1a7fc6..3891eb7c4 100644 --- a/e2e/setup-flow-a11y.spec.ts +++ b/e2e/setup-flow-a11y.spec.ts @@ -165,6 +165,10 @@ async function runAxe(page: Page): Promise { }); } +// Serial: every test here drives the ONE account through the API, and two +// workers doing that at once would each find the other's answers. +test.describe.configure({ mode: "serial" }); + test.describe("setup flow — axe on every screen", () => { test.use({ storageState: SETUP_A11Y_STORAGE_STATE_PATH }); diff --git a/e2e/setup-flow-bp.spec.ts b/e2e/setup-flow-bp.spec.ts index 939449727..ab3e9cdd9 100644 --- a/e2e/setup-flow-bp.spec.ts +++ b/e2e/setup-flow-bp.spec.ts @@ -138,15 +138,12 @@ test.describe("setup flow — blood pressure and weight, typed in", () => { "every step settled", ).toBe(true); - // The tile order: the two areas' tiles first, in the default layout's - // own order, and the rest behind them. + // The tile order: the two areas' five tiles lead, in the default + // layout's own relative order, and everything else sits behind them. const order = await readTileOrder(page); - expect(order.slice(0, 5)).toEqual([ - "weight", - "bp", - "pulse", - "bodyFat", - "bpInTarget", - ]); + expect(new Set(order.slice(0, 5))).toEqual( + new Set(["weight", "bp", "pulse", "bodyFat", "bpInTarget"]), + ); + expect(order.indexOf("hrv")).toBeGreaterThan(4); }); }); diff --git a/e2e/setup-flow-locale-overflow.spec.ts b/e2e/setup-flow-locale-overflow.spec.ts index 44c20e72a..41b3ed824 100644 --- a/e2e/setup-flow-locale-overflow.spec.ts +++ b/e2e/setup-flow-locale-overflow.spec.ts @@ -122,6 +122,10 @@ async function padAndMeasure(page: Page) { }, SHELL); } +// Serial: every test here drives the ONE account through the API, and two +// workers doing that at once would each find the other's answers. +test.describe.configure({ mode: "serial" }); + test.describe("setup flow — every locale at 30% longer strings", () => { test.use({ storageState: SETUP_LOCALE_STORAGE_STATE_PATH }); diff --git a/src/app/api/onboarding/complete/route.ts b/src/app/api/onboarding/complete/route.ts index fc455d1de..7af775dac 100644 --- a/src/app/api/onboarding/complete/route.ts +++ b/src/app/api/onboarding/complete/route.ts @@ -242,12 +242,15 @@ async function completeNeedsFlow( // lands first — leaves `count = 0` and the seed is skipped rather than // overwriting it. An answer set that speaks to no tile builds `null` and // writes nothing, which leaves the default layout in place. + // `AnyNull`, not `JsonNull`: an unset column is a SQL NULL, which + // `JsonNull` (a JSON `null` value) does not match — the retired step route + // carried that filter and its seed never landed on a fresh account. const seededLayout = buildNeedsSeededDashboardLayout(state.needs); if (seededLayout) { const seeded = await prisma.user.updateMany({ where: { id: userId, - dashboardWidgetsJson: { equals: Prisma.JsonNull }, + dashboardWidgetsJson: { equals: Prisma.AnyNull }, }, data: { dashboardWidgetsJson: toJson(seededLayout) }, }); diff --git a/src/components/layout/auth-shell.tsx b/src/components/layout/auth-shell.tsx index 093044697..a57947e4d 100644 --- a/src/components/layout/auth-shell.tsx +++ b/src/components/layout/auth-shell.tsx @@ -130,7 +130,11 @@ export function AuthShell({ inSharedRecord && !isDestinationInSharedRecord(pathname, sections) && !isRecordSettingsPath; - const isOnboardingPage = pathname === "/onboarding"; + // The whole setup flow, not only its front door: every screen lives under + // `/onboarding/` (v1.39 C2), and an exact match here put the + // questions inside the full app chrome, sidebar and all. + const isOnboardingPage = + pathname === "/onboarding" || pathname.startsWith("/onboarding/"); const showUnlockNotifier = isAuthenticated && !isPublicPage && !!user?.id; // v1.9.0 — the document-level scrollbar-gutter (globals.css) is reserved diff --git a/src/components/onboarding/baseline-form.tsx b/src/components/onboarding/baseline-form.tsx index 51589368e..2a090031e 100644 --- a/src/components/onboarding/baseline-form.tsx +++ b/src/components/onboarding/baseline-form.tsx @@ -202,16 +202,12 @@ export function BaselineForm({ return (
-
- {/* Onboarding hero H1: intentionally semibold, not the app-wide bold PageHeader H1 (UI-STANDARDS §5 hero exception). Do not sweep to font-bold. */} -

+
+ {/* A section of the confirm screen, which owns the page's h1. */} +

{t("onboarding.baseline.title")} -

-

+

+

{t("onboarding.baseline.body")}

diff --git a/src/components/onboarding/confirm-screen.tsx b/src/components/onboarding/confirm-screen.tsx index 5f3338f96..213da86be 100644 --- a/src/components/onboarding/confirm-screen.tsx +++ b/src/components/onboarding/confirm-screen.tsx @@ -20,11 +20,15 @@ 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"; import { firstResultApplies, nextScreen, previousScreen, + questionScreens, } from "@/lib/onboarding/wizard-steps"; /** @@ -71,6 +75,19 @@ export function ConfirmScreen({ state }: { state: OnboardingStateDto }) { 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. + const shown = new Set(questionScreens(state)); + for (const id of ONBOARDING_SKIPPABLE_STEP_IDS) { + if (id === "first-result") continue; + const pending = + state.steps.find((step) => step.id === id)?.status === "pending"; + if (!shown.has(id) && pending) { + await answer.mutateAsync({ step: id, status: "skipped" }); + } + } const { onboarding } = await complete.mutateAsync(); let written = onboarding ?? state; if (!firstResultApplies(written)) { @@ -122,7 +139,7 @@ export function ConfirmScreen({ state }: { state: OnboardingStateDto }) { {t("onboarding.flow.confirm.everythingElse")}{" "} {t("onboarding.flow.confirm.modulesLink")} @@ -162,7 +179,7 @@ export function ConfirmScreen({ state }: { state: OnboardingStateDto }) {

{t("onboarding.flow.confirm.changeInSettings")} diff --git a/src/components/onboarding/done-screen.tsx b/src/components/onboarding/done-screen.tsx index 14fe62f84..53b78ca92 100644 --- a/src/components/onboarding/done-screen.tsx +++ b/src/components/onboarding/done-screen.tsx @@ -241,7 +241,7 @@ export function DoneScreen({ state }: { state: OnboardingStateDto }) { {t("onboarding.ai.setupCta")} @@ -323,14 +323,14 @@ export function DoneScreen({ state }: { state: OnboardingStateDto }) {

{t("onboarding.done.importCta")} {t("onboarding.done.modulesCta")} diff --git a/src/components/onboarding/onboarding-shell.tsx b/src/components/onboarding/onboarding-shell.tsx index 3ce3c4b0f..a76b7861d 100644 --- a/src/components/onboarding/onboarding-shell.tsx +++ b/src/components/onboarding/onboarding-shell.tsx @@ -80,29 +80,34 @@ export async function OnboardingShell({

-
    - {steps.map((id, index) => { - const reached = currentIndex >= index; - return ( -
  1. - {t(`onboarding.steps.${id}`)} -
  2. - ); - })} -
+ {/* A landmark of its own: every piece of page content sits inside one + (axe `region`), and the step list is the flow's navigation. */} + + {/* The page's one `
`: the auth shell's onboarding branch renders + no landmark of its own around this frame. */}
{children}
); diff --git a/src/components/onboarding/welcome-screen.tsx b/src/components/onboarding/welcome-screen.tsx index 8e4a37e89..9eab4594e 100644 --- a/src/components/onboarding/welcome-screen.tsx +++ b/src/components/onboarding/welcome-screen.tsx @@ -159,7 +159,7 @@ export function WelcomeScreen({ variant }: { variant: "fresh" | "again" }) { href="/privacy#medical-boundary" target="_blank" rel="noopener noreferrer" - className="text-primary underline-offset-4 hover:underline" + className="text-primary underline underline-offset-4" > {t("onboarding.disclaimer.learnMore")} diff --git a/tests/integration/onboarding-needs.test.ts b/tests/integration/onboarding-needs.test.ts index 68b1b1280..cf7fcd38e 100644 --- a/tests/integration/onboarding-needs.test.ts +++ b/tests/integration/onboarding-needs.test.ts @@ -52,6 +52,12 @@ vi.mock("@/lib/db-compat", () => ({ })); import { acceptGrant, inviteGrant } from "@/lib/sharing/grants"; +import { + DEFAULT_DASHBOARD_LAYOUT, + resolveDashboardLayout, + serializeDashboardLayout, +} from "@/lib/dashboard-layout"; +import { toJson } from "@/lib/db"; import { emptyOnboardingNeeds } from "@/lib/onboarding/needs"; import type { OnboardingStateDto, @@ -355,6 +361,58 @@ describe("POST /api/onboarding/complete", () => { expect((await readState(second)).completedAt).toBe(state.completedAt); }); + it("seeds the dashboard order from the answers, and only while the layout is unset", async () => { + const user = await makeUser("seed"); + await signIn(user.id); + await answerTheQuestions(); + expect((await postComplete()).status).toBe(200); + + const stored = async () => + ( + await getPrismaClient().user.findUniqueOrThrow({ + where: { id: user.id }, + select: { dashboardWidgetsJson: true }, + }) + ).dashboardWidgetsJson; + + // Blood pressure and a daily medication were answered: their tiles lead, + // visible on both surfaces, and nothing is dropped. + const seeded = await stored(); + expect(seeded).not.toBeNull(); + const layout = resolveDashboardLayout(seeded); + const order = [...layout.widgets] + .sort((a, b) => a.order - b.order) + .map((w) => w.id); + expect(new Set(order.slice(0, 4))).toEqual( + new Set(["bp", "bpInTarget", "pulse", "medications"]), + ); + for (const id of ["bp", "bpInTarget", "pulse", "medications"]) { + const widget = layout.widgets.find((w) => w.id === id); + expect(widget?.visible, id).toBe(true); + expect(widget?.tileVisible, id).toBe(true); + } + expect(layout.widgets.length).toBe(DEFAULT_DASHBOARD_LAYOUT.widgets.length); + + // A layout somebody arranged is never clobbered: put the default back + // (weight first), ask the questions again, confirm again — the seed sees + // a set column and writes nothing. + await getPrismaClient().user.update({ + where: { id: user.id }, + data: { + dashboardWidgetsJson: toJson( + serializeDashboardLayout(DEFAULT_DASHBOARD_LAYOUT), + ), + }, + }); + expect((await postRestart()).status).toBe(200); + await answerTheQuestions(); + expect((await postComplete()).status).toBe(200); + const kept = resolveDashboardLayout(await stored()); + expect([...kept.widgets].sort((a, b) => a.order - b.order)[0]?.id).toBe( + "weight", + ); + }); + it("never switches off a module the person switched on by hand", async () => { const user = await makeUser("handmade"); await signIn(user.id);