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] 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 }; +}