import type { Extension, Schedule } from 'fhir/r4'; /** * HOW FAR APART THE BOOKABLE START TIMES ARE ON ONE PRACTITIONER'S SCHEDULE BLOCK. * * Ruled 2026-09-06: *"the slot increment is set on the practitioner's schedule block, choosing 10, 15, * 20 or 30 minutes as Epic does; appointment length comes from the service; the practice default is 15."* * * IT IS NOT THE APPOINTMENT LENGTH AND THE TWO MUST NOT BE CONFLATED. The increment decides where a * visit may START - the grid a patient picks from. The LENGTH comes from the service, so a 40-minute * service on a 15-minute grid may begin at 9:00, 9:15, 9:30 and runs 40 minutes from wherever it began. * Reading one as the other is how a booking screen ends up offering times nothing can fit into, or * hiding times that fit perfectly well. * * WHY IT LIVES ON THE BLOCK RATHER THAN ON THE PRACTICE. One clinician may run 30-minute consultations * while another next door runs 10-minute follow-ups, and the same clinician may want a different grid * for a Saturday clinic than for a weekday. The block is the smallest thing that already carries the * where and the when, so it is the right place for the how-often. * * ABSENT MEANS FIFTEEN, and that default is the ruling rather than a convenience. Until this existed the * patient's grid was a hand-typed table of half-hours - so every practice, whatever it had chosen, was * offered thirty. A practice that has never opened this setting gets the stated default and not the * shape of the old table. */ export const SCHEDULE_SLOT_INCREMENT_EXTENSION_URL = 'https://akashichealthrecords.com/fhir/StructureDefinition/schedule-slot-increment-minutes'; /** * The increments a practice may choose. * * A CLOSED SET, because the grid is a promise to the patient rather than a free number. Seven minutes * would divide an hour into a ragged set of start times nobody could hold in their head, and an * arbitrary value here would silently reshape every booking screen that reads it. The four are the ones * the ruling names. */ export const SCHEDULE_SLOT_INCREMENTS = [10, 15, 20, 30] as const; export type ScheduleSlotIncrement = (typeof SCHEDULE_SLOT_INCREMENTS)[number]; /** The practice default, stated by the ruling. */ export const DEFAULT_SLOT_INCREMENT_MINUTES: ScheduleSlotIncrement = 15; const isSupported = (value: unknown): value is ScheduleSlotIncrement => typeof value === 'number' && (SCHEDULE_SLOT_INCREMENTS as readonly number[]).includes(value); /** * The increment configured on this block, or the default when it has none. * * NEVER RETURNS SOMETHING UNSUPPORTED, whatever is stored. A value outside the set - written by an older * client, a hand edit, or a future option this build has not been taught - falls back to the default * rather than being honoured, because honouring it would build a grid this product cannot describe and * the practice never chose. Falling back is visible in the grid; honouring a stray number is not. * * A MISSING BLOCK IS THE DEFAULT TOO. Callers reach for this while a schedule is still loading, and * answering "no grid" there would render an empty picker that looks like a practice with no hours. */ export const getScheduleSlotIncrement = ( schedule?: Schedule | null, ): ScheduleSlotIncrement => { const raw = schedule?.extension?.find( (extension) => extension.url === SCHEDULE_SLOT_INCREMENT_EXTENSION_URL, )?.valueInteger; return isSupported(raw) ? raw : DEFAULT_SLOT_INCREMENT_MINUTES; }; /** * The block with its increment set, or with the extension removed when it is the default. * * THE DEFAULT IS STORED AS ABSENCE, deliberately. A practice that never opened this setting and one that * opened it and chose 15 are the same practice, and writing the value for both would make the record * claim a decision nobody made - which matters the day the default changes, because a stored 15 would * silently keep the old behaviour while an absent one follows the ruling. * * EVERY OTHER EXTENSION SURVIVES. Rebuilding the array from scratch here is how an unrelated setting on * the same block gets dropped by a save that had nothing to do with it. */ export const applyScheduleSlotIncrement = ( schedule: Schedule, minutes: ScheduleSlotIncrement, ): Schedule => { const others = (schedule.extension ?? []).filter( (extension) => extension.url !== SCHEDULE_SLOT_INCREMENT_EXTENSION_URL, ); const next: Extension[] = minutes === DEFAULT_SLOT_INCREMENT_MINUTES ? others : [...others, { url: SCHEDULE_SLOT_INCREMENT_EXTENSION_URL, valueInteger: minutes }]; // FHIR forbids an empty repeating element - the property must be ABSENT rather than `[]`, and // HealthLake enforces it. Clearing the last extension on a block is exactly how one gets there. if (next.length === 0) { const { extension: _dropped, ...withoutExtensions } = schedule; return withoutExtensions; } return { ...schedule, extension: next }; }; /** * The bookable start times a block offers on one day, as `HH:MM`, walking the block's own hours. * * IT WALKS THE BLOCK RATHER THAN FILTERING A FIXED LADDER, and that is the other half of the defect this * replaces. The patient's grid was a hand-typed 24-hour table from 06:00 to 05:30 filtered by * availability, so it could only ever offer half-hours and it offered them at times the practice does * not work. Generating from the block means the grid is the practice's own hours at the practice's own * increment, and nothing else. * * THE LAST SLOT IS THE LAST ONE THAT STARTS BEFORE THE END, not the one that ends on it. A block closing * at 17:00 on a 15-minute grid offers 16:45; whether a 40-minute service actually fits there is the * DURATION question, answered downstream against the service, and answering it here would make this * function silently service-specific. */ export const slotStartTimes = ( startTime: string, endTime: string, incrementMinutes: number, ): string[] => { const toMinutes = (value: string): number => { const [hours, minutes] = value.split(':').map(Number); return Number.isFinite(hours) && Number.isFinite(minutes) ? hours * 60 + minutes : NaN; }; const from = toMinutes(startTime); const to = toMinutes(endTime); // A malformed or inside-out window yields NOTHING rather than a guess. An hour that cannot be read // is not an hour the practice offered, and inventing slots from it would put a patient in front of // times nobody works. // // ONE ARM OF THIS IS LOAD-BEARING AND THE REST IS BELT-AND-BRACES, which is worth saying rather // than leaving somebody to discover: `incrementMinutes <= 0` prevents a loop that never // terminates. The NaN and inside-out arms restate what the loop below would do anyway - its // condition is already false for both - so they are here to state the intent, not to change the // outcome. A mutation that removes them cannot fail a test, and that is a fact about them rather // than a gap in the tests. if (!Number.isFinite(from) || !Number.isFinite(to) || to <= from || incrementMinutes <= 0) { return []; } const times: string[] = []; for (let minute = from; minute < to; minute += incrementMinutes) { const hours = String(Math.floor(minute / 60)).padStart(2, '0'); const minutes = String(minute % 60).padStart(2, '0'); times.push(`${hours}:${minutes}`); } return times; }; /** * The closing wall clock to hand `slotStartTimes` for a window that may run past midnight. * * A BLOCK SAVED AS "ALWAYS AVAILABLE" RUNS MIDNIGHT TO THE NEXT MIDNIGHT, so both ends of it read as * `00:00`. Every caller reduced the window to two wall clocks and lost the day in doing so, which made * a full 24-hour window arrive at `slotStartTimes` as `00:00` to `00:00` - an inside-out range, which * correctly yields nothing. MEASURED 2026-09-15 at a practice whose block was saved that way: the desk * and the patient both answered "no available times" on a day the block covered, with no error * anywhere and every other screen showing the block as all-day. * * `24:00` is the end of the day in the same minutes-since-midnight arithmetic `slotStartTimes` already * does, so the last start before it is the last one the day offers. * * A window ending EARLIER than the day it is asked about yields nothing, which is what an inside-out * range should do; this function does not try to rescue that. */ export const windowEndOnDay = ( dayDate: string, windowEndDate: string, windowEndWallClock: string, ): string => (windowEndDate > dayDate ? '24:00' : windowEndWallClock);