/** * WHAT THE FRONT DESK SHOULD TRY WHEN ITS PICKER COMES BACK EMPTY. * * The patient dialog answers this from the matcher: `softConstraints` drops one axis at a time, * re-runs `evaluateScheduleMatch`, and names whichever change opens the most schedules. The * receptionist dialog cannot use that, and the reason is structural rather than an oversight - it does * not use the matcher at all. Its list comes from a hand-rolled predicate over the role, location and * service, so the matcher's idea of what is bookable and the desk's idea of what is bookable are two * different narrowings. * * AND THAT IS THE HAZARD THIS FILE EXISTS TO REMOVE, NOT TO ACCEPT. Explaining the desk's empty * picker with the MATCHER's answer means two independent narrowings have to agree. When they disagree * the screen names a remedy the picker then refuses - a receptionist told "try another location" * changes the location and the list is still empty. That is worse than saying nothing, because silence * costs them one guess and a wrong remedy costs them several plus the belief that the screen knows. * * SO THIS TAKES THE COUNTING FUNCTION FROM THE CALLER RATHER THAN COMPUTING ITS OWN. The caller * passes the very predicate that populates its picker, and a remedy is only ever named because THAT * predicate returned something. Disagreement is not handled here; it is made impossible. * * There is no `why` half, deliberately. The desk has no blockers to name - those come from the * matcher - and inventing a cause would send somebody to fix a setting that may be correct. Half an * explanation that is true beats a whole one that guesses; `explainDeadEnd` already treats either half * as optional for the same reason. */ /** The three narrowings the desk's own picker applies, and the only ones it can offer to give up. */ export type FrontDeskAxis = 'location' | 'service' | 'role'; export type FrontDeskSelection = Partial>; export interface FrontDeskRelaxation { axis: FrontDeskAxis; /** How many schedules the desk's own predicate returns if this one choice is dropped. */ matches: number; } /** * TIE-BREAK ORDER, AND IT IS THE PATIENT SIDE'S ORDER READ THROUGH A DESK'S CONSTRAINTS. * * `softConstraints` ranks location, then service, then practitioner - least personal change first. * The desk's third axis is a provider TYPE rather than a named person, but it sits in the same place * for the same reason: it is the choice most likely to be the point of the booking, since a caller * asking for a physician usually means it. */ const RELAXATION_ORDER: readonly FrontDeskAxis[] = ['location', 'service', 'role']; const NOUN: Record = { location: 'another location', service: 'a different visit service', role: 'a different provider type', }; const without = (chosen: FrontDeskSelection, axis: FrontDeskAxis): FrontDeskSelection => { const relaxed = { ...chosen }; delete relaxed[axis]; return relaxed; }; /** * The axes that would open something up, most-opened first, ties broken by `RELAXATION_ORDER`. * * RETURNS EMPTY WHEN THE SELECTION ALREADY MATCHES SOMETHING, which is control ④ of this card arriving * through the data rather than through a flag: "what should I give up" is not a question anybody is * asking while their combination still works, and a panel that speaks on every booking is one nobody * reads. * * AND EMPTY WHEN NOTHING HELPS. If dropping each axis in turn still yields nothing, no single change * opens the picker, and "try another location" is a door that does not open. */ export const frontDeskRelaxations = ( chosen: FrontDeskSelection, countMatching: (selection: FrontDeskSelection) => number, ): FrontDeskRelaxation[] => { if (countMatching(chosen) > 0) return []; const relaxations: FrontDeskRelaxation[] = []; for (const axis of RELAXATION_ORDER) { // Only a narrowing they actually APPLIED can be given up. Dropping one that was never set is // not a change that is theirs to make, and its count is by definition the unrelaxed count - // which the early return above has already established is zero. if (chosen[axis] === undefined) continue; const matches = countMatching(without(chosen, axis)); if (matches > 0) relaxations.push({ axis, matches }); } return relaxations.sort( (a, b) => b.matches - a.matches || RELAXATION_ORDER.indexOf(a.axis) - RELAXATION_ORDER.indexOf(b.axis), ); }; /** * The one sentence, or nothing at all. * * Nothing is a real answer here and is returned more often than it looks: it covers both "the * selection already works" and "no single change helps", and in neither case does the desk benefit * from being spoken to. */ export const frontDeskRemedyText = (relaxations: readonly FrontDeskRelaxation[]): string | undefined => relaxations[0] ? `Try ${NOUN[relaxations[0].axis]}.` : undefined; export const FRONT_DESK_AXIS_NOUNS = NOUN; /** * IS THIS SERVICE OFFERED BY ANYBODY HERE AT ALL? * * A CONFIGURATION PROBLEM AND A SCHEDULING PROBLEM LOOK IDENTICAL AT THE DESK, and until this they were * told apart by neither. "Try a different visit service" is the honest remedy when other services work * at this location - and it is the wrong sentence entirely when NOBODY offers the chosen one anywhere, * because then the receptionist is being asked to work around a catalogue gap as though it were a busy * afternoon. One of those is fixed by picking another time; the other is fixed by an administrator. * * MEASURED at the main test practice: seven services in the catalogue and two attached to any schedule * block. Five of the seven can never resolve to anybody, so five of seven empty pickers were the chain * working correctly - and the desk had no way to say so. That is why a partially-wired catalogue reads * as a working one until somebody picks the service nobody wired. * * IT USES THE CALLER'S OWN PREDICATE, for the reason the whole file exists: the desk's idea of bookable * and the matcher's idea of bookable are two different narrowings, and a remedy derived from the other * one names a door that does not open. Dropping EVERY axis but the service asks the desk's own list a * question only it can answer. * * FALSE WHEN NO SERVICE IS CHOSEN. An unchosen service is not an unoffered one, and saying so would * accuse a catalogue of a gap the receptionist has not looked for yet. */ export const serviceIsOfferedByNobody = ( chosen: FrontDeskSelection, countMatching: (selection: FrontDeskSelection) => number, ): boolean => { if (chosen.service === undefined) return false; return countMatching({ service: chosen.service }) === 0; }; /** * What the desk is told when nobody offers the chosen service. * * IT NAMES THE SERVICE AND IT NAMES WHO CAN FIX IT. "No provider offers this" leaves a receptionist * re-picking times against a wall; the point of separating this from the relaxations is that there is * nothing here for THEM to try, and pretending otherwise is what cost the guesses. */ export const serviceOfferedByNobodyText = (serviceName: string): string => `No practitioner at this practice offers ${serviceName}. An administrator must add it to a ` + `practitioner's schedule. Changing the time or location will not help.`;