Build custom container images / build (map[base_image:php:8-fpm-alpine build_args:PHP_VERSION=8
context:php8-pgsql fingerprint_command:{ apk info -v | LC_ALL=C sort; find /usr/local/lib/php/extensions /usr/local/etc/php/conf.d -type f -exec sha256sum {} + | LC_ALL=C sort; }
name:ph… (push) Successful in 49s
Build custom container images / build (map[base_image:postgres:18 build_args:PG_VERSION=18
POSTGIS_VERSION=3
VCHORD_VERSION=0.5.3
context:postgres fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; find /usr/lib/postgresql -type f -exec sha256su… (push) Successful in 1m42s
Build custom container images / build (map[base_image:python:3 build_args:PYTHON_VERSION=3
context:python-tools fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; pip freeze | LC_ALL=C sort; }
name:python-tools oci_labels:org.opencontainers.ima… (push) Successful in 54s
Build custom container images / build (map[base_image:python:3.12-slim build_args:PYTHON_VERSION=3.12-slim
context:linkki-tiedotus fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; pip freeze | LC_ALL=C sort; }
name:linkki-tiedotus oci_labels:… (push) Successful in 29s
Build custom container images / build (map[base_image:python:3.12-slim build_args:PYTHON_VERSION=3.12-slim
context:timepoll fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; pip freeze | LC_ALL=C sort; }
name:timepoll oci_labels:org.opencontai… (push) Successful in 44s
143 lines
5.3 KiB
Python
143 lines
5.3 KiB
Python
"""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
|