mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
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.
276 lines
9.7 KiB
TypeScript
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 };
|
|
}
|