"""Slot generation for the booking module (#16). Pure functions only -- no I/O. Callers (booking_api.py) fetch a resource's hours/existing bookings via booking_db.py and pass them in here, which keeps this module trivially unit-testable against hand-computed expectations, including across a DST transition. """ from datetime import date, datetime, timedelta, timezone from zoneinfo import ZoneInfo def generate_slots(hours_by_weekday, duration_minutes, date_from, date_to, tz_name, now, min_notice_minutes=60, max_advance_days=30, buffer_minutes=0, busy=()): """Return a sorted list of tz-aware UTC datetimes, one per bookable slot start, for [date_from, date_to] inclusive. hours_by_weekday: {0..6: (opens_at time, closes_at time)}, 0=Monday, matching date.weekday(); a missing weekday means closed that day. tz_name: IANA zone the hours are local to (e.g. "Europe/Berlin"). now: tz-aware datetime "now" is measured from, for min-notice/max-advance. busy: iterable of (start, end) tz-aware datetimes already booked on this resource -- a candidate slot within buffer_minutes of one is dropped. """ tz = ZoneInfo(tz_name) duration = timedelta(minutes=duration_minutes) buffer_td = timedelta(minutes=buffer_minutes) earliest = now + timedelta(minutes=min_notice_minutes) latest = now + timedelta(days=max_advance_days) busy_utc = [(s.astimezone(timezone.utc), e.astimezone(timezone.utc)) for s, e in busy] slots = [] day = date_from while day <= date_to: hours = hours_by_weekday.get(day.weekday()) if hours: opens_at, closes_at = hours cursor = datetime.combine(day, opens_at, tzinfo=tz) close = datetime.combine(day, closes_at, tzinfo=tz) while cursor + duration <= close: start_utc = cursor.astimezone(timezone.utc) end_utc = (cursor + duration).astimezone(timezone.utc) if (earliest <= start_utc <= latest and not _conflicts(start_utc, end_utc, busy_utc, buffer_td)): slots.append(start_utc) cursor += duration day += timedelta(days=1) return slots def _conflicts(start, end, busy_utc, buffer_td): for b_start, b_end in busy_utc: if start < b_end + buffer_td and end + buffer_td > b_start: return True return False