Functional Weave
Code in TypeScript

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;
}