Files
ajp_anton 4cc2e8fc80
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
Add Timepoll availability polling application and image
2026-09-09 19:39:40 +00:00

185 lines
9.8 KiB
Markdown

# 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).