Courtside MVP: engine, web app, Docker + Cloudflare Tunnel deploy, docs
Tournament engine (pools, single/double elimination, standings with proper tiebreaks, withdrawals, two-court queue with ETAs and alerts), a single-process Node server with SQLite via node:sqlite and a WebSocket live board, organizer desk, QR landing page that follows the tournament phase, Dockerfile and compose with cloudflared, and documentation for deploying and running a day. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01MB7nCCAscYsb3zzkT6LHZi
This commit is contained in:
+81
@@ -0,0 +1,81 @@
|
||||
# HTTP and WebSocket endpoints
|
||||
|
||||
All pages are server-rendered HTML except where noted. `<slug>` is the URL-safe tournament id created from its name (e.g. `labor-day-2s`).
|
||||
|
||||
## Public
|
||||
|
||||
| Method | Path | Returns |
|
||||
| --- | --- | --- |
|
||||
| GET | `/` | List of tournaments. |
|
||||
| GET | `/healthz` | `{ ok, tournaments, uptime }` JSON. Used by the Docker healthcheck. |
|
||||
| GET | `/t/<slug>` | The QR landing page. Registration form while phase is `checkin`; otherwise the live board / results (client-rendered from state JSON). |
|
||||
| POST | `/t/<slug>/register` | Form fields `name`, `captain`, `phone`, `players`, `playerNames` (newline-separated). 303 to the team page on success, 400 with the form re-rendered on error. |
|
||||
| GET | `/t/<slug>/team/<code>` | Personalized board for the team with that 4-character code. `?new=1` shows the post-registration confirmation instead. |
|
||||
| GET | `/t/<slug>/display` | TV display mode of the board. |
|
||||
| GET | `/t/<slug>/state.json` | The public state document (below). |
|
||||
| WS | `/ws/<slug>` | Sends the public state document immediately on connect and again on every change. Clients send nothing. Server pings every 30 s. |
|
||||
| GET | `/static/*` | CSS, JS, icons. |
|
||||
|
||||
### Public state document
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"slug": "labor-day-2s", "name": "Labor Day 2s", "date": "2026-09-07", "notes": "", "phase": "live", // checkin | closed | live | final
|
||||
"banner": null, "rules": { "teamSize": 2, "pointsTo": 21, "winBy": 2, "cap": 25, "bestOf": 1, "tiebreaks": ["headToHead","setRatio","pointRatio","pointDiff","seed"] },
|
||||
"stages": ["pool","bracket"], "courtCount": 2,
|
||||
"teams": [{ "id": "t1", "name": "Net Gains", "players": 2, "seed": 1, "status": "registered", "poolId": "A" }],
|
||||
"withdrawn": [],
|
||||
"pools": [{ "id": "A",
|
||||
"standings": [{ "teamId": "t1", "name": "Net Gains", "w": 2, "l": 1, "setsW": 2, "setsL": 1, "pf": 59, "pa": 45, "pointDiff": 14, "decidedBy": "pointRatio", "status": "registered" }],
|
||||
"matches": [ /* match objects */ ] }],
|
||||
"bracket": [ /* match objects with stage bracket | losers | final */ ],
|
||||
"courts": [{ "court": 1, "status": "open", "match": { /* match object */ } }, { "court": 2, "status": "paused", "match": null }],
|
||||
"upNext": [{ /* match object */ , "court": 2, "etaMin": 12 }],
|
||||
"avgMatchMin": 14, "champion": null,
|
||||
"recentAlerts": [{ "kind": "on_deck", "text": "Net Gains vs Block Party: you're ON DECK for Court 2 (~12 min).", "at": 1757260000000, "teams": ["t1","t2"], "match": "m7" }],
|
||||
"generatedAt": 1757260000000
|
||||
}
|
||||
```
|
||||
|
||||
A match object:
|
||||
|
||||
```jsonc
|
||||
{ "id": "m7", "stage": "pool", "round": 2, "slot": null, "label": null,
|
||||
"status": "live", // pending | on_deck | live | final | forfeit | void | bye
|
||||
"court": 1,
|
||||
"a": { "id": "t1", "name": "Net Gains" }, "b": { "id": "t2", "name": "Block Party" }, // null while TBD
|
||||
"winner": null, "sets": [], "live": [14, 11], // running score while live, else null
|
||||
"feeds": "m12", "feedsSide": "A", "conditional": false }
|
||||
```
|
||||
|
||||
Phone numbers, captain names, and team codes never appear in the public document.
|
||||
|
||||
## Organizer
|
||||
|
||||
All organizer routes require the session cookie set by `/admin/login` (or Cloudflare Access when `TRUST_CF_ACCESS=true`). Unauthenticated requests are redirected to the login page.
|
||||
|
||||
| Method | Path | Body (form or JSON) | Effect |
|
||||
| --- | --- | --- | --- |
|
||||
| GET | `/admin/login` | | Login form. |
|
||||
| POST | `/admin/login` | `password`, `next` | Sets cookie, redirects to `next`. |
|
||||
| GET | `/admin/logout` | | Clears cookie. |
|
||||
| GET | `/admin` | | Desk home: tournament list and creation form. |
|
||||
| POST | `/admin/new` | `name`, `date`, `courtCount`, `teamSize`, `pointsTo`, `winBy`, `cap` (0 = none), `bestOf`, `stages` (`pool,bracket` / `pool` / `bracket`), `notes` | Creates a tournament. |
|
||||
| GET | `/admin/t/<slug>` | | Tournament desk. |
|
||||
| GET | `/admin/t/<slug>/qr` | | Printable QR page. `qr.png` returns the PNG. |
|
||||
| POST | `/admin/t/<slug>/phase` | `phase` = `checkin` / `closed` / `live` / `final` | Change phase. `live` requires generated play and starts court assignment. |
|
||||
| POST | `/admin/t/<slug>/banner` | `banner` | Set (or clear with empty) the persistent banner. |
|
||||
| POST | `/admin/t/<slug>/broadcast` | `text` | One-time alert to all team pages. |
|
||||
| POST | `/admin/t/<slug>/generate` | `what=pools`, `poolSize` — or `what=bracket`, `perPool`, `wildcards`, `type` (`single`/`double`), `thirdPlace` | Generate pools or bracket. |
|
||||
| POST | `/admin/t/<slug>/regenerate` | | Clear all generated play (only before any score). |
|
||||
| POST | `/admin/t/<slug>/score` | `match`, `sets` (`"21-18, 19-21, 15-9"`) | Record or correct a result. Validated against the rules. |
|
||||
| POST | `/admin/t/<slug>/live` | `match`, `a`, `b` | Update the running score of a live match (JSON, used by the score pad). |
|
||||
| POST | `/admin/t/<slug>/forfeit` | `match`, `team` | Record a forfeit by `team`. |
|
||||
| POST | `/admin/t/<slug>/swap` | `match`, `court` | Move a live match to another open court. |
|
||||
| POST | `/admin/t/<slug>/pushback` | `match` | Push a pending match two places back in the queue. |
|
||||
| POST | `/admin/t/<slug>/court` | `court`, `op` = `pause`/`resume`, `reason` | Pause or resume a court. |
|
||||
| POST | `/admin/t/<slug>/team` | `op` = `add` (`name`,`captain`,`players`) / `edit` (`id`,`name`,`captain`,`players`) / `seed` (`id`,`seed`) / `remove` (`id`) | Roster management. |
|
||||
| POST | `/admin/t/<slug>/withdraw` | `id`, `mode` = `forfeit`/`void` | Withdraw a team. |
|
||||
| POST | `/admin/t/<slug>/delete` | `confirm=yes` | Delete the tournament and its data. |
|
||||
|
||||
Form posts respond with a 303 back to the desk carrying `?msg=` or `?error=`. Requests with `Content-Type: application/json` get `{ ok: true, msg }` or `{ ok: false, error }` with a 400 on failure. Every action is recorded in the `events` table with the organizer identity as `actor`.
|
||||
@@ -0,0 +1,55 @@
|
||||
# Architecture
|
||||
|
||||
## Shape
|
||||
|
||||
One Node process. It serves HTML pages, a JSON state endpoint, a WebSocket that pushes that same JSON on every change, and it persists to a single SQLite file. There is no build step, no framework, and no native module: the only runtime dependencies are `ws` (WebSocket server) and `qrcode` (PNG generation). SQLite comes from Node's built-in `node:sqlite` (Node 22.13+).
|
||||
|
||||
```
|
||||
phones / TV ──HTTP──▶ server/index.js ──▶ routes.js ──▶ Registry.mutate() ──▶ engine/*
|
||||
◀──WS─── (ws fan-out) │ │
|
||||
│ ├─▶ Store (SQLite JSON docs + event log)
|
||||
│ └─▶ notify(slug) → publicState() → every socket
|
||||
└─▶ views/*.js (HTML) + public/*.js (client render)
|
||||
```
|
||||
|
||||
## The engine is pure
|
||||
|
||||
`engine/` knows nothing about HTTP or storage. It has three parts:
|
||||
|
||||
- `formats.js` — generators. Snake-seeded pools, circle-method round robin, single elimination with standard seed order and byes, double elimination (winners, losers, grand final, conditional reset). Output is a flat list of match objects linked by `feeds`/`loserFeeds` edges.
|
||||
- `tournament.js` — state and rules. Registration validation, phases, generation, result recording with rally-scoring validation, propagation of winners and losers along the bracket edges, withdrawals (forfeit remaining games, or void all of a team's games), and standings with an ordered tiebreak list where head-to-head is evaluated as a mini-league among only the tied teams (so a three-way circle falls through to the next rule).
|
||||
- `courts.js` — the court engine. It never stores a schedule. On every change it recomputes: which pending matches are eligible (both teams known, neither on a court, all feeder matches decided), in priority order (stage, round, push-backs, longest rest), and assigns the top ones to open courts. It then simulates the queue against expected court finish times to predict a court and ETA for each waiting match, marks the first N as `on_deck`, and emits `up_now` / `on_deck` / `broadcast` alert events exactly once per match. ETAs use the rolling average of the last six match durations.
|
||||
|
||||
Because the schedule is derived, a withdrawal, a paused court, a corrected score, or a late team never leaves a stale plan behind; the next `tick()` simply produces the new truth.
|
||||
|
||||
Everything in the engine is unit-tested (`npm test`) and exercised end-to-end by `simulate.js`, which plays a 12-team day with random scores, a mid-pool withdrawal, and a court pause, then asserts invariants (no team on two courts, no duplicate alerts, withdrawn teams never reach the bracket, everything decided at the end).
|
||||
|
||||
## Persistence: JSON documents, not tables
|
||||
|
||||
`server/store.js` keeps two JSON documents per tournament (`Tournament.toJSON()` and `CourtEngine.toJSON()`) in a `tournaments` table, rewritten on every mutation, plus an append-only `events` table with every engine event (registrations, results with actor, alerts, phase changes) as an audit trail. A tournament's state is a few hundred KB at most, a mutation happens a few times a minute, and SQLite in WAL mode handles that without noticing. The trade-off is that you cannot query across tournaments with SQL; for a season leaderboard later, a small read model can be built from the event log.
|
||||
|
||||
On startup `Registry` loads every document, rebuilds the in-memory objects, and reattaches the event logger. All reads are served from memory.
|
||||
|
||||
## One state document for every public page
|
||||
|
||||
`server/public-state.js` builds a single JSON object: phase, banner, rules, teams (public fields only: never phone numbers or team codes), pools with standings and matches, bracket matches, court status with live scores, the up-next queue with ETAs, the champion, and the last twenty alerts. The public page, the team page, and the TV display are the same HTML shell and the same `board.js`; they differ only in a `data-team` id and a `data-display` flag. The initial state is embedded in the page, then a WebSocket at `/ws/<slug>` replaces it on every change, and a two-minute fetch of `/t/<slug>/state.json` covers a silently dropped socket.
|
||||
|
||||
Team pages compare the alert list against what they have already shown and raise an in-page toast, a vibration, and (if the visitor allowed it) a browser notification when their team is on deck or up. This is the alert channel in the MVP; SMS and Web Push are the next steps (see ROADMAP).
|
||||
|
||||
## The organizer desk
|
||||
|
||||
`/admin` is server-rendered HTML with plain forms. Every action is a `POST /admin/t/<slug>/<action>` that runs inside `Registry.mutate()`, which applies the change, ticks the court engine if live, persists, and notifies subscribers. Form posts redirect back with a flash message; the score pad posts JSON via `fetch` for the running score (`/live`) and the final (`/score`). The desk also listens on the WebSocket and reloads when a court or phase changes, so two organizers can work at once.
|
||||
|
||||
Authentication is a signed cookie (HMAC-SHA256 of an expiry and identity) issued after `ADMIN_PASSWORD` is checked with a timing-safe compare, or, when `TRUST_CF_ACCESS=true`, Cloudflare Access's identity header.
|
||||
|
||||
## Why not a framework, React, Postgres, Redis…
|
||||
|
||||
The whole system is a few dozen phones hitting one process for one afternoon. A single Node process with in-memory state and a file-backed SQLite store is simpler to reason about, simpler to deploy (one image, one volume), and has nothing that can be misconfigured. The client code is one file of template literals; the pages are small enough that a framework would be most of the code. If the app ever needs multiple instances, the `Registry` is the seam: replace its in-memory map and `notify()` with a shared store and pub/sub.
|
||||
|
||||
## Security notes
|
||||
|
||||
- Public pages expose team names, player counts, and results; captain phone numbers and team codes are only shown in the desk and on the registration confirmation.
|
||||
- A team code is a 4-character token (about 1M combinations) that only grants a read-only personalized view; nothing can be changed from a team page.
|
||||
- Registration is open to anyone with the URL while the phase is `checkin`, by design. Names are length-limited and HTML-escaped everywhere; bodies are capped at 64 KB.
|
||||
- Organizer actions require the cookie; the WebSocket only sends public state and ignores client messages.
|
||||
- The container runs as the unprivileged `node` user and publishes no host ports in the tunnel configuration.
|
||||
@@ -0,0 +1,25 @@
|
||||
# Deploy checklist (for a person or an automated agent)
|
||||
|
||||
Follow in order. Full explanations are in [DEPLOY.md](DEPLOY.md).
|
||||
|
||||
1. Host has Docker with Compose v2: `docker compose version`.
|
||||
2. `git clone https://gitea.cloudfreeiot.com/bkvargyas/courtside.git && cd courtside`
|
||||
3. `cp .env.example .env` and set:
|
||||
- `ADMIN_PASSWORD` — strong password for `/admin`.
|
||||
- `SESSION_SECRET` — `openssl rand -hex 32`.
|
||||
- `CLOUDFLARE_TUNNEL_TOKEN` — from Cloudflare Zero Trust → Networks → Tunnels → your tunnel → connector token. The tunnel's public hostname must route to **`http://app:3000`**.
|
||||
- `TRUST_CF_ACCESS` — leave `false` unless an Access policy protects `/admin` on that hostname.
|
||||
4. `docker compose up -d --build`
|
||||
5. Verify:
|
||||
- `docker compose ps` → `app` healthy, `cloudflared` running.
|
||||
- `docker compose exec app wget -qO- http://127.0.0.1:3000/healthz` → `{"ok":true,...}`.
|
||||
- `docker compose logs cloudflared | grep -i "registered tunnel connection"`.
|
||||
- `curl -sI https://<hostname>/` → `200`.
|
||||
- `https://<hostname>/admin` shows the login page and accepts `ADMIN_PASSWORD`.
|
||||
6. Optional: seed the demo tournament to give the site something to show:
|
||||
`docker compose stop app && docker compose run --rm app node scripts/seed-demo.js && docker compose start app`
|
||||
then open `https://<hostname>/t/demo-labor-day-2s`. Delete it later from the desk's Danger zone.
|
||||
7. Back up: the named volume `courtside-data` (one SQLite file). See DEPLOY.md §5.
|
||||
8. Update later: `git pull && docker compose up -d --build`.
|
||||
|
||||
Nothing else is required. No host ports are published; no reverse proxy, TLS certificate, or database server is involved.
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
# Deploying Courtside
|
||||
|
||||
Target: a Docker host (Compose v2) with outbound internet, exposed to the public through a Cloudflare Tunnel. No inbound ports are opened on the host. This guide is written so that a person or an automated agent can follow it top to bottom.
|
||||
|
||||
## 1. Prerequisites
|
||||
|
||||
- Docker Engine 24+ with the Compose plugin (`docker compose version` works).
|
||||
- A Cloudflare account with a zone (domain) on it, e.g. `example.net`.
|
||||
- A hostname to use, e.g. `vb.example.net`.
|
||||
|
||||
## 2. Create the Cloudflare Tunnel
|
||||
|
||||
In the Cloudflare dashboard: **Zero Trust → Networks → Tunnels → Create a tunnel → Cloudflared**. Name it (e.g. `courtside`). On the connector step, copy the token from the `docker run ... --token <TOKEN>` command; that is `CLOUDFLARE_TUNNEL_TOKEN`. You do not need to run the command they show.
|
||||
|
||||
On the **Public Hostname** tab add one route:
|
||||
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| Subdomain | `vb` (or whatever you chose) |
|
||||
| Domain | your zone |
|
||||
| Type | `HTTP` |
|
||||
| URL | `app:3000` |
|
||||
|
||||
`app` is the Compose service name; cloudflared runs on the same Compose network and resolves it. Cloudflare will create the DNS record for you.
|
||||
|
||||
Optional but recommended, **protect the organizer desk with Cloudflare Access**: Zero Trust → Access → Applications → Add an application → Self-hosted. Application domain `vb.example.net`, path `admin`. Add a policy allowing your email (one-time PIN login is fine). Then set `TRUST_CF_ACCESS=true` in `.env` so organizers who pass Access are signed in automatically and no app password is needed. `ADMIN_PASSWORD` continues to work as a fallback when set. Note: `TRUST_CF_ACCESS` trusts the `Cf-Access-Authenticated-User-Email` header; only enable it when the app is reachable exclusively through the tunnel (which is the case with the provided compose file, since no host ports are published).
|
||||
|
||||
## 3. Configure and start
|
||||
|
||||
```bash
|
||||
git clone https://gitea.cloudfreeiot.com/bkvargyas/courtside.git
|
||||
cd courtside
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `.env`:
|
||||
|
||||
```
|
||||
ADMIN_PASSWORD=<a strong password>
|
||||
SESSION_SECRET=<output of: openssl rand -hex 32>
|
||||
TRUST_CF_ACCESS=false # true if you set up Access in step 2
|
||||
CLOUDFLARE_TUNNEL_TOKEN=<token from step 2>
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
docker compose ps # app should be "healthy", cloudflared "running"
|
||||
docker compose logs -f cloudflared # look for "Registered tunnel connection"
|
||||
```
|
||||
|
||||
Open `https://vb.example.net`. You should see the (empty) tournaments list. Open `https://vb.example.net/admin`, sign in, create a tournament, and open its QR page.
|
||||
|
||||
## 4. What the deployment consists of
|
||||
|
||||
| Piece | Where | Notes |
|
||||
| --- | --- | --- |
|
||||
| `app` container | image built from `Dockerfile` (node:22-alpine) | Runs `node server/index.js` as the unprivileged `node` user. Listens on 3000 inside the network only. Healthcheck at `/healthz`. |
|
||||
| `cloudflared` container | `cloudflare/cloudflared:latest` | Outbound-only connection to Cloudflare. Starts after `app` is healthy. |
|
||||
| `courtside-data` volume | Docker named volume mounted at `/data` | Contains `courtside.sqlite` (+ `-wal`/`-shm`). This is the only state. |
|
||||
|
||||
Environment variables the app reads:
|
||||
|
||||
| Variable | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `PORT` | `3000` | Listen port. |
|
||||
| `HOST` | `0.0.0.0` | Bind address. |
|
||||
| `DB_PATH` | `./data/courtside.sqlite` (image: `/data/courtside.sqlite`) | SQLite file; directory is created if missing. |
|
||||
| `ADMIN_PASSWORD` | unset | Organizer password for `/admin/login`. |
|
||||
| `SESSION_SECRET` | random per start | HMAC key for the organizer cookie. Set it so logins survive restarts. |
|
||||
| `TRUST_CF_ACCESS` | `false` | Accept Cloudflare Access identity header as organizer login. |
|
||||
|
||||
The app trusts `X-Forwarded-Proto` and `X-Forwarded-Host` (which cloudflared sets) to build the absolute URLs used in QR codes and team links. If you put a different reverse proxy in front, make sure it sets those headers.
|
||||
|
||||
## 5. Backups
|
||||
|
||||
Everything is in one SQLite file. A consistent copy while running:
|
||||
|
||||
```bash
|
||||
docker compose exec app node -e "
|
||||
const {DatabaseSync}=require('node:sqlite');
|
||||
new DatabaseSync('/data/courtside.sqlite').exec(\"VACUUM INTO '/data/backup-'||strftime('%Y%m%d-%H%M','now')||'.sqlite'\")"
|
||||
docker cp "$(docker compose ps -q app)":/data/ ./backups/
|
||||
```
|
||||
|
||||
Or simply stop the stack and copy the volume. Restoring is copying the file back to `/data/courtside.sqlite` and restarting `app`. A tournament day produces well under a megabyte.
|
||||
|
||||
## 6. Updating
|
||||
|
||||
```bash
|
||||
git pull
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
The database schema is created with `CREATE TABLE IF NOT EXISTS`; state is stored as JSON documents, so engine changes do not require migrations. If a future version changes the JSON shape, its release notes will say so.
|
||||
|
||||
## 7. Running without Cloudflare
|
||||
|
||||
For a LAN-only event or local testing:
|
||||
|
||||
```bash
|
||||
ADMIN_PASSWORD=letmein docker compose -f docker-compose.local.yml up --build
|
||||
# http://<host-ip>:3000
|
||||
```
|
||||
|
||||
QR codes will embed whatever host the organizer used to open the desk, so open the desk via the LAN IP, not `localhost`, before printing. Browser notifications on team pages require HTTPS on most phones; on plain HTTP the team page still updates live and shows in-page toasts.
|
||||
|
||||
## 8. Troubleshooting
|
||||
|
||||
- **`docker compose ps` shows `app` unhealthy.** `docker compose logs app`. The most common cause is a `DB_PATH` directory the `node` user cannot write; the provided compose uses a named volume that is created with the right ownership.
|
||||
- **Tunnel connects but the site shows Cloudflare error 502.** The public hostname URL must be `http://app:3000` (service name, not `localhost`).
|
||||
- **Organizer login loops.** `ADMIN_PASSWORD` is unset in `.env` or contains a character your shell mangled; quote it. With `TRUST_CF_ACCESS=true` but no Access policy on `/admin`, the header is absent and password login is used.
|
||||
- **Live board doesn't update.** The WebSocket at `/ws/<slug>` must pass through the proxy; Cloudflare tunnels support WebSockets by default. As a fallback every public page re-fetches `/t/<slug>/state.json` every two minutes.
|
||||
- **Wrong hostname in QR codes / team links.** The app builds URLs from `X-Forwarded-Host`; check the proxy sets it.
|
||||
|
||||
## 9. Resource expectations
|
||||
|
||||
A 16-team day with 40 phones open uses a few tens of MB of RAM and negligible CPU. The container has no memory limit set; add one in compose if you want (`mem_limit: 256m` is generous).
|
||||
@@ -0,0 +1,17 @@
|
||||
# Known gaps and things to watch
|
||||
|
||||
Honest list of what the MVP does not do well yet. None of these block running a tournament; all of them are worth knowing before you do.
|
||||
|
||||
- **No real-event mileage yet.** The engine has been exercised by `simulate.js` (dozens of seeds, single and double elimination) and the HTTP smoke test, and the pages have been eyeballed on phone and desktop sizes. It has not run an actual tournament.
|
||||
- **Bracket corrections don't re-propagate.** Correcting a bracket match after its winner has already played the next round does not rewind the later match; you must correct that one too. Pool corrections are fine (standings are recomputed).
|
||||
- **Double elimination with byes** is tested at 6 teams (8-bracket). Very uneven fields (9–10 teams in a 16-bracket) get the same bye logic but have had less scrutiny.
|
||||
- **Queue priority is round-first.** A round-2 match between two rested teams waits behind a round-1 match whose teams just came off court. That keeps pools fair (everyone plays round N before anyone plays round N+1) but can leave a court briefly idle. The engine is a single function (`_eligible`) if you want a different policy.
|
||||
- **ETAs are estimates.** They come from the rolling average of the last six matches and assume a fixed changeover. Early in the day (fewer than one finished match) the default is 15 minutes.
|
||||
- **Alerts are on-page only.** A team must have their team page open (or the notification permission granted) to be alerted. SMS and Web Push are the top roadmap items.
|
||||
- **Browser notifications need HTTPS** and, on iOS, the page added to the home screen. Through the Cloudflare tunnel HTTPS is automatic.
|
||||
- **Registration is open to anyone with the URL** during check-in, by design. If someone registers junk teams, remove them from the desk. There is no rate limiting beyond the 64 KB body cap.
|
||||
- **Team codes are 4 characters** (about a million combinations) and only reveal a personalized read-only view, so guessing is harmless. They are not a write credential.
|
||||
- **Organizer sessions reset on restart** unless `SESSION_SECRET` is set.
|
||||
- **`node:sqlite` is marked experimental** by Node (stable API since 22.13, still emits a warning that the start script suppresses). If a future Node changes it, `server/store.js` is the only file that touches it and is about 50 lines.
|
||||
- **Single process.** Restarting the container drops WebSocket connections; clients reconnect with backoff and re-fetch state, and no data is lost since every mutation is written before it is broadcast.
|
||||
- **The desk reloads itself** when court or phase state changes (for multi-organizer freshness). If you are mid-typing in a form when another organizer marks a match final, the page reloads. The score pad is exempt while hovered.
|
||||
@@ -0,0 +1,64 @@
|
||||
# Organizer guide
|
||||
|
||||
Everything an organizer does happens at `/admin` on a phone or laptop. Sign in with the organizer password (or through Cloudflare Access if your deployment uses it).
|
||||
|
||||
## Before the day
|
||||
|
||||
**Create the tournament.** Name, date, number of courts, team size (2s/3s/4s/6s), scoring (points to, win by, cap, best of), and stages: pools then bracket, pools only, or bracket only. Notes you enter here appear on the registration page.
|
||||
|
||||
**Print the QR code.** From the tournament desk click "QR code", then Print. Put it on the check-in table, on the shelter, wherever people gather. That one code is used all day; it always points at `/t/<slug>` and shows whatever phase you are in.
|
||||
|
||||
**Rule of thumb for fitting the day.** A 2s set to 21 runs 12–15 minutes plus a 3-minute changeover. Two courts give you about 8 matches an hour. Twelve teams in three pools of four is 18 pool matches (about 2¼ hours), then an 8-team bracket adds 7 matches (about an hour). Pools of 4 or 5 fit two courts best; with 16+ teams consider pools of 4 and advancing only the top 2.
|
||||
|
||||
## Check-in
|
||||
|
||||
Teams scan the code and fill in team name, captain, mobile, number of players, optional player names. They get a private team link on the confirmation screen with a "Text it to myself" button. The team link shows their schedule, record, and alerts all day.
|
||||
|
||||
From the desk you can rename a team, fix the captain or player count, remove a team, add a walk-up team, and set seeds (1 = strongest). Seeds decide pool placement (snake order) and bracket placement. If you don't seed, registration order is used.
|
||||
|
||||
## Starting play
|
||||
|
||||
1. **Close registration.** The public page switches to the roster and "schedule coming."
|
||||
2. **Generate pools** (pick the pool size) or, for bracket-only, generate the bracket. You can clear and regenerate freely until the first score is entered. A late team? Clear generated play, add the team, regenerate.
|
||||
3. **Go live.** The engine immediately assigns the first matches to the courts. Teams whose match is next see "on deck for Court 2, ~15 min" on their team page and the public board.
|
||||
|
||||
## Scoring
|
||||
|
||||
Each court panel on the desk has a +/− pad. Every tap updates the running score on the public board and the TV display. When a set is over tap **Mark final**; the engine records the result, standings update, and the next match goes onto that court.
|
||||
|
||||
For best-of-3, or if you'd rather type, use "Save sets" with `21-18, 19-21, 15-9`. The score must obey the rules you set (win by 2, cap, etc.); the desk tells you if it doesn't.
|
||||
|
||||
**Corrections.** Open "All matches" and save the right score on any match, even after later matches have been played. Pool standings recompute. Bracket results propagate forward only when a match is first decided, so if you correct a bracket match whose winner has already played on, also correct the affected later match.
|
||||
|
||||
**Forfeits.** The "Forfeits" button next to a live match records a forfeit by the selected team (0 to points-to for standings purposes).
|
||||
|
||||
## Things that go wrong on the day
|
||||
|
||||
**A team leaves.** In the Teams list click Withdraw and choose:
|
||||
|
||||
- *forfeit remaining* — their unplayed pool games become forfeit wins for the opponents; played games stand. This is the fair default when a team leaves mid-pool.
|
||||
- *void all games* — every game they played or would play is removed from standings, as if they never entered. Use this when a team leaves early and forfeits would distort the pool.
|
||||
|
||||
In a bracket the opponent advances automatically either way.
|
||||
|
||||
**Rain, injury, net repair.** Pause the court (with an optional reason shown on the board). The queue keeps flowing to the other court; Resume picks up where it left off.
|
||||
|
||||
**Move a match to the other court.** "Move to court" on the live match, e.g. for sun fairness or a scheduled feature match.
|
||||
|
||||
**A team isn't back from lunch.** Push back their pending match in the Queue; it drops two places and the next ready match goes on instead.
|
||||
|
||||
**Tell everyone something.** Set a banner (persistent, shown on every public page until cleared) or send a broadcast (one-time alert on every team page). Both are good for "lunch is out", "Court 2 closed 20 min", "finals start at 2:00".
|
||||
|
||||
## From pools to bracket
|
||||
|
||||
When pool play is done (the desk allows it earlier, with a warning), choose how many teams advance per pool and how many wildcards fill the bracket, pick single or double elimination and whether to play a 3rd-place match, and generate. Pool winners are seeded first, then runners-up, ordered by win percentage and point ratio; wildcards are the best remaining teams by pool position and record. Byes go to the top seeds automatically.
|
||||
|
||||
Standings tiebreaks, in order: head-to-head (among the tied teams only), set ratio, point ratio, point differential, seed. A `*` next to a team on the public board means a tiebreak decided its position; hover or tap for which one.
|
||||
|
||||
## The TV display
|
||||
|
||||
Open `/t/<slug>/display` on a tablet or TV browser and go full-screen. It shows the courts with big scores, the up-next queue, and standings, in a dark high-contrast theme, and gently scrolls between top and bottom every 15 seconds. It updates live.
|
||||
|
||||
## After the last match
|
||||
|
||||
Once every match is decided the tournament marks itself **Final** and the QR page becomes the results page with the champion, bracket, and standings. If you need to end early, press Final yourself. The page stays up as long as the tournament exists; delete it from the Danger zone when you no longer want it public.
|
||||
@@ -0,0 +1,26 @@
|
||||
# Roadmap
|
||||
|
||||
What exists today is the MVP: run one tournament on one or two courts from a phone, with a QR landing page that follows the day and a live board. The list below is in rough priority order; each item is independent.
|
||||
|
||||
## Next
|
||||
|
||||
- **SMS alerts (Twilio).** The engine already emits `up_now`, `on_deck`, and `broadcast` events with the team ids and message text; a sender that subscribes to `Tournament.on()` and posts to Twilio when `TWILIO_*` env vars are set is a small addition. Captains opt in at registration ("text me when we're up").
|
||||
- **Web Push.** VAPID keys generated at first start, a service worker on the team page, subscriptions stored per team. Works on Android in any browser and on iOS when the page is added to the home screen.
|
||||
- **Captain-submitted scores.** Winning captain enters the score from the team page, losing captain confirms with one tap, organizer only intervenes on disputes. Needs a per-team write token (the existing code plus a signed nonce) and a "pending confirmation" match status.
|
||||
- **Co-organizers with roles.** Invite links; a `scorekeeper` role that can enter scores but not change the bracket or roster.
|
||||
- **Run a real event and fix what it teaches.** The simulator is not a beach in September.
|
||||
|
||||
## Later
|
||||
|
||||
- **Divisions** (competitive / rec) inside one tournament, each with its own pools and bracket, sharing the court queue.
|
||||
- **Swiss pairing** for stage 1 when there are too many teams for pools and too few hours for a full round robin.
|
||||
- **Timed sets** (e.g. 12-minute sets) as an alternative to point targets, for keeping a large field on schedule.
|
||||
- **Season leaderboard** across tournaments: wins, points, streaks; the thing that brings people back. Build as a read model over the `events` table.
|
||||
- **Player identities** that persist across tournaments so individual stats are possible (currently a player is just a name on a team).
|
||||
- **Templates**: save a format/rules setup and reuse it.
|
||||
- **Sponsor strip and photos** on the public page.
|
||||
- **Multi-instance deployment**: replace the in-memory `Registry` with a shared store and pub/sub if one process is ever not enough (it will be enough for a very long time).
|
||||
|
||||
## Non-goals
|
||||
|
||||
Payments, waivers, and league scheduling across weeks. Plenty of products do those; Courtside is the desk for the day itself.
|
||||
Reference in New Issue
Block a user