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")} -

-
- -
-
- ) : ( - - )} -
- ); - } - - // 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 ( -
-
- {/* 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.goals.title")} -

-

- {t("onboarding.goals.body")} -

-

- {t("onboarding.goals.helpHint")} -

-
- -
- {t("onboarding.goals.title")} - {GOAL_OPTIONS.map((option) => { - const checked = selected.has(option.slug); - const Icon = option.Icon; - return ( - - ); - })} -
- -
- -
- - -
-
-
- ); -} 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 ` - ) : ( -