// The booking flow's step machine - pure, React-free, and the single source of truth for which step the // patient is on. It exists because the previous design keyed a control's VISIBILITY on how many options it // had left, which let a chosen service become invisible while still filtering (see the design doc ยง0). Here, // a step is hidden because it has not been reached, current because it is next, or answered because it has a // value - option count never decides whether a control exists. // `patientType` WAS THE FIRST STEP AND IS GONE. It asked the patient whether they were new or // existing, because the signal behind it could not tell an established patient from an unrecorded // one. "Seen" is derived from an Encounter now and the server reads the same record, so the answer // is known and the question is not asked. Removed from the vocabulary rather than left switched // off: the flag that hid it was assigned `false` at its only call site, so the type, the step list // and a grep all reported a step nothing could produce - which cost a walking session a night // looking for a fixture that would provoke a question that no longer existed. export type BookingStepId = | 'reason' | 'service' | 'location' | 'practitioner' | 'date' | 'time'; export const STEP_ORDER: BookingStepId[] = [ 'reason', 'service', 'location', 'practitioner', 'date', 'time', ]; export interface BookingAnswers { reason?: string; service?: string; location?: string; practitioner?: string; date?: string; time?: string; } export interface StepOption { id: string; display: string } export type StepStatus = | { kind: 'hidden' } | { kind: 'current' } | { kind: 'answered'; value: string; editable: true } | { kind: 'answered'; value: string; editable: false; reason: string }; export type AnsweredStep = Extract; // Render code MUST narrow through this before reading `value`/`reason`. `PatientBookAppointment.tsx` has no // type gate (services-barrel import scoped tsc unsafe, full tsc banned, and both the build and vitest are // transpile-only), so an unguarded `.value` on the union would fail at runtime rather than at compile time. export const isAnswered = (s: StepStatus): s is AnsweredStep => s.kind === 'answered'; export interface FlowInput { answers: BookingAnswers; /** Options remaining per narrowable axis, already filtered by the upstream answers. */ options: { service: StepOption[]; location: StepOption[]; practitioner: StepOption[] }; /** Set when the new-patient exam policy has determined the service for the patient. */ servicePinnedTo?: StepOption; } export interface FlowState { steps: Record; currentStep: BookingStepId | null; /** False when the service is pinned - asking an LLM to match an already-decided service is meaningless. */ serviceAssistRequired: boolean; } // Copy lives here, not in the components, so the "why is this locked?" sentence can never be omitted at one // call site and present at another. A locked step with no stated cause is the defect this redesign removes. const LOCK_REASON: Partial> = { service: 'the only service available', location: 'the only location offering this service', practitioner: 'the only practitioner offering this service at this location', }; const OPTIONS_FOR: Partial> = { service: 'service', location: 'location', practitioner: 'practitioner', }; export function resolveFlowState(input: FlowInput): FlowState { const { answers, options, servicePinnedTo } = input; const steps = {} as Record; let currentStep: BookingStepId | null = null; for (const id of STEP_ORDER) { if (id === 'service' && servicePinnedTo) { steps[id] = { kind: 'answered', value: servicePinnedTo.display, editable: false, reason: 'required for your first visit with this practice', }; continue; } // Unanswered. It is only reachable if nothing before it is still open. if (currentStep !== null) { steps[id] = { kind: 'hidden' }; continue; } const answer = answers[id]; if (answer) { const optionKey = OPTIONS_FOR[id]; const match = optionKey ? options[optionKey].find((o) => o.id === answer) : undefined; steps[id] = { kind: 'answered', value: match?.display ?? String(answer), editable: true }; continue; } // A single remaining option is an ANSWER, not a disappearance - and it must say why it is locked, // because editing the upstream step is the only way to change it. const optionKey = OPTIONS_FOR[id]; const remaining = optionKey ? options[optionKey] : undefined; if (remaining && remaining.length === 1) { steps[id] = { kind: 'answered', value: remaining[0].display, editable: false, reason: LOCK_REASON[id] as string, }; continue; } steps[id] = { kind: 'current' }; currentStep = id; } return { steps, currentStep, serviceAssistRequired: !servicePinnedTo }; } export function clearFrom(answers: BookingAnswers, step: BookingStepId): BookingAnswers { const from = STEP_ORDER.indexOf(step); const next: BookingAnswers = { ...answers }; for (const id of STEP_ORDER.slice(from)) delete next[id]; return next; } // The only service left is the patient's service for every later step, not just a line on screen. Left // uncommitted, later steps narrow for no service, and a rule that needs one (video delivery) offers nowhere. // Returns the filter to hold, '' to drop one this rule set, or undefined to leave the filter alone. export function lockedServiceFilter(input: { service: StepStatus; options: StepOption[]; selected: string; committed?: string; autoSet?: string; }): string | undefined { const { service, options, selected, committed, autoSet } = input; if (isAnswered(service) && !service.editable && options.length === 1) { return selected === options[0].id ? undefined : options[0].id; } if (autoSet && selected === autoSet && !committed) return ''; return undefined; }