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,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).
|
||||
Reference in New Issue
Block a user