Functional Weave
Code in TypeScript

health.appointment-slots@1.0.1

impl/python.py

3,310 bytes · the Python 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.

from typing import List, Optional, Sequence, Tuple

from .health_appointment_slots_types import AppointmentSlot, Booking, ClinicBreak, ClinicSession
from .time_minutes_between import minutes_between  ← from time.minutes-between ^1.0.0 · built alongside by fune

#: A break without a date still needs a real date for the time check.
_ANY_DATE = "2000-01-01"


def _minute_of_day(date: str, time: str) -> int:
    """Minute of the day, 0 to 1439; raises on a malformed date or time."""
    return minutes_between(date, "00:00", date, time)


def _interval(kind: str, date: Optional[str], start: str, end: str) -> Tuple[str, int, int]:
    on = _ANY_DATE if date is None else date
    begin = _minute_of_day(on, start)
    finish = _minute_of_day(on, end)
    if finish <= begin:
        raise ValueError(
            "%s on %s must end after it starts (%s to %s)" % (kind, "every day" if date is None else date, start, end)
        )
    return on, begin, finish


def _clock(minute: int) -> str:
    return "%02d:%02d" % (minute // 60, minute % 60)


def appointment_slots(
    sessions: Sequence[ClinicSession],
    slot_minutes: int,
    breaks: Sequence[ClinicBreak],
    bookings: Sequence[Booking],
) -> List[AppointmentSlot]:
    """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.
    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.
    """
    if isinstance(slot_minutes, bool) or not isinstance(slot_minutes, int) or slot_minutes < 1 or slot_minutes > 480:
        raise ValueError("slotMinutes must be a whole number from 1 to 480, received %r" % (slot_minutes,))
    clinic = [_interval("session", s.date, s.start, s.end) for s in sessions]
    closed = [(b.date is None,) + _interval("break", b.date, b.start, b.end) for b in breaks]
    booked = [_interval("booking", b.date, b.start, b.end) for b in bookings]

    clinic.sort(key=lambda s: (s[0], s[1]))
    for previous, current in zip(clinic, clinic[1:]):
        if current[0] == previous[0] and current[1] < previous[2]:
            raise ValueError("sessions overlap on %s" % (current[0],))

    slots: List[AppointmentSlot] = []
    for date, start, end in clinic:
        pauses = sorted(
            ((b_start, b_end) for always, b_date, b_start, b_end in closed if always or b_date == date),
        )
        segments: List[Tuple[int, int]] = []
        cursor = start
        for p_start, p_end in pauses:
            if p_end <= cursor or p_start >= end:
                continue
            if p_start > cursor:
                segments.append((cursor, p_start))
            cursor = max(cursor, p_end)
        if cursor < end:
            segments.append((cursor, end))

        for seg_start, seg_end in segments:
            t = seg_start
            while t + slot_minutes <= seg_end:
                slot_end = t + slot_minutes
                taken = any(b_date == date and b_start < slot_end and b_end > t for b_date, b_start, b_end in booked)
                if not taken:
                    slots.append(AppointmentSlot(date=date, start=_clock(t), end=_clock(slot_end)))
                t += slot_minutes
    return slots