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