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,7 @@
|
||||
__pycache__
|
||||
.pytest_cache
|
||||
tests
|
||||
data
|
||||
*.md
|
||||
pytest.ini
|
||||
compose.example.yaml
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
[pytest]
|
||||
pythonpath = .
|
||||
testpaths = tests
|
||||
@@ -0,0 +1,3 @@
|
||||
fastapi>=0.115,<1
|
||||
uvicorn>=0.30,<1
|
||||
tzdata
|
||||
@@ -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 |
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Vendored
+9
@@ -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
@@ -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.
|
||||
Vendored
+12
File diff suppressed because one or more lines are too long
@@ -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);
|
||||
});
|
||||
@@ -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]
|
||||
Reference in New Issue
Block a user