from typing import List, Optional, Sequence, Tuple from .health_appointment_slots_types import AppointmentSlot, Booking, ClinicBreak, ClinicSession from .time_minutes_between import minutes_between #: 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