"""UTC intervals and availability rules, independent of HTTP and storage.""" from datetime import date, datetime, time, timedelta from zoneinfo import ZoneInfo def make_slots( dates: list[str], start: str, end: str, minutes: int, zone: str ) -> list[int]: tz = ZoneInfo(zone) start_time = time.fromisoformat(start) end_time = time.min if end == "24:00" else time.fromisoformat(end) if any(t.second or t.microsecond or t.tzinfo for t in (start_time, end_time)): raise ValueError("Use local times with minute precision.") if end != "24:00" and end_time <= start_time: raise ValueError("The end must be after the start; use 24:00 for midnight.") slots = set() for day in sorted(set(dates)): first = datetime.combine(date.fromisoformat(day), start_time) last = datetime.combine(first.date(), end_time) if end == "24:00": last += timedelta(days=1) if (last - first).total_seconds() % (minutes * 60): raise ValueError("The daily range must contain whole slots.") current = first valid_minutes = set() while current < last: # Build valid UTC ranges first, then fit whole slots. This also handles # half-hour DST changes without producing overlapping hour-long slots. for fold in (0, 1): instant = int(current.replace(tzinfo=tz, fold=fold).timestamp()) if datetime.fromtimestamp(instant, tz).replace(tzinfo=None) == current: valid_minutes.add(instant) current += timedelta(minutes=1) step = minutes * 60 for first_utc, last_utc in intervals(sorted(valid_minutes), 60): slots.update(range(first_utc, last_utc - step + 1, step)) return sorted(slots) def intervals(slots: list[int], seconds: int) -> list[tuple[int, int]]: result = [] for slot in sorted(slots): if result and result[-1][1] == slot: result[-1] = (result[-1][0], slot + seconds) else: result.append((slot, slot + seconds)) return result def resize_slots( slots: list[int], old_minutes: int, new_minutes: int, zone: str = "UTC" ) -> list[int]: step = new_minutes * 60 ranges = [] tz = ZoneInfo(zone) for first, last in intervals(slots, old_minutes * 60): start_clock, end_clock = ( datetime.fromtimestamp(first, tz), datetime.fromtimestamp(last, tz), ) first -= (start_clock.minute % new_minutes) * 60 last += (-end_clock.minute % new_minutes) * 60 if ranges and first <= ranges[-1][1]: ranges[-1] = (ranges[-1][0], max(last, ranges[-1][1])) else: ranges.append((first, last)) return [s for first, last in ranges for s in range(first, last - step + 1, step)] def remap_votes( votes: dict[str, str], old_minutes: int, slots: list[int], new_minutes: int ) -> dict[str, str]: old = sorted( (int(start), int(start) + old_minutes * 60, value) for start, value in votes.items() ) result = {} index = 0 for start in slots: end = start + new_minutes * 60 while index < len(old) and old[index][1] <= start: index += 1 yes_seconds = 0 overlaps = False for old_index in range(index, len(old)): a, b, value = old[old_index] if a >= end: break overlap = min(end, b) - max(start, a) if overlap > 0: overlaps = True if value == "yes": yes_seconds += overlap if overlaps: result[str(start)] = "yes" if yes_seconds == end - start else "maybe" return result def votes_lost( votes: dict[str, str], old_minutes: int, slots: list[int], minutes: int ) -> bool: ranges = intervals(slots, minutes * 60) return any( not any(a <= int(slot) and b >= int(slot) + old_minutes * 60 for a, b in ranges) for slot in votes ) def summaries(slots: list[int], minutes: int, people: list[dict]) -> dict: """Longest runs retain the exact participating set, not just its count.""" result = {"yes": [], "inclusive": []} if not people: return result for mode, groups in result.items(): runs = [] for start in slots: ids = tuple( p["id"] for p in people if p["votes"].get(str(start)) in (("yes",) if mode == "yes" else ("yes", "maybe")) ) if runs and runs[-1]["end"] == start and runs[-1]["people"] == ids: runs[-1]["end"] += minutes * 60 else: runs.append( {"start": start, "end": start + minutes * 60, "people": ids} ) for missing in range(min(3, len(people) // 4) + 1): eligible = [r for r in runs if len(r["people"]) >= len(people) - missing] longest = max((r["end"] - r["start"] for r in eligible), default=0) groups.append( { "missing": missing, "seconds": longest, "stretches": [ r for r in eligible if r["end"] - r["start"] == longest ], } ) return result