78 lines
2.6 KiB
Python
78 lines
2.6 KiB
Python
|
|
"""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
|