"""Slot generation for Google Calendar tenants (§8.1). working hours − busy blocks, quantized to service duration (+ optional buffer), respecting min_notice_hours and max_days_ahead. Partner tenants never hit this — their software returns ready slots. """ from __future__ import annotations from datetime import date, datetime, timedelta from zoneinfo import ZoneInfo from gogo.domain import Slot from gogo.hours import day_intervals, iter_days QUANTIZE_MIN = 15 # slot starts snap to :00/:15/:30/:45 def subtract_busy( interval: tuple[datetime, datetime], busy: list[tuple[datetime, datetime]] ) -> list[tuple[datetime, datetime]]: """Subtract busy blocks from one open interval → list of free intervals.""" free = [interval] for b_start, b_end in sorted(busy): next_free = [] for f_start, f_end in free: if b_end <= f_start or b_start >= f_end: next_free.append((f_start, f_end)) continue if b_start > f_start: next_free.append((f_start, b_start)) if b_end < f_end: next_free.append((b_end, f_end)) free = next_free return free def compute_slots( *, working_hours: dict, busy: list[tuple[datetime, datetime]], duration_min: int, buffer_min: int = 0, date_from: date, date_to: date, tz: ZoneInfo, now: datetime, min_notice_hours: int = 2, max_days_ahead: int = 14, max_slots: int = 20, ) -> list[Slot]: """Generate offerable free slots, earliest first.""" earliest_start = now + timedelta(hours=min_notice_hours) horizon = (now + timedelta(days=max_days_ahead)).date() date_to = min(date_to, horizon) total_min = duration_min + buffer_min step = timedelta(minutes=total_min) # spec §8.1: quantized to service duration (+ buffer) slots: list[Slot] = [] for day in iter_days(date_from, date_to): for open_start, open_end in day_intervals(working_hours, day, tz): for f_start, f_end in subtract_busy((open_start, open_end), busy): cursor = _quantize_up(max(f_start, earliest_start.astimezone(tz))) while cursor + timedelta(minutes=total_min) <= f_end: slots.append(Slot(start=cursor, end=cursor + timedelta(minutes=duration_min))) if len(slots) >= max_slots: return slots cursor += step return slots def _quantize_up(dt: datetime) -> datetime: dt = dt.replace(second=0, microsecond=0) rem = dt.minute % QUANTIZE_MIN if rem: dt += timedelta(minutes=QUANTIZE_MIN - rem) return dt