import type { AvailabilityEventLike } from './scheduleYieldsTimes'; import { addCalendarDays, zonedWallClockToInstant } from '../practiceTimezone'; /** * Which DATES a schedule can actually be booked on - asked before a date is chosen, not after. * * THE DEFECT THIS EXISTS TO REMOVE (, finding B41) * * The Book Appointment date picker was built from the calendar month alone: every remaining day of the * selected month, always selectable. Picking one then fetched slots and, if there were none, said * *"No available times for this date. Please select a different date."* * * Measured live: the picker offers the same practitioner twice, once per location, and only one of * those has bookable hours. On the wrong one **all 19 remaining dates were offered and all 19 came back * empty** - each time blaming the DATE, when the fix was to change the PRACTITIONER, which it never said. * A receptionist pays that round-trip with a patient on the phone. * * It needs no new request. The availability windows are already in hand the moment a practitioner is * chosen - the dialog only ever consulted them one date at a time, after the fact. * * WHAT THIS DELIBERATELY DOES NOT ANSWER * * **"Is there a free time?"** - only *"does this practitioner work at all that day?"* Whether the day is * already **fully booked** depends on existing appointments, which is a per-date fetch this cannot make. * It errs toward OFFERING a day: it may leave a fully-booked day enabled (the existing message then * explains it), and it will never hide a day that could have been booked. **That direction is the safe * one** - a date picker that hides a bookable day is worse than one that occasionally explains itself. * * It also ignores the time GRID. A window shorter than the appointment is excluded, but a window that * offers no grid-aligned start is not - the same over-offer, in the same safe direction. */ export interface AvailabilityWindow { start: string | Date; end: string | Date; } export interface DateAvailabilityInput { /** "Now", passed in so this is deterministic and the caller owns the clock. */ now: Date; /** The appointment length being booked. A window shorter than this cannot hold it. */ durationMinutes: number; /** The clock the date is read on. Omitted, the viewer's own. */ timeZone?: string | null; } const dayBounds = (dateISO: string, timeZone?: string | null): [Date, Date] => { if (timeZone) { return [ zonedWallClockToInstant(dateISO, '00:00', timeZone), zonedWallClockToInstant(addCalendarDays(dateISO, 1), '00:00', timeZone), ]; } const start = new Date(`${dateISO}T00:00:00`); const end = new Date(start); end.setDate(end.getDate() + 1); return [start, end]; }; /** Keep the caller from having to remember which events are bookable and which are time-off markers. */ export const bookableWindows = (events: T[]): T[] => events.filter((event) => event.extendedProps?.type === 'availability'); /** * Can an appointment of `durationMinutes` start on this local date and finish inside ONE window? * * @param dateISO local calendar date, `YYYY-MM-DD` - the same string the date picker uses as its value. */ export const dateHasBookableWindow = ( dateISO: string, windows: AvailabilityWindow[], { now, durationMinutes, timeZone }: DateAvailabilityInput, ): boolean => { if (!dateISO || windows.length === 0) return false; // Local midnight to local midnight. Not UTC: the picker's value is a local calendar date, and // parsing it as UTC shifts the whole day for anyone west of Greenwich - which is everyone here. const [dayStart, dayEnd] = dayBounds(dateISO, timeZone); if (Number.isNaN(dayStart.getTime())) return false; return windows.some((window) => { const windowStart = new Date(window.start); const windowEnd = new Date(window.end); if (Number.isNaN(windowStart.getTime()) || Number.isNaN(windowEnd.getTime())) return false; // The earliest this appointment could begin: inside the window, on this date, and not in the past. // That last clip is what stops a 9-5 schedule offering "today" at 23:00 - the window is real, it // is simply over. const earliestStart = Math.max(windowStart.getTime(), dayStart.getTime(), now.getTime()); // It must START on this date - that is what makes it an option in a DATE picker. if (earliestStart >= dayEnd.getTime()) return false; // CORRECTED by a mutation run: the end was clipped to midnight too, and that is wrong. The rule // the time picker applies is that the appointment fits inside ONE WINDOW - it says nothing about // the calendar day. Clipping at midnight refuses a legitimate late start on a window that runs // overnight (urgent care, sleep studies), which is a real schedule shape, and the mutant that // removed the clip passed every test - correctly, because the clip was the defect. return windowEnd.getTime() - earliestStart >= durationMinutes * 60_000; }); }; /** * Why a DATE cannot be booked - the distinction three surfaces were found disagreeing over. * * THE OBSERVATION * One clinician, one day, three answers, measured within minutes of each other at ~22:30 local: * - the front desk's "On today" panel: **"9:00 AM - 5:00 PM"** * - the STAFF booking date picker: **"not working"** * - the PATIENT booking date list: offered the day as the first selectable date * * AND THE FRONT-DESK PANEL WAS RIGHT. It answers *"who is in the building today"*, which is a fact * about the DAY and has nothing to do with the clock. It is not one of the three that needed fixing, and * reconciling it to the pickers would have replaced a true statement with a false one. * * THE OTHER TWO WERE READING THE SAME SCHEDULE AND ASKING DIFFERENT QUESTIONS: * - the staff picker asked *"can an appointment still START here?"* - correct, and then rendered the * answer with the words **"not working"**, which is a claim about the WEEK, not about the hour; * - the patient picker asked only *"is this date in the past?"*, so a day whose hours were over was * offered, and every time on it came back empty. * **Two defects, one cause: neither surface could say "the day is over".** This type is that word. */ export type DateUnavailableReason = /** No availability window on this date at all - this practitioner does not work that day. */ | 'not-working' /** * There ARE windows on this date, but none can still hold the appointment — normally because the day * is over, and occasionally because the remaining sliver is shorter than the visit. * * ⚠️ It is deliberately ONE reason rather than two. From the schedule alone, "you are too late" and * "the last 10 minutes cannot hold a 30-minute visit" are the same fact to the person booking: this * day is spent, try another. Splitting them would put a distinction on screen that changes nothing * about what the reader should do next. */ | 'day-over'; /** * Why a date cannot be booked, or `undefined` when it can. * * Both pickers must call THIS rather than each deriving "not working" themselves - that divergence is * the whole of. `reassignFeasibility` already says it keeps the rule identical on purpose. */ export const dateUnavailableReason = ( dateISO: string, windows: AvailabilityWindow[], input: DateAvailabilityInput, ): DateUnavailableReason | undefined => { if (dateHasBookableWindow(dateISO, windows, input)) return undefined; // Asked with the clock moved to the START of the day: if the appointment WOULD have fitted at // 00:00, the practitioner does work this date and the only thing that stopped it was time passing. // Not `windows.some(w => sameDay(w, date))` - that would call a day "workable" when its only window // is a 10-minute sliver that could never have held the visit, which is a different and misleading // answer. const [dayStart] = dayBounds(dateISO, input.timeZone); if (Number.isNaN(dayStart.getTime())) return 'not-working'; return dateHasBookableWindow(dateISO, windows, { ...input, now: dayStart }) ? 'day-over' : 'not-working'; }; /** Why a practitioner offers no dates at all - the distinction the old message could not draw. */ export type NoDatesReason = /** The schedule declares no bookable hours whatsoever. Choosing another date cannot help. */ | 'no-hours-at-all' /** There are hours, but none left in the month being viewed. Another month might work. */ | 'none-left-this-month'; export interface MonthAvailability { /** The subset of `dates` that can hold this appointment. */ bookable: Set; /** Set only when NOTHING in `dates` is bookable - otherwise undefined. */ reason?: NoDatesReason; } /** * Classify a month's worth of date options in one pass. * * The `reason` is the point, not the set. Disabling all 19 dates without saying why is no more * actionable than the message it replaces - worse, arguably, because a disabled control offers nowhere * to click and no text to read. **A practitioner with no hours at all has to be named as the cause.** */ export const monthAvailability = ( dates: string[], windows: AvailabilityWindow[], input: DateAvailabilityInput, ): MonthAvailability => { const bookable = new Set(dates.filter((date) => dateHasBookableWindow(date, windows, input))); if (bookable.size > 0) return { bookable }; return { bookable, reason: windows.length === 0 ? 'no-hours-at-all' : 'none-left-this-month' }; };