import type { Schedule } from 'fhir/r4'; import { evaluateScheduleMatch, type MatchContext, type MatchCriteria } from './matchSchedules'; /** * WHICH OF THE THINGS THEY ASKED FOR IS THE ONE TO GIVE UP. * * When a combination yields nothing, the person is told the combination is empty and left to guess which * of three choices to change. That guess is the whole reason the assist exists - Spencer's own words on * this: somebody wanting a service their preferred location does not offer should get alternatives * "without the user having to play a guessing game of what combination of options is the correct one." * * THE MATCHER PRODUCES THE ANSWER. NOTHING HERE INVENTS ONE, and that is deliberate rather than modest: * the settled rule for this feature is that the matcher enumerates and the model may only phrase and * rank. So this drops one axis at a time and re-asks `evaluateScheduleMatch` - the same function the * patient flow filters on, so a suggestion can never name a combination the flow would then refuse. * There is no model here at all, which also means it cannot fail open or time out. */ /** The three axes a person chooses. `practitionerId` is last on purpose - see `RELAXATION_ORDER`. */ export type RelaxableAxis = 'service' | 'location' | 'practitionerId'; export interface Relaxation { axis: RelaxableAxis; /** How many schedules open up if this one choice is dropped and the other two are kept. */ matches: number; } /** * TIE-BREAK ORDER, AND IT IS A PRODUCT JUDGEMENT RATHER THAN AN ALPHABETICAL ONE. * * Two axes opening the same number of schedules are not equally cheap to give up. Location is the least * personal - a different site is an inconvenience. Practitioner is the most - being sent to somebody * else is a different visit to most people, and for a patient with a relationship it may be the whole * point of booking. Service sits between: it is often the thing they are least sure of, since the * catalogue is clinical, but changing it changes what they are actually getting. */ const RELAXATION_ORDER: readonly RelaxableAxis[] = ['location', 'service', 'practitionerId']; const withoutAxis = (criteria: MatchCriteria, axis: RelaxableAxis): MatchCriteria => { const relaxed = { ...criteria }; delete relaxed[axis]; return relaxed; }; /** * The axes that would open something up, most-opened first, ties broken by `RELAXATION_ORDER`. * * RETURNS EMPTY WHEN THE CRITERIA ALREADY MATCH SOMETHING. This answers "what should I give up", * which is not a question anybody is asking while their combination still works - and a panel that * offers alternatives to a person who has options is noise on the surface this feature exists to * quieten. * * AND EMPTY WHEN NOTHING HELPS. Dropping every axis in turn and still finding nothing means the * practice cannot do this at all, and saying "try a different location" there sends somebody hunting * through a menu that has no answer in it. Silence is the honest output; the caller already has the * blockers from the matcher to explain WHY. */ export const softConstraints = ( schedules: Schedule[], criteria: MatchCriteria, ctx: MatchContext, ): Relaxation[] => { const matchCount = (c: MatchCriteria): number => schedules.filter((schedule) => evaluateScheduleMatch(schedule, c, ctx).ok).length; if (matchCount(criteria) > 0) return []; const relaxations: Relaxation[] = []; for (const axis of RELAXATION_ORDER) { // Only an axis they actually CHOSE can be given up - dropping one they never set is not a // relaxation that is theirs to make. // // AND THIS SAVES WORK RATHER THAN CHANGING THE ANSWER, which is worth stating because the // comment here used to claim otherwise. Removing an absent key leaves the criteria identical, so // its count is the unrelaxed count - and that is already known to be 0, because the early return // above is the only way execution reaches this loop. The entry could never have been pushed. // Mutation-tested: deleting this line leaves every assertion green. It is a skipped pass over // the schedule list, not a guard, and calling it a guard would be a claim no test can support. if (criteria[axis] === undefined) continue; const matches = matchCount(withoutAxis(criteria, axis)); if (matches > 0) relaxations.push({ axis, matches }); } // Most doors opened first; RELAXATION_ORDER breaks a tie, because `sort` is stable and the list was // built in that order. return relaxations.sort((a, b) => b.matches - a.matches); };