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);