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:
2026-09-03 20:31:46 +00:00
co-authored by Claude Fable 5.1
commit 5ade592384
39 changed files with 3103 additions and 0 deletions
+81
View File
@@ -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`.