import type { Appointment, Encounter } from 'fhir/r4'; import { getLocalDayRange } from '../../../utils/localDayRange'; /** * - WHICH appointments the front desk board is built from, and which encounters may * speak for one. The React half lives in `useFrontDeskDay.ts`; these rules are pure so they can be tested * without a DOM or a provider tree. * * THE COST, STATED RATHER THAN DISCOVERED: an Encounter search REQUIRES a subject. `Encounter/get.ts` * refuses a subject-less one with a 400 by design, so there is no "give me this organisation's encounters * for today" read - they are fetched PER APPOINTMENT, exactly as `useClinicianDay` already does for one * clinician's slice. At the volume this card's own `verify:` names (34 appointments) that is 34 reads per * refresh, which is why these scope functions exist at all: the board reads only for the appointments * that could actually have an encounter. */ /** These are not on the board at any hour. A cancelled appointment needs nothing from the front desk. */ const NEVER_ON_BOARD = new Set(['cancelled', 'entered-in-error']); /** Whether the board shows an appointment in this status at all. A finished (fulfilled) visit waits in Checkout. */ export const isOnFrontDeskBoard = (status?: Appointment['status']): boolean => status === undefined || !NEVER_ON_BOARD.has(status); /** A self-booking held for a receptionist to approve - which is due NOW whatever day the visit is for. */ const AWAITING_APPROVAL = new Set(['pending', 'proposed', 'waitlist']); const startMs = (iso?: string): number | undefined => { if (!iso) return undefined; const ms = new Date(iso).getTime(); return Number.isNaN(ms) ? undefined : ms; }; /** * Does this start sit inside the LOCAL day containing `now`? * * EXPORTED FOR THE LATE TOAST, and the export is the fix rather than a convenience. The * toast used to loop the raw appointment list with no day bound at all - only `status === 'booked'` and * elapsed minutes - so an appointment left `booked` on ANY past date alerted for ever. Measured on * staging: a patient with **no appointment today** and three still-booked ones from the previous fortnight * raised *"...is at least 60 minutes late. Please update the appointment status."* all evening, on a screen * scoped to today where that appointment does not appear. * * For a `booked` appointment `frontDeskBoardAppointments` reduces to exactly this test, so a toast * gated on it names the same set the board shows - by construction rather than by restatement. Two * collections feeding one screen, one filtered and one not, is what produced the defect. */ export const isToday = (iso: string | undefined, now: number): boolean => { const at = startMs(iso); if (at === undefined) return false; const { start, end } = getLocalDayRange(new Date(now)); return at >= start.getTime() && at < end.getTime(); }; /** * Which of the appointments the board already holds the four queues may be built from. * * TWO DIFFERENT QUESTIONS, and conflating them loses a queue either way. *Today* is what the waiting * room, the rooms and the day ahead are made of. *Awaiting approval* is a job that exists the moment a * patient self-books, whatever day they booked FOR - the drawing's own "Thu - self-booked 2:30pm" row is * two days out, and scoping Needs you to today would silently drop most requests. */ export const frontDeskBoardAppointments = ( appointments: readonly Appointment[], now: number, ): Appointment[] => appointments.filter((appointment) => { if (!isOnFrontDeskBoard(appointment.status)) return false; // A START IS REQUIRED TO SIT ON A DAY, AND NOT REQUIRED TO BE A JOB. `Appointment.start` is // optional in FHIR and a waitlist request legitimately has none - the shipped page already renders // those with "No date". Requiring one here would make a self-booking request INVISIBLE rather than // undated, which is the one row on this board that a patient is actively waiting on an answer to. // Found by a mutant, and the fixture that missed it was `booked`: with no start it was excluded by // the day test anyway, so the guard's only real subject was never exercised. if (AWAITING_APPROVAL.has(appointment.status)) return true; return isToday(appointment.start, now); }); /** * Which appointments may raise a late-arrival toast, and how late each one is.. * * THE DEFECT THIS REPLACES: the effect looped the raw appointment list testing only `booked` and * elapsed minutes. **No day bound at all** a visit nobody marked `fulfilled`/`noshow` became permanent * chrome, the count only ever grew, and the instruction it carries - *"please update the appointment * status"* - sent the receptionist to a screen scoped to today where the appointment is not. * * WHY IT IS PURE AND LIVES HERE rather than inside the effect: the property that matters is an * agreement between two collections, and an agreement cannot be asserted from inside one of them. Its * test pins that this is a SUBSET of `frontDeskBoardAppointments` - so the toast can never name somebody * the board is not showing, which is precisely what it was doing. * * Deliberately not a cap on how many render. A cap hides the symptom and leaves the alert lying about * the day; sonner's own `visibleToasts` limit is a separate, layout concern. */ export const lateToastAppointments = ( appointments: readonly Appointment[], now: number, warningMinutes: number, // A booked appointment whose visit has already begun is on the board as checked in or roomed, not late. inVisit: ReadonlySet = new Set(), ): Array<{ appointment: Appointment; id: string; elapsedMinutes: number }> => appointments.flatMap((appointment) => { const id = appointment.id; if (!id || appointment.status !== 'booked' || inVisit.has(id)) return []; if (!isToday(appointment.start, now)) return []; const at = startMs(appointment.start); if (at === undefined) return []; const elapsedMinutes = Math.floor(Math.max(0, now - at) / 60000); if (elapsedMinutes < warningMinutes) return []; // The id travels with the row so the caller needs no non-null assertion to key its toast. return [{ appointment, id, elapsedMinutes }]; }); /** * Which of those are worth spending an encounter read on. * * `fulfilled` IS INCLUDED, and that is load-bearing rather than tidy. Finishing a visit is exactly what * produces the Checkout row, and the finished encounter that says so is reachable only through the * appointment it belongs to - skipping it would make Checkout unreachable by construction while every * other test still passed. */ export const appointmentsNeedingEncounters = ( boardAppointments: readonly Appointment[], now: number, ): Appointment[] => boardAppointments.filter( (appointment) => Boolean(appointment.id) && !AWAITING_APPROVAL.has(appointment.status) && isToday(appointment.start, now), ); /** * Which of the encounters that came back may speak for the appointment we asked about. * * THIS IS A GUARD AGAINST A SERVER THAT ANSWERS 200 WITH THE WRONG THING. The read is * `Encounter?appointment=&subject=Patient/`, and HealthLake SILENTLY IGNORES a search parameter it * cannot honour rather than erroring - so if `appointment` ever stops being honoured the response is the * patient's WHOLE encounter history, at 200, and any patient with a past finished visit sits in Checkout * for ever on a board with nothing red anywhere. * * An encounter naming no appointment is refused as well. A walk-in started from the chart is real, but * it can never belong to an appointment-shaped row, and accepting it would attach it to whichever * appointment happened to be read first. */ export const encountersForAppointment = ( appointmentId: string | undefined, fetched: readonly Encounter[], ): Encounter[] => { if (!appointmentId) return []; const wanted = `Appointment/${appointmentId}`; return fetched.filter((encounter) => (encounter.appointment ?? []).some((ref) => ref.reference === wanted), ); }; /** * The appointments in range that the board shows but none of its slot queries returned: one with no * slot, or one whose slot sits on a schedule that has since been deleted. Both are real visits the * Appointments list still shows, so the board keeps them instead of dropping them. */ export const appointmentsTheSlotsMissed = ( range: readonly T[], reachedIds: ReadonlySet, shownOnBoard: (status?: Appointment['status']) => boolean, ): T[] => range.filter((appointment) => Boolean(appointment.id) && shownOnBoard(appointment.status) && !reachedIds.has(appointment.id));