Add Timepoll availability polling application and image
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
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
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user