Files
bkvargyasandClaude Fable 5.1 5ade592384 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
2026-09-03 20:31:46 +00:00

5.7 KiB

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

{
  "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:

{ "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.