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