From ceb0a84de67dadd84b1715fee5fd3fc860e5fc69 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 10 Sep 2026 14:59:43 +0200
Subject: [PATCH 01/21] Add the query-key module for the setup flow
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The needs-based onboarding has no read of its own — its state rides
GET /api/auth/me — but every screen that persists an answer, the confirm
screen, the disclaimer acknowledgment and the Settings restart card are
mutations, and the queryKey-factory rule allows no bare key at a call
site. One module names them, and one read-back key for the reading the
first-result step logs, under the measurements prefix so a later write
evicts it.
---
src/lib/query-keys/index.ts | 2 ++
src/lib/query-keys/onboarding.ts | 27 +++++++++++++++++++++++++++
2 files changed, 29 insertions(+)
create mode 100644 src/lib/query-keys/onboarding.ts
diff --git a/src/lib/query-keys/index.ts b/src/lib/query-keys/index.ts
index dbec8a3e2..3fc184256 100644
--- a/src/lib/query-keys/index.ts
+++ b/src/lib/query-keys/index.ts
@@ -36,6 +36,7 @@ import { medicationKeys } from "./medications";
import { mentalHealthKeys } from "./mental-health";
import { moodKeys } from "./mood";
import { nutrientKeys } from "./nutrients";
+import { onboardingKeys } from "./onboarding";
import { recordSettingsKeys } from "./record-settings";
import { profileKeys } from "./profile";
import { settingsKeys } from "./settings";
@@ -68,6 +69,7 @@ export const queryKeys = {
...encounterKeys,
...customMetrics,
...nutrientKeys,
+ ...onboardingKeys,
...recordSettingsKeys,
...profileKeys,
...sharingKeys,
diff --git a/src/lib/query-keys/onboarding.ts b/src/lib/query-keys/onboarding.ts
new file mode 100644
index 000000000..6538823f8
--- /dev/null
+++ b/src/lib/query-keys/onboarding.ts
@@ -0,0 +1,27 @@
+/**
+ * Query keys — the needs-based setup flow (v1.39 C2).
+ * Part of the centralized factory; aggregated in `./index.ts`.
+ *
+ * The flow has no read of its own: its state rides `GET /api/auth/me` as
+ * `onboarding`, so every screen reads `queryKeys.authMe()` and every write
+ * below invalidates it. What lives here are the mutation keys, one per
+ * endpoint, so a screen that persists an answer and a settings card that
+ * restarts the flow cannot collide on an anonymous mutation.
+ */
+export const onboardingKeys = {
+ /** `PATCH /api/onboarding/answers` — one step, answered or skipped. */
+ onboardingAnswerMutation: () => ["onboarding", "answer"] as const,
+ /** `POST /api/onboarding/complete` — the confirm screen, and "skip for now". */
+ onboardingCompleteMutation: () => ["onboarding", "complete"] as const,
+ /** `POST /api/onboarding/restart` — "Set up again", from Settings. */
+ onboardingRestartMutation: () => ["onboarding", "restart"] as const,
+ /** `POST /api/onboarding/disclaimer` — the one-time acknowledgment. */
+ onboardingDisclaimerMutation: () => ["onboarding", "disclaimer"] as const,
+ /**
+ * The latest reading of one type, read back after the first-result step
+ * logged it so the screen can show the value on its tile. Rides under the
+ * `["measurements"]` prefix so `measurementDependentKeys` evicts it.
+ */
+ onboardingLatestReading: (type: string) =>
+ ["measurements", "onboarding-latest", type] as const,
+};
From 13f11ed06cfe6961edb9802f75f6c95eab4e6072 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 10 Sep 2026 15:02:17 +0200
Subject: [PATCH 02/21] Add the pure step machine for the setup screens
Which screen is next, where a returning person resumes, whether a URL
may be visited, and what the counter says are all one function of the
published ledger and answers, so the page guard, the screens and the
shell cannot disagree. Q6 is in the order only while an area needs a
unit and the account does not already answer it; the first-result task
follows the spec's fixed priority (a connection the browser can finish,
then the first medication for a daily schedule, then one reading for
the first area) and is left out for a record somebody else will run,
because the three tasks write the caller's own record.
Tested before the screens exist; the two rules a screen would most
easily get wrong were broken on purpose and the tests went red.
---
.../onboarding/__tests__/wizard-steps.test.ts | 387 ++++++++++++++++++
src/lib/onboarding/wizard-steps.ts | 238 +++++++++++
2 files changed, 625 insertions(+)
create mode 100644 src/lib/onboarding/__tests__/wizard-steps.test.ts
create mode 100644 src/lib/onboarding/wizard-steps.ts
diff --git a/src/lib/onboarding/__tests__/wizard-steps.test.ts b/src/lib/onboarding/__tests__/wizard-steps.test.ts
new file mode 100644
index 000000000..13752b2a3
--- /dev/null
+++ b/src/lib/onboarding/__tests__/wizard-steps.test.ts
@@ -0,0 +1,387 @@
+/**
+ * v1.39 (C2) — the step machine behind the setup screens.
+ *
+ * Pure, so every question the screens ask — which screen is next, where a
+ * returning person resumes, whether a URL may be visited, what the counter
+ * says — is answered here without a browser. The server publishes the ledger
+ * and the answers; this file turns them into a route.
+ */
+import { describe, expect, it } from "vitest";
+
+import {
+ defaultOnboardingSteps,
+ emptyOnboardingNeeds,
+ type OnboardingNeeds,
+ type OnboardingStateDto,
+ type OnboardingStepId,
+ type OnboardingStepStatus,
+} from "../needs";
+import {
+ BROWSER_CONNECTABLE_SOURCES,
+ canVisitScreen,
+ chooseFirstResultTask,
+ firstResultApplies,
+ nextScreen,
+ previousScreen,
+ questionScreens,
+ resumeScreen,
+ screenOrder,
+ stepCounter,
+ unitsQuestionApplies,
+} from "../wizard-steps";
+
+function state(
+ statuses: Partial> = {},
+ needs: Partial = {},
+ extra: Partial> = {},
+): OnboardingStateDto {
+ return {
+ steps: defaultOnboardingSteps().map((step) => ({
+ ...step,
+ status: statuses[step.id] ?? step.status,
+ })),
+ needs: { ...emptyOnboardingNeeds(), ...needs },
+ completedAt: extra.completedAt ?? null,
+ firstResult: extra.firstResult ?? null,
+ };
+}
+
+/** Every question answered, no units question in play. */
+const QUESTIONS_DONE = {
+ who: "done",
+ areas: "done",
+ medication: "done",
+ sources: "done",
+ visit: "done",
+} as const;
+
+describe("unitsQuestionApplies", () => {
+ it("is off while no area needs a unit", () => {
+ expect(unitsQuestionApplies(state({}, { areas: ["mood", "sleep"] }))).toBe(
+ false,
+ );
+ });
+
+ it("is on for glucose or weight while the account holds no preference", () => {
+ expect(unitsQuestionApplies(state({}, { areas: ["glucose"] }))).toBe(true);
+ expect(unitsQuestionApplies(state({}, { areas: ["weight-body"] }))).toBe(
+ true,
+ );
+ });
+
+ it("is off when the server resolved units from the account's own columns", () => {
+ // `units: done` with no recorded answer is exactly what
+ // `resolveOnboardingSteps` publishes for an account that holds both.
+ expect(
+ unitsQuestionApplies(state({ units: "done" }, { areas: ["glucose"] })),
+ ).toBe(false);
+ });
+
+ it("stays in the order once the flow itself recorded the answer", () => {
+ expect(
+ unitsQuestionApplies(
+ state(
+ { units: "done" },
+ {
+ areas: ["glucose"],
+ units: { glucoseUnit: "mmol/L", unitPreference: null },
+ },
+ ),
+ ),
+ ).toBe(true);
+ });
+
+ it("stays in the order after a deliberate skip, so it can be revisited", () => {
+ expect(
+ unitsQuestionApplies(state({ units: "skipped" }, { areas: ["glucose"] })),
+ ).toBe(true);
+ });
+});
+
+describe("questionScreens", () => {
+ it("is the five questions, plus units only when it applies", () => {
+ expect(questionScreens(state())).toEqual([
+ "who",
+ "areas",
+ "medication",
+ "sources",
+ "visit",
+ ]);
+ expect(questionScreens(state({}, { areas: ["glucose"] }))).toEqual([
+ "who",
+ "areas",
+ "medication",
+ "sources",
+ "visit",
+ "units",
+ ]);
+ });
+});
+
+describe("chooseFirstResultTask", () => {
+ it("prefers a connection the browser can complete, in the person's own order", () => {
+ expect(
+ chooseFirstResultTask({
+ ...emptyOnboardingNeeds(),
+ sources: ["manual", "apple-health", "oura", "withings"],
+ medication: "yes",
+ areas: ["blood-pressure"],
+ }),
+ ).toEqual({ task: "connect-source", target: "oura" });
+ });
+
+ it("never offers a source the browser cannot connect", () => {
+ for (const source of ["manual", "apple-health", "file"] as const) {
+ expect(BROWSER_CONNECTABLE_SOURCES).not.toContain(source);
+ expect(
+ chooseFirstResultTask({ ...emptyOnboardingNeeds(), sources: [source] }),
+ ).toBeNull();
+ }
+ });
+
+ it("offers the first medication only for a daily schedule", () => {
+ expect(
+ chooseFirstResultTask({
+ ...emptyOnboardingNeeds(),
+ medication: "yes",
+ areas: ["mood"],
+ }),
+ ).toEqual({ task: "add-medication", target: null });
+ expect(
+ chooseFirstResultTask({
+ ...emptyOnboardingNeeds(),
+ medication: "sometimes",
+ areas: ["mood"],
+ }),
+ ).toEqual({ task: "log-reading", target: "mood" });
+ });
+
+ it("falls back to one reading for the first area, and to nothing", () => {
+ expect(
+ chooseFirstResultTask({
+ ...emptyOnboardingNeeds(),
+ areas: ["weight-body", "glucose"],
+ }),
+ ).toEqual({ task: "log-reading", target: "weight-body" });
+ expect(chooseFirstResultTask(emptyOnboardingNeeds())).toBeNull();
+ });
+});
+
+describe("firstResultApplies", () => {
+ it("is off when there is no task to offer", () => {
+ expect(firstResultApplies(state(QUESTIONS_DONE))).toBe(false);
+ });
+
+ it("is off for a record somebody else will run", () => {
+ // The three tasks write the CALLER's record, and the routes refuse under
+ // a switch — so for "someone I look after" the profile is created on the
+ // confirm screen and the flow ends there.
+ expect(
+ firstResultApplies(
+ state(QUESTIONS_DONE, {
+ recordTarget: "someone-else",
+ areas: ["blood-pressure"],
+ }),
+ ),
+ ).toBe(false);
+ expect(
+ firstResultApplies(
+ state(QUESTIONS_DONE, {
+ recordTarget: "both",
+ areas: ["blood-pressure"],
+ }),
+ ),
+ ).toBe(true);
+ });
+});
+
+describe("screenOrder", () => {
+ it("runs welcome, the questions, confirm, the first result, done", () => {
+ expect(
+ screenOrder(
+ state(
+ {},
+ { recordTarget: "me", areas: ["glucose"], medication: "yes" },
+ ),
+ ),
+ ).toEqual([
+ "welcome",
+ "who",
+ "areas",
+ "medication",
+ "sources",
+ "visit",
+ "units",
+ "confirm",
+ "first-result",
+ "done",
+ ]);
+ });
+
+ it("drops the first-result screen when nothing can be offered", () => {
+ expect(screenOrder(state({}, { recordTarget: "me" }))).toEqual([
+ "welcome",
+ "who",
+ "areas",
+ "medication",
+ "sources",
+ "visit",
+ "confirm",
+ "done",
+ ]);
+ });
+});
+
+describe("resumeScreen", () => {
+ it("starts a record that never entered the flow on the welcome screen", () => {
+ expect(resumeScreen(state())).toBe("welcome");
+ });
+
+ it("resumes at the first question still pending", () => {
+ expect(resumeScreen(state({ who: "done" }, { recordTarget: "me" }))).toBe(
+ "areas",
+ );
+ expect(
+ resumeScreen(
+ state(
+ { who: "done", areas: "skipped", medication: "done" },
+ { recordTarget: "me", medication: "no" },
+ ),
+ ),
+ ).toBe("sources");
+ });
+
+ it("puts a restarted flow back on the first question even though the answers remain", () => {
+ // `POST /api/onboarding/restart` resets the ledger and keeps the answers
+ // as prefill: every step pending, Q1 answered.
+ expect(
+ resumeScreen(
+ state(
+ {},
+ { recordTarget: "me", areas: ["glucose"], medication: "yes" },
+ ),
+ ),
+ ).toBe("who");
+ });
+
+ it("goes to confirm once every question is settled", () => {
+ expect(resumeScreen(state(QUESTIONS_DONE, { recordTarget: "me" }))).toBe(
+ "confirm",
+ );
+ });
+
+ it("skips a units question the account already answers", () => {
+ expect(
+ resumeScreen(
+ state(
+ { ...QUESTIONS_DONE, units: "done" },
+ { recordTarget: "me", areas: ["glucose"] },
+ ),
+ ),
+ ).toBe("confirm");
+ });
+
+ it("stops on the units question while it is still owed", () => {
+ expect(
+ resumeScreen(
+ state(QUESTIONS_DONE, { recordTarget: "me", areas: ["glucose"] }),
+ ),
+ ).toBe("units");
+ });
+
+ it("offers the first result after confirm, then done", () => {
+ const confirmed = state(
+ { ...QUESTIONS_DONE, confirm: "done" },
+ { recordTarget: "me", medication: "yes" },
+ { completedAt: "2026-09-10T08:00:00.000Z" },
+ );
+ expect(resumeScreen(confirmed)).toBe("first-result");
+ expect(
+ resumeScreen({
+ ...confirmed,
+ steps: confirmed.steps.map((s) =>
+ s.id === "first-result" ? { ...s, status: "skipped" as const } : s,
+ ),
+ }),
+ ).toBe("done");
+ });
+
+ it("goes straight to done after confirm when no task applies", () => {
+ expect(
+ resumeScreen(
+ state(
+ { ...QUESTIONS_DONE, confirm: "done" },
+ { recordTarget: "me" },
+ { completedAt: "2026-09-10T08:00:00.000Z" },
+ ),
+ ),
+ ).toBe("done");
+ });
+});
+
+describe("nextScreen / previousScreen", () => {
+ const s = state({}, { recordTarget: "me", areas: ["weight-body"] });
+
+ it("walks the order in both directions", () => {
+ expect(nextScreen(s, "welcome")).toBe("who");
+ expect(nextScreen(s, "visit")).toBe("units");
+ expect(nextScreen(s, "units")).toBe("confirm");
+ expect(nextScreen(s, "confirm")).toBe("first-result");
+ expect(nextScreen(s, "done")).toBeNull();
+ expect(previousScreen(s, "who")).toBe("welcome");
+ expect(previousScreen(s, "welcome")).toBeNull();
+ expect(previousScreen(s, "confirm")).toBe("units");
+ });
+
+ it("steps over the units question when it does not apply", () => {
+ const noUnits = state({}, { recordTarget: "me", areas: ["mood"] });
+ expect(nextScreen(noUnits, "visit")).toBe("confirm");
+ expect(previousScreen(noUnits, "confirm")).toBe("visit");
+ });
+
+ it("answers null for a screen that is not in this flow's order", () => {
+ const noUnits = state({}, { recordTarget: "me", areas: ["mood"] });
+ expect(nextScreen(noUnits, "units")).toBeNull();
+ });
+});
+
+describe("canVisitScreen", () => {
+ const midway = state(
+ { who: "done", areas: "done" },
+ { recordTarget: "me", areas: ["glucose"] },
+ );
+
+ it("allows every screen up to the resume point and refuses the ones ahead", () => {
+ expect(canVisitScreen(midway, "welcome")).toBe(true);
+ expect(canVisitScreen(midway, "who")).toBe(true);
+ expect(canVisitScreen(midway, "medication")).toBe(true);
+ expect(canVisitScreen(midway, "sources")).toBe(false);
+ expect(canVisitScreen(midway, "confirm")).toBe(false);
+ expect(canVisitScreen(midway, "done")).toBe(false);
+ });
+
+ it("refuses a screen the order does not contain", () => {
+ const noUnits = state(QUESTIONS_DONE, {
+ recordTarget: "me",
+ areas: ["mood"],
+ });
+ expect(canVisitScreen(noUnits, "units")).toBe(false);
+ expect(canVisitScreen(noUnits, "first-result")).toBe(false);
+ });
+});
+
+describe("stepCounter", () => {
+ it("counts the questions only, at the size this flow actually has", () => {
+ expect(stepCounter(state(), "who")).toEqual({ current: 1, total: 5 });
+ expect(stepCounter(state({}, { areas: ["glucose"] }), "units")).toEqual({
+ current: 6,
+ total: 6,
+ });
+ });
+
+ it("has nothing to say on the screens that are not questions", () => {
+ expect(stepCounter(state(), "welcome")).toBeNull();
+ expect(stepCounter(state(), "confirm")).toBeNull();
+ expect(stepCounter(state(), "done")).toBeNull();
+ });
+});
diff --git a/src/lib/onboarding/wizard-steps.ts b/src/lib/onboarding/wizard-steps.ts
new file mode 100644
index 000000000..33f859a88
--- /dev/null
+++ b/src/lib/onboarding/wizard-steps.ts
@@ -0,0 +1,238 @@
+/**
+ * v1.39 (C2) — the step machine behind the setup screens.
+ *
+ * The server publishes the ledger (`steps`) and the answers (`needs`); this
+ * file turns them into a route. Pure and browser-free, so the page that
+ * guards a URL, the screen that decides where "Next" goes, and the shell
+ * that prints "2 of 6" all ask the same function and cannot disagree.
+ *
+ * One vocabulary is added on top of the server's: `welcome`, the screen
+ * before the first question. It is not a step the server records — a person
+ * who has not answered Q1 has not entered the flow, whichever screen they are
+ * looking at — so it exists only here.
+ */
+import type { OnboardingSourceKey } from "./needs";
+import {
+ hasEnteredOnboardingFlow,
+ ONBOARDING_STEP_IDS,
+ type OnboardingFirstResultTask,
+ type OnboardingNeeds,
+ type OnboardingStateDto,
+ type OnboardingStepId,
+} from "./needs";
+
+export type OnboardingScreenId = "welcome" | OnboardingStepId;
+
+export const ONBOARDING_SCREEN_IDS: readonly OnboardingScreenId[] = [
+ "welcome",
+ ...ONBOARDING_STEP_IDS,
+];
+
+export function isOnboardingScreenId(
+ value: string,
+): value is OnboardingScreenId {
+ return (ONBOARDING_SCREEN_IDS as readonly string[]).includes(value);
+}
+
+/**
+ * The Q4 sources whose connection the browser can finish on its own — an
+ * OAuth handshake or a URL-plus-secret form on Settings → Integrations.
+ * Apple Health needs the phone, and "a file" is an import rather than a
+ * connection, so neither can be the one task the flow ends on.
+ */
+export const BROWSER_CONNECTABLE_SOURCES = [
+ "withings",
+ "oura",
+ "whoop",
+ "polar",
+ "fitbit",
+ "strava",
+ "nightscout",
+] as const satisfies readonly OnboardingSourceKey[];
+
+export type BrowserConnectableSource =
+ (typeof BROWSER_CONNECTABLE_SOURCES)[number];
+
+export function isBrowserConnectableSource(
+ source: OnboardingSourceKey,
+): source is BrowserConnectableSource {
+ return (BROWSER_CONNECTABLE_SOURCES as readonly string[]).includes(source);
+}
+
+type FlowState = Pick;
+
+function status(state: FlowState, id: OnboardingStepId) {
+ return state.steps.find((step) => step.id === id)?.status ?? "pending";
+}
+
+const UNIT_AREAS = new Set([
+ "glucose",
+ "weight-body",
+]);
+
+/**
+ * Whether Q6 is part of this flow at all.
+ *
+ * Two conditions from the spec: an area that needs a unit is among the
+ * answers, and the account does not already hold the preferences. The
+ * second is read off the published ledger rather than off the account —
+ * the server resolves `units` to `done` when both columns are held — with
+ * one refinement: a `done` the FLOW itself produced (an answer is recorded
+ * beside it) keeps the screen in the order, so a person can go back to it,
+ * and a deliberate skip does the same.
+ */
+export function unitsQuestionApplies(state: FlowState): boolean {
+ if (!state.needs.areas.some((area) => UNIT_AREAS.has(area))) return false;
+ const flowAnswered =
+ state.needs.units.glucoseUnit !== null ||
+ state.needs.units.unitPreference !== null;
+ return !(status(state, "units") === "done" && !flowAnswered);
+}
+
+const FIXED_QUESTIONS: readonly OnboardingStepId[] = [
+ "who",
+ "areas",
+ "medication",
+ "sources",
+ "visit",
+];
+
+/** The question screens this flow shows, in order. */
+export function questionScreens(state: FlowState): OnboardingStepId[] {
+ return unitsQuestionApplies(state)
+ ? [...FIXED_QUESTIONS, "units"]
+ : [...FIXED_QUESTIONS];
+}
+
+export interface FirstResultOffer {
+ task: OnboardingFirstResultTask;
+ /** A source key for a connection, an area key for a reading, else null. */
+ target: string | null;
+}
+
+/**
+ * The one task the flow ends on, by the spec's fixed priority: a connection
+ * the browser can complete → the first medication with a reminder, if the
+ * answer to Q3 was a daily schedule → one reading for the first Q2 area.
+ * Everything the priority passes over goes to the checklist instead.
+ */
+export function chooseFirstResultTask(
+ needs: OnboardingNeeds,
+): FirstResultOffer | null {
+ const connectable = needs.sources.find(isBrowserConnectableSource);
+ if (connectable) return { task: "connect-source", target: connectable };
+ if (needs.medication === "yes")
+ return { task: "add-medication", target: null };
+ const area = needs.areas[0];
+ if (area) return { task: "log-reading", target: area };
+ return null;
+}
+
+/**
+ * Whether the first-result screen is part of this flow.
+ *
+ * Not for "someone I look after": the three tasks write the caller's own
+ * record and the routes refuse under a switch, so the profile is created on
+ * the confirm screen and the flow ends there — the new record's first entry
+ * happens inside that record, from the done screen's link into it.
+ */
+export function firstResultApplies(state: FlowState): boolean {
+ if (state.needs.recordTarget === "someone-else") return false;
+ return chooseFirstResultTask(state.needs) !== null;
+}
+
+/** Every screen this flow shows, in order. */
+export function screenOrder(state: FlowState): OnboardingScreenId[] {
+ return [
+ "welcome",
+ ...questionScreens(state),
+ "confirm",
+ ...(firstResultApplies(state) ? (["first-result"] as const) : []),
+ "done",
+ ];
+}
+
+/**
+ * Where a person lands when they come back.
+ *
+ * The first screen still owed, in order: a record that never entered the
+ * flow starts at the welcome, a question still pending is asked, an
+ * unconfirmed set of answers goes to confirm, an offered task not yet acted
+ * on goes to the first result, and everything else is done. A restart puts
+ * every step back to pending, so a restarted flow resumes on Q1 with the old
+ * answers as prefill — which is the point of keeping them.
+ */
+export function resumeScreen(state: FlowState): OnboardingScreenId {
+ if (
+ !hasEnteredOnboardingFlow({
+ ...state,
+ completedAt: null,
+ firstResult: null,
+ })
+ ) {
+ return "welcome";
+ }
+ for (const id of questionScreens(state)) {
+ if (status(state, id) === "pending") return id;
+ }
+ if (status(state, "confirm") === "pending") return "confirm";
+ if (
+ firstResultApplies(state) &&
+ status(state, "first-result") === "pending"
+ ) {
+ return "first-result";
+ }
+ return "done";
+}
+
+export function nextScreen(
+ state: FlowState,
+ current: OnboardingScreenId,
+): OnboardingScreenId | null {
+ const order = screenOrder(state);
+ const index = order.indexOf(current);
+ if (index === -1) return null;
+ return order[index + 1] ?? null;
+}
+
+export function previousScreen(
+ state: FlowState,
+ current: OnboardingScreenId,
+): OnboardingScreenId | null {
+ const order = screenOrder(state);
+ const index = order.indexOf(current);
+ if (index <= 0) return null;
+ return order[index - 1] ?? null;
+}
+
+/**
+ * May this URL be shown? Everything up to the resume point, nothing past it:
+ * going back is always allowed, jumping ahead is bounced to where the flow
+ * actually is. A screen the order does not contain (units for someone who
+ * ticked no unit-bearing area) is refused too, so a stale link cannot show
+ * a question whose answer would mean nothing.
+ */
+export function canVisitScreen(
+ state: FlowState,
+ screen: OnboardingScreenId,
+): boolean {
+ const order = screenOrder(state);
+ const index = order.indexOf(screen);
+ if (index === -1) return false;
+ return index <= order.indexOf(resumeScreen(state));
+}
+
+/**
+ * "2 of 6" — for the question screens only. The welcome, confirm, first
+ * result and done screens carry no counter: the count is a promise about how
+ * many questions are left, and those screens are not questions.
+ */
+export function stepCounter(
+ state: FlowState,
+ screen: OnboardingScreenId,
+): { current: number; total: number } | null {
+ const questions = questionScreens(state);
+ const index = (questions as readonly string[]).indexOf(screen);
+ if (index === -1) return null;
+ return { current: index + 1, total: questions.length };
+}
From 1c2e7c29de2913cc888d141f1b54cfe1ea87dfd5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 10 Sep 2026 15:10:50 +0200
Subject: [PATCH 03/21] Add the setup flow's welcome screen and shell
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The front door reads the same ledger every screen reads: a record that
never entered the flow sees the welcome, one mid-way is sent to the step
it owes, a finished one is offered the questions again. The shell prints
the counter and an ordered step list off the step machine, so it shows
exactly the screens this flow has, and every screen moves focus to its
heading on arrival.
"Skip for now" is a real exit — it stamps the account past the first-run
redirect and opens the checklist on the dashboard — and the one-time
medical disclaimer now gates it as well as "Set up", because a skip that
reached the dashboard without it would be a way round the acknowledgment.
The five-step wizard's welcome carousel, goal picker and source grid go
with it; the numbered step route follows in the next commit.
---
messages/en.json | 26 +-
.../success-affordance-guard.test.ts | 1 -
src/app/onboarding/[step]/page.tsx | 182 --------
src/app/onboarding/__tests__/page.test.tsx | 157 ++++---
src/app/onboarding/layout.tsx | 11 +-
src/app/onboarding/page.tsx | 65 +--
.../__tests__/source-card-grid.test.tsx | 134 ------
.../onboarding/goals-chip-picker.tsx | 228 ----------
.../onboarding/onboarding-shell.tsx | 182 +++-----
.../onboarding/source-card-grid.tsx | 425 ------------------
src/components/onboarding/step-actions.tsx | 83 ++++
src/components/onboarding/step-heading.tsx | 50 +++
.../onboarding/use-onboarding-flow.ts | 86 ++++
.../onboarding/welcome-carousel.tsx | 340 --------------
src/components/onboarding/welcome-screen.tsx | 193 ++++++++
.../onboarding/__tests__/wizard-steps.test.ts | 5 +
src/lib/onboarding/checklist-storage.ts | 56 +++
src/lib/onboarding/load-flow-state.ts | 40 ++
src/lib/onboarding/wizard-steps.ts | 6 +-
19 files changed, 743 insertions(+), 1527 deletions(-)
delete mode 100644 src/app/onboarding/[step]/page.tsx
delete mode 100644 src/components/onboarding/__tests__/source-card-grid.test.tsx
delete mode 100644 src/components/onboarding/goals-chip-picker.tsx
delete mode 100644 src/components/onboarding/source-card-grid.tsx
create mode 100644 src/components/onboarding/step-actions.tsx
create mode 100644 src/components/onboarding/step-heading.tsx
create mode 100644 src/components/onboarding/use-onboarding-flow.ts
delete mode 100644 src/components/onboarding/welcome-carousel.tsx
create mode 100644 src/components/onboarding/welcome-screen.tsx
create mode 100644 src/lib/onboarding/checklist-storage.ts
create mode 100644 src/lib/onboarding/load-flow-state.ts
diff --git a/messages/en.json b/messages/en.json
index 2b0fd25c8..20c28a1a5 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -4490,7 +4490,9 @@
"step1of4": "Step 1 of 4",
"step2of4": "Step 2 of 4",
"step3of4": "Step 3 of 4",
- "step4of4": "Step 4 of 4"
+ "step4of4": "Step 4 of 4",
+ "stepOf": "Step {current} of {total}",
+ "stepsLabel": "Setup steps"
},
"goals": {
"title": "What would you like to track?",
@@ -4620,6 +4622,28 @@
"keylessLine": "HealthLog is fully useful without AI. Your tiles, charts, trends and exports all work — AI only adds the plain-language read on top.",
"setupCta": "Set it up (about a minute)",
"sharedKeyNote": "This instance already provides a shared AI provider, so insights work for you right now — no setup needed."
+ },
+ "steps": {
+ "who": "Who this record is for",
+ "areas": "What to keep an eye on",
+ "medication": "Medication",
+ "sources": "Where readings come from",
+ "visit": "Doctor's visit",
+ "units": "Units",
+ "confirm": "Confirm",
+ "first-result": "First result",
+ "done": "Done"
+ },
+ "flow": {
+ "welcome": {
+ "title": "Welcome to HealthLog",
+ "body": "Five short questions decide what HealthLog shows you first. It takes about two minutes.",
+ "changeLater": "Nothing is final: every choice can be changed later under Settings.",
+ "setUp": "Set up",
+ "skip": "Skip for now",
+ "againBody": "Your setup is finished. Answer the questions again to change what HealthLog shows first, or go to the dashboard.",
+ "setUpAgain": "Set up again"
+ }
}
},
"gettingStarted": {
diff --git a/src/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts
index 6df0bd37e..f1c58ba81 100644
--- a/src/__tests__/success-affordance-guard.test.ts
+++ b/src/__tests__/success-affordance-guard.test.ts
@@ -275,7 +275,6 @@ const PINNED_AFFORDANCES: Record<
"src/components/mood/mood-form.tsx": { "toast.success": 1 },
"src/components/mood/mood-list.tsx": { "toast.success": 3 },
"src/components/onboarding/done-screen.tsx": { CheckCircle2: 2 },
- "src/components/onboarding/source-card-grid.tsx": { CheckCircle2: 2 },
"src/components/records/allergy-form.tsx": { "toast.success": 1 },
"src/components/records/allergy-manager.tsx": { "toast.success": 1 },
"src/components/records/conditions-manager.tsx": { "toast.success": 1 },
diff --git a/src/app/onboarding/[step]/page.tsx b/src/app/onboarding/[step]/page.tsx
deleted file mode 100644
index 14b825d5d..000000000
--- a/src/app/onboarding/[step]/page.tsx
+++ /dev/null
@@ -1,182 +0,0 @@
-import { redirect, notFound } from "next/navigation";
-import Link from "next/link";
-
-import { OnboardingShell } from "@/components/onboarding/onboarding-shell";
-import { WelcomeCarousel } from "@/components/onboarding/welcome-carousel";
-import { GoalsChipPicker } from "@/components/onboarding/goals-chip-picker";
-import { SourceCardGrid } from "@/components/onboarding/source-card-grid";
-import { BaselineForm } from "@/components/onboarding/baseline-form";
-import { DoneScreen } from "@/components/onboarding/done-screen";
-import { Button } from "@/components/ui/button";
-import { getSession } from "@/lib/auth/session";
-import { getServerTranslator } from "@/lib/i18n/server-translator";
-import { resolveServerLocale } from "@/lib/i18n/server-locale";
-
-/**
- * Onboarding wizard step page.
- *
- * Routes:
- * /onboarding/0 welcome → carousel / value-prop intro
- * /onboarding/1 goals → "what do you want to track?"
- * /onboarding/2 source → device/import source cards (see
- * source-card-grid.tsx for the shipped set)
- * /onboarding/3 baseline → first measurement or sync confirmation
- * /onboarding/4 done → success screen + return to dashboard
- *
- * Step gating:
- * - Unauthenticated → `/auth/login` (the proxy also enforces this,
- * but the server-side check keeps the contract explicit).
- * - `onboardingCompletedAt != null` → `/` (no replaying the wizard
- * by URL once the user has finished).
- * - Out-of-order requests (e.g. user lands on `/onboarding/3` while
- * `onboardingStep == 1`) redirect to the user's current step. The
- * "current step" is `User.onboardingStep ?? 0`.
- *
- * The user MAY navigate backwards (e.g. `/onboarding/0` while on step
- * 2). Backwards navigation is non-destructive — the shell's "Back"
- * button uses it. Only forward jumps are blocked.
- */
-
-const VALID_STEPS = [0, 1, 2, 3, 4] as const;
-type Step = (typeof VALID_STEPS)[number];
-
-export const dynamicParams = false;
-
-export function generateStaticParams() {
- return VALID_STEPS.map((step) => ({ step: String(step) }));
-}
-
-interface PageProps {
- params: Promise<{ step: string }>;
-}
-
-export default async function OnboardingStepPage({ params }: PageProps) {
- const { step: stepParam } = await params;
- const parsedStep = Number.parseInt(stepParam, 10);
-
- if (
- !Number.isFinite(parsedStep) ||
- !VALID_STEPS.includes(parsedStep as Step)
- ) {
- notFound();
- }
- const requested = parsedStep as Step;
-
- const session = await getSession();
- if (!session) {
- redirect("/auth/login");
- }
- const { user } = session;
-
- const completed = user.onboardingCompletedAt != null;
-
- // Completed users hitting any mid-flow step (1, 2, 3) are bounced
- // back to the dashboard — replaying half the wizard is never useful.
- // Step 0 stays accessible as the welcome-back surface (banner instead
- // of carousel) and step 4 stays accessible as the success screen so
- // a re-landing user has a clean "Open dashboard" exit.
- if (completed && requested > 0 && requested < 4) {
- redirect("/");
- }
-
- const current = clampCurrentStep(user.onboardingStep);
- if (!completed && requested > current) {
- redirect(`/onboarding/${current}`);
- }
-
- const locale = await resolveServerLocale({ userLocale: user.locale });
- const { t } = getServerTranslator(locale);
-
- // Welcome (step 0)
- // * Fresh user → value-prop carousel.
- // * Returning user (onboarding already completed) → welcome-back
- // banner with "Open dashboard" CTA. The maintainer note 2026-05-14: the
- // "restart onboarding" affordance lives in Settings → Account in
- // v1.4.26; for now the banner is informational only.
- if (requested === 0) {
- return (
-
- {completed ? (
-
- {/* Onboarding hero H1: intentionally semibold, not the app-wide bold PageHeader H1 (UI-STANDARDS §5 hero exception). Do not sweep to font-bold. */}
-
- {t("onboarding.welcomeBack.title")}
-
-
- {t("onboarding.welcomeBack.body")}
-
-
-
- {t("onboarding.welcomeBack.cta")}
-
-
-
- ) : (
-
- )}
-
- );
- }
-
- // Goals (step 1) — multi-select chip grid. Component owns its own
- // Back/Skip/Next row so the shell drops every footer href to avoid
- // duplicate controls. The user id is threaded as a prop so the
- // client hydration reads localStorage synchronously in its state
- // initializer (avoids the setState-in-effect anti-pattern).
- if (requested === 1) {
- return (
-
-
-
- );
- }
-
- // Source (step 2) — card grid. Manual is the implicit default; the
- // connectable sources (Apple Health, WHOOP, Oura, Polar, Nightscout,
- // Fitbit/Pixel via Google Health) live in `source-card-grid.tsx`'s
- // SOURCE_CARDS / MORE_SOURCES lists — refer to that component for the
- // current shipped set rather than duplicating it here. The selection
- // is non-binding: nothing here auto-configures a sync.
- if (requested === 2) {
- return (
-
-
-
- );
- }
-
- // Baseline (step 3) — profile form. PUT /api/auth/profile saves the
- // four fields, then POST step:4 advances + flips
- // `onboardingCompletedAt`.
- if (requested === 3) {
- return (
-
-
-
- );
- }
-
- // Done (step 4) — success screen + dashboard CTA. Reachable both
- // after first-completion (POST step:4) and again if a completed
- // user lands here by URL.
- return (
-
-
-
- );
-}
-
-function clampCurrentStep(value: number | null | undefined): Step {
- if (value == null || !Number.isFinite(value)) return 0;
- const floor = Math.floor(value);
- if (floor <= 0) return 0;
- if (floor >= 4) return 4;
- return floor as Step;
-}
diff --git a/src/app/onboarding/__tests__/page.test.tsx b/src/app/onboarding/__tests__/page.test.tsx
index 1a43f2256..64e546bd2 100644
--- a/src/app/onboarding/__tests__/page.test.tsx
+++ b/src/app/onboarding/__tests__/page.test.tsx
@@ -1,19 +1,24 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
+import type { ReactElement } from "react";
+
+import {
+ defaultOnboardingSteps,
+ emptyOnboardingNeeds,
+ type OnboardingStateDto,
+ type OnboardingStepId,
+ type OnboardingStepStatus,
+} from "@/lib/onboarding/needs";
/**
- * v1.4.25 W14b-Content — onboarding root redirect tests.
+ * v1.39 (C2) — the setup flow's front door.
*
- * The v1.4.20 single-file wizard was deleted in this commit; the root
- * `/onboarding` page is now a server-side redirect into the new
- * `/onboarding/[step]` flow. These tests cover the three redirect
- * branches: missing session, mid-flow user, completed user.
+ * Three ways in, decided by the step machine: a record that never entered
+ * the flow sees the welcome screen, a record mid-way is sent to the step it
+ * owes, and a finished record sees the welcome's "again" variant. No session
+ * mirrors the proxy's gate.
*/
const redirectMock = vi.fn((href: string) => {
- // next/navigation `redirect()` throws a special sentinel inside the
- // server-component renderer to short-circuit rendering. Mimic that
- // by throwing an error tagged with the href so each test can
- // unambiguously assert the redirect target.
const err = new Error(`__redirect__:${href}`);
(err as Error & { __redirect__: string }).__redirect__ = href;
throw err;
@@ -23,79 +28,119 @@ vi.mock("next/navigation", () => ({
redirect: (href: string) => redirectMock(href),
}));
-const getSessionMock = vi.fn();
-vi.mock("@/lib/auth/session", () => ({
- getSession: () => getSessionMock(),
+const loadMock = vi.fn();
+vi.mock("@/lib/onboarding/load-flow-state", () => ({
+ loadOnboardingFlowState: () => loadMock(),
+}));
+
+// The shell resolves a locale from cookies; the tree is inspected, not
+// rendered, so a plain passthrough is all the test needs.
+vi.mock("@/components/onboarding/onboarding-shell", () => ({
+ OnboardingShell: ({ children }: { children: ReactElement }) => children,
}));
import OnboardingRootPage from "../page";
+import { WelcomeScreen } from "@/components/onboarding/welcome-screen";
beforeEach(() => {
redirectMock.mockClear();
- getSessionMock.mockReset();
+ loadMock.mockReset();
});
-function makeSession(opts: {
- onboardingStep?: number | null;
- onboardingCompletedAt?: Date | null;
-}) {
+function state(
+ statuses: Partial> = {},
+ needs: Partial = {},
+ completedAt: string | null = null,
+): OnboardingStateDto {
return {
- session: { id: "sess-1", expiresAt: new Date(Date.now() + 86400000) },
- user: {
- id: "user-1",
- onboardingStep: opts.onboardingStep ?? null,
- onboardingCompletedAt: opts.onboardingCompletedAt ?? null,
- },
+ steps: defaultOnboardingSteps().map((step) => ({
+ ...step,
+ status: statuses[step.id] ?? step.status,
+ })),
+ needs: { ...emptyOnboardingNeeds(), ...needs },
+ completedAt,
+ firstResult: null,
};
}
-async function runRedirect(): Promise {
+type ShellElement = ReactElement<{
+ children: ReactElement<{ variant: string }>;
+}>;
+
+async function run(): Promise<
+ { redirect: string } | { element: ShellElement }
+> {
try {
- await OnboardingRootPage();
+ const element = (await OnboardingRootPage()) as ShellElement;
+ return { element };
} catch (e) {
const tagged = e as Error & { __redirect__?: string };
- if (tagged.__redirect__) return tagged.__redirect__;
+ if (tagged.__redirect__) return { redirect: tagged.__redirect__ };
throw e;
}
- throw new Error("expected redirect, none thrown");
}
-describe(" root redirect", () => {
- it("redirects to /auth/login when no session", async () => {
- getSessionMock.mockResolvedValueOnce(null);
- const href = await runRedirect();
- expect(href).toBe("/auth/login");
+function welcomeVariant(element: ShellElement): string {
+ const child = element.props.children;
+ expect(child.type).toBe(WelcomeScreen);
+ return child.props.variant;
+}
+
+describe("", () => {
+ it("redirects to /auth/login when there is no session", async () => {
+ loadMock.mockResolvedValueOnce(null);
+ expect(await run()).toEqual({ redirect: "/auth/login" });
});
- it("redirects fresh user (onboardingStep null) to /onboarding/0", async () => {
- getSessionMock.mockResolvedValueOnce(makeSession({}));
- const href = await runRedirect();
- expect(href).toBe("/onboarding/0");
+ it("shows the welcome screen to a record that never entered the flow", async () => {
+ loadMock.mockResolvedValueOnce({
+ userId: "u1",
+ userLocale: "en",
+ state: state(),
+ });
+ const result = await run();
+ expect("element" in result && welcomeVariant(result.element)).toBe("fresh");
});
- it("redirects mid-flow user to /onboarding/", async () => {
- getSessionMock.mockResolvedValueOnce(makeSession({ onboardingStep: 2 }));
- const href = await runRedirect();
- expect(href).toBe("/onboarding/2");
+ it("sends a record mid-way to the step it owes", async () => {
+ loadMock.mockResolvedValueOnce({
+ userId: "u1",
+ userLocale: "en",
+ state: state({ who: "done", areas: "done" }, { recordTarget: "me" }),
+ });
+ expect(await run()).toEqual({ redirect: "/onboarding/medication" });
});
- it("clamps an out-of-range onboardingStep into the 0..4 window", async () => {
- getSessionMock.mockResolvedValueOnce(makeSession({ onboardingStep: 99 }));
- const href = await runRedirect();
- expect(href).toBe("/onboarding/4");
+ it("sends a confirmed flow with an open first result to that screen", async () => {
+ loadMock.mockResolvedValueOnce({
+ userId: "u1",
+ userLocale: "en",
+ state: state(
+ {
+ who: "done",
+ areas: "done",
+ medication: "done",
+ sources: "done",
+ visit: "done",
+ confirm: "done",
+ },
+ { recordTarget: "me", areas: ["blood-pressure"] },
+ "2026-09-10T08:00:00.000Z",
+ ),
+ });
+ expect(await run()).toEqual({ redirect: "/onboarding/first-result" });
});
- it("redirects a completed user to /onboarding/", async () => {
- getSessionMock.mockResolvedValueOnce(
- makeSession({
- onboardingStep: 4,
- onboardingCompletedAt: new Date("2026-05-01"),
- }),
- );
- const href = await runRedirect();
- // The step page handles the welcome-back banner at step 0 and the
- // done-screen at step 4 — the root page just bounces into the
- // current step regardless of completion state.
- expect(href).toBe("/onboarding/4");
+ it("offers the questions again to a finished record", async () => {
+ const done = Object.fromEntries(
+ defaultOnboardingSteps().map((s) => [s.id, "done" as const]),
+ ) as Record;
+ loadMock.mockResolvedValueOnce({
+ userId: "u1",
+ userLocale: "en",
+ state: state(done, { recordTarget: "me" }, "2026-09-10T08:00:00.000Z"),
+ });
+ const result = await run();
+ expect("element" in result && welcomeVariant(result.element)).toBe("again");
});
});
diff --git a/src/app/onboarding/layout.tsx b/src/app/onboarding/layout.tsx
index 5a233e2b0..1f4ae68a5 100644
--- a/src/app/onboarding/layout.tsx
+++ b/src/app/onboarding/layout.tsx
@@ -1,14 +1,11 @@
import type { ReactNode } from "react";
/**
- * v1.4.25 W14b — onboarding route-segment layout.
+ * The setup flow's route-segment layout.
*
- * Intentionally pass-through. The `OnboardingShell` chrome lives inside
- * each `[step]/page.tsx` so the existing `/onboarding` root page
- * (`page.tsx` — the legacy v1.4.20 single-file wizard) keeps rendering
- * unchanged while the rebuilt multi-step flow ships beside it. The
- * Content agent that follows W14b-Foundation will swap the root page's
- * redirect target once every step has real content.
+ * Pass-through on purpose: the `OnboardingShell` chrome is rendered by each
+ * page, because the shell's counter and step list depend on the flow state
+ * the page loads, and a layout cannot read what its page decided.
*/
export default function OnboardingLayout({
children,
diff --git a/src/app/onboarding/page.tsx b/src/app/onboarding/page.tsx
index 680293ecc..7acc07fe8 100644
--- a/src/app/onboarding/page.tsx
+++ b/src/app/onboarding/page.tsx
@@ -1,44 +1,49 @@
import { redirect } from "next/navigation";
-import { getSession } from "@/lib/auth/session";
+import { OnboardingShell } from "@/components/onboarding/onboarding-shell";
+import { WelcomeScreen } from "@/components/onboarding/welcome-screen";
+import { loadOnboardingFlowState } from "@/lib/onboarding/load-flow-state";
+import { isOnboardingSettled } from "@/lib/onboarding/needs";
+import { resumeScreen } from "@/lib/onboarding/wizard-steps";
/**
- * v1.4.25 W14b — onboarding root redirect.
+ * v1.39 (C2) — the setup flow's front door.
*
- * The proxy redirect (`src/proxy.ts:179`) lands every still-pending user
- * on `/onboarding`. Until W14b shipped, this file rendered the v1.4.20
- * single-file 3-step wizard inline. The new flow lives under
- * `/onboarding/[step]/page.tsx`, so this root page now resolves to a
- * server-side redirect into the right step:
+ * The proxy lands every account still pending its first run here, and
+ * Settings → "Set up again" does too. What is shown depends on where the
+ * flow stands, read off the same ledger every screen reads:
*
- * - No session → `/auth/login` (the proxy enforces
- * this too; we mirror it to keep the contract explicit).
- * - `onboardingCompletedAt != null` (returning user)
- * → `/onboarding/0` so the welcome-back
- * banner can show; the dashboard is
- * one click away from there.
- * - Otherwise → `/onboarding/` where
- * `current = user.onboardingStep ?? 0`.
+ * - never entered → the welcome screen ("Set up" / "Skip for now")
+ * - a step still owed → redirect to that step
+ * - finished → the welcome screen's "again" variant, which
+ * offers the questions once more or the dashboard
*
- * The previous wizard remained the entry point through the
- * W14b-Foundation phase to keep an unbroken flow; this commit swaps it
- * out now that every step page renders real UI.
+ * No session mirrors the proxy's own gate (`/onboarding` is a public path,
+ * so the page has to say it too).
*/
export default async function OnboardingRootPage() {
- const session = await getSession();
- if (!session) {
+ const flow = await loadOnboardingFlowState();
+ if (!flow) {
redirect("/auth/login");
}
- const { user } = session;
+ const { state, userLocale } = flow;
- const current = clampCurrentStep(user.onboardingStep);
- redirect(`/onboarding/${current}`);
-}
+ const resume = resumeScreen(state);
+ if (resume !== "welcome") {
+ const finished = state.completedAt !== null && isOnboardingSettled(state);
+ if (!finished) {
+ redirect(`/onboarding/${resume}`);
+ }
+ return (
+
+
+
+ );
+ }
-function clampCurrentStep(value: number | null | undefined): 0 | 1 | 2 | 3 | 4 {
- if (value == null || !Number.isFinite(value)) return 0;
- const floor = Math.floor(value);
- if (floor <= 0) return 0;
- if (floor >= 4) return 4;
- return floor as 0 | 1 | 2 | 3 | 4;
+ return (
+
+
+
+ );
}
diff --git a/src/components/onboarding/__tests__/source-card-grid.test.tsx b/src/components/onboarding/__tests__/source-card-grid.test.tsx
deleted file mode 100644
index f419960c4..000000000
--- a/src/components/onboarding/__tests__/source-card-grid.test.tsx
+++ /dev/null
@@ -1,134 +0,0 @@
-import { describe, it, expect, vi } from "vitest";
-import { renderToStaticMarkup } from "react-dom/server";
-
-import { I18nProvider } from "@/lib/i18n/context";
-
-/**
- * v1.29.x — UX audit H1: the wizard's Withings card must not launch an
- * OAuth handshake that can only 400 for a brand-new account (Withings
- * credentials are per-user BYO with no env fallback). The card reads the
- * consolidated integrations-status envelope and swaps its CTA + href
- * depending on whether the account already has its own credentials saved.
- */
-
-const integrationStatusState = vi.hoisted(() => ({
- data: undefined as
- | {
- threshold: number;
- integrations: Array<{
- integration: string;
- configured?: boolean;
- connected?: boolean;
- }>;
- }
- | undefined,
-}));
-
-vi.mock("@tanstack/react-query", () => ({
- useQuery: () => ({ data: integrationStatusState.data }),
- useQueryClient: () => ({ invalidateQueries: vi.fn() }),
-}));
-
-vi.mock("@/hooks/use-auth", () => ({
- useAuth: () => ({ isAuthenticated: true }),
-}));
-
-vi.mock("next/navigation", () => ({
- useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
-}));
-
-vi.mock("next/link", () => ({
- default: ({
- href,
- children,
- ...rest
- }: {
- href: string;
- children: React.ReactNode;
- }) => (
-
- {children}
-
- ),
-}));
-
-import { SourceCardGrid } from "../source-card-grid";
-
-function render() {
- return renderToStaticMarkup(
-
-
- ,
- );
-}
-
-describe(" Withings card", () => {
- it("points at Settings instead of launching OAuth when unconfigured", () => {
- integrationStatusState.data = {
- threshold: 3,
- integrations: [{ integration: "withings", configured: false }],
- };
- const html = render();
- expect(html).toContain('data-testid="source-card-withings-setup"');
- expect(html).toContain('href="/settings/integrations#withings"');
- expect(html).not.toContain('href="/api/withings/connect"');
- expect(html).toContain("Set up");
- });
-
- it("also redirects to Settings when the status envelope hasn't loaded yet", () => {
- integrationStatusState.data = undefined;
- const html = render();
- expect(html).toContain('href="/settings/integrations#withings"');
- expect(html).not.toContain('href="/api/withings/connect"');
- });
-
- it("opens the real OAuth connect flow once credentials are configured", () => {
- integrationStatusState.data = {
- threshold: 3,
- integrations: [{ integration: "withings", configured: true }],
- };
- const html = render();
- expect(html).toContain('href="/api/withings/connect"');
- expect(html).not.toContain('data-testid="source-card-withings-setup"');
- expect(html).toContain("Connect Withings");
- });
-
- // 2026-07-17 UX-onboarding audit M6 — the connect CTA opens OAuth in a
- // new tab; the callback flips the connection in THAT tab while this
- // wizard tab used to render the same unchanged button forever. Once
- // `useIntegrationStatuses` (refetches on window focus) reports
- // `connected: true`, the card must swap to a badge instead of a dead
- // "Connect Withings" button that would just reopen a redundant flow.
- it("shows a connected badge instead of the connect button once Withings is linked", () => {
- integrationStatusState.data = {
- threshold: 3,
- integrations: [
- { integration: "withings", configured: true, connected: true },
- ],
- };
- const html = render();
- expect(html).toContain('data-testid="source-card-withings-connected"');
- expect(html).not.toContain('href="/api/withings/connect"');
- expect(html).not.toContain("Connect Withings");
- });
-});
-
-// #778 — the step listed Apple Health next to the web connectors with nothing
-// saying its setup happens in the iOS app afterwards, so it read like one more
-// OAuth flow to hunt for.
-describe(" Apple Health note", () => {
- it("says Apple Health setup completes in the iPhone app, not on the web", () => {
- integrationStatusState.data = undefined;
- const html = render();
- expect(html).toContain('data-testid="source-more-apple-health-note"');
- expect(html).toContain("open the HealthLog app on your iPhone");
- expect(html).toContain("no web connection");
- });
-
- it("keeps the Apple Health chip tappable so the note is not a dead end", () => {
- integrationStatusState.data = undefined;
- const html = render();
- expect(html).toContain('data-testid="source-more-apple-health"');
- expect(html).toContain('href="/settings/integrations"');
- });
-});
diff --git a/src/components/onboarding/goals-chip-picker.tsx b/src/components/onboarding/goals-chip-picker.tsx
deleted file mode 100644
index c83877eba..000000000
--- a/src/components/onboarding/goals-chip-picker.tsx
+++ /dev/null
@@ -1,228 +0,0 @@
-"use client";
-
-import { useCallback, useState } from "react";
-import Link from "next/link";
-import { useRouter } from "next/navigation";
-import { useQueryClient } from "@tanstack/react-query";
-import { toast } from "sonner";
-import {
- Brain,
- Droplet,
- HeartPulse,
- Moon,
- Pill,
- Scale,
- type LucideIcon,
-} from "lucide-react";
-
-import { Button } from "@/components/ui/button";
-import { useTranslations } from "@/lib/i18n/context";
-import { cn } from "@/lib/utils";
-import { apiPost } from "@/lib/api/api-fetch";
-import { localizedApiError } from "@/lib/api/localized-error";
-import { queryKeys } from "@/lib/query-keys";
-
-/**
- * v1.4.25 W14b-Content — onboarding step 1 (goals).
- *
- * Multi-select chip picker for the metrics the user wants to track.
- * The goal slugs are the closed enum `ONBOARDING_GOAL_SLUGS`
- * (`src/lib/onboarding/goals.ts`). Selection lives in component state
- * while the step is open; on advance it is persisted server-side to
- * `User.onboardingGoals` (v1.17.1), which seeds the dashboard tile
- * layout on completion and feeds reminder suggestions.
- *
- * CTAs:
- * - Back → `/onboarding/0`
- * - Skip → advance with empty goal set (explicit "no preference")
- * - Next → advance with chosen goal set
- *
- * Both Skip and Next call POST `/api/onboarding/step` with
- * `{ step: 2, goals }` and `router.push("/onboarding/2")`.
- */
-
-export type OnboardingGoalSlug =
- | "weight-management"
- | "bp-tracking"
- | "glucose-tracking"
- | "sleep-improvement"
- | "medication-compliance"
- | "general-wellness";
-
-interface GoalOption {
- slug: OnboardingGoalSlug;
- Icon: LucideIcon;
- labelKey: string;
-}
-
-const GOAL_OPTIONS: ReadonlyArray = [
- {
- slug: "weight-management",
- Icon: Scale,
- labelKey: "onboarding.goals.options.weightManagement",
- },
- {
- slug: "bp-tracking",
- Icon: HeartPulse,
- labelKey: "onboarding.goals.options.bpTracking",
- },
- {
- slug: "glucose-tracking",
- Icon: Droplet,
- labelKey: "onboarding.goals.options.glucoseTracking",
- },
- {
- slug: "sleep-improvement",
- Icon: Moon,
- labelKey: "onboarding.goals.options.sleepImprovement",
- },
- {
- slug: "medication-compliance",
- Icon: Pill,
- labelKey: "onboarding.goals.options.medicationCompliance",
- },
- {
- slug: "general-wellness",
- Icon: Brain,
- labelKey: "onboarding.goals.options.generalWellness",
- },
-];
-
-export interface GoalsChipPickerProps {
- /**
- * The current user's id. Threaded in from the server step page
- * (`getSession().user.id`) so the v1.4.26 column write can reuse
- * the same call site without a prop reshuffle.
- */
- userId: string;
-}
-
-export function GoalsChipPicker({ userId: _userId }: GoalsChipPickerProps) {
- const { t } = useTranslations();
- const router = useRouter();
- const queryClient = useQueryClient();
-
- const [selected, setSelected] = useState>(
- () => new Set(),
- );
- const [advancing, setAdvancing] = useState(false);
-
- const toggle = useCallback((slug: OnboardingGoalSlug) => {
- setSelected((prev) => {
- const next = new Set(prev);
- if (next.has(slug)) next.delete(slug);
- else next.add(slug);
- return next;
- });
- }, []);
-
- async function advance() {
- if (advancing) return;
- setAdvancing(true);
- try {
- // v1.17.1 — persist the chosen goal slugs alongside the step
- // write. Skip sends an empty array (explicit "no preference"),
- // not omission, so a resume can read the deliberate empty choice.
- // The server seeds the dashboard from these on completion.
- await apiPost("/api/onboarding/step", {
- step: 2,
- goals: Array.from(selected),
- });
- await queryClient.invalidateQueries({ queryKey: queryKeys.auth() });
- router.push("/onboarding/2");
- } catch (err) {
- toast.error(localizedApiError(err, t, "onboarding.errorGeneric"));
- setAdvancing(false);
- }
- }
-
- return (
-
-
-
-
- {t("onboarding.goals.title")}
- {GOAL_OPTIONS.map((option) => {
- const checked = selected.has(option.slug);
- const Icon = option.Icon;
- return (
-
- toggle(option.slug)}
- className="sr-only"
- />
-
-
-
-
- {t(option.labelKey)}
-
-
- );
- })}
-
-
-
-
- {t("onboarding.shell.back")}
-
-
-
- {t("onboarding.shell.skip")}
-
-
- {t("onboarding.shell.next")}
-
-
-
-
- );
-}
diff --git a/src/components/onboarding/onboarding-shell.tsx b/src/components/onboarding/onboarding-shell.tsx
index 1084ba693..3ce3c4b0f 100644
--- a/src/components/onboarding/onboarding-shell.tsx
+++ b/src/components/onboarding/onboarding-shell.tsx
@@ -1,171 +1,109 @@
-import Link from "next/link";
import { cn } from "@/lib/utils";
import { Logo } from "@/components/ui/logo";
-import { Button } from "@/components/ui/button";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
+import type {
+ OnboardingStateDto,
+ OnboardingStepId,
+} from "@/lib/onboarding/needs";
+import {
+ screenOrder,
+ stepCounter,
+ type OnboardingScreenId,
+} from "@/lib/onboarding/wizard-steps";
/**
- * v1.4.25 W14b — onboarding wizard chrome.
+ * v1.39 (C2) — the chrome around every setup screen.
*
- * Renders the shared frame around every step in the rebuilt
- * `/onboarding/[step]` flow: logo, "Step N of 4" label, four-dot
- * progress strip, the step body slot, and the back / skip / next
- * action row pinned to the bottom safe-area.
+ * Logo, the "Step 2 of 6" counter, the step list, and the body slot. The
+ * counter and the list are both read off the step machine, so the shell
+ * shows exactly the screens THIS flow has — six questions for somebody who
+ * ticked glucose, five for somebody who did not, a first-result screen only
+ * when there is a task to offer.
*
- * Step encoding (mirrors `User.onboardingStep`):
- * 0 = welcome (no progress dot; intro screen, primary action is "next")
- * 1 = goals (dot 1 active)
- * 2 = source (dot 2 active)
- * 3 = baseline (dot 3 active)
- * 4 = done (dot 4 active; "done" copy; primary CTA = return to dashboard)
+ * The step list is a real ordered list (design spec §Principles 6), each
+ * entry with its name for a screen reader and `aria-current="step"` on the
+ * one in view; the dots are the visual rendering of that list, not a
+ * separate progress bar. The counter is `aria-live` so a step change is
+ * announced without the heading having to carry the number.
*
- * Server component — every step page is also a server component and
- * the shell carries no client state. Back / skip / next are plain
- * ` `s wrapping ``s; the W14b-Content agent will replace
- * those with form submissions / mutations on the step pages that need
- * them, while the chrome itself stays static.
- *
- * Locale resolution mirrors the rest of the server tree
- * (`resolveServerLocale` → cookie → User.locale → Accept-Language).
+ * Server component: nothing here holds state. Each screen owns its own
+ * action row (`StepActions`), because what "next" persists differs per
+ * screen and the shell should not know.
*/
-
export interface OnboardingShellProps {
- /** 0..4 — see step encoding above. Out-of-range values clamp to 0. */
- step: number;
- /**
- * Step body slot. Step pages render their step-specific UI here; the
- * shell handles every surrounding pixel.
- */
+ screen: OnboardingScreenId;
+ state: OnboardingStateDto;
+ userLocale: string | null;
children: React.ReactNode;
- /** Href for the back button. Hidden when undefined (e.g. step 0). */
- backHref?: string;
- /** Href for the "Skip" link. Hidden when undefined. */
- skipHref?: string;
- /** Href for the primary forward CTA. Hidden when undefined. */
- nextHref?: string;
- /**
- * Override the primary CTA label. Defaults to `onboarding.shell.next`
- * (or `onboarding.shell.done.returnCta` at step 4).
- */
- nextLabel?: string;
- /** Override the userLocale used for translator resolution (test hook). */
- userLocale?: string | null;
- className?: string;
}
-const TOTAL_STEPS = 4;
-
export async function OnboardingShell({
- step,
- children,
- backHref,
- skipHref,
- nextHref,
- nextLabel,
+ screen,
+ state,
userLocale,
- className,
+ children,
}: OnboardingShellProps) {
- const clamped = clampStep(step);
- const locale = await resolveServerLocale({ userLocale: userLocale ?? null });
+ const locale = await resolveServerLocale({ userLocale });
const { t } = getServerTranslator(locale);
- // Progress label only appears for the four real steps; the welcome
- // intro (step 0) and the done screen (step 4) both reuse the
- // ordinal keys (1/4 and 4/4 respectively) so the strip never goes
- // blank between transitions.
- const progressLabel =
- clamped === 0
- ? t("onboarding.shell.step1of4")
- : t(`onboarding.shell.step${clamped}of4`);
-
- const primaryLabel =
- nextLabel ??
- (clamped === 4
- ? t("onboarding.done.returnCta")
- : t("onboarding.shell.next"));
+ const counter = stepCounter(state, screen);
+ const steps = screenOrder(state).filter(
+ (id): id is OnboardingStepId => id !== "welcome",
+ );
+ const currentIndex = (steps as readonly string[]).indexOf(screen);
return (
- {progressLabel}
+ {counter
+ ? t("onboarding.shell.stepOf", {
+ current: counter.current,
+ total: counter.total,
+ })
+ : null}
-
- {Array.from({ length: TOTAL_STEPS }, (_, i) => {
- const dotStep = i + 1;
- const active = clamped >= dotStep || (clamped === 0 && dotStep === 1);
+ {steps.map((id, index) => {
+ const reached = currentIndex >= index;
return (
-
+ >
+ {t(`onboarding.steps.${id}`)}
+
);
})}
-
+
{children}
-
-
);
}
-
-function clampStep(step: number): 0 | 1 | 2 | 3 | 4 {
- if (!Number.isFinite(step)) return 0;
- const rounded = Math.floor(step);
- if (rounded < 0) return 0;
- if (rounded > 4) return 4;
- return rounded as 0 | 1 | 2 | 3 | 4;
-}
diff --git a/src/components/onboarding/source-card-grid.tsx b/src/components/onboarding/source-card-grid.tsx
deleted file mode 100644
index 4e872c0a6..000000000
--- a/src/components/onboarding/source-card-grid.tsx
+++ /dev/null
@@ -1,425 +0,0 @@
-"use client";
-
-import { useState } from "react";
-import Link from "next/link";
-import { useRouter } from "next/navigation";
-import { useQueryClient } from "@tanstack/react-query";
-import { toast } from "sonner";
-import {
- CheckCircle2,
- ClipboardCheck,
- FileUp,
- Plug,
- Watch,
- Wifi,
- type LucideIcon,
-} from "lucide-react";
-
-import { Button } from "@/components/ui/button";
-import {
- pickStatus,
- useIntegrationStatuses,
-} from "@/components/settings/integrations/shared";
-import { useAuth } from "@/hooks/use-auth";
-import { useTranslations } from "@/lib/i18n/context";
-import { cn } from "@/lib/utils";
-import { apiPost } from "@/lib/api/api-fetch";
-import { localizedApiError } from "@/lib/api/localized-error";
-import { queryKeys } from "@/lib/query-keys";
-
-/**
- * Onboarding step 2 (source).
- *
- * Presents the data sources HealthLog ships today. The headline cards
- * cover the two paths most people start on:
- * 1. Manual entry (enabled, recommended — works on any device)
- * 2. Withings — opens OAuth `/api/withings/connect` in a new tab once
- * the account has its own Withings developer app configured; the
- * callback flips the connection server-side. Before that, the card
- * is honest about the prerequisite (v1.29.x, UX audit H1/H2): the
- * CTA reads "Set up in Settings" and points at the credentials form
- * instead of launching an OAuth handshake that can only 400.
- *
- * Below them a calm "more sources" row lists the remaining shipped
- * integrations (Apple Health, WHOOP, Oura, Polar, Nightscout, Fitbit),
- * each linking to Settings → Integrations where it is actually
- * connected. We don't try to run six OAuth flows inside the wizard;
- * the row sets the expectation and hands off. A discreet import link
- * points at the Apple Health export.zip path — the cold-start escape
- * hatch for users with existing history.
- *
- * Garmin is omitted: not shipped, so a card would over-claim.
- *
- * Selection is non-binding — Manual is the implicit default and the
- * Next CTA always advances. Nothing here is auto-configured; the step
- * only signposts where each source is connected.
- */
-
-interface SourceCard {
- slug: "manual" | "withings";
- Icon: LucideIcon;
- titleKey: string;
- bodyKey: string;
- /**
- * One of:
- * - "enabled-select": tap selects the card (Manual)
- * - "enabled-oauth": tap opens OAuth in a new tab (Withings)
- */
- state: "enabled-select" | "enabled-oauth";
- /** External href for `enabled-oauth`. */
- href?: string;
- /** Optional recommended badge. */
- recommendedKey?: string;
-}
-
-const SOURCE_CARDS: ReadonlyArray = [
- {
- slug: "manual",
- Icon: ClipboardCheck,
- titleKey: "onboarding.source.manual.title",
- bodyKey: "onboarding.source.manual.body",
- state: "enabled-select",
- recommendedKey: "onboarding.source.recommended",
- },
- {
- slug: "withings",
- Icon: Watch,
- titleKey: "onboarding.source.withings.title",
- bodyKey: "onboarding.source.withings.body",
- state: "enabled-oauth",
- href: "/api/withings/connect",
- },
-];
-
-/**
- * The remaining shipped integrations. Listed compactly so the step
- * stays calm — the full connect flow for each lives in Settings.
- */
-const MORE_SOURCES: ReadonlyArray<{ slug: string; labelKey: string }> = [
- { slug: "apple-health", labelKey: "onboarding.source.more.appleHealth" },
- { slug: "whoop", labelKey: "onboarding.source.more.whoop" },
- { slug: "oura", labelKey: "onboarding.source.more.oura" },
- { slug: "polar", labelKey: "onboarding.source.more.polar" },
- { slug: "nightscout", labelKey: "onboarding.source.more.nightscout" },
- { slug: "fitbit", labelKey: "onboarding.source.more.fitbit" },
-];
-
-export function SourceCardGrid() {
- const { t } = useTranslations();
- const router = useRouter();
- const queryClient = useQueryClient();
- const { isAuthenticated } = useAuth();
-
- const [selected, setSelected] = useState("manual");
- const [advancing, setAdvancing] = useState(false);
-
- // v1.29.x — the wizard's "Connect Withings" card must not launch an OAuth
- // handshake that can only fail: Withings credentials are per-user BYO with
- // no env fallback, and a brand-new account never has them yet. Read the
- // same consolidated envelope Settings → Integrations uses so the card
- // knows whether the handshake can actually succeed.
- const { data: integrationStatus } = useIntegrationStatuses(isAuthenticated);
- const withingsConfigured =
- pickStatus(integrationStatus, "withings")?.configured ?? false;
- // 2026-07-17 UX-onboarding audit M6 — the connect CTA opens the OAuth
- // handshake in a new tab; the callback lands THAT tab on
- // Settings → Integrations while this wizard tab never learned the
- // connection succeeded. `useIntegrationStatuses` already refetches on
- // window focus, so returning to this tab after the OAuth round trip
- // picks up the fresh `connected` flag for free — the only missing piece
- // was rendering it as a badge instead of leaving the button looking
- // untouched.
- const withingsConnected =
- pickStatus(integrationStatus, "withings")?.connected ?? false;
-
- async function advance() {
- if (advancing) return;
- setAdvancing(true);
- try {
- await apiPost("/api/onboarding/step", { step: 3 });
- await queryClient.invalidateQueries({ queryKey: queryKeys.auth() });
- router.push("/onboarding/3");
- } catch (err) {
- toast.error(localizedApiError(err, t, "onboarding.errorGeneric"));
- setAdvancing(false);
- }
- }
-
- return (
-
-
-
-
- {SOURCE_CARDS.map((card) => (
- setSelected(card.slug)}
- titleLabel={t(card.titleKey)}
- bodyLabel={t(card.bodyKey)}
- recommendedLabel={
- card.recommendedKey ? t(card.recommendedKey) : null
- }
- withingsCtaLabel={t("onboarding.source.withings.cta")}
- withingsConfigured={withingsConfigured}
- withingsConnected={withingsConnected}
- withingsConnectedLabel={t("settings.integrationPill.connected")}
- withingsSetupCtaLabel={t("onboarding.source.withings.setupCta")}
- withingsSetupHint={t("onboarding.source.withings.setupHint")}
- />
- ))}
-
-
-
-
-
-
-
-
-
- {t("onboarding.source.more.title")}
-
-
- {t("onboarding.source.more.body")}
-
-
-
-
- {MORE_SOURCES.map((item) => (
-
-
-
- {t(item.labelKey)}
-
-
-
- ))}
-
- {/* #778 — Apple Health looked like one more web connector here, but
- there is nothing to connect on the web: the sync is set up in the
- iOS app after onboarding. Say so, so nobody hunts Settings for an
- OAuth flow that does not exist. The chip stays tappable (it lands
- on the Integrations page, whose Apple Health card walks through
- the iPhone-app setup) and Next / Skip advance as always. */}
-
- {t("onboarding.source.more.appleHealthHint")}
-
-
-
-
-
- {t("onboarding.source.import")}
-
-
-
-
- {t("onboarding.shell.back")}
-
-
-
- {t("onboarding.shell.skip")}
-
-
- {t("onboarding.shell.next")}
-
-
-
-
- );
-}
-
-interface SourceCardItemProps {
- card: SourceCard;
- selected: boolean;
- onSelect: () => void;
- titleLabel: string;
- bodyLabel: string;
- recommendedLabel: string | null;
- withingsCtaLabel: string;
- /** Whether the account already has its own Withings developer-app
- * credentials saved — i.e. whether `/api/withings/connect` can
- * actually succeed right now. */
- withingsConfigured: boolean;
- /** Whether Withings is already connected (OAuth completed in the tab the
- * connect CTA opened). Replaces the connect button with a badge so
- * returning to this wizard tab shows the outcome instead of an
- * unchanged card. */
- withingsConnected: boolean;
- /** Localised "Connected" label for the badge above. */
- withingsConnectedLabel: string;
- /** CTA label shown instead of "Connect Withings" when unconfigured. */
- withingsSetupCtaLabel: string;
- /** One-sentence explanation of the BYO credential prerequisite. */
- withingsSetupHint: string;
-}
-
-function SourceCardItem({
- card,
- selected,
- onSelect,
- titleLabel,
- bodyLabel,
- recommendedLabel,
- withingsCtaLabel,
- withingsConfigured,
- withingsConnected,
- withingsConnectedLabel,
- withingsSetupCtaLabel,
- withingsSetupHint,
-}: SourceCardItemProps) {
- const Icon = card.Icon;
-
- const baseClasses = cn(
- "relative flex flex-col gap-3 rounded-lg border p-4 text-left transition-colors duration-150 ease-out motion-reduce:transition-none",
- "bg-card border-border",
- );
-
- const headerRow = (
-
-
-
-
-
-
{titleLabel}
- {recommendedLabel ? (
-
- {recommendedLabel}
-
- ) : null}
-
-
- );
-
- const body = (
- {bodyLabel}
- );
-
- if (card.state === "enabled-oauth") {
- return (
-
- {headerRow}
- {body}
- {withingsConnected ? (
-
-
- {withingsConnectedLabel}
-
- ) : withingsConfigured ? (
-
- ) : (
- // No per-user Withings credentials yet — an OAuth handshake here
- // can only 400 (Withings has no env fallback). Point at the
- // credentials form instead of launching a doomed connect flow.
-
-
{withingsSetupHint}
-
-
-
- {withingsSetupCtaLabel}
-
-
-
- )}
-
- );
- }
-
- // enabled-select (Manual)
- return (
-
- {headerRow}
- {body}
-
- );
-}
diff --git a/src/components/onboarding/step-actions.tsx b/src/components/onboarding/step-actions.tsx
new file mode 100644
index 000000000..3247508b3
--- /dev/null
+++ b/src/components/onboarding/step-actions.tsx
@@ -0,0 +1,83 @@
+"use client";
+
+import Link from "next/link";
+import { Loader2 } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { useTranslations } from "@/lib/i18n/context";
+
+/**
+ * The action row every setup screen ends on: back on the left, skip and the
+ * primary on the right. One shape, so the buttons sit in the same place on
+ * every screen and a keyboard user's tab order never changes between them.
+ *
+ * Back is a link (a plain navigation to a screen that already has its
+ * answer), skip and next are buttons (they persist something first). Each
+ * one is optional: the welcome screen has no back, the confirm screen has no
+ * skip, the done screen has only the primary.
+ */
+export function StepActions({
+ backHref,
+ onSkip,
+ onNext,
+ nextLabel,
+ nextDisabled = false,
+ pending = false,
+}: {
+ backHref?: string;
+ onSkip?: () => void;
+ onNext?: () => void;
+ nextLabel?: string;
+ nextDisabled?: boolean;
+ pending?: boolean;
+}) {
+ const { t } = useTranslations();
+
+ return (
+
+ {backHref ? (
+
+
+ {t("onboarding.shell.back")}
+
+
+ ) : (
+
+ )}
+
+ {onSkip ? (
+
+ {t("onboarding.shell.skip")}
+
+ ) : null}
+ {onNext ? (
+
+ {pending ? (
+
+ ) : null}
+ {nextLabel ?? t("onboarding.shell.next")}
+
+ ) : null}
+
+
+ );
+}
diff --git a/src/components/onboarding/step-heading.tsx b/src/components/onboarding/step-heading.tsx
new file mode 100644
index 000000000..85b4645d7
--- /dev/null
+++ b/src/components/onboarding/step-heading.tsx
@@ -0,0 +1,50 @@
+"use client";
+
+import { useEffect, useRef, type ReactNode } from "react";
+
+/**
+ * The `h1` of every setup screen, and the one place focus goes when a screen
+ * arrives.
+ *
+ * Moving focus to the heading on each step is what lets a screen-reader user
+ * follow the flow: without it, a client-side navigation leaves focus on the
+ * button that was just pressed, which no longer exists, and the new question
+ * is never announced. `preventScroll` keeps the viewport where the layout
+ * put it — the heading is at the top of the column anyway.
+ *
+ * Semibold rather than the app-wide bold PageHeader H1: the onboarding hero
+ * is the documented exception (UI-STANDARDS §5). Do not sweep to font-bold.
+ */
+export function StepHeading({
+ id,
+ title,
+ description,
+}: {
+ id: string;
+ title: ReactNode;
+ description?: ReactNode;
+}) {
+ const ref = useRef(null);
+
+ useEffect(() => {
+ ref.current?.focus({ preventScroll: true });
+ }, []);
+
+ return (
+
+
+ {title}
+
+ {description ? (
+
+ {description}
+
+ ) : null}
+
+ );
+}
diff --git a/src/components/onboarding/use-onboarding-flow.ts b/src/components/onboarding/use-onboarding-flow.ts
new file mode 100644
index 000000000..8cefea63b
--- /dev/null
+++ b/src/components/onboarding/use-onboarding-flow.ts
@@ -0,0 +1,86 @@
+"use client";
+
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+
+import { apiPatch, apiPost } from "@/lib/api/api-fetch";
+import type { OnboardingStateDto } from "@/lib/onboarding/needs";
+import { queryKeys } from "@/lib/query-keys";
+import type { OnboardingAnswerInput } from "@/lib/validations/onboarding-needs";
+
+interface OnboardingStateEnvelope {
+ onboarding: OnboardingStateDto;
+}
+
+/**
+ * The three writes the setup screens make, as mutations that hand back the
+ * published state.
+ *
+ * Every one of them returns the flow's state after the write, and every
+ * screen navigates from THAT rather than from what it thinks it just did —
+ * the step machine reads the same ledger the server wrote, so "where next"
+ * is never a client-side guess. `authMe` is invalidated because the account
+ * payload carries the same state and the checklist reads it from there.
+ */
+export function useOnboardingAnswer() {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationKey: queryKeys.onboardingAnswerMutation(),
+ mutationFn: async (input: OnboardingAnswerInput) => {
+ const { onboarding } = await apiPatch(
+ "/api/onboarding/answers",
+ input,
+ );
+ return onboarding;
+ },
+ onSuccess: async () => {
+ await queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
+ },
+ });
+}
+
+interface CompleteEnvelope {
+ completed: true;
+ onboarding?: OnboardingStateDto;
+}
+
+/**
+ * `POST /api/onboarding/complete` — the confirm screen, and "skip for now".
+ *
+ * The body is always empty: profile values go through `PUT /api/auth/profile`
+ * (the canonical write, with its per-field refusals), never through the
+ * completion stamp's optional fields.
+ */
+export function useOnboardingComplete() {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationKey: queryKeys.onboardingCompleteMutation(),
+ mutationFn: async () =>
+ apiPost("/api/onboarding/complete", {}),
+ onSuccess: async () => {
+ await queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
+ },
+ });
+}
+
+/** `POST /api/onboarding/restart` — "Set up again". */
+export function useOnboardingRestart() {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationKey: queryKeys.onboardingRestartMutation(),
+ mutationFn: async () => {
+ const { onboarding } = await apiPost(
+ "/api/onboarding/restart",
+ {},
+ );
+ return onboarding;
+ },
+ onSuccess: async () => {
+ await queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
+ },
+ });
+}
+
+/** The route of one setup screen. */
+export function screenHref(screen: string): string {
+ return screen === "welcome" ? "/onboarding" : `/onboarding/${screen}`;
+}
diff --git a/src/components/onboarding/welcome-carousel.tsx b/src/components/onboarding/welcome-carousel.tsx
deleted file mode 100644
index 12c3d3ab3..000000000
--- a/src/components/onboarding/welcome-carousel.tsx
+++ /dev/null
@@ -1,340 +0,0 @@
-"use client";
-
-import { useCallback, useEffect, useId, useRef, useState } from "react";
-import { useRouter } from "next/navigation";
-import { toast } from "sonner";
-import { useQueryClient } from "@tanstack/react-query";
-import {
- ChevronLeft,
- ChevronRight,
- HeartPulse,
- Plug,
- Sparkles,
- type LucideIcon,
-} from "lucide-react";
-
-import Link from "next/link";
-
-import { Button } from "@/components/ui/button";
-import { Checkbox } from "@/components/ui/checkbox";
-import { useAuth } from "@/hooks/use-auth";
-import { useTranslations } from "@/lib/i18n/context";
-import { scrollBehaviorForUser } from "@/lib/motion";
-import { cn } from "@/lib/utils";
-import { apiPost } from "@/lib/api/api-fetch";
-import { localizedApiError } from "@/lib/api/localized-error";
-import { queryKeys } from "@/lib/query-keys";
-import {
- DISCLAIMER_VERSION,
- isDisclaimerAcknowledgmentCurrent,
-} from "@/lib/onboarding/disclaimer";
-
-/**
- * v1.4.25 W14b-Content — onboarding welcome carousel.
- *
- * Three value-prop slides shown on `/onboarding/0` before any data is
- * asked of the user. Mirrors the Withings / Strava pattern surfaced in
- * the W14b research file (§2.1, §2.5): pitch the product, then ask.
- *
- * Slides:
- * 1. Tagline — "Your health data, owned by you."
- * 2. Multi-source — Apple Health + Withings + manual, one view.
- * 3. AI Coach + Insights — evidence-grounded, your data only.
- *
- * The carousel is a CSS scroll-snap rail; arrow buttons + dot pager
- * mirror the active slide. `prefers-reduced-motion` disables the smooth
- * scroll behaviour (per WCAG 2.3.3 / research §4.6).
- *
- * The primary CTA ("Get started") POSTs `/api/onboarding/step` with
- * `{ step: 1 }`. On success it invalidates the `auth` query (so the
- * user-shape's `onboardingStep` re-reads) and pushes to `/onboarding/1`.
- */
-
-interface CarouselSlide {
- id: "tagline" | "sources" | "coach";
- Icon: LucideIcon;
- titleKey: string;
- bodyKey: string;
-}
-
-const SLIDES: ReadonlyArray = [
- {
- id: "tagline",
- Icon: HeartPulse,
- titleKey: "onboarding.welcome.slide1.title",
- bodyKey: "onboarding.welcome.slide1.body",
- },
- {
- id: "sources",
- Icon: Plug,
- titleKey: "onboarding.welcome.slide2.title",
- bodyKey: "onboarding.welcome.slide2.body",
- },
- {
- id: "coach",
- Icon: Sparkles,
- titleKey: "onboarding.welcome.slide3.title",
- bodyKey: "onboarding.welcome.slide3.body",
- },
-];
-
-export function WelcomeCarousel() {
- const { t } = useTranslations();
- const router = useRouter();
- const queryClient = useQueryClient();
- const { user } = useAuth();
-
- const railRef = useRef(null);
- const liveRegionId = useId();
- const disclaimerId = useId();
- const [active, setActive] = useState(0);
- const [advancing, setAdvancing] = useState(false);
- // v1.18.6 (DISC-02) — the one-time medical-disclaimer acknowledgment gates
- // "Get started". Pre-checked only when the account acknowledged the
- // CURRENT disclaimer version (a re-walk of step 0), so a returning user is
- // not re-asked — but a revised disclaimer re-prompts instead of riding an
- // acknowledgment given for older text.
- const disclaimerCurrent = isDisclaimerAcknowledgmentCurrent(
- user?.disclaimerAcknowledgedAt,
- user?.disclaimerAcknowledgedVersion,
- );
- const [acknowledged, setAcknowledged] = useState(() => disclaimerCurrent);
-
- // Track which slide is centred in the rail. We observe each slide
- // with an IntersectionObserver — simpler and more reliable than
- // computing scroll-positions, and it handles touch flings naturally.
- useEffect(() => {
- const rail = railRef.current;
- if (!rail) return;
-
- const slides = Array.from(
- rail.querySelectorAll("[data-slide-index]"),
- );
- if (slides.length === 0) return;
-
- const io = new IntersectionObserver(
- (entries) => {
- // Pick the entry whose intersection ratio is highest — that's
- // the slide currently snapped into view.
- let best: { idx: number; ratio: number } | null = null;
- for (const e of entries) {
- const idx = Number((e.target as HTMLElement).dataset.slideIndex);
- if (!Number.isFinite(idx)) continue;
- if (!best || e.intersectionRatio > best.ratio) {
- best = { idx, ratio: e.intersectionRatio };
- }
- }
- if (best && best.ratio > 0.5) {
- setActive(best.idx);
- }
- },
- { root: rail, threshold: [0.5, 0.75, 1] },
- );
-
- for (const s of slides) io.observe(s);
- return () => io.disconnect();
- }, []);
-
- const scrollToSlide = useCallback((idx: number) => {
- const rail = railRef.current;
- if (!rail) return;
- const target = rail.querySelector(
- `[data-slide-index="${idx}"]`,
- );
- if (!target) return;
- target.scrollIntoView({
- behavior: scrollBehaviorForUser(),
- inline: "start",
- block: "nearest",
- });
- }, []);
-
- async function handleGetStarted() {
- if (advancing || !acknowledged) return;
- setAdvancing(true);
- try {
- // v1.18.6 (DISC-02) — record the acknowledgment before advancing.
- // Skipped only when the account already acknowledged the CURRENT
- // version (re-walk); an older-version acknowledgment re-records.
- if (!disclaimerCurrent) {
- await apiPost("/api/onboarding/disclaimer", {
- version: DISCLAIMER_VERSION,
- });
- }
- await apiPost("/api/onboarding/step", { step: 1 });
- await queryClient.invalidateQueries({ queryKey: queryKeys.auth() });
- router.push("/onboarding/1");
- } catch (err) {
- toast.error(localizedApiError(err, t, "onboarding.errorGeneric"));
- setAdvancing(false);
- }
- }
-
- return (
-
- {/* Onboarding hero H1: intentionally semibold, not the app-wide bold PageHeader H1 (UI-STANDARDS §5 hero exception). Do not sweep to font-bold. */}
-
- {t("onboarding.welcome.title")}
-
-
-
-
- {SLIDES.map((slide, idx) => (
-
-
-
-
-
- {t(slide.titleKey)}
-
-
- {t(slide.bodyKey)}
-
-
- ))}
-
-
- {/* Single live region announces slide changes to AT users —
- slides themselves carry `aria-label="slide N of total"` so a
- second sr-only mirror would double-announce on every move.
- The carousel-level region wins because it's tied to the
- chevron + dot pager via `aria-controls`. */}
-
- {t("onboarding.welcome.slideOf", {
- current: active + 1,
- total: SLIDES.length,
- })}
-
-
-
-
-
scrollToSlide(Math.max(0, active - 1))}
- disabled={active === 0}
- aria-label={t("onboarding.welcome.prevSlide")}
- aria-controls={liveRegionId}
- className="min-h-11 min-w-11"
- >
-
-
-
-
- {SLIDES.map((slide, idx) => (
- scrollToSlide(idx)}
- className="inline-flex size-11 items-center justify-center"
- >
-
-
- ))}
-
-
-
scrollToSlide(Math.min(SLIDES.length - 1, active + 1))}
- disabled={active === SLIDES.length - 1}
- aria-label={t("onboarding.welcome.nextSlide")}
- aria-controls={liveRegionId}
- className="min-h-11 min-w-11"
- >
-
-
-
-
- {/* v1.18.6 (DISC-02) — one-time medical-disclaimer acknowledgment. This
- replaces the per-page / per-chart disclaimer banners removed app-wide
- in the same release; the full legal text stays reachable on the
- public privacy page (link below). */}
-
- setAcknowledged(next === true)}
- className="mt-0.5"
- />
-
- {t("onboarding.disclaimer.acknowledge")}{" "}
-
- {t("onboarding.disclaimer.learnMore")}
-
-
-
-
-
-
- {t("onboarding.welcome.cta")}
-
-
-
- );
-}
diff --git a/src/components/onboarding/welcome-screen.tsx b/src/components/onboarding/welcome-screen.tsx
new file mode 100644
index 000000000..8e4a37e89
--- /dev/null
+++ b/src/components/onboarding/welcome-screen.tsx
@@ -0,0 +1,193 @@
+"use client";
+
+import { useId, useState } from "react";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import { useMutation } from "@tanstack/react-query";
+import { toast } from "sonner";
+
+import { Button } from "@/components/ui/button";
+import { Checkbox } from "@/components/ui/checkbox";
+import { StepHeading } from "@/components/onboarding/step-heading";
+import {
+ screenHref,
+ useOnboardingComplete,
+ useOnboardingRestart,
+} from "@/components/onboarding/use-onboarding-flow";
+import { useAuth } from "@/hooks/use-auth";
+import { apiPost } from "@/lib/api/api-fetch";
+import { localizedApiError } from "@/lib/api/localized-error";
+import { useTranslations } from "@/lib/i18n/context";
+import { markChecklistExpanded } from "@/lib/onboarding/checklist-storage";
+import {
+ DISCLAIMER_VERSION,
+ isDisclaimerAcknowledgmentCurrent,
+} from "@/lib/onboarding/disclaimer";
+import { queryKeys } from "@/lib/query-keys";
+
+/**
+ * v1.39 (C2) — the screen before the first question.
+ *
+ * One sentence on what happens and how long it takes, "Set up", and "Skip for
+ * now" (design spec §The screens, 1). The skip is a real exit: it stamps the
+ * account as past the first-run redirect and lands on the dashboard with the
+ * checklist open, and the questions stay one click away in Settings.
+ *
+ * The one-time medical disclaimer gates BOTH buttons. It used to gate only
+ * "Get started", which was fine while the wizard was the only way past this
+ * screen; a skip that reached the dashboard without it would be a way round
+ * the acknowledgment. Pre-checked only when the account acknowledged the
+ * CURRENT version, so a revised disclaimer re-prompts.
+ *
+ * `variant="again"` is the same screen for an account whose setup is
+ * finished: it offers the questions again (through the restart route, which
+ * resets the ledger and keeps the answers as prefill) or the dashboard.
+ */
+export function WelcomeScreen({ variant }: { variant: "fresh" | "again" }) {
+ const { t } = useTranslations();
+ const router = useRouter();
+ const { user } = useAuth();
+ const disclaimerId = useId();
+
+ const disclaimerCurrent = isDisclaimerAcknowledgmentCurrent(
+ user?.disclaimerAcknowledgedAt,
+ user?.disclaimerAcknowledgedVersion,
+ );
+ const [acknowledged, setAcknowledged] = useState(() => disclaimerCurrent);
+
+ const acknowledge = useMutation({
+ mutationKey: queryKeys.onboardingDisclaimerMutation(),
+ mutationFn: async () => {
+ if (disclaimerCurrent) return;
+ await apiPost("/api/onboarding/disclaimer", {
+ version: DISCLAIMER_VERSION,
+ });
+ },
+ });
+ const complete = useOnboardingComplete();
+ const restart = useOnboardingRestart();
+ const pending =
+ acknowledge.isPending || complete.isPending || restart.isPending;
+
+ const fail = (err: unknown) =>
+ toast.error(localizedApiError(err, t, "onboarding.errorGeneric"));
+
+ async function setUp() {
+ if (pending || !acknowledged) return;
+ try {
+ await acknowledge.mutateAsync();
+ router.push(screenHref("who"));
+ } catch (err) {
+ fail(err);
+ }
+ }
+
+ async function skipForNow() {
+ if (pending || !acknowledged) return;
+ try {
+ await acknowledge.mutateAsync();
+ await complete.mutateAsync();
+ markChecklistExpanded();
+ router.push("/");
+ } catch (err) {
+ fail(err);
+ }
+ }
+
+ async function setUpAgain() {
+ if (pending) return;
+ try {
+ await restart.mutateAsync();
+ router.push(screenHref("who"));
+ } catch (err) {
+ fail(err);
+ }
+ }
+
+ if (variant === "again") {
+ return (
+
+
+
+
+ {t("onboarding.welcomeBack.cta")}
+
+
+ {t("onboarding.flow.welcome.setUpAgain")}
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ {t("onboarding.flow.welcome.changeLater")}
+
+ {/* One-time medical-disclaimer acknowledgment; the full text stays
+ reachable on the public privacy page. */}
+
+ setAcknowledged(next === true)}
+ className="mt-0.5"
+ data-slot="onboarding-disclaimer"
+ />
+
+ {t("onboarding.disclaimer.acknowledge")}{" "}
+
+ {t("onboarding.disclaimer.learnMore")}
+
+
+
+
+
+
+ {t("onboarding.flow.welcome.skip")}
+
+
+ {t("onboarding.flow.welcome.setUp")}
+
+
+
+ );
+}
diff --git a/src/lib/onboarding/__tests__/wizard-steps.test.ts b/src/lib/onboarding/__tests__/wizard-steps.test.ts
index 13752b2a3..532b15abf 100644
--- a/src/lib/onboarding/__tests__/wizard-steps.test.ts
+++ b/src/lib/onboarding/__tests__/wizard-steps.test.ts
@@ -360,6 +360,11 @@ describe("canVisitScreen", () => {
expect(canVisitScreen(midway, "done")).toBe(false);
});
+ it("always admits the first question, which is where the welcome screen goes", () => {
+ expect(canVisitScreen(state(), "who")).toBe(true);
+ expect(canVisitScreen(state(), "areas")).toBe(false);
+ });
+
it("refuses a screen the order does not contain", () => {
const noUnits = state(QUESTIONS_DONE, {
recordTarget: "me",
diff --git a/src/lib/onboarding/checklist-storage.ts b/src/lib/onboarding/checklist-storage.ts
new file mode 100644
index 000000000..df4669783
--- /dev/null
+++ b/src/lib/onboarding/checklist-storage.ts
@@ -0,0 +1,56 @@
+/**
+ * The getting-started checklist's per-browser state.
+ *
+ * Three keys, all of them conveniences rather than record: which rows this
+ * browser hid, whether the whole card was hidden, and whether it is open.
+ * They were private to the card until the setup flow needed two of them —
+ * the done screen and "skip for now" open the list, and Settings brings a
+ * hidden list back — and a second copy of a storage key is how two surfaces
+ * end up writing different ones.
+ *
+ * Every access is wrapped: storage can be full, disabled, or absent, and the
+ * card renders correctly with no stored value.
+ */
+export const CHECKLIST_DISMISSED_ITEMS_KEY =
+ "healthlog-getting-started-dismissed";
+export const CHECKLIST_DISMISSED_ALL_KEY = "healthlog-getting-started-hidden";
+export const CHECKLIST_EXPANDED_KEY = "healthlog-getting-started-expanded";
+
+function storage(): Storage | null {
+ if (typeof window === "undefined") return null;
+ try {
+ return window.localStorage;
+ } catch {
+ return null;
+ }
+}
+
+/** Open the checklist on the next dashboard visit. */
+export function markChecklistExpanded(): void {
+ try {
+ storage()?.setItem(CHECKLIST_EXPANDED_KEY, "1");
+ } catch {
+ /* storage may be full or disabled */
+ }
+}
+
+/**
+ * "Show the checklist again": forget every dismissal and open the list.
+ * Returns whether anything was stored to forget, so the caller can say
+ * "it is back" rather than "done" to somebody who never hid it.
+ */
+export function resetChecklistDismissals(): boolean {
+ const store = storage();
+ if (!store) return false;
+ try {
+ const hadState =
+ store.getItem(CHECKLIST_DISMISSED_ALL_KEY) === "1" ||
+ (store.getItem(CHECKLIST_DISMISSED_ITEMS_KEY) ?? "[]") !== "[]";
+ store.removeItem(CHECKLIST_DISMISSED_ITEMS_KEY);
+ store.removeItem(CHECKLIST_DISMISSED_ALL_KEY);
+ store.setItem(CHECKLIST_EXPANDED_KEY, "1");
+ return hadState;
+ } catch {
+ return false;
+ }
+}
diff --git a/src/lib/onboarding/load-flow-state.ts b/src/lib/onboarding/load-flow-state.ts
new file mode 100644
index 000000000..75debf729
--- /dev/null
+++ b/src/lib/onboarding/load-flow-state.ts
@@ -0,0 +1,40 @@
+/**
+ * v1.39 (C2) — the setup flow's state, for the server-rendered pages.
+ *
+ * One read per request: the page that guards a URL and the metadata that
+ * titles it both ask for the same row, and `cache` makes the second ask free.
+ * The DTO is the same one `GET /api/auth/me` publishes (`toOnboardingStateDto`
+ * with the account's own unit columns), so the screen the server paints and
+ * the screen the client reasons about after hydration are built from one
+ * shape.
+ *
+ * The caller's OWN record, always. The flow's three writes refuse under an
+ * acting-account switch, so a page that read the switched record's state would
+ * show questions nobody could answer.
+ */
+import { cache } from "react";
+
+import { getSession } from "@/lib/auth/session";
+import { prisma } from "@/lib/db";
+import { readHeldUnitPreferences, type OnboardingStateDto } from "./needs";
+import { loadOnboardingRecordRow, toOnboardingStateDto } from "./needs-store";
+
+export interface OnboardingFlowPageState {
+ userId: string;
+ userLocale: string | null;
+ state: OnboardingStateDto;
+}
+
+export const loadOnboardingFlowState = cache(
+ async (): Promise => {
+ const session = await getSession();
+ if (!session) return null;
+ const { user } = session;
+ const row = await loadOnboardingRecordRow(prisma, user.id);
+ return {
+ userId: user.id,
+ userLocale: user.locale ?? null,
+ state: toOnboardingStateDto(row, readHeldUnitPreferences(user)),
+ };
+ },
+);
diff --git a/src/lib/onboarding/wizard-steps.ts b/src/lib/onboarding/wizard-steps.ts
index 33f859a88..35bc14172 100644
--- a/src/lib/onboarding/wizard-steps.ts
+++ b/src/lib/onboarding/wizard-steps.ts
@@ -211,6 +211,10 @@ export function previousScreen(
* actually is. A screen the order does not contain (units for someone who
* ticked no unit-bearing area) is refused too, so a stale link cannot show
* a question whose answer would mean nothing.
+ *
+ * The first question is the one exception: it is where the welcome screen's
+ * "Set up" goes, and a record that has not entered the flow resumes on the
+ * welcome, so without this arm nobody could ever start.
*/
export function canVisitScreen(
state: FlowState,
@@ -219,7 +223,7 @@ export function canVisitScreen(
const order = screenOrder(state);
const index = order.indexOf(screen);
if (index === -1) return false;
- return index <= order.indexOf(resumeScreen(state));
+ return index <= Math.max(order.indexOf(resumeScreen(state)), 1);
}
/**
From 7b279aec7237a9ce5a35c61b4fb8c9675d52c23c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 10 Sep 2026 15:15:24 +0200
Subject: [PATCH 04/21] Add the six question screens of the setup flow
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
One question per screen, Q1 to Q6, each a chip set with a real radio or
checkbox inside every chip so the browser owns grouping and keyboard
movement, and a focus ring the five-step picker's hidden inputs never
had. The selection is pre-filled from the stored answers, "Next"
persists it through the answers route and navigates from the state the
server hands back, and Q1 is the one question without a skip.
The questions are data (which step, which answers, one or many, may it
be passed) with two pure translations beside them — stored answers into
a selection, a selection into the strict body — so the wire never sees
a chip this file did not offer. Q6 asks only about the units the ticked
areas need and sends nothing for a value the account already had.
The step page guards every URL with the step machine and stays under
/onboarding/, which is what the first-run redirect lets through.
---
messages/en.json | 76 +++++++
src/app/onboarding/[step]/page.tsx | 102 ++++++++++
.../__tests__/question-screen.test.tsx | 76 +++++++
src/components/onboarding/choice-chip.tsx | 82 ++++++++
src/components/onboarding/question-screen.tsx | 166 ++++++++++++++++
src/components/onboarding/units-screen.tsx | 131 ++++++++++++
.../__tests__/question-config.test.ts | 137 +++++++++++++
src/lib/onboarding/question-config.ts | 186 ++++++++++++++++++
8 files changed, 956 insertions(+)
create mode 100644 src/app/onboarding/[step]/page.tsx
create mode 100644 src/components/onboarding/__tests__/question-screen.test.tsx
create mode 100644 src/components/onboarding/choice-chip.tsx
create mode 100644 src/components/onboarding/question-screen.tsx
create mode 100644 src/components/onboarding/units-screen.tsx
create mode 100644 src/lib/onboarding/__tests__/question-config.test.ts
create mode 100644 src/lib/onboarding/question-config.ts
diff --git a/messages/en.json b/messages/en.json
index 20c28a1a5..551ea6af8 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -4643,6 +4643,82 @@
"skip": "Skip for now",
"againBody": "Your setup is finished. Answer the questions again to change what HealthLog shows first, or go to the dashboard.",
"setUpAgain": "Set up again"
+ },
+ "who": {
+ "title": "Who is this record for?",
+ "body": "The answers that follow apply to this record.",
+ "options": {
+ "me": "Me",
+ "someone-else": "Someone I look after",
+ "both": "Both"
+ },
+ "hints": {
+ "someone-else": "A child or a parent, for example. Their profile is created at the end.",
+ "both": "Set up your own record now; the profile for the other person is offered at the end."
+ }
+ },
+ "areas": {
+ "title": "What do you want to keep an eye on?",
+ "body": "Pick any number. These decide which modules are switched on and what the dashboard shows first.",
+ "options": {
+ "blood-pressure": "Blood pressure",
+ "weight-body": "Weight and body",
+ "glucose": "Blood glucose",
+ "sleep": "Sleep and recovery",
+ "mood": "Mood and mental wellbeing",
+ "cycle": "Cycle",
+ "activity": "Activity and workouts",
+ "labs": "Lab results",
+ "illness": "Illnesses and symptoms"
+ }
+ },
+ "medication": {
+ "title": "Do you take medication on a schedule?",
+ "body": "A schedule switches on the medication module and reminders.",
+ "options": {
+ "yes": "Yes",
+ "no": "No",
+ "sometimes": "Sometimes"
+ }
+ },
+ "sources": {
+ "title": "Where do your readings come from today?",
+ "body": "Pick any number. One connection is offered at the end; none is required.",
+ "options": {
+ "manual": "I type them in",
+ "apple-health": "Apple Health on my iPhone",
+ "withings": "Withings",
+ "oura": "Oura",
+ "whoop": "WHOOP",
+ "polar": "Polar",
+ "fitbit": "Fitbit",
+ "strava": "Strava",
+ "nightscout": "Nightscout",
+ "file": "A file I already have"
+ },
+ "hints": {
+ "apple-health": "Synced by the iOS app, or imported from an export.",
+ "file": "Imported under Settings once you are done here."
+ }
+ },
+ "visit": {
+ "title": "Is there a doctor's visit coming up?",
+ "body": "A visit switches on the doctor report and adds preparing for it to your checklist.",
+ "options": {
+ "within-a-month": "Yes, within a month",
+ "later": "Later",
+ "no": "No"
+ }
+ },
+ "units": {
+ "title": "Which units do you use?",
+ "body": "Readings are stored once and shown in the unit you choose. You can change it later under Settings.",
+ "glucoseLegend": "Blood glucose",
+ "weightLegend": "Weight and height",
+ "options": {
+ "metric": "kg and cm",
+ "imperial": "lb and ft/in"
+ }
}
}
},
diff --git a/src/app/onboarding/[step]/page.tsx b/src/app/onboarding/[step]/page.tsx
new file mode 100644
index 000000000..d4d0ded2a
--- /dev/null
+++ b/src/app/onboarding/[step]/page.tsx
@@ -0,0 +1,102 @@
+import type { Metadata } from "next";
+import { notFound, redirect } from "next/navigation";
+
+import { OnboardingShell } from "@/components/onboarding/onboarding-shell";
+import { QuestionScreen } from "@/components/onboarding/question-screen";
+import { UnitsScreen } from "@/components/onboarding/units-screen";
+import { getServerTranslator } from "@/lib/i18n/server-translator";
+import { resolveServerLocale } from "@/lib/i18n/server-locale";
+import { loadOnboardingFlowState } from "@/lib/onboarding/load-flow-state";
+import {
+ ONBOARDING_STEP_IDS,
+ type OnboardingStateDto,
+ type OnboardingStepId,
+} from "@/lib/onboarding/needs";
+import { isQuestionStepId } from "@/lib/onboarding/question-config";
+import {
+ canVisitScreen,
+ resumeScreen,
+ stepCounter,
+} from "@/lib/onboarding/wizard-steps";
+
+/**
+ * v1.39 (C2) — one setup screen per step id.
+ *
+ * /onboarding/who … /onboarding/visit the five questions
+ * /onboarding/units Q6, when it applies
+ * /onboarding/confirm the module map and the profile
+ * /onboarding/first-result the one task the flow ends on
+ * /onboarding/done the exit
+ *
+ * The URL is guarded by the step machine: going back is always allowed,
+ * jumping ahead bounces to the screen the flow actually owes, and a screen
+ * this flow does not contain (units for someone with no unit-bearing area)
+ * bounces the same way. Everything lives under `/onboarding/`, which is what
+ * the proxy's first-run redirect lets through — a segment outside it would
+ * loop.
+ *
+ * Server component: the state is loaded once per request (`cache`), the
+ * screens that hold a selection are the client parts.
+ */
+export const dynamicParams = false;
+
+export function generateStaticParams() {
+ return ONBOARDING_STEP_IDS.map((step) => ({ step }));
+}
+
+interface PageProps {
+ params: Promise<{ step: string }>;
+}
+
+function asStepId(value: string): OnboardingStepId | null {
+ return (ONBOARDING_STEP_IDS as readonly string[]).includes(value)
+ ? (value as OnboardingStepId)
+ : null;
+}
+
+export async function generateMetadata({
+ params,
+}: PageProps): Promise {
+ const { step } = await params;
+ const screen = asStepId(step);
+ const flow = screen ? await loadOnboardingFlowState() : null;
+ if (!screen || !flow) return {};
+ const locale = await resolveServerLocale({ userLocale: flow.userLocale });
+ const { t } = getServerTranslator(locale);
+ const counter = stepCounter(flow.state, screen);
+ const name = t(`onboarding.steps.${screen}`);
+ return {
+ title: counter ? `${t("onboarding.shell.stepOf", counter)}: ${name}` : name,
+ };
+}
+
+export default async function OnboardingStepPage({ params }: PageProps) {
+ const { step } = await params;
+ const screen = asStepId(step);
+ if (!screen) notFound();
+
+ const flow = await loadOnboardingFlowState();
+ if (!flow) redirect("/auth/login");
+ const { state, userLocale } = flow;
+
+ if (!canVisitScreen(state, screen)) {
+ const resume = resumeScreen(state);
+ redirect(resume === "welcome" ? "/onboarding" : `/onboarding/${resume}`);
+ }
+
+ return (
+
+ {renderScreen(screen, state)}
+
+ );
+}
+
+function renderScreen(screen: OnboardingStepId, state: OnboardingStateDto) {
+ if (isQuestionStepId(screen)) {
+ return ;
+ }
+ if (screen === "units") {
+ return ;
+ }
+ notFound();
+}
diff --git a/src/components/onboarding/__tests__/question-screen.test.tsx b/src/components/onboarding/__tests__/question-screen.test.tsx
new file mode 100644
index 000000000..142c18e1d
--- /dev/null
+++ b/src/components/onboarding/__tests__/question-screen.test.tsx
@@ -0,0 +1,76 @@
+/**
+ * v1.39 (C2) — the chip question screen, as the server paints it.
+ *
+ * Vitest runs without a DOM here, so what is pinned is the first paint: the
+ * stored answer is pre-selected, Q1 cannot be passed and cannot continue
+ * without an answer, every other question can be passed, and a real radio or
+ * checkbox sits inside each chip so the browser owns the grouping. The click
+ * path is the e2e journey's.
+ */
+import { describe, expect, it, vi } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+
+import { I18nProvider } from "@/lib/i18n/context";
+import {
+ defaultOnboardingSteps,
+ emptyOnboardingNeeds,
+ type OnboardingStateDto,
+} from "@/lib/onboarding/needs";
+import { QuestionScreen } from "../question-screen";
+
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
+}));
+
+function state(
+ needs: Partial,
+): OnboardingStateDto {
+ return {
+ steps: defaultOnboardingSteps(),
+ needs: { ...emptyOnboardingNeeds(), ...needs },
+ completedAt: null,
+ firstResult: null,
+ };
+}
+
+function render(step: "who" | "areas" | "visit", needs = {}) {
+ return renderToStaticMarkup(
+
+
+
+
+ ,
+ );
+}
+
+describe("", () => {
+ it("pre-selects the stored answer", () => {
+ const html = render("areas", { areas: ["glucose", "labs"] });
+ expect(html).toContain('data-value="glucose" data-checked="true"');
+ expect(html).toContain('data-value="labs" data-checked="true"');
+ expect(html).toContain('data-value="mood" data-checked="false"');
+ });
+
+ it("uses radios for one answer and checkboxes for many", () => {
+ expect(render("who")).toContain('type="radio"');
+ expect(render("who")).not.toContain('type="checkbox"');
+ expect(render("areas")).toContain('type="checkbox"');
+ });
+
+ it("gives Q1 no skip and no way on without an answer", () => {
+ const html = render("who");
+ expect(html).not.toContain('data-slot="onboarding-skip"');
+ expect(html).toMatch(/data-slot="onboarding-next"[^>]*disabled/);
+ });
+
+ it("lets every other question be passed", () => {
+ expect(render("visit")).toContain('data-slot="onboarding-skip"');
+ });
+
+ it("renders no raw key", () => {
+ for (const step of ["who", "areas", "visit"] as const) {
+ expect(render(step)).not.toMatch(/onboarding\.flow\./);
+ }
+ });
+});
diff --git a/src/components/onboarding/choice-chip.tsx b/src/components/onboarding/choice-chip.tsx
new file mode 100644
index 000000000..c2e67c6b7
--- /dev/null
+++ b/src/components/onboarding/choice-chip.tsx
@@ -0,0 +1,82 @@
+"use client";
+
+import type { LucideIcon } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+
+/**
+ * One answer chip of a setup question: a real radio or checkbox inside a
+ * label, so the browser owns selection, grouping and keyboard movement
+ * (arrow keys across a radio group, space on a checkbox), and the chip is
+ * only the paint.
+ *
+ * The input is visually hidden and the label carries the focus ring through
+ * `has-[:focus-visible]`, because a hidden control with no visible focus is
+ * how a keyboard user loses their place in a chip grid — the five-step
+ * wizard's picker had exactly that gap.
+ */
+export function ChoiceChip({
+ name,
+ value,
+ kind,
+ checked,
+ onChange,
+ label,
+ hint,
+ Icon,
+}: {
+ name: string;
+ value: string;
+ kind: "single" | "multi";
+ checked: boolean;
+ onChange: (value: string) => void;
+ label: string;
+ hint?: string;
+ Icon?: LucideIcon;
+}) {
+ return (
+
+ onChange(value)}
+ className="sr-only"
+ />
+ {Icon ? (
+
+
+
+ ) : null}
+
+
+ {label}
+
+ {hint ? (
+
+ {hint}
+
+ ) : null}
+
+
+ );
+}
diff --git a/src/components/onboarding/question-screen.tsx b/src/components/onboarding/question-screen.tsx
new file mode 100644
index 000000000..336794c2c
--- /dev/null
+++ b/src/components/onboarding/question-screen.tsx
@@ -0,0 +1,166 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import { toast } from "sonner";
+import {
+ Activity,
+ Brain,
+ CalendarHeart,
+ Droplets,
+ FlaskConical,
+ HeartPulse,
+ Moon,
+ Scale,
+ Thermometer,
+ type LucideIcon,
+} from "lucide-react";
+
+import { ChoiceChip } from "@/components/onboarding/choice-chip";
+import { StepActions } from "@/components/onboarding/step-actions";
+import { StepHeading } from "@/components/onboarding/step-heading";
+import {
+ screenHref,
+ useOnboardingAnswer,
+} from "@/components/onboarding/use-onboarding-flow";
+import { localizedApiError } from "@/lib/api/localized-error";
+import { useTranslations } from "@/lib/i18n/context";
+import type { OnboardingStateDto } from "@/lib/onboarding/needs";
+import {
+ QUESTIONS,
+ questionAnswerBody,
+ questionOptionLabelKey,
+ questionPrefill,
+ questionSkipBody,
+ type QuestionStepId,
+} from "@/lib/onboarding/question-config";
+import { nextScreen, previousScreen } from "@/lib/onboarding/wizard-steps";
+
+/**
+ * v1.39 (C2) — one question per screen, Q1 to Q5.
+ *
+ * The chip set is the whole screen (design spec §Principles 3: one screen,
+ * one decision). The selection lives in component state while the screen is
+ * open, pre-filled from the stored answers so a returning person sees what
+ * they said; "Next" persists it through the answers route and navigates from
+ * the state the server hands back, so "where next" is the step machine's
+ * reading of the ledger the server wrote, not this screen's guess.
+ *
+ * Q1 is required and has no skip; every other question may be passed, and a
+ * pass records the pass without touching the stored answer.
+ */
+const AREA_ICONS: Readonly> = {
+ "blood-pressure": HeartPulse,
+ "weight-body": Scale,
+ glucose: Droplets,
+ sleep: Moon,
+ mood: Brain,
+ cycle: CalendarHeart,
+ activity: Activity,
+ labs: FlaskConical,
+ illness: Thermometer,
+};
+
+/** Answers that carry a second line under the label. */
+const OPTION_HINTS: Readonly> = {
+ "who.someone-else": "onboarding.flow.who.hints.someone-else",
+ "who.both": "onboarding.flow.who.hints.both",
+ "sources.apple-health": "onboarding.flow.sources.hints.apple-health",
+ "sources.file": "onboarding.flow.sources.hints.file",
+};
+
+export function QuestionScreen({
+ step,
+ state,
+}: {
+ step: QuestionStepId;
+ state: OnboardingStateDto;
+}) {
+ const { t } = useTranslations();
+ const router = useRouter();
+ const question = QUESTIONS[step];
+ const answer = useOnboardingAnswer();
+
+ const [selected, setSelected] = useState(() =>
+ questionPrefill(step, state.needs),
+ );
+
+ const choose = (value: string) => {
+ setSelected((prev) => {
+ if (question.kind === "single") return [value];
+ return prev.includes(value)
+ ? prev.filter((v) => v !== value)
+ : [...prev, value];
+ });
+ };
+
+ const body = questionAnswerBody(step, selected);
+ const back = previousScreen(state, step);
+
+ async function persist(input: NonNullable) {
+ try {
+ const written = await answer.mutateAsync(input);
+ router.push(screenHref(nextScreen(written, step) ?? "confirm"));
+ } catch (err) {
+ toast.error(localizedApiError(err, t, "onboarding.errorGeneric"));
+ }
+ }
+
+ const titleId = `onboarding-${step}-title`;
+
+ return (
+
+
+
+
+
+ {t(`onboarding.flow.${step}.title`)}
+
+ {question.options.map((option) => {
+ const hintKey = OPTION_HINTS[`${step}.${option}`];
+ return (
+
+ );
+ })}
+
+
+
+ void persist(
+ questionSkipBody(step as Exclude),
+ )
+ : undefined
+ }
+ onNext={() => {
+ if (body) void persist(body);
+ }}
+ nextDisabled={body === null}
+ pending={answer.isPending}
+ />
+
+ );
+}
diff --git a/src/components/onboarding/units-screen.tsx b/src/components/onboarding/units-screen.tsx
new file mode 100644
index 000000000..b2fec48bd
--- /dev/null
+++ b/src/components/onboarding/units-screen.tsx
@@ -0,0 +1,131 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import { toast } from "sonner";
+
+import { ChoiceChip } from "@/components/onboarding/choice-chip";
+import { StepActions } from "@/components/onboarding/step-actions";
+import { StepHeading } from "@/components/onboarding/step-heading";
+import {
+ screenHref,
+ useOnboardingAnswer,
+} from "@/components/onboarding/use-onboarding-flow";
+import { useAuth } from "@/hooks/use-auth";
+import { localizedApiError } from "@/lib/api/localized-error";
+import { useTranslations } from "@/lib/i18n/context";
+import type { OnboardingStateDto } from "@/lib/onboarding/needs";
+import {
+ unitQuestionsFor,
+ unitsAnswerBody,
+} from "@/lib/onboarding/question-config";
+import { nextScreen, previousScreen } from "@/lib/onboarding/wizard-steps";
+
+type GlucoseUnit = "mg/dL" | "mmol/L";
+type UnitPreference = "metric" | "imperial";
+
+/**
+ * v1.39 (C2) — Q6, the optional units question.
+ *
+ * Shown only while an area that needs a unit is among the answers and the
+ * account does not already hold the preference (the step machine decides;
+ * this screen is never reached otherwise). Each of the two questions is
+ * asked only for the area that needs it, and each is pre-filled from what
+ * the flow recorded or, failing that, from the account's own column — so
+ * confirming what the account already had sends nothing for that field.
+ */
+export function UnitsScreen({ state }: { state: OnboardingStateDto }) {
+ const { t } = useTranslations();
+ const router = useRouter();
+ const { user } = useAuth();
+ const answer = useOnboardingAnswer();
+ const asked = unitQuestionsFor(state.needs);
+
+ const [glucoseUnit, setGlucoseUnit] = useState(
+ () =>
+ state.needs.units.glucoseUnit ??
+ (user?.glucoseUnit === "mmol/L" || user?.glucoseUnit === "mg/dL"
+ ? user.glucoseUnit
+ : null),
+ );
+ const [unitPreference, setUnitPreference] = useState(
+ () => state.needs.units.unitPreference ?? user?.unitPreference ?? null,
+ );
+
+ const back = previousScreen(state, "units");
+
+ async function persist(
+ input:
+ ReturnType | { step: "units"; status: "skipped" },
+ ) {
+ try {
+ const written = await answer.mutateAsync(input);
+ router.push(screenHref(nextScreen(written, "units") ?? "confirm"));
+ } catch (err) {
+ toast.error(localizedApiError(err, t, "onboarding.errorGeneric"));
+ }
+ }
+
+ return (
+
+
+
+ {asked.glucose ? (
+
+
+ {t("onboarding.flow.units.glucoseLegend")}
+
+ {(["mg/dL", "mmol/L"] as const).map((option) => (
+ setGlucoseUnit(option)}
+ label={option}
+ />
+ ))}
+
+ ) : null}
+
+ {asked.weight ? (
+
+
+ {t("onboarding.flow.units.weightLegend")}
+
+ {(["metric", "imperial"] as const).map((option) => (
+ setUnitPreference(option)}
+ label={t(`onboarding.flow.units.options.${option}`)}
+ />
+ ))}
+
+ ) : null}
+
+ void persist({ step: "units", status: "skipped" })}
+ onNext={() =>
+ void persist(unitsAnswerBody({ asked, glucoseUnit, unitPreference }))
+ }
+ pending={answer.isPending}
+ />
+
+ );
+}
diff --git a/src/lib/onboarding/__tests__/question-config.test.ts b/src/lib/onboarding/__tests__/question-config.test.ts
new file mode 100644
index 000000000..0151f4204
--- /dev/null
+++ b/src/lib/onboarding/__tests__/question-config.test.ts
@@ -0,0 +1,137 @@
+/**
+ * v1.39 (C2) — the chip questions as data, and the two translations the
+ * screen relies on: stored answers → chips to pre-select, and a selection →
+ * the strict body the answers route accepts.
+ */
+import { describe, expect, it } from "vitest";
+
+import { ONBOARDING_AREA_KEYS } from "@/lib/modules/registry";
+import { onboardingAnswerSchema } from "@/lib/validations/onboarding-needs";
+
+import { emptyOnboardingNeeds, ONBOARDING_SOURCE_KEYS } from "../needs";
+import {
+ QUESTION_STEP_IDS,
+ QUESTIONS,
+ questionAnswerBody,
+ questionPrefill,
+ questionSkipBody,
+ unitQuestionsFor,
+ unitsAnswerBody,
+} from "../question-config";
+
+describe("QUESTIONS", () => {
+ it("offers every value of each server vocabulary, in the server's order", () => {
+ expect(QUESTIONS.areas.options).toEqual(ONBOARDING_AREA_KEYS);
+ expect(QUESTIONS.sources.options).toEqual(ONBOARDING_SOURCE_KEYS);
+ expect(QUESTIONS.who.options).toEqual(["me", "someone-else", "both"]);
+ });
+
+ it("makes Q1 the one question that cannot be passed", () => {
+ expect(QUESTIONS.who.skippable).toBe(false);
+ for (const step of QUESTION_STEP_IDS.filter((s) => s !== "who")) {
+ expect(QUESTIONS[step].skippable).toBe(true);
+ }
+ });
+});
+
+describe("questionPrefill", () => {
+ it("reads the stored answer of each step back as a selection", () => {
+ const needs = {
+ ...emptyOnboardingNeeds(),
+ recordTarget: "both" as const,
+ areas: ["glucose" as const, "mood" as const],
+ medication: "sometimes" as const,
+ sources: ["oura" as const],
+ visit: "later" as const,
+ };
+ expect(questionPrefill("who", needs)).toEqual(["both"]);
+ expect(questionPrefill("areas", needs)).toEqual(["glucose", "mood"]);
+ expect(questionPrefill("medication", needs)).toEqual(["sometimes"]);
+ expect(questionPrefill("sources", needs)).toEqual(["oura"]);
+ expect(questionPrefill("visit", needs)).toEqual(["later"]);
+ });
+
+ it("is empty for a question never answered", () => {
+ const needs = emptyOnboardingNeeds();
+ for (const step of QUESTION_STEP_IDS) {
+ expect(questionPrefill(step, needs)).toEqual([]);
+ }
+ });
+});
+
+describe("questionAnswerBody", () => {
+ it("builds a body the answers route accepts, for every step", () => {
+ const bodies = [
+ questionAnswerBody("who", ["me"]),
+ questionAnswerBody("areas", ["glucose", "labs"]),
+ questionAnswerBody("medication", ["yes"]),
+ questionAnswerBody("sources", ["manual", "withings"]),
+ questionAnswerBody("visit", ["within-a-month"]),
+ ];
+ for (const body of bodies) {
+ expect(body).not.toBeNull();
+ expect(onboardingAnswerSchema.safeParse(body).success).toBe(true);
+ }
+ });
+
+ it("is null for a single-answer question with nothing chosen", () => {
+ expect(questionAnswerBody("who", [])).toBeNull();
+ expect(questionAnswerBody("medication", [])).toBeNull();
+ expect(questionAnswerBody("visit", [])).toBeNull();
+ });
+
+ it("sends an empty list for a many-answer question with nothing chosen", () => {
+ expect(questionAnswerBody("areas", [])).toEqual({
+ step: "areas",
+ areas: [],
+ });
+ });
+
+ it("drops a value the step's vocabulary does not contain", () => {
+ expect(questionAnswerBody("areas", ["glucose", "no-such-area"])).toEqual({
+ step: "areas",
+ areas: ["glucose"],
+ });
+ expect(questionAnswerBody("who", ["nobody"])).toBeNull();
+ });
+
+ it("passes a step with the skip arm and nothing else", () => {
+ const body = questionSkipBody("visit");
+ expect(body).toEqual({ step: "visit", status: "skipped" });
+ expect(onboardingAnswerSchema.safeParse(body).success).toBe(true);
+ });
+});
+
+describe("units", () => {
+ it("asks only about the units the ticked areas need", () => {
+ expect(
+ unitQuestionsFor({ ...emptyOnboardingNeeds(), areas: ["glucose"] }),
+ ).toEqual({ glucose: true, weight: false });
+ expect(
+ unitQuestionsFor({
+ ...emptyOnboardingNeeds(),
+ areas: ["weight-body", "glucose"],
+ }),
+ ).toEqual({ glucose: true, weight: true });
+ });
+
+ it("sends only the units that were asked and chosen", () => {
+ const body = unitsAnswerBody({
+ asked: { glucose: true, weight: false },
+ glucoseUnit: "mmol/L",
+ unitPreference: "imperial",
+ });
+ expect(body).toEqual({ step: "units", units: { glucoseUnit: "mmol/L" } });
+ expect(onboardingAnswerSchema.safeParse(body).success).toBe(true);
+ });
+
+ it("sends an empty answer when nothing was chosen, which leaves the columns alone", () => {
+ expect(
+ unitsAnswerBody({
+ asked: { glucose: true, weight: true },
+ glucoseUnit: null,
+ unitPreference: null,
+ }),
+ ).toEqual({ step: "units", units: {} });
+ });
+});
diff --git a/src/lib/onboarding/question-config.ts b/src/lib/onboarding/question-config.ts
new file mode 100644
index 000000000..547b28cd0
--- /dev/null
+++ b/src/lib/onboarding/question-config.ts
@@ -0,0 +1,186 @@
+/**
+ * v1.39 (C2) — the five chip questions, as data.
+ *
+ * Which step asks what, with which answers, whether one answer or many, and
+ * whether it may be skipped — and the two pure translations the screen needs:
+ * the stored answers into a selection to prefill, and a selection into the
+ * body `PATCH /api/onboarding/answers` accepts. Both are here rather than in
+ * the component so they can be pinned without a DOM, and so the body that
+ * reaches the wire is built field by field from a closed vocabulary rather
+ * than from whatever the chips happened to hold.
+ *
+ * The answer vocabularies are the server's (`./needs`, the registry); this
+ * file only orders them and names their labels. Units (Q6) is not a chip
+ * set and has its own screen.
+ */
+import { ONBOARDING_AREA_KEYS } from "@/lib/modules/registry";
+import type { OnboardingAnswerInput } from "@/lib/validations/onboarding-needs";
+
+import {
+ ONBOARDING_MEDICATION_ANSWERS,
+ ONBOARDING_RECORD_TARGETS,
+ ONBOARDING_SOURCE_KEYS,
+ ONBOARDING_VISIT_ANSWERS,
+ type OnboardingNeeds,
+} from "./needs";
+
+export const QUESTION_STEP_IDS = [
+ "who",
+ "areas",
+ "medication",
+ "sources",
+ "visit",
+] as const;
+
+export type QuestionStepId = (typeof QUESTION_STEP_IDS)[number];
+
+export function isQuestionStepId(value: string): value is QuestionStepId {
+ return (QUESTION_STEP_IDS as readonly string[]).includes(value);
+}
+
+export interface QuestionDefinition {
+ /** One answer (radio) or any number (checkbox). */
+ kind: "single" | "multi";
+ /** Q1 is the one question that cannot be passed. */
+ skippable: boolean;
+ /** The answer values, in the order they are offered. */
+ options: readonly string[];
+}
+
+export const QUESTIONS: Readonly> =
+ Object.freeze({
+ who: {
+ kind: "single",
+ skippable: false,
+ options: ONBOARDING_RECORD_TARGETS,
+ },
+ areas: { kind: "multi", skippable: true, options: ONBOARDING_AREA_KEYS },
+ medication: {
+ kind: "single",
+ skippable: true,
+ options: ONBOARDING_MEDICATION_ANSWERS,
+ },
+ sources: {
+ kind: "multi",
+ skippable: true,
+ options: ONBOARDING_SOURCE_KEYS,
+ },
+ visit: {
+ kind: "single",
+ skippable: true,
+ options: ONBOARDING_VISIT_ANSWERS,
+ },
+ });
+
+/** The i18n key of one answer chip. */
+export function questionOptionLabelKey(
+ step: QuestionStepId,
+ option: string,
+): string {
+ return `onboarding.flow.${step}.options.${option}`;
+}
+
+/** The stored answers of one step, as the chips to pre-select. */
+export function questionPrefill(
+ step: QuestionStepId,
+ needs: OnboardingNeeds,
+): string[] {
+ switch (step) {
+ case "who":
+ return needs.recordTarget ? [needs.recordTarget] : [];
+ case "areas":
+ return [...needs.areas];
+ case "medication":
+ return needs.medication ? [needs.medication] : [];
+ case "sources":
+ return [...needs.sources];
+ case "visit":
+ return needs.visit ? [needs.visit] : [];
+ }
+}
+
+function only(
+ values: readonly T[],
+ selected: readonly string[],
+): T[] {
+ return selected.filter((value): value is T =>
+ (values as readonly string[]).includes(value),
+ );
+}
+
+/**
+ * The body for one answered step, or null when the selection cannot be an
+ * answer (Q1 with nothing chosen). Every arm names its field; a value that is
+ * not in the step's vocabulary is dropped rather than sent, so the strict
+ * server schema never sees a chip this file did not offer.
+ */
+export function questionAnswerBody(
+ step: QuestionStepId,
+ selected: readonly string[],
+): OnboardingAnswerInput | null {
+ switch (step) {
+ case "who": {
+ const [recordTarget] = only(ONBOARDING_RECORD_TARGETS, selected);
+ return recordTarget ? { step, recordTarget } : null;
+ }
+ case "areas":
+ return { step, areas: only(ONBOARDING_AREA_KEYS, selected) };
+ case "medication": {
+ const [medication] = only(ONBOARDING_MEDICATION_ANSWERS, selected);
+ return medication ? { step, medication } : null;
+ }
+ case "sources":
+ return { step, sources: only(ONBOARDING_SOURCE_KEYS, selected) };
+ case "visit": {
+ const [visit] = only(ONBOARDING_VISIT_ANSWERS, selected);
+ return visit ? { step, visit } : null;
+ }
+ }
+}
+
+/** The body that passes one skippable step without answering it. */
+export function questionSkipBody(
+ step: Exclude,
+): OnboardingAnswerInput {
+ return { step, status: "skipped" };
+}
+
+/**
+ * Q6 — which of the two unit questions this flow asks. Glucose needs a
+ * glucose unit, weight needs the metric/imperial preference; an area not
+ * ticked is not asked about, so a person who chose only glucose never sees
+ * "kg or lb".
+ */
+export function unitQuestionsFor(needs: OnboardingNeeds): {
+ glucose: boolean;
+ weight: boolean;
+} {
+ return {
+ glucose: needs.areas.includes("glucose"),
+ weight: needs.areas.includes("weight-body"),
+ };
+}
+
+/**
+ * The Q6 body. Only the questions that were asked reach the wire, and only
+ * with a chosen value: an omitted field leaves the account's own column
+ * alone, which is what "never re-ask a value the account holds" needs when
+ * a person confirms the one they already had.
+ */
+export function unitsAnswerBody(input: {
+ asked: { glucose: boolean; weight: boolean };
+ glucoseUnit: "mg/dL" | "mmol/L" | null;
+ unitPreference: "metric" | "imperial" | null;
+}): OnboardingAnswerInput {
+ const units: {
+ glucoseUnit?: "mg/dL" | "mmol/L";
+ unitPreference?: "metric" | "imperial";
+ } = {};
+ if (input.asked.glucose && input.glucoseUnit) {
+ units.glucoseUnit = input.glucoseUnit;
+ }
+ if (input.asked.weight && input.unitPreference) {
+ units.unitPreference = input.unitPreference;
+ }
+ return { step: "units", units };
+}
From 58bc91a0a398ddb2e9502bdc044b4c00aa48e2b5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 10 Sep 2026 15:19:48 +0200
Subject: [PATCH 05/21] Add the setup flow's confirm screen
What the answers switch on is read off the registry's derivation and
never computed here, so the list the person confirms is the list the
completion route writes. Units, language and time zone are shown once
beside it, changeable in Settings, never asked again.
The profile half keeps the baseline form and its per-field refusals:
the fields are seeded from the account so a value it holds is confirmed
rather than typed again, and a refused field still stops the flow. For
"someone I look after" the managed-profile form takes the profile's
place, because that record is created here, after the questions; an
account without a second factor can finish without it and create it
later under Settings.
Confirming posts the completion, passes the first-result step outright
when there is no task to offer, and goes where the step machine says.
---
messages/en.json | 21 ++
src/app/onboarding/[step]/page.tsx | 4 +
src/components/onboarding/baseline-form.tsx | 125 +++++-----
src/components/onboarding/confirm-screen.tsx | 221 ++++++++++++++++++
.../__tests__/height-entry-surfaces.test.tsx | 7 +-
.../access/managed-profile-create-form.tsx | 18 +-
.../__tests__/confirm-summary.test.ts | 68 ++++++
src/lib/onboarding/confirm-summary.ts | 52 +++++
8 files changed, 459 insertions(+), 57 deletions(-)
create mode 100644 src/components/onboarding/confirm-screen.tsx
create mode 100644 src/lib/onboarding/__tests__/confirm-summary.test.ts
create mode 100644 src/lib/onboarding/confirm-summary.ts
diff --git a/messages/en.json b/messages/en.json
index 551ea6af8..caa8d7be3 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -4719,6 +4719,27 @@
"metric": "kg and cm",
"imperial": "lb and ft/in"
}
+ },
+ "confirm": {
+ "title": "Here is what your answers switch on",
+ "body": "One look, then HealthLog is set up. Nothing here is final.",
+ "modulesTitle": "Modules",
+ "switchedOn": "Switched on: {modules}.",
+ "nothingExtra": "Your answers switch on no extra module; the core readings are always there.",
+ "alwaysOn": "Always on: {modules}, and the core readings (weight, blood pressure, pulse).",
+ "everythingElse": "Everything else stays one click away under",
+ "modulesLink": "Settings, Modules",
+ "glucoseUnit": "Blood glucose",
+ "unitPreference": "Units",
+ "language": "Language",
+ "timezone": "Time zone",
+ "changeInSettings": "Change these under Settings, Account",
+ "cta": "Confirm and continue",
+ "managedTitle": "The person you look after",
+ "managedBody": "Their record has no login of its own; you look after it from your account. A second factor on your account is required to create it.",
+ "managedCreate": "Create the profile and finish",
+ "managedLater": "You can also create it later under Settings, Shared access.",
+ "finishWithoutProfile": "Finish without the profile"
}
}
},
diff --git a/src/app/onboarding/[step]/page.tsx b/src/app/onboarding/[step]/page.tsx
index d4d0ded2a..fdb80a1c4 100644
--- a/src/app/onboarding/[step]/page.tsx
+++ b/src/app/onboarding/[step]/page.tsx
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import { OnboardingShell } from "@/components/onboarding/onboarding-shell";
+import { ConfirmScreen } from "@/components/onboarding/confirm-screen";
import { QuestionScreen } from "@/components/onboarding/question-screen";
import { UnitsScreen } from "@/components/onboarding/units-screen";
import { getServerTranslator } from "@/lib/i18n/server-translator";
@@ -98,5 +99,8 @@ function renderScreen(screen: OnboardingStepId, state: OnboardingStateDto) {
if (screen === "units") {
return ;
}
+ if (screen === "confirm") {
+ return ;
+ }
notFound();
}
diff --git a/src/components/onboarding/baseline-form.tsx b/src/components/onboarding/baseline-form.tsx
index 6bb195692..51589368e 100644
--- a/src/components/onboarding/baseline-form.tsx
+++ b/src/components/onboarding/baseline-form.tsx
@@ -2,8 +2,6 @@
import { useEffect, useState } from "react";
import Link from "next/link";
-import { useRouter } from "next/navigation";
-import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Loader2 } from "lucide-react";
@@ -14,14 +12,12 @@ import {
} from "@/components/onboarding/baseline-fields";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import {
- EMPTY_HEIGHT_DRAFT,
resolveHeightUnitAdapter,
type HeightDraft,
} from "@/lib/profile/height-unit-display";
import { useTranslations } from "@/lib/i18n/context";
-import { apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
+import { apiGet, apiPut } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
-import { queryKeys } from "@/lib/query-keys";
import {
AnamnesisCard,
buildAnamnesisAboutMeBody,
@@ -35,36 +31,36 @@ import {
} from "@/components/onboarding/baseline-form-utils";
/**
- * v1.4.25 W14b-Content — onboarding step 3 (baseline).
+ * The profile half of the confirm screen: display name, height, date of
+ * birth, sex — and the optional anamnesis card.
*
- * Captures the four profile fields the original v1.4.20 wizard
- * collected on its "About you" screen — display name, height, date of
- * birth, gender — but spread across a single mobile-friendly card
- * instead of the older grid layout. The legacy `/api/onboarding/complete`
- * endpoint is *not* used here: completion now flips on the new
- * `POST /api/onboarding/step` with `{ step: 4 }`. Profile fields are
- * persisted via `PUT /api/auth/profile`, the existing canonical write
- * path (see `applyProfileUpdate` in `src/lib/auth/profile-update.ts`).
+ * Values the account already holds are seeded into the fields, so a person
+ * confirms them once rather than typing them again (design spec §The
+ * questions: never re-ask a value the account holds). Profile fields are
+ * persisted through `PUT /api/auth/profile`, the canonical write path, and
+ * the answer is READ rather than assumed: a field the server declined is
+ * named under its own input and the flow stays put, so the account is never
+ * stamped as set up over a value that never landed (v1.38.14, iOS #97). See
+ * `baseline-form-utils.ts` for the outcome logic.
*
- * Submit flow on "Save and continue":
- * 1. PUT profile (empty fields are skipped). The write is
- * field-independent, so the answer is read rather than assumed:
- * a field the server declined is named on screen and the rest
- * still counts as saved; a submission where nothing landed keeps
- * the person on this step with the blocking field named. See
- * `baseline-form-utils.ts`.
- * 2. PUT /api/coach/about-me — only when the optional anamnesis card
- * (v1.17.1) was filled; preserves any existing `aboutMe` and
- * writes conditions / allergies encrypted at rest.
- * 3. POST step:4 — flips `onboardingCompletedAt` server-side and
- * clears the proxy cookie.
- * 4. router.push("/onboarding/4") — the done screen.
+ * Submit flow on "Confirm and continue":
+ * 1. PUT profile (untouched fields are left out). A refusal stops here.
+ * 2. PUT /api/coach/about-me — only when the anamnesis card was filled;
+ * preserves any existing `aboutMe` and writes conditions / allergies
+ * encrypted at rest.
+ * 3. `onConfirmed()` — the confirm screen's own completion, which derives
+ * the module map and moves on.
*
- * "Skip" advances without writing profile data; the wizard still
- * completes onboarding (the user can fill profile later from
- * Settings).
+ * "Skip" runs step 3 alone: the flow still completes, the profile can be
+ * filled later from Settings.
*/
+export interface BaselineInitialValues {
+ heightCm: number | null;
+ dateOfBirth: string | null;
+ gender: string | null;
+}
+
interface BaselineFormState {
displayName: string;
/**
@@ -77,21 +73,30 @@ interface BaselineFormState {
gender: string;
}
-const EMPTY_FORM: BaselineFormState = {
- displayName: "",
- height: EMPTY_HEIGHT_DRAFT,
- dateOfBirth: "",
- gender: "",
-};
-
-export function BaselineForm() {
+export function BaselineForm({
+ initial,
+ backHref,
+ onConfirmed,
+ confirming = false,
+}: {
+ initial: BaselineInitialValues;
+ backHref?: string;
+ /** The confirm screen's completion; awaited after a clean save. */
+ onConfirmed: () => Promise;
+ /** True while the completion the parent owns is in flight. */
+ confirming?: boolean;
+}) {
const { t } = useTranslations();
- const router = useRouter();
- const queryClient = useQueryClient();
const { preference } = useUnitDisplay();
const heightAdapter = resolveHeightUnitAdapter(preference);
- const [form, setForm] = useState(EMPTY_FORM);
+ const [form, setForm] = useState(() => ({
+ displayName: "",
+ height: heightAdapter.toDraft(initial.heightCm),
+ // The payload's ISO instant, as the date field's YYYY-MM-DD.
+ dateOfBirth: initial.dateOfBirth ? initial.dateOfBirth.slice(0, 10) : "",
+ gender: initial.gender ?? "",
+ }));
const [saving, setSaving] = useState(false);
// Refusals from the last save, one sentence per field. Cleared on
// every attempt so a slot never keeps a reason the server no longer
@@ -148,7 +153,7 @@ export function BaselineForm() {
}
async function advance(opts: { saveProfile: boolean }) {
- if (saving) return;
+ if (saving || confirming) return;
setSaving(true);
setFieldErrors({});
try {
@@ -188,9 +193,7 @@ export function BaselineForm() {
await apiPut("/api/coach/about-me", aboutMeBody);
}
}
- await apiPost("/api/onboarding/step", { step: 4 });
- await queryClient.invalidateQueries({ queryKey: queryKeys.auth() });
- router.push("/onboarding/4");
+ await onConfirmed();
} catch (err) {
toast.error(localizedApiError(err, t, "onboarding.errorGeneric"));
setSaving(false);
@@ -226,30 +229,44 @@ export function BaselineForm() {
disabled={saving}
/>
-
-
- {t("onboarding.shell.back")}
-
+
+ {backHref ? (
+
+
+ {t("onboarding.shell.back")}
+
+
+ ) : (
+
+ )}
advance({ saveProfile: false })}
- disabled={saving}
+ disabled={saving || confirming}
className="min-h-11 min-w-11"
+ data-slot="onboarding-skip"
>
{t("onboarding.shell.skip")}
advance({ saveProfile: true })}
- disabled={saving}
+ disabled={saving || confirming}
className="min-h-11 min-w-11"
+ data-slot="onboarding-next"
>
- {saving ? (
-
+ {saving || confirming ? (
+
) : null}
- {t("onboarding.baseline.saveCta")}
+ {t("onboarding.flow.confirm.cta")}
diff --git a/src/components/onboarding/confirm-screen.tsx b/src/components/onboarding/confirm-screen.tsx
new file mode 100644
index 000000000..6734638d4
--- /dev/null
+++ b/src/components/onboarding/confirm-screen.tsx
@@ -0,0 +1,221 @@
+"use client";
+
+import { useState } from "react";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import { toast } from "sonner";
+
+import { BaselineForm } from "@/components/onboarding/baseline-form";
+import { StepHeading } from "@/components/onboarding/step-heading";
+import {
+ screenHref,
+ useOnboardingAnswer,
+ useOnboardingComplete,
+} from "@/components/onboarding/use-onboarding-flow";
+import { ManagedProfileCreateForm } from "@/components/settings/access/managed-profile-create-form";
+import { Button } from "@/components/ui/button";
+import { useAuth } from "@/hooks/use-auth";
+import { localizedApiError } from "@/lib/api/localized-error";
+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 {
+ firstResultApplies,
+ nextScreen,
+ previousScreen,
+} from "@/lib/onboarding/wizard-steps";
+
+/**
+ * v1.39 (C2) — the confirm screen.
+ *
+ * Three things on one screen, all of them confirmations rather than
+ * questions (design spec §The screens, 3):
+ *
+ * 1. What the answers switch on — read off the registry's derivation
+ * (`confirmedModules`), never computed here, so the list confirmed is
+ * the list `POST /api/onboarding/complete` writes.
+ * 2. The preferences the account already holds: units, language, timezone.
+ * Shown once, changeable in Settings, never asked again.
+ * 3. The profile. For a record of one's own, the baseline fields (height,
+ * date of birth, sex, display name) with their per-field refusals; for
+ * "someone I look after", the managed-profile form instead, because
+ * that record is created HERE, after the questions, per the
+ * maintainer's answer to spec question 2.
+ *
+ * Confirming posts the completion — which derives the module map once and
+ * stamps the account past the first-run redirect — and then goes wherever
+ * the step machine says: the first-result screen when there is a task to
+ * offer, otherwise done. When there is no task, the first-result step is
+ * passed explicitly so the ledger reads as settled rather than owed.
+ */
+export function ConfirmScreen({ state }: { state: OnboardingStateDto }) {
+ const { t, locale } = useTranslations();
+ const router = useRouter();
+ const { user } = useAuth();
+ const complete = useOnboardingComplete();
+ const answer = useOnboardingAnswer();
+ const [finishing, setFinishing] = useState(false);
+
+ const needs = state.needs;
+ const forSomeoneElse = needs.recordTarget === "someone-else";
+ const { chosen, alwaysOn } = confirmedModules(needs);
+ const back = previousScreen(state, "confirm");
+
+ const glucoseUnit = needs.units.glucoseUnit ?? user?.glucoseUnit ?? null;
+ const unitPreference =
+ needs.units.unitPreference ?? user?.unitPreference ?? "metric";
+
+ async function finish() {
+ if (finishing) return;
+ setFinishing(true);
+ try {
+ const { onboarding } = await complete.mutateAsync();
+ 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"));
+ } catch (err) {
+ toast.error(localizedApiError(err, t, "onboarding.errorGeneric"));
+ setFinishing(false);
+ }
+ }
+
+ const moduleNames = (keys: readonly (keyof typeof MODULE_REGISTRY)[]) =>
+ keys.map((key) => t(MODULE_REGISTRY[key].labelKey)).join(", ");
+
+ return (
+
+
+
+
+
+ {t("onboarding.flow.confirm.modulesTitle")}
+
+
+ {chosen.length > 0
+ ? t("onboarding.flow.confirm.switchedOn", {
+ modules: moduleNames(chosen),
+ })
+ : t("onboarding.flow.confirm.nothingExtra")}
+
+
+ {t("onboarding.flow.confirm.alwaysOn", {
+ modules: moduleNames(alwaysOn),
+ })}
+
+
+ {t("onboarding.flow.confirm.everythingElse")}{" "}
+
+ {t("onboarding.flow.confirm.modulesLink")}
+
+
+
+
+
+ {glucoseUnit ? (
+ <>
+
+ {t("onboarding.flow.confirm.glucoseUnit")}
+
+ {glucoseUnit}
+ >
+ ) : null}
+
+ {t("onboarding.flow.confirm.unitPreference")}
+
+ {t(`onboarding.flow.units.options.${unitPreference}`)}
+
+ {t("onboarding.flow.confirm.language")}
+
+ {localeLabels[locale]}
+ {user?.timezone ? (
+ <>
+
+ {t("onboarding.flow.confirm.timezone")}
+
+ {user.timezone}
+ >
+ ) : null}
+
+
+ {t("onboarding.flow.confirm.changeInSettings")}
+
+
+
+
+ {forSomeoneElse ? (
+
+
+
+ {t("onboarding.flow.confirm.managedTitle")}
+
+
+ {t("onboarding.flow.confirm.managedBody")}
+
+
+
void finish()}
+ />
+ {t("onboarding.flow.confirm.managedLater")}
+
+ {back ? (
+
+
+ {t("onboarding.shell.back")}
+
+
+ ) : (
+
+ )}
+ void finish()}
+ disabled={finishing}
+ className="min-h-11"
+ data-slot="onboarding-finish-without-profile"
+ >
+ {t("onboarding.flow.confirm.finishWithoutProfile")}
+
+
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/src/components/profile/__tests__/height-entry-surfaces.test.tsx b/src/components/profile/__tests__/height-entry-surfaces.test.tsx
index 97af6e93a..86661e422 100644
--- a/src/components/profile/__tests__/height-entry-surfaces.test.tsx
+++ b/src/components/profile/__tests__/height-entry-surfaces.test.tsx
@@ -73,7 +73,12 @@ function renderUnder(
const SURFACES = [
{
name: "onboarding baseline step",
- node:
,
+ node: (
+
{}}
+ />
+ ),
firstInputId: "ob-baseline-height",
metricLabel: "Height (cm)",
},
diff --git a/src/components/settings/access/managed-profile-create-form.tsx b/src/components/settings/access/managed-profile-create-form.tsx
index 9b6e91606..ee62c9a2e 100644
--- a/src/components/settings/access/managed-profile-create-form.tsx
+++ b/src/components/settings/access/managed-profile-create-form.tsx
@@ -15,6 +15,7 @@ import { useTranslations } from "@/lib/i18n/context";
import {
useCreateManagedProfile,
type ManagedProfileGender,
+ type ManagedProfileView,
} from "@/lib/queries/use-managed-profiles";
import {
DEFAULT_TIMEZONE,
@@ -73,7 +74,19 @@ import {
* invitation form does, rather than the generic "could not be created", which
* would send somebody looking for a problem with what they typed.
*/
-export function ManagedProfileCreateForm() {
+export function ManagedProfileCreateForm({
+ submitLabel,
+ onCreated,
+}: {
+ /** The button's label when the form sits somewhere other than the panel. */
+ submitLabel?: string;
+ /**
+ * v1.39 (C2) — fired after a successful creation, with the record. The
+ * setup flow's confirm screen mounts this form and continues from here;
+ * the sharing panel leaves it unset and shows the confirmation line.
+ */
+ onCreated?: (profile: ManagedProfileView) => void;
+} = {}) {
const { t, locale: actorLocale } = useTranslations();
const create = useCreateManagedProfile();
@@ -116,6 +129,7 @@ export function ManagedProfileCreateForm() {
setGender(null);
setTimezone(browserTimezone());
setCreated(profile.displayName ?? trimmedName);
+ onCreated?.(profile);
},
// Nothing is cleared here, on purpose: a refused creation is one the
// person is being asked to retry, and a form that emptied itself
@@ -258,7 +272,7 @@ export function ManagedProfileCreateForm() {
>
{create.isPending
? t("recordSharing.managed.creating")
- : t("recordSharing.managed.create")}
+ : (submitLabel ?? t("recordSharing.managed.create"))}
{error && (
diff --git a/src/lib/onboarding/__tests__/confirm-summary.test.ts b/src/lib/onboarding/__tests__/confirm-summary.test.ts
new file mode 100644
index 000000000..3ced0603a
--- /dev/null
+++ b/src/lib/onboarding/__tests__/confirm-summary.test.ts
@@ -0,0 +1,68 @@
+/**
+ * v1.39 (C2) — the confirm screen's list is the registry's derivation and
+ * nothing else.
+ */
+import { describe, expect, it } from "vitest";
+
+import {
+ deriveOnboardingModuleDefaults,
+ ONBOARDING_ALWAYS_ON_MODULES,
+ OWNED_MODULE_KEYS,
+} from "@/lib/modules/registry";
+
+import { confirmedModules } from "../confirm-summary";
+import { emptyOnboardingNeeds } from "../needs";
+
+describe("confirmedModules", () => {
+ it("lists exactly what the derivation switches on, the always-on trio aside", () => {
+ const needs = {
+ ...emptyOnboardingNeeds(),
+ recordTarget: "me" as const,
+ areas: ["glucose" as const, "sleep" as const],
+ medication: "yes" as const,
+ visit: "within-a-month" as const,
+ };
+ const derived = deriveOnboardingModuleDefaults(needs);
+ const expected = OWNED_MODULE_KEYS.filter(
+ (key) =>
+ derived.preferences[key] && !ONBOARDING_ALWAYS_ON_MODULES.includes(key),
+ );
+ const { chosen, alwaysOn } = confirmedModules(needs);
+ expect(chosen).toEqual(expected);
+ expect(chosen).toContain("medications");
+ expect(chosen).toContain("doctorReport");
+ // Registry order, so the two lists read in the same order everywhere.
+ expect(alwaysOn).toEqual(
+ OWNED_MODULE_KEYS.filter((key) =>
+ ONBOARDING_ALWAYS_ON_MODULES.includes(key),
+ ),
+ );
+ });
+
+ it("names cycle when the area was ticked, though the map never owns it", () => {
+ const { chosen } = confirmedModules({
+ ...emptyOnboardingNeeds(),
+ recordTarget: "me",
+ areas: ["cycle"],
+ });
+ expect(chosen).toContain("cycle");
+ });
+
+ it("reads a skipped question as the route does — nothing switched on for it", () => {
+ const { chosen } = confirmedModules({
+ ...emptyOnboardingNeeds(),
+ recordTarget: "me",
+ });
+ expect(chosen).not.toContain("medications");
+ expect(chosen).not.toContain("doctorReport");
+ });
+
+ it("switches on the immunization log for a record somebody else runs", () => {
+ expect(
+ confirmedModules({
+ ...emptyOnboardingNeeds(),
+ recordTarget: "someone-else",
+ }).chosen,
+ ).toContain("vaccinations");
+ });
+});
diff --git a/src/lib/onboarding/confirm-summary.ts b/src/lib/onboarding/confirm-summary.ts
new file mode 100644
index 000000000..1dfb28c9b
--- /dev/null
+++ b/src/lib/onboarding/confirm-summary.ts
@@ -0,0 +1,52 @@
+/**
+ * v1.39 (C2) — what the confirm screen says is switched on.
+ *
+ * A READ of the registry's derivation, never a second mapping: the list the
+ * person confirms is `deriveOnboardingModuleDefaults` applied to the same
+ * answers `POST /api/onboarding/complete` will apply it to, with the same
+ * conservative defaults for a skipped question. A wizard that computed its
+ * own list would drift from the one the route actually writes, and the
+ * confirm screen would then be confirming something else.
+ *
+ * What the route writes on top of this — the merge that never retracts a
+ * module switched on by hand or one holding data — only ever ADDS to the
+ * list, which is why the sentence beside it says "everything else stays
+ * under Settings" rather than "is off".
+ */
+import {
+ deriveOnboardingModuleDefaults,
+ MODULE_KEYS,
+ ONBOARDING_ALWAYS_ON_MODULES,
+ type ModuleKey,
+} from "@/lib/modules/registry";
+
+import type { OnboardingNeeds } from "./needs";
+
+export interface ConfirmedModules {
+ /** What the answers switch on, in registry order — the always-on trio aside. */
+ chosen: ModuleKey[];
+ /** On whatever the answers say. */
+ alwaysOn: ModuleKey[];
+}
+
+export function confirmedModules(needs: OnboardingNeeds): ConfirmedModules {
+ const derived = deriveOnboardingModuleDefaults({
+ // Q1 is required and a confirm screen is never reached without it; the
+ // fallback only keeps the type total.
+ recordTarget: needs.recordTarget ?? "me",
+ areas: needs.areas,
+ // The same defaults the route applies to a skipped question.
+ medication: needs.medication ?? "no",
+ visit: needs.visit ?? "no",
+ });
+ const on = new Set();
+ for (const [key, value] of Object.entries(derived.preferences)) {
+ if (value) on.add(key as ModuleKey);
+ }
+ if (derived.cycleTracking) on.add("cycle");
+ const alwaysOn = new Set(ONBOARDING_ALWAYS_ON_MODULES);
+ return {
+ chosen: MODULE_KEYS.filter((key) => on.has(key) && !alwaysOn.has(key)),
+ alwaysOn: MODULE_KEYS.filter((key) => alwaysOn.has(key)),
+ };
+}
From 5a805e8d1752de18775296c94874958fc6e87333 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 10 Sep 2026 15:24:32 +0200
Subject: [PATCH 06/21] Add the setup flow's first-result screen
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Exactly one task, by the step machine's fixed priority: connect the
wearable named in Q4, add the first medication, or log one reading for
the first area. The measurement form and the medication wizard render
inline; a connection goes through the integrations page, because the
OAuth handshake cannot run inside this screen, and the screen watches
the status envelope until it comes back connected. On completion the
screen shows what the task produced — the reading on its tile, the
medication with its next dose, the connection with its first sync — and
records the result on the ledger with the server's own instant.
The offer is not recorded on arrival: somebody who leaves mid-task
resumes here rather than on the done screen.
---
messages/en.json | 31 +
src/app/onboarding/[step]/page.tsx | 4 +
.../onboarding/first-result-screen.tsx | 528 ++++++++++++++++++
.../__tests__/first-result-config.test.ts | 40 ++
src/lib/onboarding/first-result-config.ts | 65 +++
src/lib/onboarding/wizard-steps.ts | 2 +-
6 files changed, 669 insertions(+), 1 deletion(-)
create mode 100644 src/components/onboarding/first-result-screen.tsx
create mode 100644 src/lib/onboarding/__tests__/first-result-config.test.ts
create mode 100644 src/lib/onboarding/first-result-config.ts
diff --git a/messages/en.json b/messages/en.json
index caa8d7be3..68091e25d 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -4740,6 +4740,37 @@
"managedCreate": "Create the profile and finish",
"managedLater": "You can also create it later under Settings, Shared access.",
"finishWithoutProfile": "Finish without the profile"
+ },
+ "first-result": {
+ "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.",
+ "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."
+ },
+ "add-medication": {
+ "title": "Add your first medication",
+ "body": "Name, dose and schedule; the reminder follows the schedule you set.",
+ "howTo": "The medication is added in a few short steps. You can add more later on the medications page.",
+ "cta": "Add a medication",
+ "done": "{name} is added.",
+ "doneUnnamed": "Your first medication is added.",
+ "nextDose": "Next dose: {when}.",
+ "reminders": "Reminders follow the schedule you set."
+ },
+ "log-reading": {
+ "title": "Enter one reading: {target}",
+ "body": "One reading puts the first value on your dashboard. The trend draws itself from the fifth.",
+ "done": "Your first reading is on its tile.",
+ "doneElsewhere": "Your first entry for {target} is recorded.",
+ "elsewhere": "{target} has its own page. Open it, make the first entry, then come back and mark it done.",
+ "open": "Open {target}",
+ "markDone": "I have made the entry"
+ }
}
}
},
diff --git a/src/app/onboarding/[step]/page.tsx b/src/app/onboarding/[step]/page.tsx
index fdb80a1c4..4dd9f08df 100644
--- a/src/app/onboarding/[step]/page.tsx
+++ b/src/app/onboarding/[step]/page.tsx
@@ -3,6 +3,7 @@ import { notFound, redirect } from "next/navigation";
import { OnboardingShell } from "@/components/onboarding/onboarding-shell";
import { ConfirmScreen } from "@/components/onboarding/confirm-screen";
+import { FirstResultScreen } from "@/components/onboarding/first-result-screen";
import { QuestionScreen } from "@/components/onboarding/question-screen";
import { UnitsScreen } from "@/components/onboarding/units-screen";
import { getServerTranslator } from "@/lib/i18n/server-translator";
@@ -102,5 +103,8 @@ function renderScreen(screen: OnboardingStepId, state: OnboardingStateDto) {
if (screen === "confirm") {
return ;
}
+ if (screen === "first-result") {
+ return ;
+ }
notFound();
}
diff --git a/src/components/onboarding/first-result-screen.tsx b/src/components/onboarding/first-result-screen.tsx
new file mode 100644
index 000000000..93d7c96e5
--- /dev/null
+++ b/src/components/onboarding/first-result-screen.tsx
@@ -0,0 +1,528 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import { useQuery } from "@tanstack/react-query";
+import { toast } from "sonner";
+import { CheckCircle2, Loader2 } from "lucide-react";
+
+import { MeasurementForm } from "@/components/measurements/measurement-form";
+import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/components/measurements/measurement-list-meta";
+import { MedicationWizardDialog } from "@/components/medications/wizard/medication-wizard-dialog";
+import { StepActions } from "@/components/onboarding/step-actions";
+import { StepHeading } from "@/components/onboarding/step-heading";
+import {
+ screenHref,
+ useOnboardingAnswer,
+} from "@/components/onboarding/use-onboarding-flow";
+import {
+ pickStatus,
+ useIntegrationStatuses,
+ type IntegrationKey,
+} from "@/components/settings/integrations/shared";
+import { Button } from "@/components/ui/button";
+import { useAuth } from "@/hooks/use-auth";
+import { useUnitDisplay } from "@/hooks/use-unit-display";
+import { apiGet } from "@/lib/api/api-fetch";
+import { localizedApiError } from "@/lib/api/localized-error";
+import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose";
+import { useFormatters, useTranslations } from "@/lib/i18n/context";
+import type { OnboardingAreaKey } from "@/lib/modules/registry";
+import {
+ AREA_PAGE_HREF,
+ AREA_READING_TARGETS,
+ SOURCE_INTEGRATION,
+} from "@/lib/onboarding/first-result-config";
+import type { OnboardingStateDto } from "@/lib/onboarding/needs";
+import { questionOptionLabelKey } from "@/lib/onboarding/question-config";
+import {
+ chooseFirstResultTask,
+ isBrowserConnectableSource,
+ previousScreen,
+ type BrowserConnectableSource,
+} from "@/lib/onboarding/wizard-steps";
+import { queryKeys } from "@/lib/query-keys";
+
+/**
+ * v1.39 (C2) — the one task the flow ends on.
+ *
+ * Exactly one of three, chosen by the step machine's fixed priority: connect
+ * the wearable named in Q4, add the first medication, or log one reading for
+ * the first Q2 area. The form is inline where a form exists (the measurement
+ * form, the medication wizard); a connection goes through the integrations
+ * page, because the OAuth handshake cannot run inside this screen, and this
+ * screen watches the status envelope for it to come back connected.
+ *
+ * On completion the screen shows what the task produced — the reading on its
+ * tile, the medication with its next dose, the connection with "first sync in
+ * progress" — and records the result on the ledger with the server's own
+ * instant. The offer itself is not recorded on arrival: a person who leaves
+ * mid-task then resumes HERE rather than on the done screen, and the ledger
+ * reads as owed rather than as an offer nobody acted on.
+ */
+type Offer = NonNullable>;
+
+export function FirstResultScreen({ state }: { state: OnboardingStateDto }) {
+ const { t } = useTranslations();
+ const router = useRouter();
+ const answer = useOnboardingAnswer();
+ const offer = chooseFirstResultTask(state.needs);
+ const back = previousScreen(state, "first-result");
+
+ const [completed, setCompleted] = useState(
+ state.firstResult?.task === offer?.task &&
+ state.firstResult?.completedAt !== null,
+ );
+ const [resultTarget, setResultTarget] = useState(
+ state.firstResult?.target ?? null,
+ );
+
+ async function complete(target: string | null) {
+ if (!offer) return;
+ try {
+ await answer.mutateAsync({
+ step: "first-result",
+ firstResult: { task: offer.task, target, completed: true },
+ });
+ setResultTarget(target);
+ setCompleted(true);
+ } catch (err) {
+ toast.error(localizedApiError(err, t, "onboarding.errorGeneric"));
+ }
+ }
+
+ async function skip() {
+ try {
+ await answer.mutateAsync({ step: "first-result", status: "skipped" });
+ router.push(screenHref("done"));
+ } catch (err) {
+ toast.error(localizedApiError(err, t, "onboarding.errorGeneric"));
+ }
+ }
+
+ // The page guard only renders this screen while the machine puts it in the
+ // order, so an offer is always there; the arm keeps the type total.
+ useEffect(() => {
+ if (!offer) router.replace(screenHref("done"));
+ }, [offer, router]);
+ if (!offer) return null;
+
+ return (
+
+
+
+
+
+ void skip()}
+ onNext={completed ? () => router.push(screenHref("done")) : undefined}
+ pending={answer.isPending}
+ />
+
+ );
+}
+
+function targetLabel(
+ t: (key: string, params?: Record) => string,
+ offer: Offer,
+): string {
+ if (!offer.target) return "";
+ if (offer.task === "connect-source") {
+ return t(questionOptionLabelKey("sources", offer.target));
+ }
+ return t(questionOptionLabelKey("areas", offer.target));
+}
+
+function TaskBody({
+ offer,
+ completed,
+ resultTarget,
+ onComplete,
+}: {
+ offer: Offer;
+ completed: boolean;
+ resultTarget: string | null;
+ onComplete: (target: string | null) => Promise;
+}) {
+ switch (offer.task) {
+ case "connect-source":
+ return offer.target && isBrowserConnectableSource(offer.target) ? (
+
+ ) : null;
+ case "add-medication":
+ return (
+
+ );
+ case "log-reading":
+ return offer.target ? (
+
+ ) : null;
+ }
+}
+
+/* ── connect a source ──────────────────────────────────────────────────── */
+
+function ConnectSourceTask({
+ source,
+ completed,
+ onComplete,
+}: {
+ source: BrowserConnectableSource;
+ completed: boolean;
+ onComplete: (target: string | null) => Promise;
+}) {
+ const { t } = useTranslations();
+ 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";
+
+ // 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.
+ const recorded = useRef(completed);
+ useEffect(() => {
+ if (connected && !recorded.current) {
+ recorded.current = true;
+ void onComplete(source);
+ }
+ }, [connected, onComplete, source]);
+
+ if (completed || connected) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+ {t("onboarding.flow.first-result.connect-source.howTo", {
+ target: label,
+ })}
+
+
+
+ {t("onboarding.flow.first-result.connect-source.cta", {
+ target: label,
+ })}
+
+
+
+ {statuses.isLoading
+ ? t("onboarding.flow.first-result.connect-source.checking")
+ : t("onboarding.flow.first-result.connect-source.waiting")}
+
+
+ );
+}
+
+/* ── add a medication ──────────────────────────────────────────────────── */
+
+interface MedicationDetail {
+ name: string;
+ nextDueAt: string | null;
+}
+
+function AddMedicationTask({
+ completed,
+ medicationId,
+ onComplete,
+}: {
+ completed: boolean;
+ medicationId: string | null;
+ onComplete: (target: string | null) => Promise;
+}) {
+ const { t } = useTranslations();
+ const fmt = useFormatters();
+ const [open, setOpen] = useState(false);
+
+ const detail = useQuery({
+ queryKey: queryKeys.medicationDetail(medicationId ?? ""),
+ queryFn: () => apiGet(`/api/medications/${medicationId}`),
+ enabled: completed && medicationId !== null,
+ });
+
+ if (completed) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+ {t("onboarding.flow.first-result.add-medication.howTo")}
+
+
setOpen(true)}
+ data-slot="onboarding-add-medication"
+ >
+ {t("onboarding.flow.first-result.add-medication.cta")}
+
+
{
+ setOpen(false);
+ void onComplete(id);
+ }}
+ />
+
+ );
+}
+
+/* ── log one reading ───────────────────────────────────────────────────── */
+
+function LogReadingTask({
+ area,
+ completed,
+ onComplete,
+}: {
+ area: OnboardingAreaKey;
+ completed: boolean;
+ onComplete: (target: string | null) => Promise;
+}) {
+ const { t } = useTranslations();
+ const target = AREA_READING_TARGETS[area];
+ const href = AREA_PAGE_HREF[area];
+ const label = t(questionOptionLabelKey("areas", area));
+
+ if (completed && target) {
+ return ;
+ }
+ if (completed) {
+ return (
+
+ );
+ }
+
+ if (target) {
+ return (
+
+ void onComplete(area)}
+ />
+
+ );
+ }
+
+ // An area with its own surface: point there, and let the person say when
+ // it is done — nothing here can see a mood entry or an episode land.
+ return (
+
+
+ {t("onboarding.flow.first-result.log-reading.elsewhere", {
+ target: label,
+ })}
+
+
+
+
+ {t("onboarding.flow.first-result.log-reading.open", {
+ target: label,
+ })}
+
+
+ void onComplete(area)}
+ data-slot="onboarding-reading-done"
+ >
+ {t("onboarding.flow.first-result.log-reading.markDone")}
+
+
+
+ );
+}
+
+interface MeasurementRow {
+ type: string;
+ value: number;
+ measuredAt: string;
+}
+
+function LatestReadingTile({
+ area,
+ storedTypes,
+}: {
+ area: OnboardingAreaKey;
+ storedTypes: readonly string[];
+}) {
+ const { t } = useTranslations();
+ const fmt = useFormatters();
+ const { user } = useAuth();
+ const units = useUnitDisplay();
+ const glucoseUnit = resolveGlucoseUnit(user?.glucoseUnit);
+
+ const latest = useQuery({
+ queryKey: queryKeys.onboardingLatestReading(area),
+ queryFn: async () =>
+ Promise.all(
+ storedTypes.map((type) =>
+ apiGet(
+ `/api/measurements?type=${encodeURIComponent(type)}&limit=1`,
+ ).then((rows) => rows[0] ?? null),
+ ),
+ ),
+ });
+
+ const rows = (latest.data ?? []).filter(
+ (row): row is MeasurementRow => row !== null,
+ );
+
+ if (latest.isPending) {
+ return (
+
+
+ {t("common.loading")}
+
+ );
+ }
+
+ if (rows.length === 0) {
+ return (
+
+ );
+ }
+
+ let value: string;
+ let unit: string;
+ if (area === "blood-pressure") {
+ const sys = rows.find((r) => r.type === "BLOOD_PRESSURE_SYS");
+ const dia = rows.find((r) => r.type === "BLOOD_PRESSURE_DIA");
+ value = `${sys ? fmt.integer(sys.value) : "–"}/${dia ? fmt.integer(dia.value) : "–"}`;
+ unit = "mmHg";
+ } else if (area === "glucose") {
+ const row = rows[0];
+ value = fmt.number(
+ convertGlucose(row.value, glucoseUnit),
+ glucoseUnit === "mmol/L" ? 1 : 0,
+ );
+ unit = glucoseUnit;
+ } else if (area === "sleep") {
+ // Stored in minutes since v1.4.23.
+ const minutes = Math.round(rows[0].value);
+ value = `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, "0")}`;
+ unit = t("measurements.unitHours");
+ } else {
+ const row = rows[0];
+ value = fmt.number(
+ units.toDisplay(row.type, row.value),
+ units.decimalsFor(row.type),
+ );
+ unit = units.unitFor(row.type);
+ }
+ const label = t(MEASUREMENT_TYPE_LABEL_KEYS[rows[0].type] ?? "");
+
+ return (
+
+ );
+}
+
+/* ── the result ────────────────────────────────────────────────────────── */
+
+function ResultTile({ title, detail }: { title: string; detail?: string }) {
+ return (
+
+
+
+
+
+
{title}
+ {detail ?
{detail}
: null}
+
+
+ );
+}
diff --git a/src/lib/onboarding/__tests__/first-result-config.test.ts b/src/lib/onboarding/__tests__/first-result-config.test.ts
new file mode 100644
index 000000000..40e8aa56f
--- /dev/null
+++ b/src/lib/onboarding/__tests__/first-result-config.test.ts
@@ -0,0 +1,40 @@
+/**
+ * v1.39 (C2) — every Q2 area and every connectable Q4 source has a target
+ * on a surface that exists, so the first-result screen can never be handed
+ * a task it cannot render.
+ */
+import { describe, expect, it } from "vitest";
+
+import { ONBOARDING_AREA_KEYS } from "@/lib/modules/registry";
+import { NAV_DESTINATIONS } from "@/components/layout/nav-model";
+
+import {
+ AREA_PAGE_HREF,
+ AREA_READING_TARGETS,
+ SOURCE_INTEGRATION,
+} from "../first-result-config";
+import { BROWSER_CONNECTABLE_SOURCES } from "../wizard-steps";
+
+describe("first-result targets", () => {
+ it("covers every area with exactly one of a form or a page", () => {
+ for (const area of ONBOARDING_AREA_KEYS) {
+ const hasForm = area in AREA_READING_TARGETS;
+ const hasPage = area in AREA_PAGE_HREF;
+ expect(hasForm !== hasPage, area).toBe(true);
+ }
+ });
+
+ it("points every linked area at a destination the navigation knows", () => {
+ const hrefs = new Set(NAV_DESTINATIONS.map((d) => d.href));
+ for (const href of Object.values(AREA_PAGE_HREF)) {
+ expect(hrefs.has(href), href).toBe(true);
+ }
+ });
+
+ it("names an integration for every source the browser can connect", () => {
+ for (const source of BROWSER_CONNECTABLE_SOURCES) {
+ expect(SOURCE_INTEGRATION[source].anchor).toBeTruthy();
+ expect(SOURCE_INTEGRATION[source].statusKey).toBeTruthy();
+ }
+ });
+});
diff --git a/src/lib/onboarding/first-result-config.ts b/src/lib/onboarding/first-result-config.ts
new file mode 100644
index 000000000..5b10a99bc
--- /dev/null
+++ b/src/lib/onboarding/first-result-config.ts
@@ -0,0 +1,65 @@
+/**
+ * v1.39 (C2) — what each first-result task needs to render, as data.
+ *
+ * The task itself is chosen by the step machine (`chooseFirstResultTask`);
+ * this file says what a task's target maps to on the surfaces that already
+ * exist: which measurement form and which stored types a Q2 area means,
+ * which integration a Q4 source is, and which page carries an area the
+ * measurement form cannot log.
+ */
+import type { OnboardingAreaKey } from "@/lib/modules/registry";
+
+import type { BrowserConnectableSource } from "./wizard-steps";
+
+export interface AreaReadingTarget {
+ /** The `defaultType` the measurement form opens on. */
+ formType: string;
+ /** The stored types the reading lands as, read back for the result tile. */
+ storedTypes: readonly string[];
+}
+
+/**
+ * The areas one reading in the measurement form can answer. Blood pressure
+ * is one form mode over two stored rows; the rest are one type each. Mood,
+ * cycle, labs and illness have their own surfaces and are linked instead
+ * (`AREA_PAGE_HREF`).
+ */
+export const AREA_READING_TARGETS: Readonly<
+ Partial>
+> = Object.freeze({
+ "blood-pressure": {
+ formType: "BLOOD_PRESSURE",
+ storedTypes: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"],
+ },
+ "weight-body": { formType: "WEIGHT", storedTypes: ["WEIGHT"] },
+ glucose: { formType: "BLOOD_GLUCOSE", storedTypes: ["BLOOD_GLUCOSE"] },
+ sleep: { formType: "SLEEP_DURATION", storedTypes: ["SLEEP_DURATION"] },
+ activity: { formType: "ACTIVITY_STEPS", storedTypes: ["ACTIVITY_STEPS"] },
+});
+
+/** Where an area the form cannot log is logged instead. */
+export const AREA_PAGE_HREF: Readonly<
+ Partial>
+> = Object.freeze({
+ mood: "/mood",
+ cycle: "/cycle",
+ labs: "/labs",
+ illness: "/illness",
+});
+
+/**
+ * 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
+> = 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" },
+});
diff --git a/src/lib/onboarding/wizard-steps.ts b/src/lib/onboarding/wizard-steps.ts
index 35bc14172..d53b2210c 100644
--- a/src/lib/onboarding/wizard-steps.ts
+++ b/src/lib/onboarding/wizard-steps.ts
@@ -54,7 +54,7 @@ export type BrowserConnectableSource =
(typeof BROWSER_CONNECTABLE_SOURCES)[number];
export function isBrowserConnectableSource(
- source: OnboardingSourceKey,
+ source: string,
): source is BrowserConnectableSource {
return (BROWSER_CONNECTABLE_SOURCES as readonly string[]).includes(source);
}
From b446911d7d6ede338567d8fbbe2d6d823331d96b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 10 Sep 2026 15:28:16 +0200
Subject: [PATCH 07/21] Add the setup flow's done screen
The dashboard is one click away, already ordered, and the checklist
opens on arrival. The tour is an offer here rather than an automatic
launch: pressing it sets the referrer the shell's launcher reads, and
nothing else does. Two record-shaped exits follow the answer to Q1: an
account that said "both" is offered the profile for the person it looks
after, and one that set up a record for somebody else is offered that
record through the same switch the account menu uses, so the first
entry lands where it belongs. The step is marked done on arrival, once.
---
messages/en.json | 5 +
src/app/onboarding/[step]/page.tsx | 3 +-
.../onboarding/__tests__/done-screen.test.tsx | 43 +++++-
src/components/onboarding/done-screen.tsx | 134 +++++++++++++++---
4 files changed, 159 insertions(+), 26 deletions(-)
diff --git a/messages/en.json b/messages/en.json
index 68091e25d..5534da01c 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -4771,6 +4771,11 @@
"open": "Open {target}",
"markDone": "I have made the entry"
}
+ },
+ "done": {
+ "tourCta": "Take a short tour",
+ "openRecord": "Open the record of {name}",
+ "createProfile": "Create the profile for the person you look after"
}
}
},
diff --git a/src/app/onboarding/[step]/page.tsx b/src/app/onboarding/[step]/page.tsx
index 4dd9f08df..73f4e8efe 100644
--- a/src/app/onboarding/[step]/page.tsx
+++ b/src/app/onboarding/[step]/page.tsx
@@ -3,6 +3,7 @@ import { notFound, redirect } from "next/navigation";
import { OnboardingShell } from "@/components/onboarding/onboarding-shell";
import { ConfirmScreen } from "@/components/onboarding/confirm-screen";
+import { DoneScreen } from "@/components/onboarding/done-screen";
import { FirstResultScreen } from "@/components/onboarding/first-result-screen";
import { QuestionScreen } from "@/components/onboarding/question-screen";
import { UnitsScreen } from "@/components/onboarding/units-screen";
@@ -106,5 +107,5 @@ function renderScreen(screen: OnboardingStepId, state: OnboardingStateDto) {
if (screen === "first-result") {
return ;
}
- notFound();
+ return ;
}
diff --git a/src/components/onboarding/__tests__/done-screen.test.tsx b/src/components/onboarding/__tests__/done-screen.test.tsx
index 72038c37b..628d87837 100644
--- a/src/components/onboarding/__tests__/done-screen.test.tsx
+++ b/src/components/onboarding/__tests__/done-screen.test.tsx
@@ -17,12 +17,22 @@ const aiProviderState = vi.hoisted(() => ({
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({ data: aiProviderState.data }),
+ useMutation: () => ({ mutate: () => {}, isPending: false }),
+ useQueryClient: () => ({ invalidateQueries: async () => {} }),
+}));
+
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ push: () => {}, replace: () => {} }),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ user: { id: "u1" } }),
}));
+vi.mock("@/hooks/use-account-switch", () => ({
+ useAccountSwitch: () => ({ mutate: () => {}, isPending: false }),
+}));
+
vi.mock("@/components/onboarding/tour-launcher", () => ({
setTourReferrer: () => {},
}));
@@ -42,12 +52,28 @@ vi.mock("next/link", () => ({
),
}));
+import {
+ defaultOnboardingSteps,
+ emptyOnboardingNeeds,
+ type OnboardingStateDto,
+} from "@/lib/onboarding/needs";
import { DoneScreen } from "../done-screen";
-function render() {
+function state(
+ recordTarget: OnboardingStateDto["needs"]["recordTarget"] = "me",
+): OnboardingStateDto {
+ return {
+ steps: defaultOnboardingSteps(),
+ needs: { ...emptyOnboardingNeeds(), recordTarget },
+ completedAt: "2026-09-10T08:00:00.000Z",
+ firstResult: null,
+ };
+}
+
+function render(recordTarget?: OnboardingStateDto["needs"]["recordTarget"]) {
return renderToStaticMarkup(
-
+
,
);
}
@@ -71,7 +97,18 @@ describe(" AI panel", () => {
const html = render();
expect(html).toContain('href="/settings/integrations"');
expect(html).toContain('href="/measurements"');
- expect(html).toContain('href="/"');
+ expect(html).toContain('data-slot="onboarding-open-dashboard"');
+ });
+
+ it("offers the tour rather than launching it", () => {
+ aiProviderState.data = undefined;
+ expect(render()).toContain('data-slot="onboarding-take-tour"');
+ });
+
+ it("offers the managed profile only to an answer that asked for it later", () => {
+ aiProviderState.data = undefined;
+ expect(render("both")).toContain('href="/settings/access"');
+ expect(render("me")).not.toContain('href="/settings/access"');
});
it("shows the shared-key note only when the operator key serves the user", () => {
diff --git a/src/components/onboarding/done-screen.tsx b/src/components/onboarding/done-screen.tsx
index bb8c56689..c4a2c43e2 100644
--- a/src/components/onboarding/done-screen.tsx
+++ b/src/components/onboarding/done-screen.tsx
@@ -1,25 +1,33 @@
"use client";
import Link from "next/link";
-import { useEffect, useState } from "react";
+import { useEffect, useRef, useState } from "react";
+import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import {
CheckCircle2,
+ Compass,
FileUp,
PlusCircle,
Plug,
Settings2,
Sparkles,
+ UserPlus,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { MedicalDisclaimer } from "@/components/common/medical-disclaimer";
import { SampleBriefingCard } from "@/components/onboarding/sample-briefing-card";
+import { useOnboardingAnswer } from "@/components/onboarding/use-onboarding-flow";
import { useAuth } from "@/hooks/use-auth";
+import { useAccountSwitch } from "@/hooks/use-account-switch";
import { setTourReferrer } from "@/components/onboarding/tour-launcher";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
+import { markChecklistExpanded } from "@/lib/onboarding/checklist-storage";
+import type { OnboardingStateDto } from "@/lib/onboarding/needs";
+import { accountLabel } from "@/lib/sharing/account-access-view";
interface AiProviderStatus {
/** Origin of the provider that would serve this user, if any. */
@@ -27,33 +35,70 @@ interface AiProviderStatus {
}
/**
- * v1.4.25 W14b-Content — onboarding step 4 (done).
+ * v1.39 (C2) — the setup flow's exit.
*
- * Confirms the user has finished onboarding. `onboardingCompletedAt`
- * was flipped server-side by the baseline form's `POST step:4` write,
- * so this page is purely presentational — no further mutation, no
- * cookie flips, no API calls.
+ * The dashboard is one click away, already ordered, and the checklist opens
+ * on arrival (design spec §The screens, 5). The tour is an OFFER here rather
+ * than an automatic launch: pressing "Take a short tour" sets the referrer
+ * the shell-level `` reads, and nothing else does, so somebody
+ * who wants the dashboard gets the dashboard.
*
- * The single CTA returns the user to the dashboard. The link goes
- * through `next/link` so the regular client-side navigation kicks in
- * (the proxy redirect was cleared with the same step-API write that
- * landed the user here).
+ * Two record-shaped exits ride the answer to Q1: "both" is offered the
+ * profile for the person they look after, and "someone I look after" — whose
+ * profile was created on the confirm screen — is offered that record, through
+ * the same switch the account menu uses, so their first entry lands in the
+ * right record.
*
- * v1.18.6 — on mount we write the per-user wizard-return marker so the
- * shell-level `` auto-opens the module tour immediately
- * on the next dashboard arrival (sequenced after the post-wizard
- * grace), instead of waiting out the 24 h fallback. Whichever exit the
- * user picks from this screen, the next `/` mount finds the marker.
+ * The step is marked done on arrival, once. Everything else on this screen
+ * is presentational: the module map was derived on confirm, the cookie
+ * cleared there too.
+ *
+ * The AI panel leads with the payoff (a static, clearly-labelled SAMPLE
+ * briefing — no model call, no egress, no consent), then the honest
+ * local-first ladder and the "useful without AI" release valve. Value-first,
+ * never a gate: every exit below stays, and setup is a single optional
+ * deep-link.
*/
-export function DoneScreen() {
+export function DoneScreen({ state }: { state: OnboardingStateDto }) {
const { t } = useTranslations();
+ const router = useRouter();
const { user } = useAuth();
const [sampleOpen, setSampleOpen] = useState(false);
+ const answer = useOnboardingAnswer();
+ const accountSwitch = useAccountSwitch();
+ // Mark the step once. The write is idempotent, but a strict-mode double
+ // mount would still send it twice; the ref keeps it to one.
+ const marked = useRef(false);
+ const donePending =
+ state.steps.find((step) => step.id === "done")?.status === "pending";
useEffect(() => {
+ if (!donePending || marked.current) return;
+ marked.current = true;
+ answer.mutate({ step: "done" });
+ // `answer` is a fresh object every render; the mutation it wraps is not.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [donePending]);
+
+ const recordTarget = state.needs.recordTarget;
+ const managedRecord =
+ recordTarget === "someone-else"
+ ? (user?.accountAccess?.accounts.find(
+ (entry) => entry.recordKind === "managed",
+ ) ?? null)
+ : null;
+
+ function openDashboard() {
+ markChecklistExpanded();
+ router.push("/");
+ }
+
+ function takeTour() {
if (user?.id) setTourReferrer(user.id);
- }, [user?.id]);
+ markChecklistExpanded();
+ router.push("/");
+ }
// The shared-key note is the ONLY honest divergence in the panel: on a
// deployment that ships an operator key, insights already work for the
@@ -208,7 +253,55 @@ export function DoneScreen() {
-
+
+ {t("onboarding.done.returnCta")}
+
+
+
+ {t("onboarding.flow.done.tourCta")}
+
+ {managedRecord ? (
+ accountSwitch.mutate(managedRecord.accountId)}
+ disabled={accountSwitch.isPending}
+ className="inline-flex min-h-11 items-center gap-2"
+ data-slot="onboarding-open-managed-record"
+ >
+
+ {t("onboarding.flow.done.openRecord", {
+ name: accountLabel(managedRecord),
+ })}
+
+ ) : null}
+ {recordTarget === "both" ? (
+
+
+
+ {t("onboarding.flow.done.createProfile")}
+
+
+ ) : null}
+
-
+
{t("onboarding.done.logCta")}
-
- {t("onboarding.done.returnCta")}
-
{/* 2026-07-17 UX-onboarding audit M5 — the module system (default-on
From a062610895689fb27e14008ab5d07225aaf4992c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 10 Sep 2026 15:32:35 +0200
Subject: [PATCH 08/21] Retire the five-step wizard's route, goals and columns
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The needs-based flow is the one setup flow now. POST /api/onboarding/step
and the six goal slugs go, and with them the two account columns the
wizard kept — its checkpoint and its goal list — through a migration
that drops them; onboarding_completed_at stays as the first-run
redirect's gate. The one durable effect the goals had, the dashboard
tile order, moves to the completion route: the answers now carry both
halves of Q2, which modules are on and what sits on top, and the seed
keeps the wizard's one contract of writing only while the layout column
is still unset.
The OpenAPI table loses the step path and the completion's description
says what the route does today; the demo-allowlist test names a
mutation that still exists.
---
docs/api/openapi.yaml | 180 +-------
.../0339_retire_wizard_columns/migration.sql | 11 +
prisma/schema.prisma | 82 ++--
.../proxy-demo-mode-allowlist.test.ts | 2 +-
src/app/api/onboarding/__tests__/step.test.ts | 399 ------------------
src/app/api/onboarding/complete/route.ts | 54 ++-
src/app/api/onboarding/step/route.ts | 244 -----------
src/lib/data-wipe/wipe-plan.ts | 2 -
.../__tests__/dashboard-seed.test.ts | 102 +++++
src/lib/onboarding/__tests__/goals.test.ts | 104 -----
src/lib/onboarding/dashboard-seed.ts | 102 +++++
src/lib/onboarding/goals.ts | 112 -----
src/lib/onboarding/needs.ts | 20 +-
src/lib/openapi/routes/onboarding.ts | 96 +----
src/lib/validations/onboarding.ts | 38 +-
tests/integration/settings-data-wipe.test.ts | 1 -
16 files changed, 330 insertions(+), 1219 deletions(-)
create mode 100644 prisma/migrations/0339_retire_wizard_columns/migration.sql
delete mode 100644 src/app/api/onboarding/__tests__/step.test.ts
delete mode 100644 src/app/api/onboarding/step/route.ts
create mode 100644 src/lib/onboarding/__tests__/dashboard-seed.test.ts
delete mode 100644 src/lib/onboarding/__tests__/goals.test.ts
create mode 100644 src/lib/onboarding/dashboard-seed.ts
delete mode 100644 src/lib/onboarding/goals.ts
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 45961ad43..f5800f9fd 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -16315,85 +16315,6 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
- /api/onboarding/step:
- post:
- tags:
- - Onboarding
- summary: Advance the onboarding wizard by one step
- description: >-
- Persists progress through the four-step wizard. The contract is strictly ordered: the submitted `step` must be
- exactly the stored step plus one, so a client cannot skip ahead or replay a step it already sent.
-
-
- Submitting step 4 completes onboarding — the completion instant is stamped in the same write, the proxy-readable
- pending cookie is cleared so the next navigation stops redirecting to `/onboarding`, and the stored goal
- selection seeds the dashboard layout. That seed is ONE-TIME and conditional: it only runs while the layout
- column is still unset, so a person who already arranged their tiles is never clobbered, and a concurrent layout
- save that lands first wins.
-
-
- The write is guarded on the state it validated, so two tabs submitting the same step do not both succeed —
- exactly one lands and the other gets 409.
-
-
- Rate-limited to 30 writes per 10 minutes per user, which is generous for a four-step flow and tight enough to
- defang a stuck retry loop. Every accepted call writes an audit row. Cookie or wildcard Bearer; `userId` is never
- read from the body.
- requestBody:
- required: true
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/OnboardingStepRequest"
- responses:
- "200":
- description: The stored step and completion stamp after the write.
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/OnboardingStepEnvelope"
- "401": *a1
- "404":
- description: The session's user row no longer exists. `meta.errorCode` = `onboarding.user.notFound`.
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/ErrorEnvelope"
- "409":
- description: "The write was refused and nothing changed. `meta.errorCode` distinguishes three cases:
- `onboarding.step.completed` — onboarding is already finished, so the wizard has nothing left to advance;
- `onboarding.step.outOfOrder` — the submitted step is not the stored step plus one, and the message names the
- current step; `onboarding.step.concurrent` — the row moved between the check and the write, which is what a
- second tab sees."
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/ErrorEnvelope"
- "413":
- description: Body exceeds 64 KiB.
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/ErrorEnvelope"
- "415":
- description: "`Content-Type` is not `application/json`."
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/ErrorEnvelope"
- "422":
- description: The body did not validate. `meta.errorCode` = `onboarding.step.invalid`. Single-message, not the
- multi-issue envelope — the per-field detail is not on the wire.
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/ErrorEnvelope"
- "429":
- description: More than 30 onboarding writes in 10 minutes. `meta.errorCode` = `onboarding.step.rateLimited`.
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/ErrorEnvelope"
/api/onboarding/complete:
post:
tags:
@@ -16404,33 +16325,24 @@ paths:
cookie so the next navigation stops redirecting to `/onboarding`.
- v1.39 — this is also the needs-based flow's confirm endpoint, and the two halves live side by side. For a record
- that FINISHED the questions — every one of them answered or deliberately passed — it derives that record's
- module map from the answers and applies it, ONCE: the derivation marker is what stops a second confirm
- re-applying the questionnaire over decisions taken in Settings since, and `POST /api/onboarding/restart` clears
- the marker when the person asks for the questions again. The merge is one-directional — a module the answers
- name is switched on, a module they do not name is switched off only where the record does not already carry an
- explicit on. The `cycle` key is not written here at all: it delegates to the cycle profile, which this route
- sets to `true` when the cycle area was chosen and never to `false`. A half-answered flow derives nothing and is
- not stamped as complete: the answers become a module map, and a flow somebody abandoned after the first screen
- would have every remaining question read as its conservative default. The same gate is what keeps the LEGACY
- wizard's completion out of the derivation, since that wizard never puts these questions.
+ v1.39 — this is the needs-based flow's confirm endpoint. For a record that FINISHED the questions — every one of
+ them answered or deliberately passed — it derives that record's module map from the answers and applies it,
+ ONCE: the derivation marker is what stops a second confirm re-applying the questionnaire over decisions taken in
+ Settings since, and `POST /api/onboarding/restart` clears the marker when the person asks for the questions
+ again. The merge is one-directional — a module the answers name is switched on, a module they do not name is
+ switched off only where the record does not already carry an explicit on and holds no data in that domain. The
+ `cycle` key is not written here at all: it delegates to the cycle profile, which this route sets to `true` when
+ the cycle area was chosen and never to `false`. In the same derivation the dashboard order is seeded from the
+ answers — the tiles of the chosen areas (and the medication tile for a schedule) move to the front and are made
+ visible — but only while the layout column is still unset, so a person who already arranged their tiles is never
+ clobbered. A half-answered flow derives nothing and is not stamped as complete.
- The response then carries `onboarding` beside `completed`; a caller that never entered the needs flow gets the
- fixed acknowledgement it always did.
-
-
- The older sibling of `POST /api/onboarding/step`. It is unconditional in a way the step route is not: it stamps
- the completion whatever the stored step is, it re-stamps on every call rather than refusing a second one, and it
- enforces no rate limit of its own. Its legacy half writes no audit row; the needs half writes one
- (`user.modules.update`) when it derives, because that is the same column the dedicated modules route audits. It
- also does NOT seed the dashboard from the goal selection — that only happens on the step route's completing
- call.
-
-
- Calling this mid-wizard makes every remaining `POST /api/onboarding/step` answer 409
- `onboarding.step.completed`, because that route's guarded update requires the completion stamp to still be null.
+ The response carries `onboarding` beside `completed`; a caller that never entered the needs flow gets the fixed
+ acknowledgement alone — it stamps the completion whatever the answers say, re-stamps on every call rather than
+ refusing a second one, and enforces no rate limit of its own. The legacy half writes no audit row; the needs
+ half writes one (`user.modules.update`) when it derives, because that is the same column the dedicated modules
+ route audits.
Cookie or wildcard Bearer; `userId` is never read from the body.
@@ -28003,30 +27915,6 @@ components:
properties: {}
additionalProperties: false
description: No input beyond the session. Send `{}`.
- OnboardingStepRequest:
- type: object
- properties:
- step:
- type: integer
- minimum: 1
- maximum: 4
- goals:
- maxItems: 6
- type: array
- items:
- type: string
- enum:
- - weight-management
- - bp-tracking
- - glucose-tracking
- - sleep-improvement
- - medication-compliance
- - general-wellness
- required:
- - step
- description: The wizard step being COMPLETED, 1–4. It must equal the stored step plus one — the server does not clamp or
- skip. `goals` rides the step-2 submit; every slug is checked against the closed set, so one unknown slug fails
- the whole request rather than being dropped.
OnboardingCompleteRequest:
type: object
properties:
@@ -63476,42 +63364,6 @@ components:
- completedAt
additionalProperties: false
description: The one task the flow offered at the end, and whether it produced its result.
- OnboardingStepEnvelope:
- type: object
- properties:
- data:
- $ref: "#/components/schemas/OnboardingStepResponse"
- error:
- type: "null"
- meta:
- type: object
- properties:
- requestId:
- type: string
- additionalProperties: false
- required:
- - data
- - error
- additionalProperties: false
- OnboardingStepResponse:
- type: object
- properties:
- step:
- type: integer
- minimum: -9007199254740991
- maximum: 9007199254740991
- description: The stored step after the write.
- onboardingCompletedAt:
- anyOf:
- - type: string
- format: date-time
- pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
- - type: "null"
- description: Non-null once step 4 landed.
- required:
- - step
- - onboardingCompletedAt
- additionalProperties: false
OnboardingCompleteEnvelope:
type: object
properties:
diff --git a/prisma/migrations/0339_retire_wizard_columns/migration.sql b/prisma/migrations/0339_retire_wizard_columns/migration.sql
new file mode 100644
index 000000000..4647080d1
--- /dev/null
+++ b/prisma/migrations/0339_retire_wizard_columns/migration.sql
@@ -0,0 +1,11 @@
+-- v1.39 (C2) — retire the five-step wizard's two columns.
+--
+-- `onboarding_step` held the wizard's checkpoint (0..4) and `onboarding_goals`
+-- the six goal slugs whose only durable effect was a one-time dashboard seed.
+-- The needs-based flow keeps its state on `onboarding_records` and seeds the
+-- dashboard from the answers there, so nothing reads or writes either column
+-- any more. `onboarding_completed_at` stays: it is the first-run redirect's
+-- gate and the needs flow keeps stamping it.
+ALTER TABLE "users"
+ DROP COLUMN IF EXISTS "onboarding_step",
+ DROP COLUMN IF EXISTS "onboarding_goals";
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 755ae4121..c95508580 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -385,29 +385,9 @@ model User {
// contract for every other self-hoster.
documentsAutoAiRead Boolean @default(false) @map("documents_auto_ai_read")
- // Onboarding
+ // Onboarding. The first-run redirect's gate; the needs-based setup flow
+ // (`OnboardingRecord`) keeps its own ledger and stamps this on confirm.
onboardingCompletedAt DateTime? @map("onboarding_completed_at")
- // v1.4.25 W14b — last persisted onboarding wizard step.
- // Encodes resume-from-where-you-left-off semantics for the rebuilt
- // multi-step wizard at `/onboarding/[step]`. Values mirror the
- // wizard's step indices:
- // 0 = welcome
- // 1 = goals
- // 2 = source
- // 3 = baseline
- // 4 = done (triggers `onboardingCompletedAt` flip on the same
- // round-trip — the column stays at 4 for completed users)
- // Null = legacy pre-W14b user; the wizard treats null as 0 on mount.
- onboardingStep Int @default(0) @map("onboarding_step")
- // v1.17.1 — goal slugs chosen during onboarding (GoalsChipPicker).
- // Seeds dashboard tile pinning + reminder suggestions; NOT a clinical
- // target store (targets are fully derived from height/age/gender +
- // clinical refs in /api/insights/targets). Empty [] = skipped / no
- // preference. The slug set is the closed enum
- // `ONBOARDING_GOAL_SLUGS` in src/lib/onboarding/goals.ts; a String[]
- // (not a relation) mirrors `insightsExcludeMetrics` precedent — the
- // set is small and fixed, never queried relationally.
- onboardingGoals String[] @default([]) @map("onboarding_goals")
// v1.4.15 Phase B5: tracks whether the user has finished (or
// explicitly dismissed) the spotlight tour that overlays the
// dashboard on first arrival. Distinct from the wizard at
@@ -1000,7 +980,7 @@ model User {
// record. 1:1, and keyed by record rather than by the person at the
// keyboard so a guardian running a managed profile's setup writes the
// child's row, not their own. See `OnboardingRecord`.
- onboardingRecord OnboardingRecord?
+ onboardingRecord OnboardingRecord?
// v1.15.20 — user-authored "about me" context for the AI surfaces
// (1:1, encrypted free text). See `UserHealthProfile`.
@@ -4713,14 +4693,14 @@ model AppSettings {
// id or an unreachable host all parse perfectly and deliver nothing.
/// Instant the last report left successfully. Consumer: the GlitchTip card's
/// badge, which is only green while this is inside the recent window.
- glitchtipLastOkAt DateTime? @map("glitchtip_last_ok_at")
+ glitchtipLastOkAt DateTime? @map("glitchtip_last_ok_at")
/// Instant the last attempt failed. Consumer: the GlitchTip card's status
/// line, which names the failure rather than showing a reassuring badge.
- glitchtipLastFailureAt DateTime? @map("glitchtip_last_failure_at")
+ glitchtipLastFailureAt DateTime? @map("glitchtip_last_failure_at")
/// Short classification of that failure (`invalid_dsn`, `http_403`,
/// `network`) — never a response body, which is a remote host's text.
/// Consumer: the same status line.
- glitchtipLastFailureReason String? @map("glitchtip_last_failure_reason")
+ glitchtipLastFailureReason String? @map("glitchtip_last_failure_reason")
// Medication reminder thresholds (minutes after windowEnd)
reminderLateMinutes Int @default(120) @map("reminder_late_minutes")
@@ -5340,7 +5320,7 @@ model MeasurementReminder {
/// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant.
deletedAt DateTime? @map("deleted_at")
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
/// Back-relation for the appointment arm: a PLANNED `Encounter` mints
/// exactly one ENCOUNTER-origin row and points at it. SetNull on the far
/// side, so deleting the reminder leaves the visit intact.
@@ -5624,15 +5604,15 @@ model CustomMetric {
/// snapshot of the metric's unit at write time (historical truth, mirroring the
/// LabResult per-row unit). Charts read these LIVE (no rollup tier).
model CustomMetricEntry {
- id String @id @default(cuid())
- userId String @map("user_id")
- customMetricId String @map("custom_metric_id")
+ id String @id @default(cuid())
+ userId String @map("user_id")
+ customMetricId String @map("custom_metric_id")
value Float
/// Unit snapshot from the metric at write time.
unit String
- measuredAt DateTime @map("measured_at")
+ measuredAt DateTime @map("measured_at")
note String?
- createdAt DateTime @default(now()) @map("created_at")
+ createdAt DateTime @default(now()) @map("created_at")
/// v1.37.20 — soft-delete tombstone, peer parity with Measurement /
/// MoodEntry (every other entry surface deletes recoverably with an undo
/// toast; custom-metric entries were the one hard delete). NULL = live.
@@ -6140,28 +6120,28 @@ model CoachReminder {
// must never land in the table as plaintext. A cleared profile keeps the
// row with a NULL payload so `updatedAt` documents the deletion instant.
model UserHealthProfile {
- id String @id @default(cuid())
- userId String @unique @map("user_id")
+ id String @id @default(cuid())
+ userId String @unique @map("user_id")
// Free text, hard-capped at 4 000 chars by the route's Zod schema
// BEFORE encryption. NULL = cleared / never written.
- aboutMeEncrypted Bytes? @map("about_me_encrypted")
+ aboutMeEncrypted Bytes? @map("about_me_encrypted")
// v1.16.0 — structured self-context companions to the free text.
// Short answers (each capped at 500 chars BEFORE encryption).
// Age/gender deliberately do NOT live here — they come from the User
// profile and are merged into the prompt block at assembly time so
// the data is never duplicated.
- conditionsEncrypted Bytes? @map("conditions_encrypted")
- allergiesEncrypted Bytes? @map("allergies_encrypted")
- coachFocusEncrypted Bytes? @map("coach_focus_encrypted")
+ conditionsEncrypted Bytes? @map("conditions_encrypted")
+ allergiesEncrypted Bytes? @map("allergies_encrypted")
+ coachFocusEncrypted Bytes? @map("coach_focus_encrypted")
// v1.16.0 — up to 3 clarifying questions the server derives after a
// save (AI-generated when a provider + budget allow, deterministic
// hints otherwise). Encrypted JSON string[]; the Coach composer
// renders them as tappable suggestion chips. NULL = none pending.
- pendingQuestionsEncrypted Bytes? @map("pending_questions_encrypted")
+ pendingQuestionsEncrypted Bytes? @map("pending_questions_encrypted")
// Closed inclusion list for self-context sent to Coach and briefing prompts.
// The static default preserves every pre-existing prompt section for legacy
// rows without automatically opting future enum members in.
- aiIncludedSections HealthProfileAiSection[] @default([ABOUT_ME, CONDITIONS, ALLERGIES, COACH_FOCUS, FAMILY_HISTORY, SMOKING_STATUS, ALCOHOL_PATTERN, SHIFT_SCHEDULE]) @map("ai_included_sections")
+ aiIncludedSections HealthProfileAiSection[] @default([ABOUT_ME, CONDITIONS, ALLERGIES, COACH_FOCUS, FAMILY_HISTORY, SMOKING_STATUS, ALCOHOL_PATTERN, SHIFT_SCHEDULE]) @map("ai_included_sections")
// Emergency ("Notfalldaten") facts, surfaced on page one of the doctor
// report and edited in Settings -> Anamnese. Three closed-set enums are
// plaintext (read directly by the report, like `User.gender`); three free
@@ -6169,14 +6149,14 @@ model UserHealthProfile {
// `*Encrypted` column. All six are nullable: an unset emergency profile is
// absent, not empty. The report page is withheld unless the EMERGENCY leaf is
// admitted AND at least one of these holds a value.
- emergencyBloodType EmergencyBloodType? @map("emergency_blood_type")
- organDonorStatus OrganDonorStatus? @map("organ_donor_status")
- advanceDirectiveStatus AdvanceDirectiveStatus? @map("advance_directive_status")
- emergencyContactsEncrypted Bytes? @map("emergency_contacts_encrypted")
- emergencyImplantsEncrypted Bytes? @map("emergency_implants_encrypted")
- emergencyNoteEncrypted Bytes? @map("emergency_note_encrypted")
- createdAt DateTime @default(now()) @map("created_at")
- updatedAt DateTime @updatedAt @map("updated_at")
+ emergencyBloodType EmergencyBloodType? @map("emergency_blood_type")
+ organDonorStatus OrganDonorStatus? @map("organ_donor_status")
+ advanceDirectiveStatus AdvanceDirectiveStatus? @map("advance_directive_status")
+ emergencyContactsEncrypted Bytes? @map("emergency_contacts_encrypted")
+ emergencyImplantsEncrypted Bytes? @map("emergency_implants_encrypted")
+ emergencyNoteEncrypted Bytes? @map("emergency_note_encrypted")
+ createdAt DateTime @default(now()) @map("created_at")
+ updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -8380,9 +8360,9 @@ model VaccinationDocumentLink {
/// covers free text a person wrote (`UserHealthProfile.aboutMeEncrypted`, the
/// Coach transcript, the emergency note) and identifiers (the KVNR), and an
/// answer drawn from nine fixed chips is neither. It is the same class of
-/// value as `modulePreferencesJson` and `onboardingGoals`, both of which sit
-/// in plaintext beside it. Readers are fail-soft rather than schema-validated
-/// at the database edge — see `src/lib/onboarding/needs.ts`.
+/// value as `modulePreferencesJson`, which sits in plaintext beside it.
+/// Readers are fail-soft rather than schema-validated at the database edge —
+/// see `src/lib/onboarding/needs.ts`.
model OnboardingRecord {
id String @id @default(cuid())
userId String @unique @map("user_id")
diff --git a/src/__tests__/proxy-demo-mode-allowlist.test.ts b/src/__tests__/proxy-demo-mode-allowlist.test.ts
index bf43fe9d2..9053e4587 100644
--- a/src/__tests__/proxy-demo-mode-allowlist.test.ts
+++ b/src/__tests__/proxy-demo-mode-allowlist.test.ts
@@ -73,7 +73,7 @@ describe("proxy.ts DEMO_MODE mutation allowlist", () => {
expect(proxy(makeRequest("/api/onboarding/answers", "DELETE")).status).toBe(
403,
);
- expect(proxy(makeRequest("/api/onboarding/step", "POST")).status).toBe(403);
+ expect(proxy(makeRequest("/api/onboarding/tour", "POST")).status).toBe(403);
});
it("still blocks a health-data mutation (POST /api/measurements)", () => {
diff --git a/src/app/api/onboarding/__tests__/step.test.ts b/src/app/api/onboarding/__tests__/step.test.ts
deleted file mode 100644
index 5856891f4..000000000
--- a/src/app/api/onboarding/__tests__/step.test.ts
+++ /dev/null
@@ -1,399 +0,0 @@
-/**
- * v1.4.25 W21 Fix-M — concurrency regression for
- * `POST /api/onboarding/step`.
- *
- * The step write was fetch-then-update: two parallel tabs both
- * reading `onboardingStep = 2` and both POSTing `step: 3` would both
- * succeed because the second write didn't re-check the precondition.
- * The route now claims the row via `updateMany` with the precondition
- * embedded in the WHERE clause, so exactly one update lands and the
- * loser sees `count = 0` and returns 409.
- */
-import { describe, it, expect, vi, beforeEach } from "vitest";
-import { NextRequest } from "next/server";
-
-vi.mock("@/lib/db", () => ({
- prisma: {
- user: {
- findUnique: vi.fn(),
- findUniqueOrThrow: vi.fn(),
- update: vi.fn(),
- updateMany: vi.fn(),
- },
- },
- toJson: (v: unknown) => v,
-}));
-
-vi.mock("@/lib/auth/session", () => ({
- getSession: vi.fn(),
- setOnboardingPendingCookie: vi.fn().mockResolvedValue(undefined),
-}));
-
-vi.mock("@/lib/auth/audit", () => ({
- auditLog: vi.fn().mockResolvedValue(undefined),
-}));
-
-vi.mock("@/lib/rate-limit", () => ({
- checkRateLimit: vi.fn().mockResolvedValue({
- allowed: true,
- limit: 60,
- remaining: 29,
- resetAt: Date.now() + 60_000,
- }),
- rateLimitHeaders: vi.fn(() => ({})),
-}));
-
-vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
-
-vi.mock("@/lib/db-compat", () => ({
- ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
-}));
-
-vi.mock("next/headers", () => ({
- headers: vi.fn(async () => ({ get: () => null })),
- cookies: vi.fn(async () => ({
- get: () => undefined,
- set: () => {},
- delete: () => {},
- })),
-}));
-
-import { POST } from "../step/route";
-import { prisma } from "@/lib/db";
-import { getSession } from "@/lib/auth/session";
-import { checkRateLimit } from "@/lib/rate-limit";
-
-const SESSION_OK = {
- session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
- user: { id: "user-1", username: "testuser", role: "USER" as const },
-};
-
-function req(body: unknown): NextRequest {
- return new NextRequest("http://localhost/api/onboarding/step", {
- method: "POST",
- headers: { "content-type": "application/json" },
- body: JSON.stringify(body),
- });
-}
-
-beforeEach(() => {
- vi.resetAllMocks();
- vi.mocked(checkRateLimit).mockResolvedValue({
- allowed: true,
- limit: 60,
- remaining: 29,
- resetAt: Date.now() + 60_000,
- });
-});
-
-describe("POST /api/onboarding/step — happy path", () => {
- it("advances 0 → 1 on the first wizard submission", async () => {
- vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
- vi.mocked(prisma.user.findUnique).mockResolvedValue({
- onboardingStep: 0,
- onboardingCompletedAt: null,
- } as never);
- vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
- vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({
- onboardingStep: 1,
- onboardingCompletedAt: null,
- } as never);
-
- const res = await POST(req({ step: 1 }));
- expect(res.status).toBe(200);
- const body = (await res.json()) as { data: { step: number } };
- expect(body.data.step).toBe(1);
- });
-
- it("marks completion on step 4 (sets onboardingCompletedAt)", async () => {
- vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
- vi.mocked(prisma.user.findUnique).mockResolvedValue({
- onboardingStep: 3,
- onboardingCompletedAt: null,
- } as never);
- vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
- vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({
- onboardingStep: 4,
- onboardingCompletedAt: new Date("2026-05-14T12:00:00Z"),
- } as never);
-
- const res = await POST(req({ step: 4 }));
- expect(res.status).toBe(200);
- const updateCall = vi.mocked(prisma.user.updateMany).mock.calls[0][0];
- expect(updateCall.data).toMatchObject({
- onboardingStep: 4,
- });
- expect(updateCall.data).toHaveProperty("onboardingCompletedAt");
- });
-});
-
-describe("POST /api/onboarding/step — guard rails", () => {
- it("returns 401 when unauthenticated", async () => {
- vi.mocked(getSession).mockResolvedValue(null);
- const res = await POST(req({ step: 1 }));
- expect(res.status).toBe(401);
- });
-
- it("returns 422 for an out-of-range step value", async () => {
- vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
- const res = await POST(req({ step: 99 }));
- expect(res.status).toBe(422);
- });
-
- it("returns 409 when the user has already completed onboarding", async () => {
- vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
- vi.mocked(prisma.user.findUnique).mockResolvedValue({
- onboardingStep: 4,
- onboardingCompletedAt: new Date("2026-05-01T12:00:00Z"),
- } as never);
-
- const res = await POST(req({ step: 1 }));
- expect(res.status).toBe(409);
- expect(prisma.user.updateMany).not.toHaveBeenCalled();
- });
-
- it("returns 409 when the requested step is out of order", async () => {
- vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
- vi.mocked(prisma.user.findUnique).mockResolvedValue({
- onboardingStep: 1,
- onboardingCompletedAt: null,
- } as never);
-
- // The route sees current = 1; submitting step 3 (skipping 2) must
- // be rejected before the write.
- const res = await POST(req({ step: 3 }));
- expect(res.status).toBe(409);
- expect(prisma.user.updateMany).not.toHaveBeenCalled();
- });
-});
-
-describe("POST /api/onboarding/step — concurrent write race", () => {
- it("returns 409 when the conditional update affects zero rows", async () => {
- // Regression for Fix-M / code-M7: two parallel tabs both read
- // `onboardingStep = 2`, both POST `step: 3`. The first lands;
- // when the second runs the conditional updateMany, the WHERE
- // clause no longer matches (the column moved to 3) and Prisma
- // returns `count = 0`. The route must surface 409 so the client
- // can refresh and retry instead of double-advancing the wizard.
- vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
- vi.mocked(prisma.user.findUnique).mockResolvedValue({
- onboardingStep: 2,
- onboardingCompletedAt: null,
- } as never);
- vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 0 } as never);
-
- const res = await POST(req({ step: 3 }));
- expect(res.status).toBe(409);
- // findUniqueOrThrow must NOT be called when the claim fails —
- // the route returns before re-reading the row.
- expect(prisma.user.findUniqueOrThrow).not.toHaveBeenCalled();
- });
-
- it("includes the precondition in the updateMany WHERE clause", async () => {
- // The whole point of the conditional update is that the WHERE
- // clause re-asserts the precondition we read. Pin the contract
- // so a future refactor can't quietly drop the guard.
- vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
- vi.mocked(prisma.user.findUnique).mockResolvedValue({
- onboardingStep: 2,
- onboardingCompletedAt: null,
- } as never);
- vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
- vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({
- onboardingStep: 3,
- onboardingCompletedAt: null,
- } as never);
-
- await POST(req({ step: 3 }));
- const whereArg = vi.mocked(prisma.user.updateMany).mock.calls[0][0]
- .where as Record;
- expect(whereArg).toMatchObject({
- id: "user-1",
- onboardingCompletedAt: null,
- });
- // Strict-equality precondition — Migration 0060 dropped the
- // legacy nullable path so every advance uses `{ in: [current] }`.
- expect(whereArg.onboardingStep).toEqual({ in: [2] });
- });
-
- it("advances a step-0 row to step 1 using the strict-equality precondition", async () => {
- // Migration 0060 (v1.4.25 W21 Fix-O) backfilled NULL → 0 and
- // flipped `onboarding_step` to NOT NULL, so the conditional
- // update always uses the strict-equality form — there is no
- // legacy null branch left for the route to widen.
- vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
- vi.mocked(prisma.user.findUnique).mockResolvedValue({
- onboardingStep: 0,
- onboardingCompletedAt: null,
- } as never);
- vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
- vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({
- onboardingStep: 1,
- onboardingCompletedAt: null,
- } as never);
-
- const res = await POST(req({ step: 1 }));
- expect(res.status).toBe(200);
- const whereArg = vi.mocked(prisma.user.updateMany).mock.calls[0][0]
- .where as Record;
- expect(whereArg.onboardingStep).toEqual({ in: [0] });
- });
-});
-
-describe("POST /api/onboarding/step — rate limit", () => {
- it("returns 429 when the per-user rate limit is exceeded", async () => {
- vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
- vi.mocked(checkRateLimit).mockResolvedValueOnce({
- allowed: false,
- limit: 60,
- remaining: 0,
- resetAt: Date.now() + 60_000,
- });
- const res = await POST(req({ step: 1 }));
- expect(res.status).toBe(429);
- });
-});
-
-describe("POST /api/onboarding/step — goal persistence (v1.17.1)", () => {
- it("persists the selected goal slugs on the step-2 submit", async () => {
- vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
- vi.mocked(prisma.user.findUnique).mockResolvedValue({
- onboardingStep: 1,
- onboardingCompletedAt: null,
- } as never);
- vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
- vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({
- onboardingStep: 2,
- onboardingCompletedAt: null,
- } as never);
-
- const res = await POST(
- req({ step: 2, goals: ["weight-management", "bp-tracking"] }),
- );
- expect(res.status).toBe(200);
- const data = vi.mocked(prisma.user.updateMany).mock.calls[0][0].data;
- expect(data).toMatchObject({
- onboardingStep: 2,
- onboardingGoals: ["weight-management", "bp-tracking"],
- });
- });
-
- it("persists an explicit empty array on skip (no preference)", async () => {
- vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
- vi.mocked(prisma.user.findUnique).mockResolvedValue({
- onboardingStep: 1,
- onboardingCompletedAt: null,
- } as never);
- vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
- vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({
- onboardingStep: 2,
- onboardingCompletedAt: null,
- } as never);
-
- await POST(req({ step: 2, goals: [] }));
- const data = vi.mocked(prisma.user.updateMany).mock.calls[0][0].data;
- expect(data).toMatchObject({ onboardingGoals: [] });
- });
-
- it("rejects an unknown goal slug with 422", async () => {
- vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
- const res = await POST(req({ step: 2, goals: ["not-a-real-goal"] }));
- expect(res.status).toBe(422);
- expect(prisma.user.updateMany).not.toHaveBeenCalled();
- });
-
- it("does NOT write onboardingGoals when the field is omitted", async () => {
- vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
- vi.mocked(prisma.user.findUnique).mockResolvedValue({
- onboardingStep: 1,
- onboardingCompletedAt: null,
- } as never);
- vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
- vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({
- onboardingStep: 2,
- onboardingCompletedAt: null,
- } as never);
-
- await POST(req({ step: 2 }));
- const data = vi.mocked(prisma.user.updateMany).mock.calls[0][0].data;
- expect(data).not.toHaveProperty("onboardingGoals");
- });
-});
-
-describe("POST /api/onboarding/step — dashboard seeding on completion", () => {
- it("seeds the dashboard from stored goals when dashboardWidgetsJson is null", async () => {
- vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
- // 1st findUnique = the fresh step-read; 2nd = the seed-read.
- vi.mocked(prisma.user.findUnique)
- .mockResolvedValueOnce({
- onboardingStep: 3,
- onboardingCompletedAt: null,
- } as never)
- .mockResolvedValueOnce({
- onboardingGoals: ["glucose-tracking"],
- dashboardWidgetsJson: null,
- } as never);
- // 1st updateMany = the step claim; 2nd = the gated seed write.
- vi.mocked(prisma.user.updateMany)
- .mockResolvedValueOnce({ count: 1 } as never)
- .mockResolvedValueOnce({ count: 1 } as never);
- vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({
- onboardingStep: 4,
- onboardingCompletedAt: new Date("2026-06-14T12:00:00Z"),
- } as never);
-
- const res = await POST(req({ step: 4 }));
- expect(res.status).toBe(200);
- // The second updateMany is the seed; its WHERE pins the null gate
- // and its data carries a dashboard layout.
- expect(prisma.user.updateMany).toHaveBeenCalledTimes(2);
- const seedCall = vi.mocked(prisma.user.updateMany).mock.calls[1][0];
- expect(seedCall.data).toHaveProperty("dashboardWidgetsJson");
- expect(seedCall.where).toHaveProperty("dashboardWidgetsJson");
- });
-
- it("does NOT seed when dashboardWidgetsJson is already set (gate holds)", async () => {
- vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
- vi.mocked(prisma.user.findUnique)
- .mockResolvedValueOnce({
- onboardingStep: 3,
- onboardingCompletedAt: null,
- } as never)
- .mockResolvedValueOnce({
- onboardingGoals: ["glucose-tracking"],
- dashboardWidgetsJson: { version: 1, widgets: [] },
- } as never);
- vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
- vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({
- onboardingStep: 4,
- onboardingCompletedAt: new Date("2026-06-14T12:00:00Z"),
- } as never);
-
- const res = await POST(req({ step: 4 }));
- expect(res.status).toBe(200);
- // Only the step-claim updateMany fired — the seed was gated out
- // because the user already has a layout.
- expect(prisma.user.updateMany).toHaveBeenCalledTimes(1);
- });
-
- it("does NOT seed when stored goals are empty (no preference)", async () => {
- vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
- vi.mocked(prisma.user.findUnique)
- .mockResolvedValueOnce({
- onboardingStep: 3,
- onboardingCompletedAt: null,
- } as never)
- .mockResolvedValueOnce({
- onboardingGoals: [],
- dashboardWidgetsJson: null,
- } as never);
- vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
- vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({
- onboardingStep: 4,
- onboardingCompletedAt: new Date("2026-06-14T12:00:00Z"),
- } as never);
-
- await POST(req({ step: 4 }));
- expect(prisma.user.updateMany).toHaveBeenCalledTimes(1);
- });
-});
diff --git a/src/app/api/onboarding/complete/route.ts b/src/app/api/onboarding/complete/route.ts
index 1a6d0e699..fc455d1de 100644
--- a/src/app/api/onboarding/complete/route.ts
+++ b/src/app/api/onboarding/complete/route.ts
@@ -1,4 +1,5 @@
import { prisma, toJson } from "@/lib/db";
+import { Prisma } from "@/generated/prisma/client";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import {
@@ -17,6 +18,7 @@ import {
mergeDerivedModulePreferences,
OWNED_MODULE_KEYS,
} from "@/lib/modules/registry";
+import { buildNeedsSeededDashboardLayout } from "@/lib/onboarding/dashboard-seed";
import {
everyOnboardingQuestionSettled,
readHeldUnitPreferences,
@@ -37,22 +39,24 @@ import { onboardingCompleteSchema } from "@/lib/validations/onboarding";
* Complete the onboarding flow. Saves optional profile data and marks
* onboarding as completed.
*
- * v1.39 (C1) — this is also the confirm screen's endpoint for the needs-based
- * flow, and the two live side by side rather than one replacing the other.
- * The legacy half is unchanged: the optional profile fields, the completion
- * stamp on `User.onboardingCompletedAt`, and the cleared pending cookie. The
- * needs half runs only for a record that actually FINISHED the questions —
- * every one of them answered or deliberately passed — and is what turns them
- * into a module
- * map — ONCE, guarded by `OnboardingRecord.modulesDerivedAt`, because a
- * re-derivation would re-apply the questionnaire over decisions taken in
- * Settings since. `POST /api/onboarding/restart` clears that marker when the
- * person asks for the questions again.
+ * v1.39 — the confirm screen's endpoint for the needs-based flow, and the
+ * one completion write there is. The stamp half is what it always was: the
+ * optional profile fields, the completion stamp on
+ * `User.onboardingCompletedAt`, and the cleared pending cookie — the welcome
+ * screen's "skip for now" is this route with an empty body. The needs half
+ * runs only for a record that actually FINISHED the questions — every one
+ * of them answered or deliberately passed — and is what turns them into a
+ * module map and a dashboard order — ONCE, guarded by
+ * `OnboardingRecord.modulesDerivedAt`, because a re-derivation would
+ * re-apply the questionnaire over decisions taken in Settings since. `POST
+ * /api/onboarding/restart` clears that marker when the person asks for the
+ * questions again.
*
- * The goal slugs of today (`src/lib/onboarding/goals.ts`, the four-step wizard
- * at `POST /api/onboarding/step`) are untouched and stay live until C2
- * replaces that surface. They decide TILE ORDER; the answers below decide
- * WHICH MODULES ARE ON. Neither reads the other.
+ * v1.39 (C2) — the dashboard order joins the derivation. The five-step
+ * wizard's goal slugs used to seed it from their own route; the answers now
+ * carry both halves of Q2 — which modules are on, and what is on top — and
+ * the seed keeps the wizard's one contract: only while the layout column is
+ * still unset.
*/
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
@@ -166,6 +170,7 @@ async function completeNeedsFlow(
}
let derived = false;
+ let dashboardSeeded = false;
let keptForData: string[] = [];
if (record.modulesDerivedAt === null) {
const defaults = deriveOnboardingModuleDefaults({
@@ -230,6 +235,24 @@ async function completeNeedsFlow(
keptForData = [...holdsData];
derived = true;
+
+ // The ordering half of Q2. ONE-TIME and conditional: `updateMany` carries
+ // the `dashboardWidgetsJson IS NULL` precondition in its WHERE, so a
+ // layout somebody already arranged — or a concurrent layout save that
+ // 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.
+ const seededLayout = buildNeedsSeededDashboardLayout(state.needs);
+ if (seededLayout) {
+ const seeded = await prisma.user.updateMany({
+ where: {
+ id: userId,
+ dashboardWidgetsJson: { equals: Prisma.JsonNull },
+ },
+ data: { dashboardWidgetsJson: toJson(seededLayout) },
+ });
+ dashboardSeeded = seeded.count === 1;
+ }
}
const now = new Date();
@@ -256,6 +279,7 @@ async function completeNeedsFlow(
meta: {
outcome: derived ? "derived" : "already_derived",
keptForData: keptForData.length,
+ dashboard_seeded: dashboardSeeded,
},
});
diff --git a/src/app/api/onboarding/step/route.ts b/src/app/api/onboarding/step/route.ts
deleted file mode 100644
index c21dc2cc8..000000000
--- a/src/app/api/onboarding/step/route.ts
+++ /dev/null
@@ -1,244 +0,0 @@
-import type { NextRequest } from "next/server";
-
-import { apiHandler, requireAuth } from "@/lib/api-handler";
-import {
- apiError,
- apiSuccess,
- apiValidationError,
- getClientIp,
- safeJson,
- sanitiseZodIssues,
-} from "@/lib/api-response";
-import { auditLog } from "@/lib/auth/audit";
-import { setOnboardingPendingCookie } from "@/lib/auth/session";
-import { prisma, toJson } from "@/lib/db";
-import { Prisma } from "@/generated/prisma/client";
-import { annotate } from "@/lib/logging/context";
-import { checkRateLimit } from "@/lib/rate-limit";
-import { buildGoalSeededDashboardLayout } from "@/lib/onboarding/goals";
-import { onboardingStepSchema } from "@/lib/validations/onboarding";
-
-/**
- * v1.4.25 W14b — POST /api/onboarding/step.
- *
- * Persists the user's progress through the rebuilt
- * `/onboarding/[step]` wizard. Body: `{ step: number }` where `step`
- * is 1..4. The endpoint enforces a strict step-by-step contract:
- *
- * - Submitted step must equal `current + 1` (no skipping ahead).
- * - Submitting step 4 marks completion — `onboardingCompletedAt`
- * flips from null to NOW() in the same write and the
- * `hl_onboarding` proxy cookie is cleared.
- * - Already-completed users (`onboardingCompletedAt != null`)
- * receive a 409. Replays must call this endpoint only while the
- * wizard is in-progress.
- *
- * Companion to:
- * - `GET ` not implemented (the User row is already loaded into
- * every server-rendered step page).
- * - `POST /api/onboarding/complete` remains the legacy v1.4.20
- * completion path used by the old single-file wizard; the new
- * flow uses this endpoint with `step: 4` instead.
- *
- * Rate limit: 30 writes / 10 min / user — generous for a 4-step flow
- * but tight enough to defang a stuck retry loop.
- */
-
-// v1.17.1 — the optional goal slugs from the GoalsChipPicker (step 2 submit)
-// ride the same body. Validated against the closed slug enum so an unknown
-// slug 422s; persisted to `User.onboardingGoals` field-by-field (no mass
-// assignment) and, on completion (step 4), used to seed the dashboard layout
-// when the user never customized it. Omitted leaves the stored goals
-// untouched. The schema itself lives in `@/lib/validations/onboarding` so the
-// OpenAPI registry reads the same object this parse does.
-
-export const POST = apiHandler(async (request: NextRequest) => {
- const { user } = await requireAuth();
- annotate({ action: { name: "onboarding.step" } });
-
- const rl = await checkRateLimit(
- `onboarding-step:${user.id}`,
- 30,
- 10 * 60 * 1000,
- );
- if (!rl.allowed) {
- annotate({
- action: { name: "onboarding.step" },
- meta: { outcome: "rate_limited" },
- });
- return apiError("Too many onboarding writes, try again later", 429, {
- errorCode: "onboarding.step.rateLimited",
- });
- }
-
- const { data: body, error: jsonError } = await safeJson(request, {
- maxBytes: 64 * 1024,
- });
- if (jsonError) return jsonError;
-
- const parsed = onboardingStepSchema.safeParse(body);
- if (!parsed.success) {
- annotate({
- action: { name: "onboarding.step" },
- meta: { outcome: "validation_failed" },
- });
- return apiValidationError(
- "Invalid step payload",
- sanitiseZodIssues(parsed.error.issues),
- 422,
- {
- errorCode: "onboarding.step.invalid",
- },
- );
- }
- const { step, goals } = parsed.data;
-
- // Re-fetch the fresh User row so concurrent step submissions in
- // separate tabs can't race past each other — the session.user
- // snapshot might be a request-old.
- const fresh = await prisma.user.findUnique({
- where: { id: user.id },
- select: {
- onboardingStep: true,
- onboardingCompletedAt: true,
- },
- });
- if (!fresh) {
- return apiError("User not found", 404, {
- errorCode: "onboarding.user.notFound",
- });
- }
-
- if (fresh.onboardingCompletedAt) {
- annotate({
- action: { name: "onboarding.step" },
- meta: { outcome: "already_completed" },
- });
- return apiError("Onboarding already completed", 409, {
- errorCode: "onboarding.step.completed",
- });
- }
-
- const current = fresh.onboardingStep;
- if (step !== current + 1) {
- annotate({
- action: { name: "onboarding.step" },
- meta: { outcome: "out_of_order", current, requested: step },
- });
- return apiError(`Step out of order — current step is ${current}`, 409, {
- errorCode: "onboarding.step.outOfOrder",
- });
- }
-
- const completing = step === 4;
-
- // Conditional update — the WHERE clause re-asserts the
- // precondition we read above so the write can only land on a row
- // whose state still matches what we validated. Two parallel tabs
- // both reading `onboardingStep = 2` and both POSTing `step: 3`
- // would otherwise both succeed (the read-then-write race); with
- // this guard exactly one update sees `count = 1`, the second sees
- // `count = 0` and returns 409. `onboardingCompletedAt: null` also
- // catches a race with `POST /api/onboarding/complete`. Migration
- // 0060 (v1.4.25 W21) backfilled NULL → 0 and flipped the column
- // to NOT NULL, so the legacy null branch this code carried before
- // is dropped — every row now matches the strict-equality form.
- const claimed = await prisma.user.updateMany({
- where: {
- id: user.id,
- onboardingCompletedAt: null,
- onboardingStep: { in: [current] },
- },
- // Field-by-field assembly — no mass assignment. `goals` only
- // lands when the client sent it (the step-2 submit); it is already
- // validated against the closed slug enum by the Zod schema, so the
- // array reaching Prisma can never contain an out-of-set value.
- data: {
- onboardingStep: step,
- ...(completing ? { onboardingCompletedAt: new Date() } : {}),
- ...(goals !== undefined ? { onboardingGoals: goals } : {}),
- },
- });
- if (claimed.count !== 1) {
- annotate({
- action: { name: "onboarding.step" },
- meta: { outcome: "concurrent_write", current, requested: step },
- });
- return apiError(
- "Onboarding step changed concurrently, refresh and retry",
- 409,
- { errorCode: "onboarding.step.concurrent" },
- );
- }
- const updated = await prisma.user.findUniqueOrThrow({
- where: { id: user.id },
- select: {
- onboardingStep: true,
- onboardingCompletedAt: true,
- },
- });
-
- let goalsSeeded = false;
- if (completing) {
- // Mirror /api/onboarding/complete — clear the proxy-readable
- // pending cookie so the next navigation drops the /onboarding
- // redirect immediately.
- await setOnboardingPendingCookie(false);
-
- // v1.17.1 — seed the dashboard from the stored goal selection.
- // ONE-TIME, gated on `dashboardWidgetsJson == null` so a user who
- // already arranged tiles is never clobbered. The seed promotes the
- // goal-mapped tiles to the top and forces them visible; an empty /
- // general-wellness-only selection produces null and leaves the
- // column untouched (default layout). The resulting layout is what
- // both the web dashboard and the iOS widgets contract read —
- // server-authoritative, no client recompute.
- const seedRow = await prisma.user.findUnique({
- where: { id: user.id },
- select: { onboardingGoals: true, dashboardWidgetsJson: true },
- });
- if (seedRow && seedRow.dashboardWidgetsJson == null) {
- const seededLayout = buildGoalSeededDashboardLayout(
- seedRow.onboardingGoals,
- );
- if (seededLayout) {
- // updateMany so the `dashboardWidgetsJson: null` precondition
- // rides in the WHERE clause — a concurrent Settings → Dashboard
- // save that lands first leaves `count = 0` and the seed is
- // skipped rather than overwriting the user's fresh layout.
- const seeded = await prisma.user.updateMany({
- where: {
- id: user.id,
- dashboardWidgetsJson: { equals: Prisma.JsonNull },
- },
- data: { dashboardWidgetsJson: toJson(seededLayout) },
- });
- goalsSeeded = seeded.count === 1;
- }
- }
- }
-
- await auditLog("onboarding.step", {
- userId: user.id,
- ipAddress: getClientIp(request),
- details: {
- step,
- completed: completing,
- },
- });
-
- annotate({
- action: { name: "onboarding.step" },
- meta: {
- outcome: completing ? "completed" : "advanced",
- step,
- ...(goals !== undefined ? { goals_count: goals.length } : {}),
- ...(completing ? { goals_seeded: goalsSeeded } : {}),
- },
- });
-
- return apiSuccess({
- step: updated.onboardingStep,
- onboardingCompletedAt: updated.onboardingCompletedAt,
- });
-});
diff --git a/src/lib/data-wipe/wipe-plan.ts b/src/lib/data-wipe/wipe-plan.ts
index 0dd1d3a53..015bdb295 100644
--- a/src/lib/data-wipe/wipe-plan.ts
+++ b/src/lib/data-wipe/wipe-plan.ts
@@ -410,8 +410,6 @@ export const USER_RESET = {
// Back to the start of the tour — the record it was built around is gone
onboardingCompletedAt: null,
- onboardingStep: 0,
- onboardingGoals: [],
onboardingTourCompleted: false,
onboardingTourProgressJson: Prisma.DbNull,
} satisfies Prisma.UserUpdateInput;
diff --git a/src/lib/onboarding/__tests__/dashboard-seed.test.ts b/src/lib/onboarding/__tests__/dashboard-seed.test.ts
new file mode 100644
index 000000000..3aefce98b
--- /dev/null
+++ b/src/lib/onboarding/__tests__/dashboard-seed.test.ts
@@ -0,0 +1,102 @@
+/**
+ * v1.39 (C2) — the dashboard order the setup answers imply.
+ *
+ * The ordering half of Q2. Pins that every area maps only to tiles the
+ * layout knows, that the seed promotes and forces visible without dropping a
+ * tile, and that answers which speak to no tile seed nothing at all.
+ */
+import { describe, expect, it } from "vitest";
+
+import {
+ DEFAULT_DASHBOARD_LAYOUT,
+ type DashboardWidgetId,
+} from "@/lib/dashboard-layout";
+import { ONBOARDING_AREA_KEYS } from "@/lib/modules/registry";
+
+import {
+ AREA_WIDGET_SEED_MAP,
+ buildNeedsSeededDashboardLayout,
+ promotedWidgetsFor,
+} from "../dashboard-seed";
+
+describe("AREA_WIDGET_SEED_MAP", () => {
+ it("names every area, and only tiles the layout actually knows", () => {
+ const known = new Set(
+ DEFAULT_DASHBOARD_LAYOUT.widgets.map((w) => w.id as DashboardWidgetId),
+ );
+ for (const area of ONBOARDING_AREA_KEYS) {
+ expect(AREA_WIDGET_SEED_MAP[area]).toBeDefined();
+ for (const id of AREA_WIDGET_SEED_MAP[area]) {
+ expect(known.has(id), `${area} → ${id}`).toBe(true);
+ }
+ }
+ });
+});
+
+describe("buildNeedsSeededDashboardLayout", () => {
+ it("seeds nothing for answers that speak to no tile", () => {
+ expect(
+ buildNeedsSeededDashboardLayout({ areas: [], medication: null }),
+ ).toBeNull();
+ expect(
+ buildNeedsSeededDashboardLayout({
+ areas: ["labs", "illness", "cycle"],
+ medication: "no",
+ }),
+ ).toBeNull();
+ });
+
+ it("promotes glucose to the top and forces it visible", () => {
+ const layout = buildNeedsSeededDashboardLayout({
+ areas: ["glucose"],
+ medication: null,
+ });
+ expect(layout).not.toBeNull();
+ expect(layout!.widgets.find((w) => w.id === "glucose")).toMatchObject({
+ order: 0,
+ visible: true,
+ tileVisible: true,
+ });
+ });
+
+ it("promotes every tile of a multi-area answer, and the medication tile for a schedule", () => {
+ const layout = buildNeedsSeededDashboardLayout({
+ areas: ["weight-body", "blood-pressure"],
+ medication: "sometimes",
+ });
+ const promoted = new Set(
+ promotedWidgetsFor({
+ areas: ["weight-body", "blood-pressure"],
+ medication: "sometimes",
+ }),
+ );
+ expect(promoted).toEqual(
+ new Set([
+ "weight",
+ "bodyFat",
+ "bp",
+ "bpInTarget",
+ "pulse",
+ "medications",
+ ]),
+ );
+ for (const id of promoted) {
+ const w = layout!.widgets.find((x) => x.id === id);
+ expect(w?.visible).toBe(true);
+ expect(w?.tileVisible).toBe(true);
+ expect(w!.order).toBeLessThan(promoted.size);
+ }
+ });
+
+ it("keeps a dense, complete widget set", () => {
+ const layout = buildNeedsSeededDashboardLayout({
+ areas: ["sleep"],
+ medication: "no",
+ });
+ expect(layout!.widgets.length).toBe(
+ DEFAULT_DASHBOARD_LAYOUT.widgets.length,
+ );
+ const orders = layout!.widgets.map((w) => w.order).sort((a, b) => a - b);
+ expect(orders).toEqual(orders.map((_, i) => i));
+ });
+});
diff --git a/src/lib/onboarding/__tests__/goals.test.ts b/src/lib/onboarding/__tests__/goals.test.ts
deleted file mode 100644
index 039be8b02..000000000
--- a/src/lib/onboarding/__tests__/goals.test.ts
+++ /dev/null
@@ -1,104 +0,0 @@
-/**
- * v1.17.1 — onboarding goal slug enum + dashboard seeding.
- *
- * Goals are a personalization seed, not a clinical target store: the
- * only leverage they have is which dashboard tiles get promoted and
- * made visible. These tests pin (a) the closed slug guard and (b) the
- * one-time seed builder's promote/forces-visible/no-op contracts.
- */
-import { describe, it, expect } from "vitest";
-
-import {
- ONBOARDING_GOAL_SLUGS,
- GOAL_WIDGET_SEED_MAP,
- isOnboardingGoalSlug,
- buildGoalSeededDashboardLayout,
-} from "../goals";
-import {
- DEFAULT_DASHBOARD_LAYOUT,
- type DashboardWidgetId,
-} from "@/lib/dashboard-layout";
-
-describe("isOnboardingGoalSlug", () => {
- it("accepts every member of the closed slug set", () => {
- for (const slug of ONBOARDING_GOAL_SLUGS) {
- expect(isOnboardingGoalSlug(slug)).toBe(true);
- }
- });
-
- it("rejects unknown / non-string values", () => {
- expect(isOnboardingGoalSlug("not-a-goal")).toBe(false);
- expect(isOnboardingGoalSlug("")).toBe(false);
- expect(isOnboardingGoalSlug(null)).toBe(false);
- expect(isOnboardingGoalSlug(42)).toBe(false);
- });
-});
-
-describe("GOAL_WIDGET_SEED_MAP", () => {
- it("only maps to ids the dashboard layout actually knows", () => {
- const known = new Set(
- DEFAULT_DASHBOARD_LAYOUT.widgets.map((w) => w.id as DashboardWidgetId),
- );
- for (const ids of Object.values(GOAL_WIDGET_SEED_MAP)) {
- for (const id of ids) expect(known.has(id)).toBe(true);
- }
- });
-
- it("leaves general-wellness without a tile preference", () => {
- expect(GOAL_WIDGET_SEED_MAP["general-wellness"]).toEqual([]);
- });
-});
-
-describe("buildGoalSeededDashboardLayout", () => {
- it("returns null for an empty selection (no seed)", () => {
- expect(buildGoalSeededDashboardLayout([])).toBeNull();
- });
-
- it("returns null for a general-wellness-only selection", () => {
- expect(buildGoalSeededDashboardLayout(["general-wellness"])).toBeNull();
- });
-
- it("ignores unknown slugs and seeds nothing when only unknowns are passed", () => {
- expect(buildGoalSeededDashboardLayout(["bogus", "nope"])).toBeNull();
- });
-
- it("promotes glucose to the top and forces it visible", () => {
- const layout = buildGoalSeededDashboardLayout(["glucose-tracking"]);
- expect(layout).not.toBeNull();
- const widgets = layout!.widgets;
- // glucose is default-invisible + ordered low; the seed lifts it to
- // order 0 and turns both surfaces on.
- const glucose = widgets.find((w) => w.id === "glucose");
- expect(glucose).toMatchObject({
- order: 0,
- visible: true,
- tileVisible: true,
- });
- });
-
- it("promotes every mapped tile for a multi-goal selection", () => {
- const layout = buildGoalSeededDashboardLayout([
- "weight-management",
- "bp-tracking",
- ]);
- expect(layout).not.toBeNull();
- const widgets = layout!.widgets;
- const promotedIds = new Set(["weight", "bodyFat", "bp", "bpInTarget"]);
- // Every promoted id sits in the leading block and is visible.
- for (const id of promotedIds) {
- const w = widgets.find((x) => x.id === id);
- expect(w?.visible).toBe(true);
- expect(w?.tileVisible).toBe(true);
- expect(w!.order).toBeLessThan(promotedIds.size);
- }
- });
-
- it("keeps a dense, complete widget set (no tiles dropped)", () => {
- const layout = buildGoalSeededDashboardLayout(["sleep-improvement"]);
- expect(layout!.widgets.length).toBe(
- DEFAULT_DASHBOARD_LAYOUT.widgets.length,
- );
- const orders = layout!.widgets.map((w) => w.order).sort((a, b) => a - b);
- expect(orders).toEqual(orders.map((_, i) => i));
- });
-});
diff --git a/src/lib/onboarding/dashboard-seed.ts b/src/lib/onboarding/dashboard-seed.ts
new file mode 100644
index 000000000..78c85070e
--- /dev/null
+++ b/src/lib/onboarding/dashboard-seed.ts
@@ -0,0 +1,102 @@
+/**
+ * v1.39 (C2) — the dashboard tile order the setup answers imply.
+ *
+ * "Enables the matching modules and orders the dashboard" (design spec §The
+ * questions, Q2): the module half lives in the registry's derivation, this
+ * is the ordering half. Each area names the tiles it is about; the seed
+ * promotes them to the top of the default layout and forces them visible on
+ * both surfaces, and leaves every other tile exactly where the default puts
+ * it. A daily medication schedule promotes the medication tile the same way.
+ *
+ * ONE-TIME, and the caller gates it: the completion route writes the seed
+ * only while `User.dashboardWidgetsJson` is still unset, so a person who
+ * already arranged their tiles is never clobbered, and a concurrent layout
+ * save that lands first wins. Not a clinical target store — targets are
+ * derived elsewhere; this only decides what is on top.
+ *
+ * Replaces the six goal slugs of the five-step wizard, whose only durable
+ * effect was this same seed.
+ */
+import {
+ DEFAULT_DASHBOARD_LAYOUT,
+ serializeDashboardLayout,
+ type DashboardLayout,
+ type DashboardWidgetId,
+} from "@/lib/dashboard-layout";
+import type { OnboardingAreaKey } from "@/lib/modules/registry";
+
+import type { OnboardingNeeds } from "./needs";
+
+/**
+ * Area → the tiles to promote. An area whose surface is not a dashboard tile
+ * (cycle, labs, illness) maps to nothing; its module is still switched on.
+ * Ids must be members of `DASHBOARD_WIDGET_IDS`; the seed never invents one.
+ */
+export const AREA_WIDGET_SEED_MAP: Readonly<
+ Record
+> = Object.freeze({
+ "blood-pressure": ["bp", "bpInTarget", "pulse"],
+ "weight-body": ["weight", "bodyFat"],
+ glucose: ["glucose"],
+ sleep: ["sleep"],
+ mood: ["mood"],
+ cycle: [],
+ activity: ["steps"],
+ labs: [],
+ illness: [],
+});
+
+/** Q3 "yes" or "sometimes" promotes the medication tile. */
+export const MEDICATION_WIDGET_SEED: readonly DashboardWidgetId[] = [
+ "medications",
+];
+
+/**
+ * The tiles the answers promote, in the default layout's order. Empty when
+ * the answers speak to no tile, which is the "leave the column unset" case.
+ */
+export function promotedWidgetsFor(
+ needs: Pick,
+): DashboardWidgetId[] {
+ const promoted = new Set();
+ for (const area of needs.areas) {
+ for (const id of AREA_WIDGET_SEED_MAP[area] ?? []) promoted.add(id);
+ }
+ if (needs.medication === "yes" || needs.medication === "sometimes") {
+ for (const id of MEDICATION_WIDGET_SEED) promoted.add(id);
+ }
+ return DEFAULT_DASHBOARD_LAYOUT.widgets
+ .map((w) => w.id as DashboardWidgetId)
+ .filter((id) => promoted.has(id));
+}
+
+/**
+ * Build the seeded layout, or `null` when there is nothing to promote so the
+ * caller can skip the write entirely and leave the column unset (the default
+ * layout). Every tile keeps its default config; the promoted ones move to the
+ * front, in their default relative order, and are forced visible on both
+ * surfaces. The result is dense: `order` is 0..n-1 with no tile dropped.
+ */
+export function buildNeedsSeededDashboardLayout(
+ needs: Pick,
+): DashboardLayout | null {
+ const promoted = new Set(promotedWidgetsFor(needs));
+ if (promoted.size === 0) return null;
+
+ const base = DEFAULT_DASHBOARD_LAYOUT.widgets;
+ const front = base.filter((w) => promoted.has(w.id as DashboardWidgetId));
+ const rest = base.filter((w) => !promoted.has(w.id as DashboardWidgetId));
+
+ const ordered = [...front, ...rest].map((w, index) => ({
+ ...w,
+ ...(promoted.has(w.id as DashboardWidgetId)
+ ? { visible: true, tileVisible: true }
+ : {}),
+ order: index,
+ }));
+
+ return serializeDashboardLayout({
+ ...DEFAULT_DASHBOARD_LAYOUT,
+ widgets: ordered,
+ });
+}
diff --git a/src/lib/onboarding/goals.ts b/src/lib/onboarding/goals.ts
deleted file mode 100644
index bf5385d91..000000000
--- a/src/lib/onboarding/goals.ts
+++ /dev/null
@@ -1,112 +0,0 @@
-/**
- * v1.17.1 — onboarding goal slugs + dashboard seeding.
- *
- * The closed set of goal slugs the GoalsChipPicker offers, plus the
- * server-authoritative seeding that turns a goal selection into a
- * personalization signal: which dashboard tiles get promoted to the
- * top and made visible. Goals are NOT a clinical target store — the
- * targets surface (`/api/insights/targets`) is fully derived from
- * height/age/gender + clinical references. Their only leverage is the
- * one-time dashboard tile pinning below, applied by
- * `/api/onboarding/step` while the layout is still unset. Nothing else
- * reads `User.onboardingGoals` — the column is collected, seeded from
- * once, and then inert.
- *
- * One home for the slug enum so the API validation
- * (`/api/onboarding/step`), the client picker, and the seeding all
- * read the same source — same-key drift was the v1.4.16 dashboard-enum
- * bug class.
- */
-import {
- DEFAULT_DASHBOARD_LAYOUT,
- serializeDashboardLayout,
- type DashboardLayout,
- type DashboardWidgetId,
-} from "@/lib/dashboard-layout";
-
-/** Closed slug set — mirrors `OnboardingGoalSlug` in GoalsChipPicker. */
-export const ONBOARDING_GOAL_SLUGS = [
- "weight-management",
- "bp-tracking",
- "glucose-tracking",
- "sleep-improvement",
- "medication-compliance",
- "general-wellness",
-] as const;
-
-export type OnboardingGoalSlug = (typeof ONBOARDING_GOAL_SLUGS)[number];
-
-export function isOnboardingGoalSlug(
- value: unknown,
-): value is OnboardingGoalSlug {
- return (
- typeof value === "string" &&
- (ONBOARDING_GOAL_SLUGS as readonly string[]).includes(value)
- );
-}
-
-/**
- * Slug → dashboard widget ids to promote. `general-wellness` maps to
- * nothing — it carries no tile preference, so a general-wellness-only
- * selection leaves the default layout untouched. Ids must be members
- * of `DASHBOARD_WIDGET_IDS`; the seed never invents an id.
- */
-export const GOAL_WIDGET_SEED_MAP: Record<
- OnboardingGoalSlug,
- readonly DashboardWidgetId[]
-> = {
- "weight-management": ["weight", "bodyFat"],
- "bp-tracking": ["bp", "bpInTarget"],
- "glucose-tracking": ["glucose"],
- "sleep-improvement": ["sleep"],
- "medication-compliance": ["medications"],
- "general-wellness": [],
-};
-
-/**
- * Build the seeded dashboard layout for a goal selection.
- *
- * Returns a layout where the goal-mapped tiles are promoted to the top
- * of the order (preserving their relative order within the default
- * layout) and forced visible on both surfaces; every other tile keeps
- * its default config, shifted down. Returns `null` when there is
- * nothing to promote (empty selection or general-wellness only) so the
- * caller can skip the write entirely and leave the column null →
- * default layout.
- *
- * This is a ONE-TIME seed: the caller MUST gate it on
- * `User.dashboardWidgetsJson == null` so it never clobbers a user who
- * already arranged tiles.
- */
-export function buildGoalSeededDashboardLayout(
- goals: readonly string[] | null | undefined,
-): DashboardLayout | null {
- if (!goals || goals.length === 0) return null;
- const promoted = new Set();
- for (const slug of goals) {
- if (!isOnboardingGoalSlug(slug)) continue;
- for (const id of GOAL_WIDGET_SEED_MAP[slug]) promoted.add(id);
- }
- if (promoted.size === 0) return null;
-
- const base = DEFAULT_DASHBOARD_LAYOUT.widgets;
- const promotedWidgets = base.filter((w) =>
- promoted.has(w.id as DashboardWidgetId),
- );
- const rest = base.filter((w) => !promoted.has(w.id as DashboardWidgetId));
-
- const ordered = [...promotedWidgets, ...rest].map((w, index) => ({
- ...w,
- // Force the goal-mapped tiles visible on both surfaces; leave the
- // rest exactly as the default layout had them.
- ...(promoted.has(w.id as DashboardWidgetId)
- ? { visible: true, tileVisible: true }
- : {}),
- order: index,
- }));
-
- return serializeDashboardLayout({
- ...DEFAULT_DASHBOARD_LAYOUT,
- widgets: ordered,
- });
-}
diff --git a/src/lib/onboarding/needs.ts b/src/lib/onboarding/needs.ts
index c271088eb..9f5c1f390 100644
--- a/src/lib/onboarding/needs.ts
+++ b/src/lib/onboarding/needs.ts
@@ -12,18 +12,16 @@
* pulls it in without dragging the server graph along, and the browser reads
* the same step ids the server writes.
*
- * ── Coexistence with the wizard of today ───────────────────────────────────
+ * ── The completion stamp ───────────────────────────────────────────────────
*
- * Nothing here retires anything. `User.onboardingStep`, `User.onboardingGoals`
- * and the six goal slugs in `./goals.ts` keep their meaning and keep driving
- * the four-step wizard and its one-time dashboard seed until C2 replaces that
- * surface. The two states are disjoint by construction: the goal slugs decide
- * TILE ORDER, these answers decide WHICH MODULES ARE ON, and neither reads the
- * other. `User.onboardingCompletedAt` also stays what it was — the first-run
- * redirect's gate — which is why the needs flow carries a completion stamp of
- * its own rather than borrowing that column: "set up again" has to be able to
- * re-ask the questions without pushing the person back through the first-run
- * redirect.
+ * `User.onboardingCompletedAt` stays what it was — the first-run redirect's
+ * gate — which is why the needs flow carries a completion stamp of its own
+ * rather than borrowing that column: "set up again" has to be able to re-ask
+ * the questions without pushing the person back through the first-run
+ * redirect. The five-step wizard that used to share this state is gone (C2);
+ * its step counter and goal slugs left the account row with it, and the
+ * dashboard order those slugs seeded now comes from the answers here
+ * (`./dashboard-seed.ts`).
*/
import type {
OnboardingAreaKey,
diff --git a/src/lib/openapi/routes/onboarding.ts b/src/lib/openapi/routes/onboarding.ts
index f023d8be4..f7fd5616b 100644
--- a/src/lib/openapi/routes/onboarding.ts
+++ b/src/lib/openapi/routes/onboarding.ts
@@ -1,19 +1,16 @@
/**
- * OpenAPI route table — the onboarding wizard and the module tour.
+ * OpenAPI route table — the setup flow and the module tour.
*
* Part of the OpenAPI route table; aggregated in `./index.ts`.
- * The request schemas come from `src/lib/onboarding/tour-progress.ts` and
- * `src/lib/validations/onboarding.ts` so the wire contract stays
+ * The request schemas come from `src/lib/onboarding/tour-progress.ts`,
+ * `src/lib/validations/onboarding.ts` and
+ * `src/lib/validations/onboarding-needs.ts` so the wire contract stays
* single-source with the runtime parsers.
*
- * Two generations of the same flow live side by side here, and a client
- * should pick one. `POST /api/onboarding/step` is the current wizard: four
- * ordered checkpoints, the fourth of which completes. `POST
- * /api/onboarding/complete` is the older single-shot path that stamps the
- * completion and takes whatever profile fields it was given. They write the
- * same column and refuse to co-operate — the step route's conditional update
- * requires `onboardingCompletedAt: null`, so a `complete` call mid-wizard
- * makes every remaining step 409.
+ * v1.39 — one flow. The needs-based setup (`answers` → `complete` →
+ * `restart`) replaced the four-checkpoint wizard, whose `POST
+ * /api/onboarding/step` is gone with it; `complete` is the one completion
+ * write, and it now also seeds the dashboard order from the answers.
*
* v1.18.6 — the resumable module-tour contract the iOS client mirrors:
* a fire-and-forget progress checkpoint plus the coarse completion
@@ -34,10 +31,7 @@ import {
} from "@/lib/onboarding/needs";
import { tourProgressSchema } from "@/lib/onboarding/tour-progress";
import { ONBOARDING_AREA_KEYS } from "@/lib/modules/registry";
-import {
- onboardingCompleteSchema,
- onboardingStepSchema,
-} from "@/lib/validations/onboarding";
+import { onboardingCompleteSchema } from "@/lib/validations/onboarding";
import {
onboardingAnswerSchema,
onboardingRestartSchema,
@@ -216,22 +210,6 @@ const disclaimerAckResponse = z
description: "The persisted disclaimer acknowledgment version.",
});
-const onboardingStepRequest = onboardingStepSchema.meta({
- id: "OnboardingStepRequest",
- description:
- "The wizard step being COMPLETED, 1–4. It must equal the stored step plus one — the server does not clamp or skip. `goals` rides the step-2 submit; every slug is checked against the closed set, so one unknown slug fails the whole request rather than being dropped.",
-});
-
-const onboardingStepResponse = z
- .object({
- step: z.number().int().describe("The stored step after the write."),
- onboardingCompletedAt: z.iso
- .datetime({ offset: true })
- .nullable()
- .describe("Non-null once step 4 landed."),
- })
- .meta({ id: "OnboardingStepResponse" });
-
const onboardingCompleteRequest = onboardingCompleteSchema.meta({
id: "OnboardingCompleteRequest",
description:
@@ -319,66 +297,12 @@ export const onboardingPaths: NonNullable = {
},
},
},
- "/api/onboarding/step": {
- post: {
- tags: ["Onboarding"],
- summary: "Advance the onboarding wizard by one step",
- description:
- "Persists progress through the four-step wizard. The contract is strictly ordered: the submitted `step` must be exactly the stored step plus one, so a client cannot skip ahead or replay a step it already sent.\n\nSubmitting step 4 completes onboarding — the completion instant is stamped in the same write, the proxy-readable pending cookie is cleared so the next navigation stops redirecting to `/onboarding`, and the stored goal selection seeds the dashboard layout. That seed is ONE-TIME and conditional: it only runs while the layout column is still unset, so a person who already arranged their tiles is never clobbered, and a concurrent layout save that lands first wins.\n\nThe write is guarded on the state it validated, so two tabs submitting the same step do not both succeed — exactly one lands and the other gets 409.\n\nRate-limited to 30 writes per 10 minutes per user, which is generous for a four-step flow and tight enough to defang a stuck retry loop. Every accepted call writes an audit row. Cookie or wildcard Bearer; `userId` is never read from the body.",
- requestBody: {
- required: true,
- content: { "application/json": { schema: onboardingStepRequest } },
- },
- responses: {
- "200": {
- description: "The stored step and completion stamp after the write.",
- content: {
- "application/json": {
- schema: dataEnvelope(
- onboardingStepResponse,
- "OnboardingStepEnvelope",
- ),
- },
- },
- },
- "404": {
- description:
- "The session's user row no longer exists. `meta.errorCode` = `onboarding.user.notFound`.",
- content: { "application/json": { schema: errorEnvelope } },
- },
- "409": {
- description:
- "The write was refused and nothing changed. `meta.errorCode` distinguishes three cases: `onboarding.step.completed` — onboarding is already finished, so the wizard has nothing left to advance; `onboarding.step.outOfOrder` — the submitted step is not the stored step plus one, and the message names the current step; `onboarding.step.concurrent` — the row moved between the check and the write, which is what a second tab sees.",
- content: { "application/json": { schema: errorEnvelope } },
- },
- "413": {
- description: "Body exceeds 64 KiB.",
- content: { "application/json": { schema: errorEnvelope } },
- },
- "415": {
- description: "`Content-Type` is not `application/json`.",
- content: { "application/json": { schema: errorEnvelope } },
- },
- ...stdResponses,
- "422": {
- description:
- "The body did not validate. `meta.errorCode` = `onboarding.step.invalid`. Single-message, not the multi-issue envelope — the per-field detail is not on the wire.",
- content: { "application/json": { schema: errorEnvelope } },
- },
- "429": {
- description:
- "More than 30 onboarding writes in 10 minutes. `meta.errorCode` = `onboarding.step.rateLimited`.",
- content: { "application/json": { schema: errorEnvelope } },
- },
- },
- },
- },
"/api/onboarding/complete": {
post: {
tags: ["Onboarding"],
summary: "Stamp onboarding as complete, and derive the module map",
description:
- "Marks onboarding finished and saves whatever profile fields came with it, then clears the proxy-readable pending cookie so the next navigation stops redirecting to `/onboarding`.\n\nv1.39 — this is also the needs-based flow's confirm endpoint, and the two halves live side by side. For a record that FINISHED the questions — every one of them answered or deliberately passed — it derives that record's module map from the answers and applies it, ONCE: the derivation marker is what stops a second confirm re-applying the questionnaire over decisions taken in Settings since, and `POST /api/onboarding/restart` clears the marker when the person asks for the questions again. The merge is one-directional — a module the answers name is switched on, a module they do not name is switched off only where the record does not already carry an explicit on. The `cycle` key is not written here at all: it delegates to the cycle profile, which this route sets to `true` when the cycle area was chosen and never to `false`. A half-answered flow derives nothing and is not stamped as complete: the answers become a module map, and a flow somebody abandoned after the first screen would have every remaining question read as its conservative default. The same gate is what keeps the LEGACY wizard's completion out of the derivation, since that wizard never puts these questions.\n\nThe response then carries `onboarding` beside `completed`; a caller that never entered the needs flow gets the fixed acknowledgement it always did.\n\nThe older sibling of `POST /api/onboarding/step`. It is unconditional in a way the step route is not: it stamps the completion whatever the stored step is, it re-stamps on every call rather than refusing a second one, and it enforces no rate limit of its own. Its legacy half writes no audit row; the needs half writes one (`user.modules.update`) when it derives, because that is the same column the dedicated modules route audits. It also does NOT seed the dashboard from the goal selection — that only happens on the step route's completing call.\n\nCalling this mid-wizard makes every remaining `POST /api/onboarding/step` answer 409 `onboarding.step.completed`, because that route's guarded update requires the completion stamp to still be null.\n\nCookie or wildcard Bearer; `userId` is never read from the body.",
+ "Marks onboarding finished and saves whatever profile fields came with it, then clears the proxy-readable pending cookie so the next navigation stops redirecting to `/onboarding`.\n\nv1.39 — this is the needs-based flow's confirm endpoint. For a record that FINISHED the questions — every one of them answered or deliberately passed — it derives that record's module map from the answers and applies it, ONCE: the derivation marker is what stops a second confirm re-applying the questionnaire over decisions taken in Settings since, and `POST /api/onboarding/restart` clears the marker when the person asks for the questions again. The merge is one-directional — a module the answers name is switched on, a module they do not name is switched off only where the record does not already carry an explicit on and holds no data in that domain. The `cycle` key is not written here at all: it delegates to the cycle profile, which this route sets to `true` when the cycle area was chosen and never to `false`. In the same derivation the dashboard order is seeded from the answers — the tiles of the chosen areas (and the medication tile for a schedule) move to the front and are made visible — but only while the layout column is still unset, so a person who already arranged their tiles is never clobbered. A half-answered flow derives nothing and is not stamped as complete.\n\nThe response carries `onboarding` beside `completed`; a caller that never entered the needs flow gets the fixed acknowledgement alone — it stamps the completion whatever the answers say, re-stamps on every call rather than refusing a second one, and enforces no rate limit of its own. The legacy half writes no audit row; the needs half writes one (`user.modules.update`) when it derives, because that is the same column the dedicated modules route audits.\n\nCookie or wildcard Bearer; `userId` is never read from the body.",
requestBody: {
required: true,
content: { "application/json": { schema: onboardingCompleteRequest } },
diff --git a/src/lib/validations/onboarding.ts b/src/lib/validations/onboarding.ts
index 41ad776a8..1e3f5511a 100644
--- a/src/lib/validations/onboarding.ts
+++ b/src/lib/validations/onboarding.ts
@@ -1,46 +1,26 @@
/**
- * Request schemas for the two onboarding write endpoints.
+ * Request schema for the completion write.
*
- * They live outside the route files for the same reason
+ * It lives outside the route file for the same reason
* `src/lib/validations/about-me.ts` does: a route module may only export
* handlers plus the Next.js route config, so the OpenAPI registry cannot
- * import a schema declared inside one. Keeping the shapes here lets the
+ * import a schema declared inside one. Keeping the shape here lets the
* published contract and the runtime parser be the same object rather than
* two hand-kept copies.
*
- * Prisma-free by construction — `@/lib/onboarding/goals` is pure data — so
- * the generator script can pull them in without dragging the server graph
- * along.
+ * Prisma-free by construction, so the generator script can pull it in
+ * without dragging the server graph along.
*/
import { z } from "zod/v4";
-import { ONBOARDING_GOAL_SLUGS } from "@/lib/onboarding/goals";
-
/**
- * `POST /api/onboarding/step` — the step-by-step wizard checkpoint.
- *
- * `step` is the step being COMPLETED and must equal the stored step plus one;
- * the route refuses anything else with 409 rather than clamping. `goals`
- * rides the step-2 submit and is validated against the closed slug set, so an
- * unknown slug fails the whole request instead of being silently dropped.
- */
-export const onboardingStepSchema = z.object({
- step: z.number().int().min(1).max(4),
- goals: z
- .array(z.enum(ONBOARDING_GOAL_SLUGS))
- .max(ONBOARDING_GOAL_SLUGS.length)
- .optional(),
-});
-
-export type OnboardingStepInput = z.infer;
-
-/**
- * `POST /api/onboarding/complete` — the legacy single-shot completion path.
+ * `POST /api/onboarding/complete` — the completion stamp.
*
* Every field is optional: the endpoint's job is the completion stamp, and
- * the profile fields are whatever the wizard happened to collect. A field
+ * the profile fields are whatever the caller happened to collect. A field
* that parses but is falsy (an empty `displayName`, `heightCm: 0`) is not
- * written — the route only assigns truthy values.
+ * written — the route only assigns truthy values. The web flow sends an
+ * empty body and writes its profile through `PUT /api/auth/profile`.
*/
export const onboardingCompleteSchema = z.object({
displayName: z.string().trim().min(1).max(50).optional(),
diff --git a/tests/integration/settings-data-wipe.test.ts b/tests/integration/settings-data-wipe.test.ts
index 72b9e174b..48ef8eb19 100644
--- a/tests/integration/settings-data-wipe.test.ts
+++ b/tests/integration/settings-data-wipe.test.ts
@@ -104,7 +104,6 @@ describe("DELETE /api/settings/data leaves nothing it promised to delete", () =>
stravaAthleteId: "12345",
thresholdsJson: { sys: 130 },
onboardingCompletedAt: new Date(),
- onboardingStep: 4,
passwordHash: "argon2-placeholder",
locale: "en",
unitPreference: "metric",
From ec82ad3a93c68969ba9840d9da2e7ef121d354d6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 10 Sep 2026 15:41:06 +0200
Subject: [PATCH 09/21] Carry the setup answers into the checklist, Settings
and the module pages
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The getting-started checklist reads the answers: a visit named within a
month adds a "prepare the visit" row that is done once one is on the
calendar, and the data-source row names the wearable that was chosen.
Its per-browser state moves to one module so the done screen, the skip
and Settings write the same keys.
Settings → Account gains the two ways back in: "Set up again" through
the restart route, which resets the ledger, keeps the answers as
prefill and touches no module; and "Show the checklist again", which
forgets what this browser hid.
A page whose module is off now answers with the shared notice and the
one action that helps, in one place for every module page: the shell
maps the route to the module through the navigation model's own gate,
so a page and its nav entry cannot disagree about which switch they
follow. And the tour's two dead stops get their anchors on the
integrations and health-record group headings.
---
messages/en.json | 19 +++-
src/components/layout/auth-shell.tsx | 7 +-
src/components/layout/module-page-gate.tsx | 63 +++++++++++++
src/components/onboarding/choice-chip.tsx | 2 +-
.../onboarding/getting-started-checklist.tsx | 54 ++++++++++--
.../settings/account-section/index.tsx | 5 ++
.../settings/account-section/setup-card.tsx | 88 +++++++++++++++++++
.../settings/gesundheitsakte-section.tsx | 2 +
.../settings/integrations-section.tsx | 2 +
src/components/ui/section-heading.tsx | 8 ++
.../onboarding/__tests__/checklist.test.ts | 54 +++++++++++-
src/lib/onboarding/checklist.ts | 28 +++++-
src/lib/query-keys/onboarding.ts | 9 ++
13 files changed, 325 insertions(+), 16 deletions(-)
create mode 100644 src/components/layout/module-page-gate.tsx
create mode 100644 src/components/settings/account-section/setup-card.tsx
diff --git a/messages/en.json b/messages/en.json
index 5534da01c..19ce48706 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -4803,7 +4803,11 @@
"notificationsCta": "Open notifications",
"insightsTitle": "Turn on insights (optional)",
"insightsDescription": "Add a local model or your own AI key to unlock the daily briefing and Coach.",
- "insightsCta": "Turn on insights"
+ "insightsCta": "Turn on insights",
+ "visitTitle": "Prepare for the visit",
+ "visitDescription": "Put the appointment on the calendar; the doctor report bundles what to bring.",
+ "visitCta": "Open visits",
+ "dataSourceNamedTitle": "Connect {source}"
}
},
"trendHints": {
@@ -6615,7 +6619,15 @@
},
"dangerZoneDetail": "Your account and the credentials you sign in with are kept.",
"deleteAccountCardDetail": "This cannot be undone.",
- "passkeyStepUpRequired": "For your security, sign in again before removing a passkey."
+ "passkeyStepUpRequired": "For your security, sign in again before removing a passkey.",
+ "setup": {
+ "title": "Setup",
+ "description": "The questions that decide what HealthLog shows first, and the getting-started checklist on the dashboard.",
+ "setUpAgain": "Set up again",
+ "showChecklist": "Show the checklist again",
+ "checklistShown": "The checklist is back on the dashboard.",
+ "checklistNothingHidden": "The checklist was not hidden; it opens on the dashboard."
+ }
},
"admin": {
"title": "Administration",
@@ -8887,7 +8899,8 @@
"unavailable": {
"description": "The operator switched {module} off for every account on this server. No setting of your own can bring it back.",
"title": "Not available on this server"
- }
+ },
+ "openModules": "Open Settings, Modules"
},
"vaccinations": {
"title": "Vaccinations",
diff --git a/src/components/layout/auth-shell.tsx b/src/components/layout/auth-shell.tsx
index 1e48d0367..093044697 100644
--- a/src/components/layout/auth-shell.tsx
+++ b/src/components/layout/auth-shell.tsx
@@ -27,6 +27,7 @@ import { DemoBanner } from "./demo-banner";
import { OfflineBanner } from "./offline-banner";
import { SharedRecordBanner } from "./shared-record-banner";
import { SharedRecordUnavailable } from "./shared-record-unavailable";
+import { ModulePageGate } from "@/components/layout/module-page-gate";
import { RecordScopeHydrationGate } from "./record-scope-hydration-gate";
import { SidebarNav } from "./sidebar-nav";
import { TopBar } from "./top-bar";
@@ -466,7 +467,11 @@ export function AuthShell({
data-slot="main-content-wrapper"
className="mx-auto max-w-screen-xl px-4 pt-6 pb-20 md:px-6"
>
- {outsideSharedRecord ? : children}
+ {outsideSharedRecord ? (
+
+ ) : (
+ {children}
+ )}
diff --git a/src/components/layout/module-page-gate.tsx b/src/components/layout/module-page-gate.tsx
new file mode 100644
index 000000000..9046f72e9
--- /dev/null
+++ b/src/components/layout/module-page-gate.tsx
@@ -0,0 +1,63 @@
+"use client";
+
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import type { ReactNode } from "react";
+
+import { ModuleDisabledNotice } from "@/components/layout/module-disabled-notice";
+import {
+ isNavDestinationActive,
+ NAV_DESTINATIONS,
+} from "@/components/layout/nav-model";
+import { Button } from "@/components/ui/button";
+import { useAuth } from "@/hooks/use-auth";
+import { useTranslations } from "@/lib/i18n/context";
+
+/**
+ * v1.39 (C2) — the page of a module that is switched off answers with an
+ * empty state that names the switch (design spec §After the flow), in one
+ * place for every module page.
+ *
+ * The setup flow switches modules OFF for a new record — an explicit `false`
+ * for every module the answers did not name — and the navigation drops the
+ * entry. A person who reaches the page anyway (a bookmark, a link in a
+ * report, the tour) used to get the page's own empty state, which said
+ * "nothing here yet" about a module that is not there at all. This maps the
+ * route to the module through the navigation model's own gate, so a module
+ * page and its nav entry can never disagree about which switch they follow,
+ * and renders the shared notice with the one action that can help: the
+ * Modules settings, offered only when the record's own switch is what is off.
+ *
+ * Paint, not enforcement: every route behind a module refuses on its own
+ * through the server gate. Mounted inside the shell, which renders children
+ * only after the account payload resolved, so the map is never read on a
+ * hydration render.
+ */
+export function ModulePageGate({ children }: { children: ReactNode }) {
+ const pathname = usePathname();
+ const { user } = useAuth();
+ const { t } = useTranslations();
+
+ const destination = NAV_DESTINATIONS.find(
+ (d) => d.requiresModule && isNavDestinationActive(d.href, pathname),
+ );
+ const moduleKey = destination?.requiresModule;
+ const off = moduleKey !== undefined && user?.modules?.[moduleKey] === false;
+
+ if (!off || !destination || !moduleKey) return <>{children}>;
+
+ const Icon = destination.icon;
+ return (
+ }
+ action={
+
+
+ {t("moduleOff.openModules")}
+
+
+ }
+ />
+ );
+}
diff --git a/src/components/onboarding/choice-chip.tsx b/src/components/onboarding/choice-chip.tsx
index c2e67c6b7..f2062ca48 100644
--- a/src/components/onboarding/choice-chip.tsx
+++ b/src/components/onboarding/choice-chip.tsx
@@ -37,7 +37,7 @@ export function ChoiceChip({
return (
= {
profile: User2,
@@ -41,6 +45,7 @@ const ITEM_ICONS: Record = {
dataSource: Wifi,
notifications: Bell,
insights: Sparkles,
+ visit: Stethoscope,
};
const ITEM_LABEL_KEYS: Record<
@@ -77,8 +82,17 @@ const ITEM_LABEL_KEYS: Record<
description: "gettingStarted.items.insightsDescription",
cta: "gettingStarted.items.insightsCta",
},
+ visit: {
+ title: "gettingStarted.items.visitTitle",
+ description: "gettingStarted.items.visitDescription",
+ cta: "gettingStarted.items.visitCta",
+ },
};
+interface EncounterListMeta {
+ upcoming?: number;
+}
+
interface IntegrationsStatus {
integrations?: Array<{ connected?: boolean; enabled?: boolean }>;
}
@@ -288,6 +302,26 @@ export function GettingStartedChecklist() {
enabled: checklistRelevant,
});
+ // v1.39 (C2) — the "prepare the visit" row, only for the answer that asked
+ // for it: a visit within a month. Reads the visits list's own meta count so
+ // the row flips done the moment one is on the calendar.
+ const visitAsked = user?.onboarding?.needs.visit === "within-a-month";
+ const { data: encountersMeta } = useQuery({
+ queryKey: queryKeys.onboardingUpcomingVisits(),
+ queryFn: async () => {
+ const { meta } = await apiFetchEnvelope(
+ "/api/encounters",
+ );
+ return meta ?? {};
+ },
+ enabled: checklistRelevant && visitAsked,
+ });
+
+ // The named source: "Connect Oura" rather than "Connect a data source"
+ // for somebody who said their readings come from one.
+ const namedSource =
+ user?.onboarding?.needs.sources.find(isBrowserConnectableSource) ?? null;
+
const medicationCount = medsData?.length ?? 0;
const dataSourceConnected = (integrationsData?.integrations ?? []).some(
(integration) =>
@@ -312,6 +346,7 @@ export function GettingStartedChecklist() {
notificationsConfigured,
insightsConfigured,
dismissedIds,
+ upcomingVisitCount: encountersMeta?.upcoming ?? 0,
// v1.39 (C1) — the setup answers order these rows: medication first
// for somebody who said they take one daily, the data-source row first
// for somebody who named a wearable. The payload resolves them for the
@@ -331,6 +366,7 @@ export function GettingStartedChecklist() {
notificationsConfigured,
insightsConfigured,
dismissedIds,
+ encountersMeta?.upcoming,
],
);
@@ -497,7 +533,13 @@ export function GettingStartedChecklist() {
: "truncate text-sm font-medium"
}
>
- {t(labels.title)}
+ {item.id === "dataSource" && namedSource
+ ? t("gettingStarted.items.dataSourceNamedTitle", {
+ source: t(
+ questionOptionLabelKey("sources", namedSource),
+ ),
+ })
+ : t(labels.title)}
{t(labels.description)}
diff --git a/src/components/settings/account-section/index.tsx b/src/components/settings/account-section/index.tsx
index 21335c2b7..e07b9c7fa 100644
--- a/src/components/settings/account-section/index.tsx
+++ b/src/components/settings/account-section/index.tsx
@@ -48,6 +48,7 @@ import {
type StatusMessage,
} from "./account-section-utils";
import { AvatarSection } from "./avatar-section";
+import { SetupCard } from "./setup-card";
export { resolveInitialTimezone } from "./account-section-utils";
@@ -330,6 +331,10 @@ export function AccountSection() {
{/* Profile photo card */}
+ {/* v1.39 (C2) — the way back into the first-run experience: the setup
+ questions again, and the dashboard checklist this browser hid. */}
+
+