Files
HealthLog/src/lib/onboarding/wizard-steps.ts
T
Marc-André Bombeck fea1d1e0b5 chore: strip planning tags from comments and enable the rule
Output of `node scripts/strip-planning-tags.mjs --write`: 3,157 comment
lines in 1,308 files lose the work-item identifier at their head. Release
versions and issue references stay; nothing outside comment text
changes, which the script checks per file against a comment-free
printout. healthlog/no-planning-tags is enabled at error level with this
commit, so the tree it lands on is clean.
2026-10-20 09:08:40 +02:00

276 lines
9.7 KiB
TypeScript

/**
* v1.39 — 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_SKIPPABLE_STEP_IDS,
type OnboardingFirstResultTask,
type OnboardingNeeds,
type OnboardingStateDto,
type OnboardingStepId,
} from "./needs";
export type OnboardingScreenId = "welcome" | OnboardingStepId;
/**
* 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: string,
): source is BrowserConnectableSource {
return (BROWSER_CONNECTABLE_SOURCES as readonly string[]).includes(source);
}
type FlowState = Pick<OnboardingStateDto, "steps" | "needs">;
function status(state: FlowState, id: OnboardingStepId) {
return state.steps.find((step) => step.id === id)?.status ?? "pending";
}
const UNIT_AREAS = new Set<OnboardingNeeds["areas"][number]>([
"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];
}
const EMPTY_CONNECTED: ReadonlySet<string> = new Set<string>();
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.
*
* `connected` names the sources whose connection is already delivering. It is
* optional because the server picks the order from the answers alone, before
* any status is in hand; the screen re-picks once the envelope resolves. A
* source in the set is not a task — an account whose WHOOP had been feeding
* the dashboard for months was told to go and connect WHOOP, which is the
* wizard talking past the account it can already see.
*/
export function chooseFirstResultTask(
needs: OnboardingNeeds,
connected: ReadonlySet<string> = EMPTY_CONNECTED,
): FirstResultOffer | null {
const connectable = needs.sources.find(
(source) => isBrowserConnectableSource(source) && !connected.has(source),
);
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.
*
* The first question is the one exception: it is where the welcome screen's
* "Set up" goes, and a record that has not entered the flow resumes on the
* welcome, so without this arm nobody could ever start.
*/
export function canVisitScreen(
state: FlowState,
screen: OnboardingScreenId,
): boolean {
const order = screenOrder(state);
const index = order.indexOf(screen);
if (index === -1) return false;
return index <= Math.max(order.indexOf(resumeScreen(state)), 1);
}
/**
* The questions the confirm screen must pass on the ledger before it
* completes: every skippable question this flow never showed and that is
* still pending. The completion route derives only once EVERY question is
* answered or passed, and Q6 is a question the machine hides for a flow with
* no unit-bearing area — so without this the route answers "incomplete" to
* three flows out of four, which is exactly what the first real run found.
* The first-result step is not a question and is passed by the confirm
* screen on its own rule.
*/
export function questionsToPassBeforeConfirm(
state: FlowState,
): Exclude<(typeof ONBOARDING_SKIPPABLE_STEP_IDS)[number], "first-result">[] {
const shown = new Set<string>(questionScreens(state));
return ONBOARDING_SKIPPABLE_STEP_IDS.filter(
(id): id is Exclude<typeof id, "first-result"> =>
id !== "first-result" &&
!shown.has(id) &&
status(state, id) === "pending",
);
}
/**
* Whether a pathname is the setup flow's. The auth shell paints these screens
* without the app chrome, and it once matched only the front door — every
* question screen then rendered with the sidebar. Pure, so the shell's
* decision is pinned without a browser.
*/
export function isOnboardingPathname(pathname: string): boolean {
return pathname === "/onboarding" || pathname.startsWith("/onboarding/");
}
/**
* "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 };
}