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

This commit is contained in:
ajp_anton
2026-09-09 19:39:40 +00:00
parent cb80d420c0
commit 4cc2e8fc80
21 changed files with 3519 additions and 2 deletions
+24
View File
@@ -100,6 +100,24 @@ jobs:
fingerprint_command: |
{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; pip freeze | LC_ALL=C sort; }
- name: timepoll
context: timepoll
base_image: python:3.12-slim
primary_tag: git.ajpanton.se/ajp_anton/timepoll:1
oci_labels: |
org.opencontainers.image.title=Timepoll
org.opencontainers.image.description=Self-hosted availability polls with timezone-aware voting and live updates.
org.opencontainers.image.documentation=https://git.ajpanton.se/ajp_anton/docker-images/src/branch/main/timepoll/README.md
org.opencontainers.image.source=https://git.ajpanton.se/ajp_anton/docker-images
org.opencontainers.image.url=https://git.ajpanton.se/ajp_anton/-/packages/container/timepoll
tags: |
git.ajpanton.se/ajp_anton/timepoll:1
git.ajpanton.se/ajp_anton/timepoll:latest
build_args: |
PYTHON_VERSION=3.12-slim
fingerprint_command: |
{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; pip freeze | LC_ALL=C sort; }
steps:
- name: Check out repository
uses: actions/checkout@v4
@@ -107,6 +125,12 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Test Timepoll
if: matrix.image.name == 'timepoll'
run: |
tar -C timepoll -cf - . | docker run --rm -i --workdir /app python:3.12-slim \
sh -c 'tar -xf - && pip install --no-cache-dir -r requirements.txt pytest httpx && PYTHONDONTWRITEBYTECODE=1 pytest -q -p no:cacheprovider'
- name: Log in to Gitea container registry
run: |
printf '%s' "$REGISTRY_TOKEN" | docker login "$REGISTRY" --username "$REGISTRY_USERNAME" --password-stdin
+1
View File
@@ -2,3 +2,4 @@ local/
python-tools/tasks/
__pycache__/
*.py[cod]
timepoll/data/
+18 -2
View File
@@ -3,8 +3,8 @@
This repository builds a small set of OCI/container images published at
`git.ajpanton.se`.
The images are intended to stay close to their upstream base images while adding
a few commonly useful database/runtime extensions.
The images include extended database/runtime images and standalone self-hosted
applications. Each image has its own build context and documentation.
## Images
@@ -14,6 +14,7 @@ a few commonly useful database/runtime extensions.
| `git.ajpanton.se/ajp_anton/php8-pgsql:8` | PHP 8 FPM on Alpine with PostgreSQL client runtime support and the `pdo_pgsql` PHP extension enabled. |
| `git.ajpanton.se/ajp_anton/python-tools:3` | Python 3 task runner with ExifTool and a web control panel for mounted task scripts. |
| `git.ajpanton.se/ajp_anton/linkki-tiedotus:1` | Web editor and scheduler for multilingual Telegram channel broadcasts. |
| `git.ajpanton.se/ajp_anton/timepoll:1` | Timezone-aware availability polls with a live voting grid. |
## Included Components
@@ -119,6 +120,19 @@ Included application:
The image contains no configuration or broadcast data. Mount `/data` from a
persistent host directory and create its configuration from `linkki-tiedotus/config-examples/`.
### `timepoll`
Base image: `python:3.12-slim`, following Python 3.12 patch releases.
- Standalone FastAPI application with SQLite persistence and live browser updates.
- UTC-based yes/maybe voting, IANA timezones, optional blackouts, and private
poll administration links. No accounts required.
- FastAPI and Uvicorn follow releases below version 1; `tzdata` follows available
releases. Lucide browser icons are bundled at version 0.468.0.
- Persistent data is stored in `/data`; the application listens on port `8080`.
See [Timepoll's README](timepoll/README.md) for setup, voting rules and testing.
## Tags
Moving deployment tags:
@@ -127,6 +141,7 @@ Moving deployment tags:
- `git.ajpanton.se/ajp_anton/php8-pgsql:8`
- `git.ajpanton.se/ajp_anton/python-tools:3`
- `git.ajpanton.se/ajp_anton/linkki-tiedotus:1`
- `git.ajpanton.se/ajp_anton/timepoll:1`
Descriptive tags:
@@ -139,6 +154,7 @@ Convenience tags:
- `git.ajpanton.se/ajp_anton/php8-pgsql:latest`
- `git.ajpanton.se/ajp_anton/python-tools:latest`
- `git.ajpanton.se/ajp_anton/linkki-tiedotus:latest`
- `git.ajpanton.se/ajp_anton/timepoll:latest`
Tags are mutable. A weekly candidate build only repoints a deployment tag when
its content fingerprint changed: base image, build context, build arguments,
+7
View File
@@ -0,0 +1,7 @@
__pycache__
.pytest_cache
tests
data
*.md
pytest.ini
compose.example.yaml
+22
View File
@@ -0,0 +1,22 @@
ARG PYTHON_VERSION=3.12-slim
FROM python:${PYTHON_VERSION}
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
TIMEPOLL_DATA_DIR=/data
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt \
&& groupadd --gid 10001 timepoll \
&& useradd --uid 10001 --gid 10001 --create-home timepoll \
&& mkdir /data && chown timepoll:timepoll /data
COPY server.py domain.py ./
COPY static ./static
USER timepoll
EXPOSE 8080
VOLUME ["/data"]
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/healthz', timeout=3)"
CMD ["uvicorn", "server:create_app", "--factory", "--host", "0.0.0.0", "--port", "8080", "--no-access-log", "--timeout-graceful-shutdown", "10"]
+34
View File
@@ -0,0 +1,34 @@
# Timepoll Implementation
- [x] Poll model, SQLite persistence, timezone-aware schedules and vote conversion.
- [x] Public/admin API, edit conflicts, live updates and basic abuse limits.
- [x] Poll creation, administration, voting grid and overlap summaries.
- [x] Responsive light/dark UI, touch interactions and accessibility.
- [x] Backend and browser tests, Docker image definition, build workflow and documentation.
Public codes use nine Crockford Base32 characters (3-3-3); private admin
codes use twelve (4-4-4). Participant editing is intentionally trust-based.
There are no accounts, calendar integrations, notifications or meeting durations.
All persisted voting intervals are UTC instants; IANA timezones control display
and conversion of the creator's local daily schedule.
## Verification
- 24 backend tests pass, including DST transitions, half-hour transitions,
vote conversion, edit conflicts, code collisions and restart persistence.
- Chromium desktop and touch/mobile tests pass in light/dark modes, including
live updates and a 320-pixel-wide editing viewport.
- Ruff, workflow/Compose YAML validation and whitespace checks pass.
- The build workflow runs the backend tests before building and publishing the
Docker image. Publishing an image does not deploy a running service.
## Usability Follow-Up
- [x] Explicit added-date collection and pending-date validation.
- [x] Matching time dropdowns and outward rounding on slot-size changes.
- [x] Root-level public/private links; old preview routes removed.
- [x] Direct clipboard copying, tested against the actual clipboard over HTTP.
- [x] Dismiss hover details on pointer exit and page/grid scrolling.
- [x] Other participants' availability and boundaries remain visible through draft selections; combined view after saving or cancelling.
- [x] Compact 15-minute rows on desktop and mobile.
- [x] Emphasized hour labels and rules for 15- and 30-minute grids.
+184
View File
@@ -0,0 +1,184 @@
# Timepoll
A small self-hosted availability poll. Create an event, share its public link,
and vote **Yes**, **Maybe**, or leave times unavailable. No accounts are needed.
Anyone with the public link can edit or remove any participant's votes; this is
deliberately trust-based, not suitable for confidential or adversarial voting.
## Run
Use [compose.example.yaml](compose.example.yaml). The only persistent mount is
`/data`, containing the SQLite database and its WAL files. The image runs as UID
and GID `10001`; create the bind-mount directory with that ownership first:
```sh
mkdir -p timepoll-data
sudo chown 10001:10001 timepoll-data
docker compose -f compose.example.yaml up -d
```
Open port `8080`, or put an HTTPS reverse proxy in front of it. No external
database, broker, login provider or browser CDN is required. `/healthz` is the
container health endpoint. `TIMEPOLL_DATA_DIR` defaults to `/data` and normally
does not need configuring. Deploy a single application instance; keep SQLite
on a local filesystem, not an NFS/SMB share.
The registry tags `:1` and `:latest` move together for this application series.
The repository checks for rebuilds weekly on Monday at 03:27 UTC and on pushes
to `main`. It publishes a changed image only when the build fingerprint changes.
This includes base-image, application and installed-dependency changes.
### Reverse Proxy
Forward the original `Host` header and scheme. For TLS termination, configure
Uvicorn's standard `FORWARDED_ALLOW_IPS` with the proxy's IP so that it trusts
the proxy's forwarded scheme/client headers. Do not trust arbitrary clients or
use `*` on a directly reachable backend. Cross-origin writes are rejected.
Disable buffering and caching for `/api/polls/*/events`. These are long-lived
server-sent event connections with a heartbeat every two seconds. Permit at
least a 60-second read timeout. Open pages reconnect automatically after an
outage and fetch the current revision, without discarding an active vote draft.
Public links are bearer access, not a login. Use HTTPS. Add perimeter access
control if the application itself must be private. Basic per-client request and
creation limits are included, but do not replace a proxy's abuse protection.
## Creating and Editing
- Set an event name, location timezone and 15-, 30- or 60-minute slots (default 30).
- Add an inclusive date range, individual dates, or both. **Add dates** moves the
chosen range into **Dates in this poll**, which lists all added dates and their
count. Dates left in the entry fields must be added or cleared before saving.
Remove dates individually from this list.
- Apply a common daily start/end range, then optionally paint blocked times.
`00:00` begins a day; `24:00` ends that day. Overnight availability can be
represented by adjoining dates and blocking the unwanted hours.
- After creation, save the private admin link and share the public voting link.
The admin is also a participant only after saving votes through the public link.
Public codes use nine random Crockford Base32 characters, displayed `ABC-123-XYZ`.
Private codes use twelve characters, displayed `ABCD-1234-WXYZ`. `0` and `1`
are retained; digits are colored blue. Codes accept lowercase, spaces and hyphens;
`O` aliases `0`, and `I` or `L` aliases `1`. Database uniqueness checks handle
collisions. See the [alphabet specification](https://www.crockford.com/base32.html).
Public links are `https://example.com/ABC-123-XYZ`; private links are
`https://example.com/ABCD-1234-WXYZ`, with no public code in the private link.
The API resolves the private token through its SHA-256 digest and never includes
it in public poll data. API authorization uses a header, not a query parameter.
Only these root-level links are supported.
The private token is now part of the URL path: exclude these paths from reverse
proxy access logs, analytics and caches. The application disables access logging
and sends `Referrer-Policy: no-referrer`. Use HTTPS and keep the private link safe;
losing it means losing normal admin access.
Copy buttons include the current browser origin (scheme, domain and any port),
so links automatically use the domain on which the application is hosted.
HTTPS uses the browser's Clipboard
API; plain HTTP uses a user-initiated legacy copy command where supported. If
browser policy blocks both, the link is selected or shown for manual copying.
### Timezones and Existing Votes
Timepoll stores slot starts as UTC seconds and a slot duration. IANA location
timezones, not current fixed offsets, convert each date independently. Missing
spring-forward times are skipped; repeated fall-back times remain distinct.
Slots are fitted into the resulting continuous UTC ranges. A remaining fragment
shorter than a whole slot, including at a half-hour DST change, is omitted.
Viewers can choose their timezone unless the creator fixed the display zone.
Times crossing local midnight appear on separate dates, with `24:00` denoting
the preceding day's end. Disconnected daily ranges have a visual gap.
Changing the admin timezone immediately converts the displayed grid, selected
dates and editable start/end controls without moving existing UTC intervals.
Both time controls are dropdowns with steps matching the slot length. They
reflect the first displayed day's range, rounded outward to those steps;
converted days may have different ranges. **Apply dates and times** replaces the schedule
with the selected common daily range in the newly selected timezone.
Resizing slots preserves a full new slot as Yes only if Yes covered all of it.
Any partial Yes or Maybe coverage becomes Maybe. On slot-length changes, starts
round down and ends round up in the editor timezone. Existing contiguous ranges
are expanded outward before fitting the new slots; a partial slot at an unusual
DST transition is still omitted. Removing dates/times or adding blackouts removes affected votes,
with a confirmation before saving if any existing voted intervals are lost.
## Voting
Enter a unique name to start a draft, then paint Yes, Maybe or Unavailable.
Save or cancel explicitly; an empty saved vote still counts as a participant.
During editing, the background shows everyone else's saved availability,
excluding the edited participant. Your selections use a translucent gray layer
and check/minus marks; lines preserve changes in other participants' votes.
Saving or cancelling returns to the combined view.
Use the participant list or the names in a slot's hover/tap popup to filter one
person. Select them again to return to everyone. The filtered view offers edit
and removal actions. Participant editing does not require an admin link.
Green intensity counts Yes + Maybe. Blue means everyone is available, possibly
with Maybes. Diagonal stripes mark Maybes, becoming denser with their proportion.
Individual views use gray. Lines separate different voting subsets even when
their total counts are equal. Light/dark appearance follows the browser setting.
15-minute rows are compact; hour labels and rules stand out in both 15- and
30-minute grids. Hover popups close when leaving the slot/popup or
scrolling the page or grid; touch popups remain open until dismissed or scrolling.
The longest-overlap lists treat Maybe separately as unavailable and available.
They show everyone, then all but one/two/three, limited to at most 25% absent.
Each stretch keeps the same participating set. Equal longest stretches show the
earliest first, with the other ties expandable. Empty saved participants count
in these thresholds; unsaved drafts do not.
Changes arrive live, including other participants' edits. Saving rejects a stale
draft if that same participant or the actual schedule changed. Other people's
votes, title changes and display timezone changes do not invalidate it. The
browser warns before leaving an unsaved draft or unsaved admin changes.
## Data and Limits
SQLite uses WAL transactions and persists immediately, including participant and
schedule revisions. Back up with SQLite's online backup API or stop the container
before copying the entire data directory. Copying only the live `.sqlite3` file
can miss committed data still in the WAL.
Polls do not expire automatically. There is a per-poll limit of 12,000 slots and
200 participants; generated schedules accept up to 366 dates. Creation is limited
to 20 polls per client IP per hour and other API requests to 240 per minute.
These in-memory abuse limits reset on restart. This is a small-group application,
not a multi-tenant scheduling service.
## Development
Python 3.12 or newer:
```sh
python -m venv /tmp/timepoll-venv
/tmp/timepoll-venv/bin/pip install -r requirements.txt pytest httpx
TIMEPOLL_DATA_DIR=./data /tmp/timepoll-venv/bin/uvicorn server:create_app --factory --host 0.0.0.0 --port 8080
```
`data/` is gitignored. The app consists of `domain.py` (interval rules), `server.py`
(API and SQLite) and `static/` (plain browser JavaScript/CSS). No frontend build
step. FastAPI supplies request validation, Uvicorn handles HTTP and asynchronous
live streams, and `tzdata` supplies IANA data even on minimal container hosts.
```sh
/tmp/timepoll-venv/bin/pytest -q
```
For browser tests, install Playwright into a temporary directory, install its
Chromium browser, start Timepoll against a **disposable** database, and run:
```sh
npm install --prefix /tmp/timepoll-browser playwright
/tmp/timepoll-browser/node_modules/.bin/playwright install chromium
NODE_PATH=/tmp/timepoll-browser/node_modules TIMEPOLL_TEST_URL=http://127.0.0.1:8080 node tests/browser.cjs
```
The browser suite creates test polls and writes screenshots under `/tmp`.
It covers desktop/mobile voting, real-time updates, same-person conflicts,
admin timezone conversion and horizontal overflow. Backend tests cover SQLite
restart persistence, DST, interval conversion, collisions/conflicts and validation.
Asset attribution is in [static/vendor/README.md](static/vendor/README.md).
+16
View File
@@ -0,0 +1,16 @@
services:
timepoll:
image: git.ajpanton.se/ajp_anton/timepoll:1
restart: unless-stopped
ports:
- "8080:8080"
volumes:
- ./timepoll-data:/data
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
read_only: true
tmpfs:
- /tmp
stop_grace_period: 15s
+142
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
[pytest]
pythonpath = .
testpaths = tests
+3
View File
@@ -0,0 +1,3 @@
fastapi>=0.115,<1
uvicorn>=0.30,<1
tzdata
+501
View File
@@ -0,0 +1,501 @@
import asyncio
import hashlib
import json
import os
import secrets
import sqlite3
import time
import unicodedata
from collections import defaultdict, deque
from contextlib import contextmanager
from itertools import pairwise
from pathlib import Path
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError, available_timezones
from domain import make_slots, remap_votes, resize_slots, summaries, votes_lost
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field, field_validator
ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
ROOT = Path(__file__).parent
def normalize_code(value):
return (
"".join(value.upper().split())
.replace("-", "")
.translate(str.maketrans("OIL", "011"))
)
def new_code(length):
return "".join(secrets.choice(ALPHABET) for _ in range(length))
class Schedule(BaseModel):
dates: list[str] = Field(max_length=366)
start: str = "09:00"
end: str = "17:00"
minutes: int = 30
timezone: str
@field_validator("minutes")
@classmethod
def slot_size(cls, value):
if value not in (15, 30, 60):
raise ValueError("Choose 15, 30 or 60 minutes.")
return value
@field_validator("timezone")
@classmethod
def zone(cls, value):
try:
ZoneInfo(value)
except (ZoneInfoNotFoundError, ValueError):
raise ValueError("Choose an IANA location timezone.") from None
return value
class PollSettings(BaseModel):
title: str = Field(min_length=1, max_length=120)
timezone: str
fixed_timezone: bool = False
minutes: int = 30
slots: list[int] = Field(min_length=1, max_length=12000)
blocked: list[int] = Field(default_factory=list, max_length=12000)
settings_revision: int = 0
confirm_loss: bool = False
_size = field_validator("minutes")(Schedule.slot_size.__func__)
_zone = field_validator("timezone")(Schedule.zone.__func__)
@field_validator("title")
@classmethod
def title_valid(cls, value):
value = value.strip()
if not value:
raise ValueError("Enter an event name.")
return value
class Votes(BaseModel):
name: str = Field(min_length=1, max_length=80)
votes: dict[str, str] = Field(default_factory=dict, max_length=12000)
revision: int = 0
schedule_revision: int
def create_app(data_dir=None):
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
data = Path(data_dir or os.environ.get("TIMEPOLL_DATA_DIR", "/data"))
data.mkdir(parents=True, exist_ok=True)
database = data / "timepoll.sqlite3"
@contextmanager
def db(write=False):
con = sqlite3.connect(database, timeout=10, isolation_level=None)
con.row_factory = sqlite3.Row
con.execute("PRAGMA foreign_keys=ON")
try:
con.execute("BEGIN IMMEDIATE" if write else "BEGIN")
yield con
con.commit()
except BaseException:
con.rollback()
raise
finally:
con.close()
with sqlite3.connect(database) as con:
con.execute("PRAGMA journal_mode=WAL")
con.executescript("""
CREATE TABLE IF NOT EXISTS polls (
code TEXT PRIMARY KEY, admin_hash TEXT NOT NULL UNIQUE,
title TEXT NOT NULL, timezone TEXT NOT NULL, fixed_timezone INTEGER NOT NULL,
minutes INTEGER NOT NULL, slots TEXT NOT NULL, blocked TEXT NOT NULL,
revision INTEGER NOT NULL DEFAULT 1, schedule_revision INTEGER NOT NULL DEFAULT 1,
settings_revision INTEGER NOT NULL DEFAULT 1);
CREATE TABLE IF NOT EXISTS people (
id INTEGER PRIMARY KEY, poll TEXT NOT NULL REFERENCES polls(code) ON DELETE CASCADE,
name TEXT NOT NULL, name_key TEXT NOT NULL, votes TEXT NOT NULL,
revision INTEGER NOT NULL DEFAULT 1, UNIQUE(poll, name_key));
""")
limits = defaultdict(deque)
@app.middleware("http")
async def guards(request, call_next):
if request.method in ("POST", "PUT", "DELETE"):
origin = request.headers.get("origin")
if origin and origin != str(request.base_url).rstrip("/"):
return JSONResponse(
{"detail": "Cross-origin writes are not allowed."}, 403
)
# Limit actual bytes, including chunked requests without Content-Length.
body = bytearray()
async for chunk in request.stream():
body.extend(chunk)
if len(body) > 1024 * 1024:
return JSONResponse({"detail": "Request too large."}, 413)
request._body = bytes(body)
if request.url.path.startswith("/api/") and not request.url.path.endswith(
"/events"
):
now = time.monotonic()
# Bound memory and expire idle addresses, including rate-limit keys.
for key in list(limits):
if not limits[key] or limits[key][-1] < now - 3600:
del limits[key]
creating = request.method == "POST" and request.url.path == "/api/polls"
key = (request.client.host, creating)
window, maximum = (3600, 20) if creating else (60, 240)
queue = limits[key]
while queue and queue[0] <= now - window:
queue.popleft()
if len(queue) >= maximum:
return JSONResponse(
{"detail": "Too many requests. Please try again later."},
429,
headers={"Retry-After": str(window)},
)
queue.append(now)
response = await call_next(request)
response.headers.update(
{
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "no-referrer",
"X-Frame-Options": "DENY",
"Content-Security-Policy": "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'",
}
)
if request.url.path.startswith("/api/"):
response.headers["Cache-Control"] = "no-store"
return response
def load(con, code):
row = con.execute(
"SELECT * FROM polls WHERE code=?", (normalize_code(code),)
).fetchone()
if row is None:
raise HTTPException(404, "Poll not found. Check the event code.")
poll = dict(row)
poll["slots"] = json.loads(poll["slots"])
poll["blocked"] = json.loads(poll["blocked"])
poll["fixed_timezone"] = bool(poll["fixed_timezone"])
return poll
def people(con, code):
return [
{
"id": r["id"],
"name": r["name"],
"votes": json.loads(r["votes"]),
"revision": r["revision"],
}
for r in con.execute(
"SELECT * FROM people WHERE poll=? ORDER BY id", (code,)
)
]
def authorize(request, poll):
token = normalize_code(
request.headers.get("Authorization", "").removeprefix("Bearer ")
)
if not secrets.compare_digest(
hashlib.sha256(token.encode()).hexdigest(), poll["admin_hash"]
):
raise HTTPException(403, "The private admin link is required.")
def check_settings(settings):
slots = sorted(set(settings.slots))
if any(s < 0 or s > 4102444800 or s % 60 for s in slots):
raise HTTPException(
422, "Slots must be minute-aligned dates between 1970 and 2100."
)
if any(b - a < settings.minutes * 60 for a, b in pairwise(slots)):
raise HTTPException(422, "Time slots cannot overlap.")
if not set(settings.blocked) <= set(slots):
raise HTTPException(422, "Blocked times must belong to the schedule.")
return slots, sorted(set(settings.blocked))
@app.get("/healthz")
def health():
with db() as con:
con.execute("SELECT 1 FROM polls LIMIT 1").fetchone()
return {"status": "ok"}
@app.get("/api/timezones")
def zones():
return sorted(available_timezones())
@app.post("/api/schedule")
def preview(schedule: Schedule):
try:
slots = make_slots(
schedule.dates,
schedule.start,
schedule.end,
schedule.minutes,
schedule.timezone,
)
except ValueError as exc:
raise HTTPException(422, str(exc)) from exc
if not slots or len(slots) > 12000:
raise HTTPException(422, "Choose between 1 and 12,000 slots.")
return {"slots": slots}
@app.post("/api/resize")
def resize(settings: PollSettings, old_minutes: int):
if old_minutes not in (15, 30, 60):
raise HTTPException(422, "Invalid previous slot size.")
slots = resize_slots(
sorted(set(settings.slots)),
old_minutes,
settings.minutes,
settings.timezone,
)
blocked = remap_votes(
{str(s): "yes" for s in settings.blocked},
old_minutes,
slots,
settings.minutes,
)
return {"slots": slots, "blocked": [int(s) for s in blocked]}
@app.post("/api/polls", status_code=201)
def create(settings: PollSettings):
slots, blocked = check_settings(settings)
with db(True) as con:
while True:
code, token = new_code(9), new_code(12)
digest = hashlib.sha256(token.encode()).hexdigest()
try:
con.execute(
"INSERT INTO polls(code,admin_hash,title,timezone,fixed_timezone,minutes,slots,blocked) VALUES(?,?,?,?,?,?,?,?)",
(
code,
digest,
settings.title,
settings.timezone,
settings.fixed_timezone,
settings.minutes,
json.dumps(slots),
json.dumps(blocked),
),
)
break
except sqlite3.IntegrityError:
if (
con.execute(
"SELECT 1 FROM polls WHERE code=? OR admin_hash=?",
(code, digest),
).fetchone()
is None
):
raise
return {"code": code, "admin_token": token}
@app.get("/api/polls/{code}")
def get_poll(code: str):
with db() as con:
poll = load(con, code)
poll.pop("admin_hash")
poll["people"] = people(con, poll["code"])
active = sorted(set(poll["slots"]) - set(poll["blocked"]))
poll["summaries"] = summaries(active, poll["minutes"], poll["people"])
return poll
@app.get("/api/polls/{code}/admin")
def admin(code: str, request: Request):
with db() as con:
authorize(request, load(con, code))
return {"authorized": True}
@app.post("/api/admin/resolve")
def resolve_admin(request: Request):
token = normalize_code(
request.headers.get("Authorization", "").removeprefix("Bearer ")
)
with db() as con:
row = con.execute(
"SELECT code FROM polls WHERE admin_hash=?",
(hashlib.sha256(token.encode()).hexdigest(),),
).fetchone()
if row is None:
raise HTTPException(403, "The private admin link is invalid.")
return {"code": row["code"]}
@app.put("/api/polls/{code}")
def update(code: str, settings: PollSettings, request: Request):
slots, blocked = check_settings(settings)
with db(True) as con:
poll = load(con, code)
authorize(request, poll)
if settings.settings_revision != poll["settings_revision"]:
raise HTTPException(
409, "This poll was edited elsewhere. Reload before saving."
)
active = sorted(set(slots) - set(blocked))
participants = people(con, poll["code"])
if not settings.confirm_loss and any(
votes_lost(p["votes"], poll["minutes"], active, settings.minutes)
for p in participants
):
raise HTTPException(
409,
{
"code": "vote_loss",
"message": "Some existing votes will be lost. Save these changes?",
},
)
changed = (
slots != poll["slots"]
or blocked != poll["blocked"]
or settings.minutes != poll["minutes"]
)
if changed:
for person in participants:
votes = remap_votes(
person["votes"], poll["minutes"], active, settings.minutes
)
con.execute(
"UPDATE people SET votes=?, revision=revision+1 WHERE id=?",
(json.dumps(votes), person["id"]),
)
con.execute(
"UPDATE polls SET title=?, timezone=?, fixed_timezone=?, minutes=?, slots=?, blocked=?, revision=revision+1, settings_revision=settings_revision+1, schedule_revision=schedule_revision+? WHERE code=?",
(
settings.title,
settings.timezone,
settings.fixed_timezone,
settings.minutes,
json.dumps(slots),
json.dumps(blocked),
int(changed),
poll["code"],
),
)
return {"saved": True}
def save_person(code, body, person_id=None):
name = " ".join(body.name.split())
if not name:
raise HTTPException(422, "Enter your name.")
name_key = unicodedata.normalize("NFKC", name).casefold()
with db(True) as con:
poll = load(con, code)
if body.schedule_revision != poll["schedule_revision"]:
raise HTTPException(
409,
"The available times changed. Cancel and review the new schedule before saving.",
)
active = {str(s) for s in set(poll["slots"]) - set(poll["blocked"])}
if not set(body.votes) <= active or any(
v not in ("yes", "maybe") for v in body.votes.values()
):
raise HTTPException(
422, "Votes must refer to available slots and use yes or maybe."
)
if person_id is not None:
person = con.execute(
"SELECT * FROM people WHERE id=? AND poll=?",
(person_id, poll["code"]),
).fetchone()
if person is None or person["revision"] != body.revision:
raise HTTPException(
409,
"This participant was changed or removed elsewhere. Cancel and reload their votes.",
)
elif (
con.execute(
"SELECT count(*) FROM people WHERE poll=?", (poll["code"],)
).fetchone()[0]
>= 200
):
raise HTTPException(
422, "This poll has reached its limit of 200 participants."
)
try:
if person_id is None:
person_id = con.execute(
"INSERT INTO people(poll,name,name_key,votes) VALUES(?,?,?,?)",
(poll["code"], name, name_key, json.dumps(body.votes)),
).lastrowid
else:
con.execute(
"UPDATE people SET name=?, name_key=?, votes=?, revision=revision+1 WHERE id=?",
(name, name_key, json.dumps(body.votes), person_id),
)
except sqlite3.IntegrityError as exc:
raise HTTPException(
409, "That name is already in this poll. Choose a different name."
) from exc
con.execute(
"UPDATE polls SET revision=revision+1 WHERE code=?", (poll["code"],)
)
return {"id": person_id}
@app.post("/api/polls/{code}/people", status_code=201)
def add_person(code: str, body: Votes):
return save_person(code, body)
@app.put("/api/polls/{code}/people/{person_id}")
def edit_person(code: str, person_id: int, body: Votes):
return save_person(code, body, person_id)
@app.delete("/api/polls/{code}/people/{person_id}")
def delete_person(code: str, person_id: int, revision: int):
with db(True) as con:
poll = load(con, code)
cursor = con.execute(
"DELETE FROM people WHERE poll=? AND id=? AND revision=?",
(poll["code"], person_id, revision),
)
if not cursor.rowcount:
raise HTTPException(
409,
"This participant was changed or removed elsewhere. Reload before removing them.",
)
con.execute(
"UPDATE polls SET revision=revision+1 WHERE code=?", (poll["code"],)
)
return {"removed": True}
@app.get("/api/polls/{code}/events")
async def events(code: str, request: Request):
def revision():
with db() as con:
return load(con, code)["revision"]
await asyncio.to_thread(revision)
async def stream():
previous = None
while not await request.is_disconnected():
current = await asyncio.to_thread(revision)
if current != previous:
yield f"data: {current}\n\n"
previous = current
else:
yield ": heartbeat\n\n"
await asyncio.sleep(2)
return StreamingResponse(
stream(),
media_type="text/event-stream",
headers={"X-Accel-Buffering": "no", "Cache-Control": "no-cache"},
)
app.mount("/static", StaticFiles(directory=ROOT / "static"), name="static")
@app.get("/")
@app.get("/new")
@app.get("/{link_code}")
def page():
return FileResponse(
ROOT / "static" / "index.html", headers={"Cache-Control": "no-cache"}
)
return app
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 562 B

+23
View File
@@ -0,0 +1,23 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="light dark" />
<title>Timepoll</title>
<link rel="icon" href="/static/icon.png" type="image/png" />
<link rel="stylesheet" href="/static/style.css" />
<script src="/static/vendor/lucide.min.js" defer></script>
<script src="/static/app.js" defer></script>
</head>
<body>
<header class="site-header">
<a href="/" class="brand"
><img src="/static/icon.png" width="30" height="30" alt="" />Timepoll</a
><span id="connection"></span>
</header>
<main id="app"><p class="muted">Loading...</p></main>
<div id="notice" role="alert" hidden></div>
<div id="popover" role="dialog" aria-label="Slot availability" hidden></div>
</body>
</html>
+859
View File
@@ -0,0 +1,859 @@
:root {
color-scheme: light dark;
--bg: #fafcfb;
--surface: #fff;
--ink: #182a24;
--muted: #63736d;
--line: #d5dfd9;
--soft: #eef3ef;
--green: #166548;
--blue: #1254ad;
--selected: #dcece4;
--shadow: 0 8px 30px #18342625;
--slot-empty: #f1f4f2;
--stripe: #13241d75;
--gray: #68717c;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #131817;
--surface: #1b2320;
--ink: #ebf1ed;
--muted: #a4b3ac;
--line: #3a4841;
--soft: #222e27;
--green: #82d5ac;
--blue: #91c5ff;
--selected: #294237;
--shadow: 0 8px 30px #0007;
--slot-empty: #202c25;
--stripe: #e9fff39c;
--gray: #afb9c1;
}
}
* {
box-sizing: border-box;
letter-spacing: 0;
}
body {
margin: 0;
background: var(--bg);
color: var(--ink);
font:
15px/1.5 system-ui,
sans-serif;
}
button,
input,
select {
font: inherit;
}
button,
a,
input,
select {
touch-action: manipulation;
}
a {
color: var(--blue);
text-underline-offset: 3px;
}
button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
min-height: 40px;
padding: 7px 13px;
border: 1px solid var(--line);
border-radius: 6px;
color: var(--ink);
background: var(--surface);
cursor: pointer;
}
button:hover {
background: var(--soft);
}
button:disabled {
cursor: default;
opacity: 0.5;
}
.primary {
background: #196b4c;
color: #fff;
border-color: #196b4c;
}
.primary:hover {
background: #13583e;
}
.button-link {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 7px 13px;
min-height: 40px;
border-radius: 6px;
text-decoration: none;
}
.danger {
color: #ba3838;
}
.icon-button {
width: 40px;
padding: 8px;
flex: none;
}
svg.lucide {
width: 18px;
height: 18px;
flex: none;
}
input,
select {
min-height: 40px;
max-width: 100%;
padding: 7px 10px;
border: 1px solid var(--line);
border-radius: 5px;
background: var(--surface);
color: var(--ink);
}
input[type="checkbox"] {
min-height: auto;
accent-color: var(--green);
width: 17px;
height: 17px;
}
input[type="time"],
input[type="date"] {
width: auto;
}
input:focus,
select:focus,
button:focus-visible,
a:focus-visible {
outline: 2px solid var(--blue);
outline-offset: 3px;
}
label {
max-width: 100%;
min-width: 0;
display: flex;
flex-direction: column;
gap: 5px;
font-size: 14px;
font-weight: 550;
}
.check {
flex-direction: row;
align-items: center;
gap: 9px;
font-weight: 400;
}
.site-header {
height: 66px;
border-bottom: 1px solid var(--line);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 28px;
}
.brand {
display: inline-flex;
align-items: center;
gap: 10px;
font-size: 21px;
font-weight: 700;
text-decoration: none;
color: var(--ink);
}
.brand img {
border-radius: 6px;
}
#connection {
color: var(--muted);
font-size: 13px;
}
main {
max-width: 1420px;
margin: auto;
padding: 26px 28px 60px;
}
h1 {
font-size: 28px;
line-height: 1.25;
margin: 0 0 12px;
overflow-wrap: anywhere;
}
h2 {
font-size: 19px;
line-height: 1.35;
margin: 0 0 14px;
}
h3 {
font-size: 16px;
margin: 0 0 10px;
}
p {
margin: 8px 0 16px;
}
.muted {
color: var(--muted);
}
.home {
max-width: 800px;
margin: 40px auto;
}
.home h1 {
font-size: 36px;
}
.home-actions {
display: grid;
grid-template-columns: 1fr 1fr;
margin-top: 32px;
gap: 40px;
border-top: 1px solid var(--line);
padding-top: 28px;
}
.home-actions section + section {
border-left: 1px solid var(--line);
padding-left: 40px;
}
.home-actions form {
display: flex;
gap: 8px;
align-items: end;
flex-wrap: wrap;
}
.home-actions input {
width: 170px;
}
.heading {
display: flex;
justify-content: space-between;
align-items: start;
gap: 16px;
margin-bottom: 22px;
}
.heading h1 {
margin-bottom: 6px;
}
.row {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.between {
justify-content: space-between;
}
.toolbar {
display: flex;
gap: 12px;
align-items: end;
flex-wrap: wrap;
margin: 18px 0;
}
.toolbar label {
font-weight: 400;
}
.fields {
display: flex;
flex-wrap: wrap;
gap: 18px;
align-items: end;
}
.fields .wide {
width: 320px;
max-width: 100%;
}
.editor {
max-width: 1150px;
margin: auto;
}
.band {
border-top: 1px solid var(--line);
padding: 22px 0;
}
.dates {
display: flex;
flex-wrap: wrap;
gap: 6px;
max-height: 150px;
overflow: auto;
margin-top: 16px;
}
.date-chip {
min-height: 32px;
padding: 3px 9px;
font-size: 13px;
}
.date-chip svg {
width: 13px;
height: 13px;
}
.date-entry {
display: flex;
align-items: end;
gap: 18px 30px;
flex-wrap: wrap;
}
.date-range-entry,
.single-date-entry {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: 8px;
}
.date-bucket {
margin: 22px 0 0;
padding: 12px 14px 16px;
border: 1px solid var(--line);
border-radius: 5px;
min-width: 0;
background: var(--soft);
}
.date-bucket legend {
padding: 0 7px;
font-weight: 600;
}
.date-bucket .dates {
min-height: 38px;
margin: 0;
align-items: center;
}
#date-count {
margin-left: 8px;
font-size: 13px;
font-weight: 400;
}
.clipboard-buffer {
position: fixed;
left: -9999px;
top: 0;
}
.code {
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
white-space: nowrap;
font-size: 16px;
font-weight: 650;
}
.code .digit {
color: var(--blue);
}
.links {
max-width: 820px;
margin: auto;
}
.link-row {
display: flex;
gap: 8px;
align-items: center;
margin: 12px 0;
}
.link-row input {
flex: 1;
min-width: 0;
}
.links section {
padding: 22px 0;
border-top: 1px solid var(--line);
}
.links a.primary {
display: inline-flex;
border-radius: 6px;
padding: 10px 16px;
text-decoration: none;
}
.people {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 7px;
margin: 16px 0;
}
.person {
max-width: 100%;
overflow-wrap: anywhere;
min-height: 34px;
font-size: 14px;
}
.person.active {
background: var(--selected);
border-color: var(--green);
}
.person.filtered {
border-color: var(--gray);
background: var(--soft);
}
.new-person {
display: flex;
gap: 8px;
align-items: end;
flex-wrap: wrap;
}
.new-person input {
width: 240px;
}
.editbar {
display: flex;
align-items: center;
gap: 15px;
flex-wrap: wrap;
padding: 13px 0;
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
margin: 16px 0;
}
.editbar .save-actions {
margin-left: auto;
display: flex;
gap: 8px;
}
.editbar strong {
max-width: 100%;
overflow-wrap: anywhere;
}
#vote-actions {
margin-bottom: 18px;
}
.segmented {
display: inline-flex;
max-width: 100%;
flex-wrap: wrap;
}
.segmented button {
border-radius: 0;
margin-left: -1px;
}
.segmented button:first-child {
border-radius: 6px 0 0 6px;
margin-left: 0;
}
.segmented button:last-child {
border-radius: 0 6px 6px 0;
}
.segmented button[aria-pressed="true"] {
background: var(--selected);
border-color: var(--green);
position: relative;
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 16px;
font-size: 13px;
color: var(--muted);
align-items: center;
}
.swatch {
display: inline-block;
width: 13px;
height: 13px;
margin-right: 5px;
border-radius: 2px;
vertical-align: -2px;
background: #399368;
}
.swatch.blue {
background: #478aca;
}
.swatch.maybe {
background:
repeating-linear-gradient(135deg, transparent 0 3px, #405e5070 3px 4px),
#8ac5a8;
}
.swatch.gray {
background: #949fa4;
}
.grid-scroll {
overflow: auto;
max-height: 72vh;
border: 1px solid var(--line);
border-radius: 5px;
background: var(--surface);
overscroll-behavior: contain;
position: relative;
scrollbar-gutter: stable;
}
.grid {
--day-width: 112px;
--time-width: 83px;
border-spacing: 0;
table-layout: fixed;
min-width: calc(var(--time-width) + var(--columns) * var(--day-width));
width: 100%;
border-collapse: separate;
}
.grid .time-column {
width: var(--time-width);
}
.grid th {
position: sticky;
top: 0;
z-index: 2;
background: var(--surface);
font-size: 13px;
text-align: center;
padding: 10px 8px;
border-bottom: 1px solid var(--line);
min-width: 112px;
}
.grid th small {
display: block;
color: var(--muted);
font-weight: 400;
}
.grid .time {
left: 0;
z-index: 1;
position: sticky;
background: var(--surface);
width: 83px;
min-width: 83px;
text-align: right;
padding: 3px 10px 3px 6px;
font-size: 12px;
color: var(--muted);
font-weight: 400;
border-right: 1px solid var(--line);
}
.grid thead .time {
z-index: 3;
top: 0;
}
.grid td {
padding: 0;
min-width: 112px;
height: 32px;
border-right: 1px solid var(--surface);
border-bottom: 1px solid var(--line);
}
.grid td.empty {
background: var(--bg);
}
.grid button.slot {
width: 100%;
height: 32px;
min-height: 32px;
padding: 0 4px;
border: 0;
border-radius: 0;
font-size: 12px;
background-color: var(--cell, var(--slot-empty));
color: var(--ink);
display: block;
position: relative;
user-select: none;
}
.grid button.slot:hover {
box-shadow: inset 0 0 0 2px var(--blue);
}
.grid button.slot.stripes {
background-image: repeating-linear-gradient(
135deg,
transparent 0 calc(var(--spacing) - 1px),
var(--stripe) calc(var(--spacing) - 1px) var(--spacing)
);
}
.grid button.slot.blocked {
background: var(--soft);
color: var(--muted);
opacity: 0.8;
}
.grid button.slot.boundary {
border-top: 2px solid var(--ink);
}
.grid .hour-mark > .time {
font-weight: 700;
color: var(--ink);
background: var(--soft);
border-top: 1px solid var(--muted);
}
.grid .hour-mark > td {
border-top: 1px solid var(--muted);
}
.grid button.slot.peer-boundary {
border-top: 2px solid var(--green);
}
.vote-mark {
position: relative;
z-index: 1;
}
.grid button.draft-slot {
background-color: var(--slot-empty);
background-image: none;
}
.draft-slot::before,
.draft-slot::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
}
.draft-slot::before {
background-color: var(--peer-cell, transparent);
opacity: 0.75;
}
.draft-slot.peer-stripes::before {
background-image: repeating-linear-gradient(
135deg,
transparent 0 calc(var(--peer-spacing) - 1px),
var(--stripe) calc(var(--peer-spacing) - 1px) var(--peer-spacing)
);
}
.draft-slot::after {
background-color: var(--cell, transparent);
opacity: 0.35;
}
.draft-slot.stripes::after {
background-image: repeating-linear-gradient(
135deg,
transparent 0 calc(var(--spacing) - 1px),
var(--stripe) calc(var(--spacing) - 1px) var(--spacing)
);
}
.grid .gap td,
.grid .gap th {
height: 11px;
min-height: 11px;
background: var(--bg);
padding: 0;
}
.grid.editing .slot {
touch-action: none;
}
.slot svg {
width: 14px;
height: 14px;
margin: auto;
}
.grid-foot {
padding: 8px 0;
display: flex;
gap: 16px;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
font-size: 13px;
color: var(--muted);
}
.summary-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 35px;
padding-top: 26px;
}
.summary-list {
padding: 0;
margin: 0;
list-style: none;
}
.summary-list > li {
border-top: 1px solid var(--line);
padding: 13px 0;
}
.summary-list strong {
display: block;
}
.summary-list .muted {
font-size: 13px;
}
.summary-list details {
margin-top: 6px;
font-size: 13px;
}
.summary-list details summary {
cursor: pointer;
color: var(--blue);
}
#popover {
position: fixed;
z-index: 20;
width: 290px;
max-width: calc(100vw - 24px);
max-height: 65vh;
overflow: auto;
padding: 14px;
border: 1px solid var(--line);
border-radius: 6px;
background: var(--surface);
box-shadow: var(--shadow);
}
#popover h3 {
font-size: 14px;
padding-right: 28px;
}
#popover .close {
position: absolute;
right: 5px;
top: 5px;
min-height: 28px;
width: 28px;
padding: 4px;
}
#popover .voter {
display: flex;
width: 100%;
justify-content: space-between;
text-align: left;
border: 0;
min-height: 35px;
background: transparent;
overflow-wrap: anywhere;
}
#popover .voter.absent .name {
text-decoration: line-through;
color: var(--muted);
}
#popover .voter small {
color: var(--muted);
margin-left: 10px;
}
#notice {
position: fixed;
bottom: 18px;
left: 50%;
transform: translateX(-50%);
max-width: calc(100vw - 28px);
width: max-content;
background: var(--ink);
color: var(--bg);
padding: 14px 18px;
border-radius: 6px;
box-shadow: var(--shadow);
z-index: 40;
font-size: 14px;
}
#notice button {
margin-left: 12px;
color: inherit;
background: transparent;
border: 0;
min-height: 22px;
padding: 0;
}
.busy svg {
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
[hidden] {
display: none !important;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
.grid.compact td,
.grid.compact button.slot {
height: 20px;
min-height: 20px;
line-height: 16px;
}
.grid.compact tbody .time {
padding-top: 0;
padding-bottom: 0;
line-height: 16px;
}
.grid.compact .gap td {
height: 11px;
min-height: 11px;
}
@media (prefers-reduced-motion: reduce) {
.busy svg {
animation: none;
}
}
@media (max-width: 700px) {
.segmented button {
font-size: 14px;
padding: 7px 8px;
gap: 5px;
}
.grid {
--day-width: 95px;
--time-width: 69px;
}
.site-header {
padding: 0 16px;
height: 58px;
}
main {
padding: 20px 14px 45px;
}
h1 {
font-size: 25px;
}
.home {
margin: 18px auto;
}
.home h1 {
font-size: 30px;
}
.home-actions {
grid-template-columns: 1fr;
gap: 28px;
}
.home-actions section + section {
padding: 24px 0 0;
border-left: 0;
border-top: 1px solid var(--line);
}
.heading {
flex-wrap: wrap;
}
.summary-grid {
grid-template-columns: 1fr;
gap: 18px;
}
.fields {
gap: 12px;
}
.fields .wide {
width: 100%;
}
.editbar {
gap: 12px;
}
.editbar .save-actions {
margin-left: 0;
}
.grid th,
.grid td {
min-width: 95px;
}
.grid .time {
min-width: 69px;
width: 69px;
padding-right: 7px;
}
.grid-scroll {
max-height: 68vh;
}
.date-entry {
gap: 6px;
}
.date-entry input {
font-size: 14px;
}
#connection {
font-size: 12px;
}
.grid button.slot,
.grid td {
height: 36px;
}
.grid button.slot {
min-height: 36px;
}
.new-person input {
width: 190px;
}
}
+9
View File
@@ -0,0 +1,9 @@
# Bundled Assets
- `lucide.min.js`: Lucide 0.468.0, ISC license in `lucide.LICENSE`.
Source: https://unpkg.com/lucide@0.468.0/dist/umd/lucide.min.js
- `../icon.png`: Spiral Calendar (U+1F5D3), Twitter Twemoji 14.0.2.
Graphics licensed under CC BY 4.0: https://creativecommons.org/licenses/by/4.0/
Source: https://github.com/twitter/twemoji/tree/v14.0.2/assets/72x72
Assets are served locally; the browser does not contact any third-party service.
+15
View File
@@ -0,0 +1,15 @@
ISC License
Copyright (c) for portions of Lucide are held by Cole Bemis 2013-2022 as part of Feather (MIT). All other copyright (c) for Lucide are held by Lucide Contributors 2022.
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
File diff suppressed because one or more lines are too long
+310
View File
@@ -0,0 +1,310 @@
// Run against an isolated local database, not an existing production instance.
const { chromium } = require("playwright");
const assert = require("node:assert/strict");
const base = process.env.TIMEPOLL_TEST_URL || "http://127.0.0.1:8094";
(async () => {
const browser = await chromium.launch({
headless: true,
executablePath: process.env.CHROMIUM_PATH,
});
const desktop = await browser.newContext({
viewport: { width: 1440, height: 1050 },
timezoneId: "Europe/Helsinki",
});
const errors = [];
desktop.on("page", (p) =>
p.on("pageerror", (error) => errors.push(error.message)),
);
const page = await desktop.newPage();
const clipboardContext = await browser.newContext({
permissions: ["clipboard-read", "clipboard-write"],
});
const clipboardPage = await clipboardContext.newPage();
const clipboardOrigin = new URL(base);
clipboardOrigin.hostname = "127.0.0.1";
await clipboardPage.goto(clipboardOrigin.origin + "/healthz");
async function checkCopy(button, expected) {
await page.bringToFront();
if (await page.locator("#notice").isVisible())
await page.getByRole("button", { name: "Dismiss" }).click();
await button.click();
await page.getByRole("alert").filter({ hasText: "Link copied." }).waitFor();
await clipboardPage.bringToFront();
assert.equal(
await clipboardPage.evaluate(() => navigator.clipboard.readText()),
expected,
);
await page.bringToFront();
}
await page.goto(base + "/");
await page.getByRole("heading", { name: "Timepoll", exact: true }).waitFor();
await page.screenshot({ path: "/tmp/timepoll-home.png", fullPage: true });
await page.getByRole("link", { name: "Create poll", exact: true }).click();
await page.getByLabel("Event name").fill("Autumn weekend");
assert.equal(
await page.locator("#event-zone").inputValue(),
"Europe/Helsinki",
);
await page.locator("#date-from").fill("2026-10-23");
await page.locator("#date-to").fill("2026-10-26");
assert.equal(await page.locator("#date-count").textContent(), "0 added");
await page.getByRole("button", { name: "Apply dates and times" }).click();
await page
.getByRole("alert")
.filter({ hasText: "Add the pending dates" })
.waitFor();
assert.equal(await page.locator(".slot").count(), 0);
await page.getByRole("button", { name: "Dismiss" }).click();
await page.getByRole("button", { name: "Add dates", exact: true }).click();
assert.equal(await page.locator("#date-count").textContent(), "4 added");
assert.equal(await page.locator("#date-from").inputValue(), "");
assert.equal(await page.locator("#start-time option").count(), 48);
assert.equal(await page.locator("#end-time option").count(), 48);
await page.locator("#slot-size").selectOption("15");
await page.locator("#start-time").selectOption("09:15");
await page.locator("#end-time").selectOption("16:45");
await page.locator("#slot-size").selectOption("30");
assert.equal(await page.locator("#start-time").inputValue(), "09:00");
assert.equal(await page.locator("#end-time").inputValue(), "17:00");
await page.getByRole("button", { name: "Apply dates and times" }).click();
await page.locator(".slot").first().waitFor();
assert.equal(await page.locator(".slot").count(), 64);
assert.equal(await page.locator("tr.hour-mark").count(), 8);
assert.equal(
await page
.locator("tr.hour-mark .time")
.first()
.evaluate((e) => getComputedStyle(e).fontWeight),
"700",
);
await page.locator(".slot").first().click();
assert.equal(await page.locator(".slot.blocked").count(), 1);
await page.screenshot({ path: "/tmp/timepoll-editor.png", fullPage: true });
await page.getByRole("button", { name: "Create poll", exact: true }).click();
const publicLink = await page.getByLabel("Public voting link").inputValue();
const adminLink = await page.getByLabel("Private admin link").inputValue();
assert.match(
new URL(publicLink).pathname,
/^\/[A-Z0-9]{3}-[A-Z0-9]{3}-[A-Z0-9]{3}$/,
);
assert.match(
new URL(adminLink).pathname,
/^\/[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$/,
);
assert.equal(new URL(adminLink).hash, "");
await checkCopy(
page.getByRole("button", { name: "Copy private link" }),
adminLink,
);
await checkCopy(
page.getByRole("button", { name: "Copy public link" }),
publicLink,
);
await page.getByRole("link", { name: "Open poll" }).click();
await checkCopy(
page.getByRole("button", { name: "Copy public link" }),
publicLink,
);
await page.getByLabel("Your name").fill("Alice");
await page.getByRole("button", { name: "Add availability" }).click();
// Drag one contiguous day, without replacing the DOM mid-gesture.
const available = page.locator(".slot:not([disabled])");
const a = await available.nth(0).boundingBox();
const b = await available.nth(8).boundingBox();
await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2);
await page.mouse.down();
await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2, { steps: 15 });
await page.mouse.up();
await page.getByRole("button", { name: "Maybe", exact: true }).click();
await available.nth(3).click();
await page.getByRole("button", { name: "Save votes" }).click();
await page.getByRole("button", { name: "Add availability" }).waitFor();
assert.equal(
await page.locator("#everyone").getAttribute("class"),
"person active",
);
await page.getByRole("button", { name: "Everyone", exact: true }).click();
await page.getByLabel("Your name").fill("alice");
await page.getByRole("button", { name: "Add availability" }).click();
await page
.getByRole("alert")
.filter({ hasText: "already in this poll" })
.waitFor();
await page.getByRole("button", { name: "Dismiss" }).click();
const other = await desktop.newPage();
await other.goto(publicLink);
await other.getByLabel("Your name").fill("Bob");
await other.getByRole("button", { name: "Add availability" }).click();
const shadow = other.locator(".slot:not([disabled])").first();
assert.match(
await shadow.getAttribute("aria-label"),
/Other participants: 1 yes, 0 maybe out of 1/,
);
assert.ok(
await shadow.evaluate((e) => e.style.getPropertyValue("--peer-cell")),
);
const boundarySlot = Number(
await other
.locator(".peer-boundary:not([disabled])")
.first()
.getAttribute("data-slot"),
);
await other.locator(`[data-slot="${boundarySlot - 1800}"]`).click();
await other.locator(`[data-slot="${boundarySlot}"]`).click();
const peerBoundary = other.locator(`[data-slot="${boundarySlot}"]`);
assert.ok(
(await peerBoundary.getAttribute("class")).includes("peer-boundary"),
);
assert.equal(
await peerBoundary.evaluate((e) => getComputedStyle(e).borderTopWidth),
"2px",
);
assert.equal(
await peerBoundary.evaluate((e) => getComputedStyle(e, "::after").opacity),
"0.35",
);
await other.screenshot({
path: "/tmp/timepoll-vote-shadow.png",
fullPage: true,
});
await other.emulateMedia({ colorScheme: "dark" });
await other.screenshot({
path: "/tmp/timepoll-vote-shadow-dark.png",
fullPage: true,
});
await other.emulateMedia({ colorScheme: "light" });
await other.locator(".slot:not([disabled])").first().click();
await other.getByRole("button", { name: "Save votes" }).click();
await page
.getByRole("button", { name: "Bob", exact: true })
.waitFor({ timeout: 10000 });
await page.getByRole("button", { name: "Everyone", exact: true }).click();
await page.locator(".slot:not([disabled])").first().hover();
await page.locator("#popover").waitFor({ state: "visible" });
await page.locator("#poll-title").hover();
await page.locator("#popover").waitFor({ state: "hidden" });
await page.locator(".slot:not([disabled])").first().hover();
await page.locator("#popover").waitFor({ state: "visible" });
await page.evaluate(() => window.scrollBy(0, 100));
await page.locator("#popover").waitFor({ state: "hidden" });
await page.locator(".slot:not([disabled])").first().click();
await page.locator("#popover").waitFor({ state: "visible" });
assert.equal(await page.locator("#popover .voter").count(), 2);
await page.locator("#popover .voter").filter({ hasText: "Bob" }).click();
await page.getByRole("button", { name: "Edit votes" }).click();
assert.match(
await page
.locator(".slot:not([disabled])")
.first()
.getAttribute("aria-label"),
/Other participants: 1 yes, 0 maybe out of 1/,
);
// Another editor updates the same participant. This draft must not win.
await other.getByRole("button", { name: "Bob", exact: true }).click();
await other.getByRole("button", { name: "Edit votes" }).click();
await other.getByRole("button", { name: "Save votes" }).click();
await page
.getByRole("alert")
.filter({ hasText: "changed elsewhere" })
.waitFor({ timeout: 10000 });
await page.getByRole("button", { name: "Save votes" }).click();
await page
.getByRole("alert")
.filter({ hasText: "changed or removed elsewhere" })
.waitFor();
await page.getByRole("button", { name: "Cancel", exact: true }).click();
assert.equal(
await page.locator("#everyone").getAttribute("class"),
"person active",
);
await page.getByRole("button", { name: "Add availability" }).waitFor();
await page.getByRole("button", { name: "Dismiss" }).click();
await page.screenshot({ path: "/tmp/timepoll-desktop.png", fullPage: true });
// Admin timezone change must retain UTC slots and update editable clocks.
const admin = await desktop.newPage();
await admin.goto(adminLink);
const initial = await admin
.locator(".slot")
.first()
.getAttribute("data-slot");
await admin.locator("#event-zone").selectOption("America/New_York");
assert.equal(
await admin.locator(".slot").first().getAttribute("data-slot"),
initial,
);
assert.equal(await admin.locator("#start-time").inputValue(), "02:00");
await admin.getByRole("button", { name: "Save changes" }).click();
await admin.getByRole("alert").filter({ hasText: "Poll saved" }).waitFor();
await admin.locator("#slot-size").selectOption("15");
await admin.locator(".grid.compact").waitFor();
assert.ok((await admin.locator("tr.hour-mark").count()) > 0);
assert.ok((await admin.locator(".slot").first().boundingBox()).height <= 21);
await admin.getByRole("button", { name: "Save changes" }).click();
await admin.getByRole("alert").filter({ hasText: "Poll saved" }).waitFor();
const mobileContext = await browser.newContext({
viewport: { width: 390, height: 844 },
isMobile: true,
hasTouch: true,
colorScheme: "dark",
timezoneId: "America/Los_Angeles",
});
mobileContext.on("page", (p) =>
p.on("pageerror", (error) => errors.push(error.message)),
);
const mobile = await mobileContext.newPage();
await mobile.goto(publicLink);
await mobile.locator(".slot").first().waitFor();
assert.equal(
await mobile.evaluate(
() => document.documentElement.scrollWidth <= innerWidth,
),
true,
);
await mobile.locator(".slot:not([disabled])").first().tap();
await mobile.locator("#popover").waitFor({ state: "visible" });
await mobile.getByRole("button", { name: "Close", exact: true }).tap();
await mobile.screenshot({
path: "/tmp/timepoll-mobile-dark.png",
fullPage: true,
});
await mobile.getByLabel("Your name").fill("Casey");
await mobile.getByRole("button", { name: "Add availability" }).tap();
await mobile.locator(".slot:not([disabled])").first().tap();
await mobile.getByRole("button", { name: "Save votes" }).tap();
await mobile.getByRole("button", { name: "Add availability" }).waitFor();
await page
.getByRole("button", { name: "Casey", exact: true })
.waitFor({ timeout: 10000 });
await mobile.setViewportSize({ width: 320, height: 800 });
await mobile.getByRole("button", { name: "Casey", exact: true }).tap();
await mobile.getByRole("button", { name: "Edit votes" }).tap();
assert.equal(
await mobile.evaluate(() => document.documentElement.scrollWidth),
320,
);
await mobile.getByRole("button", { name: "Pan grid" }).tap();
assert.equal(await mobile.locator(".grid.editing").count(), 0);
await mobile.getByRole("button", { name: "Cancel", exact: true }).tap();
assert.equal(
await mobile.locator("#everyone").getAttribute("class"),
"person active",
);
await mobileContext.close();
assert.deepEqual(errors, []);
console.log(
JSON.stringify({
publicLink,
adminLink: "[private]",
screenshots: "/tmp/timepoll-*.png",
browserErrors: errors,
}),
);
await browser.close();
})().catch((error) => {
console.error(error);
process.exit(1);
});
+319
View File
@@ -0,0 +1,319 @@
import sqlite3
from datetime import datetime, timezone
from itertools import pairwise
import pytest
from domain import make_slots, remap_votes, resize_slots, summaries, votes_lost
from fastapi.testclient import TestClient
from server import create_app, normalize_code
def stamp(value):
return int(datetime.fromisoformat(value).replace(tzinfo=timezone.utc).timestamp())
def test_dst_spring_skips_nonexistent_hour():
slots = make_slots(["2026-03-29"], "02:00", "05:00", 30, "Europe/Helsinki")
assert len(slots) == 4
assert all(b - a == 1800 for a, b in pairwise(slots))
def test_dst_fall_keeps_both_repeated_hours():
slots = make_slots(["2026-10-25"], "02:00", "05:00", 30, "Europe/Helsinki")
assert len(slots) == 8
assert len(set(slots)) == 8
assert all(b - a == 1800 for a, b in pairwise(slots))
def test_slot_ending_at_spring_transition_is_not_lost():
assert len(make_slots(["2026-03-29"], "02:00", "03:00", 30, "Europe/Helsinki")) == 2
def test_half_hour_dst_change_never_overlaps_slots_or_escapes_range():
slots = make_slots(["2026-04-05"], "01:30", "03:30", 60, "Australia/Lord_Howe")
assert len(slots) == 2
assert all(b - a >= 3600 for a, b in pairwise(slots))
# The first repeated 01:45 precedes a reset to 01:30, outside this range.
slots = make_slots(["2026-04-05"], "01:45", "02:45", 60, "Australia/Lord_Howe")
assert slots == [stamp("2026-04-04T15:15")]
def test_timezone_uses_date_not_current_offset():
winter = make_slots(["2026-01-05"], "09:00", "10:00", 60, "Europe/Helsinki")
summer = make_slots(["2026-07-05"], "09:00", "10:00", 60, "Europe/Helsinki")
assert datetime.fromtimestamp(winter[0], timezone.utc).hour == 7
assert datetime.fromtimestamp(summer[0], timezone.utc).hour == 6
def test_midnight_and_quarter_hour_zone():
slots = make_slots(["2026-01-05"], "23:00", "24:00", 30, "Asia/Kathmandu")
assert slots == [stamp("2026-01-05T17:15"), stamp("2026-01-05T17:45")]
@pytest.mark.parametrize(
"start,end", [("17:00", "09:00"), ("09:00", "09:00"), ("09:00", "10:15")]
)
def test_invalid_ranges(start, end):
with pytest.raises(ValueError):
make_slots(["2026-01-01"], start, end, 30, "UTC")
def test_vote_resize():
assert remap_votes({"0": "yes", "1800": "yes"}, 30, [0], 60) == {"0": "yes"}
assert remap_votes({"0": "yes"}, 30, [0], 60) == {"0": "maybe"}
assert remap_votes({"0": "maybe"}, 30, [0], 60) == {"0": "maybe"}
assert remap_votes({"0": "yes", "1800": "maybe"}, 30, [0], 60) == {"0": "maybe"}
assert remap_votes({"0": "yes"}, 60, [0, 1800], 30) == {"0": "yes", "1800": "yes"}
assert resize_slots([0, 1800, 7200, 9000], 30, 60) == [0, 7200]
assert votes_lost({"0": "yes"}, 60, [0], 30)
assert not votes_lost({"0": "yes"}, 60, [0, 1800], 30)
def test_overlap_requires_same_people_and_counts_empty_voters():
people = [
{"id": 1, "votes": {"0": "yes", "1800": "yes"}},
{"id": 2, "votes": {"0": "yes"}},
{"id": 3, "votes": {"1800": "maybe"}},
{"id": 4, "votes": {"0": "yes", "1800": "yes"}},
]
result = summaries([0, 1800], 30, people)
assert result["yes"][0]["seconds"] == 0
assert result["yes"][1]["seconds"] == 1800
assert len(result["inclusive"][1]["stretches"]) == 2
assert (
result["inclusive"][1]["stretches"][0]["people"]
!= result["inclusive"][1]["stretches"][1]["people"]
)
def test_overlap_gap_and_midnight():
people = [{"id": 1, "votes": {"84600": "yes", "86400": "yes", "91800": "yes"}}]
result = summaries([84600, 86400, 91800], 30, people)
assert result["yes"][0]["seconds"] == 3600
@pytest.fixture
def client(tmp_path):
with TestClient(create_app(tmp_path)) as client:
yield client
def settings(**kwargs):
return dict(
title="Weekend",
timezone="Europe/Helsinki",
fixed_timezone=False,
minutes=30,
slots=[1800000000, 1800001800, 1800003600, 1800005400],
blocked=[],
**kwargs,
)
def create(client):
response = client.post("/api/polls", json=settings())
assert response.status_code == 201, response.text
result = response.json()
return result["code"], {"Authorization": "Bearer " + result["admin_token"]}
def add(client, code, name="Alice", votes=None):
response = client.post(
f"/api/polls/{code}/people",
json={"name": name, "votes": votes or {}, "schedule_revision": 1},
)
assert response.status_code == 201, response.text
return response.json()["id"]
def test_codes_and_private_token_are_not_public(client, tmp_path):
code, headers = create(client)
assert len(code) == 9 and len(headers["Authorization"].split()[1]) == 12
assert normalize_code("ab-oil xyz") == "AB011XYZ"
response = client.get(f"/api/polls/{code.lower()}")
assert response.status_code == 200
assert "admin" not in response.text
with sqlite3.connect(tmp_path / "timepoll.sqlite3") as db:
assert headers["Authorization"].split()[1] not in str(
db.execute("SELECT * FROM polls").fetchall()
)
assert client.get(f"/api/polls/{code}/admin").status_code == 403
assert client.get(f"/api/polls/{code}/admin", headers=headers).status_code == 200
def test_empty_duplicate_and_unavailable_votes(client):
code, _ = create(client)
add(client, code)
duplicate = client.post(
f"/api/polls/{code}/people",
json={"name": " ALICE ", "votes": {}, "schedule_revision": 1},
)
assert duplicate.status_code == 409
invalid = client.post(
f"/api/polls/{code}/people",
json={"name": "Bob", "votes": {"123": "yes"}, "schedule_revision": 1},
)
assert invalid.status_code == 422
assert len(client.get(f"/api/polls/{code}").json()["people"]) == 1
def test_conflicts_and_unrelated_edits(client):
code, headers = create(client)
alice = add(client, code)
add(client, code, "Bob")
body = {
"name": "Alice",
"votes": {"1800000000": "yes"},
"schedule_revision": 1,
"revision": 1,
}
assert client.put(f"/api/polls/{code}/people/{alice}", json=body).status_code == 200
assert client.put(f"/api/polls/{code}/people/{alice}", json=body).status_code == 409
edit = settings(settings_revision=1)
edit.update(title="Renamed", timezone="America/New_York")
assert (
client.put(f"/api/polls/{code}", json=edit, headers=headers).status_code == 200
)
assert client.get(f"/api/polls/{code}").json()["schedule_revision"] == 1
body["revision"] = 2
assert client.put(f"/api/polls/{code}/people/{alice}", json=body).status_code == 200
assert (
client.delete(f"/api/polls/{code}/people/{alice}?revision=2").status_code == 409
)
assert (
client.delete(f"/api/polls/{code}/people/{alice}?revision=3").status_code == 200
)
def test_admin_vote_loss_confirmation_and_conversion(client):
code, headers = create(client)
add(
client,
code,
votes={"1800000000": "yes", "1800001800": "yes", "1800003600": "yes"},
)
edit = settings(settings_revision=1)
edit.update(minutes=60, slots=[1800000000, 1800003600])
response = client.put(f"/api/polls/{code}", json=edit, headers=headers)
assert response.status_code == 200, response.text
poll = client.get(f"/api/polls/{code}").json()
assert poll["people"][0]["votes"] == {"1800000000": "yes", "1800003600": "maybe"}
edit.update(settings_revision=2, blocked=[1800000000])
response = client.put(f"/api/polls/{code}", json=edit, headers=headers)
assert (
response.status_code == 409 and response.json()["detail"]["code"] == "vote_loss"
)
assert client.get(f"/api/polls/{code}").json()["revision"] == poll["revision"]
edit["confirm_loss"] = True
assert (
client.put(f"/api/polls/{code}", json=edit, headers=headers).status_code == 200
)
assert client.get(f"/api/polls/{code}").json()["people"][0]["votes"] == {
"1800003600": "maybe"
}
def test_blocked_votes_and_changed_schedule_conflict(client):
code, headers = create(client)
edit = settings(settings_revision=1)
edit["blocked"] = [1800000000]
client.put(f"/api/polls/{code}", json=edit, headers=headers)
body = {"name": "Alice", "votes": {"1800000000": "yes"}, "schedule_revision": 1}
assert client.post(f"/api/polls/{code}/people", json=body).status_code == 409
body["schedule_revision"] = 2
assert client.post(f"/api/polls/{code}/people", json=body).status_code == 422
def test_persistence(tmp_path):
with TestClient(create_app(tmp_path)) as client:
code, _ = create(client)
add(client, code)
with TestClient(create_app(tmp_path)) as restarted:
assert (
restarted.get(f"/api/polls/{code}").json()["people"][0]["name"] == "Alice"
)
def test_security_validation_and_health(client):
assert client.get("/healthz").json() == {"status": "ok"}
assert client.get("/").headers["x-frame-options"] == "DENY"
assert (
client.post(
"/api/polls",
json=settings(),
headers={"Origin": "https://elsewhere.invalid"},
).status_code
== 403
)
assert (
client.post("/api/polls", content=b"x" * (1024 * 1024 + 1)).status_code == 413
)
invalid = settings()
invalid["slots"] = [1800000000, 1800000060]
assert client.post("/api/polls", json=invalid).status_code == 422
invalid.update(timezone="../etc/passwd")
assert client.post("/api/polls", json=invalid).status_code == 422
def test_schedule_preview_and_resize(client):
response = client.post(
"/api/schedule",
json={
"dates": ["2026-10-25"],
"start": "02:00",
"end": "05:00",
"minutes": 30,
"timezone": "Europe/Helsinki",
},
)
assert response.status_code == 200
assert len(response.json()["slots"]) == 8
body = settings()
body["minutes"] = 60
response = client.post("/api/resize?old_minutes=30", json=body)
assert response.json()["slots"] == [1800000000, 1800003600]
def test_random_code_collision_is_retried(client, monkeypatch):
code, headers = create(client)
token = headers["Authorization"].split()[1]
values = iter([code, token, "123456789", "123456789ABC"])
monkeypatch.setattr("server.new_code", lambda length: next(values))
response = client.post("/api/polls", json=settings())
assert response.status_code == 201
assert response.json()["code"] == "123456789"
def test_root_public_and_private_links(client):
code, headers = create(client)
token = headers["Authorization"].split()[1]
assert client.get(f"/{code}").status_code == 200
assert client.get(f"/{token}").status_code == 200
response = client.post("/api/admin/resolve", headers=headers)
assert response.json() == {"code": code}
assert client.post("/api/admin/resolve").status_code == 403
assert (
client.post(
"/api/admin/resolve", headers={"Authorization": "Bearer " + code}
).status_code
== 403
)
assert client.get(f"/p/{code}").status_code == 404
assert client.get(f"/admin/{code}").status_code == 404
assert token not in client.get(f"/api/polls/{code}").text
def test_resizing_rounds_local_range_outward():
slots = make_slots(["2026-09-15"], "09:15", "10:45", 15, "Europe/Helsinki")
assert resize_slots(slots, 15, 30, "Europe/Helsinki") == make_slots(
["2026-09-15"], "09:00", "11:00", 30, "Europe/Helsinki"
)
# A non-whole-hour UTC offset must not change the local rounding origin.
slots = make_slots(["2026-09-15"], "09:15", "10:45", 15, "Asia/Kathmandu")
assert resize_slots(slots, 15, 60, "Asia/Kathmandu") == make_slots(
["2026-09-15"], "09:00", "11:00", 60, "Asia/Kathmandu"
)
def test_resizing_merges_ranges_that_round_into_one_another():
assert resize_slots([900, 2700], 15, 60) == [0]