health.appointment-slots@1.0.1
impl/typescript.ts
3,510 bytes · the TypeScript implementation · view raw
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import { minutesBetween } from "./time_minutes_between.ts"; ← from time.minutes-between ^1.0.0 · built alongside by fune
import { type AppointmentSlot, type Booking, type ClinicBreak, type ClinicSession } from "./health_appointment_slots_types.ts";
/** A break without a date still needs a real date for the time check. */
const ANY_DATE = "2000-01-01";
interface Interval {
date: string;
start: number;
end: number;
}
/** Minute of the day, 0 to 1439; throws on a malformed date or time. */
function minuteOfDay(date: string, time: string): number {
return minutesBetween(date, "00:00", date, time);
}
function interval(kind: string, date: string | null, start: string, end: string): Interval {
const on = date === null ? ANY_DATE : date;
const from = minuteOfDay(on, start);
const to = minuteOfDay(on, end);
if (to <= from) {
throw new RangeError(`${kind} on ${date === null ? "every day" : date} must end after it starts (${start} to ${end})`);
}
return { date: on, start: from, end: to };
}
function clock(minute: number): string {
const hours = Math.floor(minute / 60);
const minutes = minute % 60;
return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`;
}
/**
* Free appointment slots. Breaks split a session into segments and the slot
* grid restarts at the start of each segment, so a 12:30-13:30 lunch gives
* slots from 13:30 rather than from wherever the morning grid would have
* landed. Bookings only take slots away: they never move the grid, because
* they are booked into slots. Intervals are half-open, so a booking ending at
* 09:15 does not touch the 09:15 slot.
*/
export function appointmentSlots(
sessions: readonly ClinicSession[],
slotMinutes: number,
breaks: readonly ClinicBreak[],
bookings: readonly Booking[],
): readonly AppointmentSlot[] {
if (!Number.isInteger(slotMinutes) || slotMinutes < 1 || slotMinutes > 480) {
throw new RangeError(`slotMinutes must be a whole number from 1 to 480, received ${slotMinutes}`);
}
const clinic = sessions.map((s) => interval("session", s.date, s.start, s.end));
const closed = breaks.map((b) => ({ always: b.date === null, ...interval("break", b.date, b.start, b.end) }));
const booked = bookings.map((b) => interval("booking", b.date, b.start, b.end));
clinic.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : a.start - b.start));
for (let i = 1; i < clinic.length; i++) {
if (clinic[i].date === clinic[i - 1].date && clinic[i].start < clinic[i - 1].end) {
throw new RangeError(`sessions overlap on ${clinic[i].date}`);
}
}
const slots: AppointmentSlot[] = [];
for (const session of clinic) {
const pauses = closed
.filter((b) => b.always || b.date === session.date)
.sort((a, b) => a.start - b.start || a.end - b.end);
const segments: [number, number][] = [];
let cursor = session.start;
for (const pause of pauses) {
if (pause.end <= cursor || pause.start >= session.end) continue;
if (pause.start > cursor) segments.push([cursor, pause.start]);
cursor = Math.max(cursor, pause.end);
}
if (cursor < session.end) segments.push([cursor, session.end]);
for (const [from, to] of segments) {
for (let t = from; t + slotMinutes <= to; t += slotMinutes) {
const end = t + slotMinutes;
const taken = booked.some((b) => b.date === session.date && b.start < end && b.end > t);
if (!taken) slots.push({ date: session.date, start: clock(t), end: clock(end) });
}
}
}
return slots;
}