/** * A practitioner's availability is the UNION of every block they work, not one of them. * * THE DEFECT THIS EXISTS FOR, measured in a browser on 2026-08-27. One clinician, two schedule periods * at the same site, and the two screens disagreed about the same day: * * Schedules page, Thursday "9:00am - 11:00pm - Scheduled - Consultation" * Staff booking date picker "Thu, 27 - not working" aria-disabled="true" * * The control sat in the same list: Monday, covered by the FIRST period, was offered; Saturday and * Sunday, covered by nothing, were correctly refused. So the picker was not broken - **it was reading * one period.** The staff dialog bound availability to a single `selectedSchedule`, while the patient * flow had already been given a union (`bookingAvailabilityUnion.ts`). The two halves of one product * answered "when does this person work" differently. * * Extra hours can be saved and never booked. The practice does the work, the schedule confirms it, and * the capacity never exists - and nobody finds out from the product, they find out from a patient who * could not get an appointment. * * WHY IT IS A PURE HELPER RATHER THAN AN EFFECT IN THE DIALOG * The dialog is ~4,000 lines and is the front desk's main surface. The rule that matters here - which * schedules belong to this practitioner, and which one owns a given day - is a branch, and it is worth * being able to fail a test rather than being reachable only by driving a browser. */ import type { Schedule } from 'fhir/r4'; import { availabilityWindowsFor, type AvailabilityEventLike } from './scheduleYieldsTimes'; /** * The minimum an availability window needs for the union. * * `string | Date` because the producer really emits both, and the string form is LOCAL-ISO rather than * UTC. That distinction is why `localDateOf` below exists instead of a `toISOString().slice(0, 10)`. */ export interface DatedAvailabilityEvent extends AvailabilityEventLike { start?: string | Date; } /** * The calendar date this window starts on, in the LOCAL zone. * * NOT `toISOString().slice(0, 10)`. That renders in UTC, so a 9:00 PM window in a US practice becomes * the FOLLOWING day and the block stops covering the date it plainly covers. The measured defect this * whole module exists for was a Thursday 9:00am-11:00pm block, and 11:00 PM Central is already Friday in * UTC - so the naive form would have reproduced the bug while looking like a fix. */ const localDateOf = (start: string | Date | undefined): string => { if (typeof start === 'string') return start.slice(0, 10); if (!(start instanceof Date) || Number.isNaN(start.getTime())) return ''; const month = `${start.getMonth() + 1}`.padStart(2, '0'); const day = `${start.getDate()}`.padStart(2, '0'); return `${start.getFullYear()}-${month}-${day}`; }; export interface ScheduleWithEvents { schedule: Schedule; events: TEvent[]; } /** * Every schedule in `candidates` worked by this practitioner. * * MATCHED ON THE ROLE'S PRACTITIONER, NOT ON THE SCHEDULE'S OWN ACTOR. A schedule names a * `PractitionerRole`, and one human can hold several roles at a practice - so comparing schedule actors * would split one person's availability across their own roles, which is the same defect one level down. * * `practitionerId` empty an empty list rather than everything. Returning all of them would silently * union two different people's hours, and "no practitioner chosen" must not read as "every practitioner". */ /** * The practitioner a schedule belongs to, read from the schedule's OWN actor list first. * * THE FIRST VERSION OF THIS RESOLVED THE ROLE AND ONLY THE ROLE, AND IT SILENTLY EXCLUDED BLOCKS. * A saved Schedule carries BOTH actors - `ScheduleService` pushes `Practitioner/` and then * `PractitionerRole/` - but the role reference is only USEFUL if that role is in the list the caller * has loaded. When it is not, resolving through it yields `undefined`, the block is dropped from the * union, and the symptom is indistinguishable from the bug this module exists to fix: a day the schedule * plainly covers reads as "not working". * -> So the schedule's own Practitioner actor is tried first. It needs no lookup and cannot fail because * some other list was incomplete. The role remains a fallback for blocks that carry no Practitioner actor. */ export const practitionerIdOfScheduleActor = (schedule: Schedule): string | undefined => (schedule.actor ?? []) .map((actor) => actor.reference) .find((reference) => reference?.startsWith('Practitioner/')) ?.split('/')[1]; export const schedulesForPractitioner = ( candidates: Schedule[], practitionerId: string | undefined, /** Fallback resolver, used only when the schedule carries no `Practitioner/` actor of its own. */ practitionerIdOfSchedule: (schedule: Schedule) => string | undefined, ): Schedule[] => { if (!practitionerId) return []; return candidates.filter( (schedule) => (practitionerIdOfScheduleActor(schedule) ?? practitionerIdOfSchedule(schedule)) === practitionerId, ); }; /** * Flatten several schedules' windows into one list, keeping only genuine availability. * * The time-off filter is `availabilityWindowsFor`, shared with the patient flow and the bookability * bands - `getScheduleAvailabilityEvents` also emits `not-available` background events, and counting one * as bookable offers slots inside declared time off. */ export const unionAvailability = ( perSchedule: ScheduleWithEvents[], ): TEvent[] => perSchedule.flatMap(({ events }) => availabilityWindowsFor(events)); /** * Which schedule owns this date? * * Needed because the union answers "can this day be booked" while everything downstream - the slot * fetch, the timezone the hours are written in, the block the appointment is filed against - is still a * question about ONE schedule. Choosing the day from a union and then filing it against whichever * schedule happened to be selected is how a Thursday booking lands on the Mon/Wed block. * * FIRST MATCH IN CANDIDATE ORDER, and that is deliberate rather than arbitrary. Two blocks covering * one day is a real configuration (a morning site and an afternoon site), and there is no rule here that * could pick between them better than the order the caller already narrowed them into. What matters is * that it is a schedule that ACTUALLY covers the date, which is exactly what the old behaviour could not * promise. */ export const scheduleCoveringDate = ( perSchedule: ScheduleWithEvents[], dateISO: string, ): Schedule | undefined => { if (!dateISO) return undefined; return perSchedule.find(({ events }) => availabilityWindowsFor(events).some((event) => localDateOf(event.start) === dateISO), )?.schedule; }; /** A block, its windows, and the PHYSICAL site its hours are written at. */ export interface ScheduleWithSite extends ScheduleWithEvents { /** * The block's physical site id, from `scheduleSiteFor`. `undefined` is a real answer and means an * "all locations" block, which is the default a new block is saved on. */ siteId?: string; } /** * The blocks that share a site with the one the desk actually chose. * * THE MEASURED DEFECT, 2026-09-06. The front desk lists one practitioner row PER BLOCK and * names the site on it. Choosing "Dr. Practitioner One - West Burlington Hospital" and leaving the * location filter on its default offered **Tuesday**, a day only that practitioner's Southeast Iowa * block covers, then offered Southeast Iowa's hours - 9:00 AM to 10:00 PM with no lunch gap - under a * row chosen for West Burlington. Selecting the day did not correct the site on screen; it removed the * Where field entirely, so the receptionist pressed Book on a summary naming no site at all. The * booking was accepted. * * WHY THE UNION ITSELF STAYS. It exists for the 2026-08-27 defect, where a clinician with two periods * AT ONE SITE had the second invisible and the picker said "not working" on a day the schedule plainly * covered. Those two blocks share a site, so narrowing by SITE keeps them and keeps that fix. Narrowing * to the chosen BLOCK alone would fix this journey and reopen that one - which is why the same-site * pair is a control in the tests rather than a footnote. * * SITE, NOT LOCATION REFERENCE. A video-enabled block carries two Location actors, its physical site * and the org's virtual one. Comparing raw references would let two blocks at different hospitals match * on the virtual location they share, which is the defect wearing a hat. The caller passes the site it * already resolved with `scheduleSiteFor` for the timezone, so there is one derivation and not two. * * AN "ALL LOCATIONS" BLOCK IS KEPT EITHER WAY, and deliberately: it practises everywhere, so it is * never narrowed out by a site, and choosing one narrows nothing because it names no site to narrow to. * That matches the rule the desk's own schedule filter already applies - "a role scoped to All * locations practices everywhere, so it matches any chosen location". * * IT TAKES THE SITE, NOT THE CHOSEN BLOCK, AND THAT IS LOAD-BEARING RATHER THAN TIDY. The dialog * re-points its selected block to whichever one owns the date the desk picks. If this narrowed by the * SELECTED block's site, the narrowing would change the selection and the selection would change the * narrowing: with an "all locations" block and another site's block both covering one day, the two * effects oscillate and the dialog never settles. The site the desk chose in the picker is captured * once, is not re-derived from the selection, and therefore cannot be moved by it. */ export const blocksAtChosenSite = ( perSchedule: ScheduleWithSite[], chosenSiteId: string | undefined, ): ScheduleWithSite[] => { if (!chosenSiteId) return perSchedule; return perSchedule.filter(({ siteId }) => !siteId || siteId === chosenSiteId); };