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:
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
data
|
||||
.env
|
||||
.git
|
||||
test
|
||||
docs
|
||||
*.md
|
||||
*.log
|
||||
@@ -0,0 +1,18 @@
|
||||
# Copy to .env and fill in. Never commit .env.
|
||||
|
||||
# Password for the organizer desk at /admin. Required unless TRUST_CF_ACCESS=true.
|
||||
ADMIN_PASSWORD=change-me
|
||||
|
||||
# Random string used to sign the organizer session cookie. Generate one with:
|
||||
# openssl rand -hex 32
|
||||
# If left empty a random secret is generated at startup (organizers must sign in again after each restart).
|
||||
SESSION_SECRET=
|
||||
|
||||
# Optional. If "true", requests carrying the Cf-Access-Authenticated-User-Email header
|
||||
# (set by Cloudflare after an Access policy on /admin*) are treated as organizers.
|
||||
# Only enable when the app is reachable exclusively through the tunnel.
|
||||
TRUST_CF_ACCESS=false
|
||||
|
||||
# Cloudflare Tunnel token from Zero Trust → Networks → Tunnels → (your tunnel) → Install connector.
|
||||
# The tunnel's public hostname should route to http://app:3000.
|
||||
CLOUDFLARE_TUNNEL_TOKEN=
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
data/
|
||||
.env
|
||||
*.sqlite
|
||||
*.sqlite-wal
|
||||
*.sqlite-shm
|
||||
SIMULATION.log
|
||||
.DS_Store
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
# Courtside — single-process Node app. No native modules (SQLite comes from node:sqlite),
|
||||
# so the image is a plain node:22-alpine with the app copied in.
|
||||
FROM node:22-alpine AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
FROM node:22-alpine
|
||||
ENV NODE_ENV=production \
|
||||
PORT=3000 \
|
||||
DB_PATH=/data/courtside.sqlite \
|
||||
NODE_OPTIONS=--disable-warning=ExperimentalWarning
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache wget && mkdir -p /data && chown node:node /data
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY package.json ./
|
||||
COPY engine ./engine
|
||||
COPY server ./server
|
||||
COPY scripts ./scripts
|
||||
USER node
|
||||
VOLUME ["/data"]
|
||||
EXPOSE 3000
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 CMD wget -qO- http://127.0.0.1:3000/healthz || exit 1
|
||||
CMD ["node", "server/index.js"]
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Brian Vargyas
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,79 @@
|
||||
# Courtside
|
||||
|
||||
A self-hosted tournament desk for small volleyball tournaments (HOA days, club nights, beach 2s). One QR code carries a team from check-in, through registration, to live scores and "you're up on Court 2."
|
||||
|
||||
- **One QR code, four phases.** The code printed on the check-in table points at `/t/<slug>`. While registration is open it is the sign-up form. After the organizer closes registration it shows the roster, then the live board, then the final results. Nobody re-scans anything.
|
||||
- **Two courts (or one, or four).** A court engine assigns the next match to whichever court frees up, keeps an "up next" queue with ETAs, and handles pauses, court swaps, and teams that leave mid-tournament.
|
||||
- **Formats.** Round-robin pools, single elimination, double elimination (with bracket reset), pools-then-bracket, 2s/3s/4s/6s, rally scoring with configurable points, win-by, cap, and best-of.
|
||||
- **Live everywhere.** Scores entered on the organizer's phone appear on every open page within a second over a WebSocket. Team pages get an on-screen alert (and a browser notification if allowed) when the team is on deck or up.
|
||||
- **Nothing to install for players.** Plain web pages that work on any phone. Organizers get a mobile-friendly desk at `/admin`.
|
||||
- **Small footprint.** One Node process, SQLite via Node's built-in `node:sqlite`, two npm dependencies (`ws`, `qrcode`), no build step. Runs comfortably on the smallest container you have.
|
||||
|
||||
Status: MVP. It has run a full simulated day and an HTTP smoke test but has not yet run a real tournament. See [docs/ROADMAP.md](docs/ROADMAP.md) for what's next and [docs/KNOWN-GAPS.md](docs/KNOWN-GAPS.md) for what to watch.
|
||||
|
||||
## Quick start (local)
|
||||
|
||||
Requires Node 22.13 or newer (for `node:sqlite`).
|
||||
|
||||
```bash
|
||||
git clone https://gitea.cloudfreeiot.com/bkvargyas/courtside.git
|
||||
cd courtside
|
||||
npm ci
|
||||
ADMIN_PASSWORD=letmein npm start
|
||||
# open http://localhost:3000 — organizer desk at /admin
|
||||
```
|
||||
|
||||
To see it with data, seed the demo tournament first (server stopped):
|
||||
|
||||
```bash
|
||||
npm run demo
|
||||
ADMIN_PASSWORD=letmein npm start
|
||||
# open http://localhost:3000/t/demo-labor-day-2s
|
||||
```
|
||||
|
||||
## Deploy with Docker behind a Cloudflare Tunnel
|
||||
|
||||
```bash
|
||||
cp .env.example .env # set ADMIN_PASSWORD, SESSION_SECRET, CLOUDFLARE_TUNNEL_TOKEN
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
The app container publishes no host ports; the only way in is the tunnel, whose public hostname should route to `http://app:3000`. Full instructions, including the Cloudflare side, backups, and updating, are in [docs/DEPLOY.md](docs/DEPLOY.md). For a local run without Cloudflare use `docker compose -f docker-compose.local.yml up --build`.
|
||||
|
||||
## How a tournament day runs
|
||||
|
||||
1. **Create** the tournament in the desk: name, date, courts, team size, scoring rules, stages. Print the QR code from the desk.
|
||||
2. **Check-in.** Teams scan and register themselves: team name, captain, mobile, player count. Each team gets a private link (`/t/<slug>/team/<code>`) that will show their schedule and alerts. The organizer can edit, add, remove, and reseed teams.
|
||||
3. **Close registration**, generate pools (or a bracket), and **go live**. The engine puts the first matches on the courts and texts, well, shows, the next teams that they're on deck.
|
||||
4. **Score** from the desk: a +/− pad per court shows the running score on the public board; "Mark final" records the result and the engine assigns the next match. Any score can be corrected later from the "All matches" list.
|
||||
5. **Pool play ends**, generate the bracket from pool standings (top N per pool plus wildcards), keep scoring.
|
||||
6. **Final.** The QR page becomes the results page and stays up.
|
||||
|
||||
The organizer guide in [docs/ORGANIZER-GUIDE.md](docs/ORGANIZER-GUIDE.md) covers withdrawals, pausing a court, moving a match, pushing a match back, broadcasts, and the TV display mode.
|
||||
|
||||
## Repository layout
|
||||
|
||||
```
|
||||
engine/ Tournament logic with no I/O: formats, state, standings, court engine. Tested.
|
||||
server/ HTTP + WebSocket server, SQLite store, page templates, static assets.
|
||||
scripts/ seed-demo.js — create a demo tournament.
|
||||
test/ node:test unit tests for the engine; smoke.sh end-to-end HTTP test.
|
||||
docs/ Deploy guide, architecture, organizer guide, API, roadmap, known gaps.
|
||||
simulate.js Plays out a full 12-team day on 2 courts and checks invariants.
|
||||
Dockerfile, docker-compose.yml, docker-compose.local.yml, .env.example
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm test # engine unit tests
|
||||
npm run sim # simulated day; try --seed=3 --bracket=double
|
||||
npm run dev # server with auto-restart
|
||||
ADMIN_PASSWORD=test PORT=3123 npm start & BASE=http://localhost:3123 ADMIN_PASSWORD=test npm run smoke
|
||||
```
|
||||
|
||||
Architecture and the reasoning behind it: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). Endpoints: [docs/API.md](docs/API.md).
|
||||
|
||||
## License
|
||||
|
||||
MIT. See [LICENSE](LICENSE).
|
||||
@@ -0,0 +1,18 @@
|
||||
# Local run without Cloudflare: http://localhost:3000
|
||||
# docker compose -f docker-compose.local.yml up --build
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
image: courtside:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- PORT=3000
|
||||
- DB_PATH=/data/courtside.sqlite
|
||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-changeme}
|
||||
- SESSION_SECRET=${SESSION_SECRET:-}
|
||||
volumes:
|
||||
- courtside-data:/data
|
||||
volumes:
|
||||
courtside-data:
|
||||
@@ -0,0 +1,35 @@
|
||||
# Courtside behind a Cloudflare Tunnel.
|
||||
# cp .env.example .env # set ADMIN_PASSWORD, SESSION_SECRET, CLOUDFLARE_TUNNEL_TOKEN
|
||||
# docker compose up -d
|
||||
# The app publishes no ports to the host: the only way in is through the tunnel
|
||||
# (cloudflared reaches it on the compose network as http://app:3000).
|
||||
# For local testing without a tunnel, use docker-compose.local.yml instead.
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
image: courtside:latest
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
environment:
|
||||
- PORT=3000
|
||||
- DB_PATH=/data/courtside.sqlite
|
||||
volumes:
|
||||
- courtside-data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/healthz"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
cloudflared:
|
||||
image: cloudflare/cloudflared:latest
|
||||
restart: unless-stopped
|
||||
command: tunnel --no-autoupdate run
|
||||
environment:
|
||||
- TUNNEL_TOKEN=${CLOUDFLARE_TUNNEL_TOKEN}
|
||||
depends_on:
|
||||
app:
|
||||
condition: service_healthy
|
||||
|
||||
volumes:
|
||||
courtside-data:
|
||||
+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.
|
||||
@@ -0,0 +1,208 @@
|
||||
// Court engine: decides which match goes on which court, keeps an "on deck" queue with ETAs,
|
||||
// and emits alert events. Everything is recomputed from state on every change.
|
||||
|
||||
const STAGE_ORDER = { pool: 0, bracket: 1, losers: 1, final: 2 };
|
||||
const MIN = 60_000;
|
||||
|
||||
export class CourtEngine {
|
||||
constructor(tournament, { courtCount = tournament.courtCount ?? 2, now = () => Date.now(), defaultMatchMinutes = 15, changeoverMinutes = 3 } = {}) {
|
||||
this.t = tournament;
|
||||
this.now = now;
|
||||
tournament.now = now;
|
||||
this.courts = Array.from({ length: courtCount }, (_, i) => ({ number: i + 1, status: 'open', matchId: null, startedAt: null, freeAt: null }));
|
||||
this.defaultMatchMs = defaultMatchMinutes * MIN;
|
||||
this.changeoverMs = changeoverMinutes * MIN;
|
||||
this.durations = []; // observed match durations (ms)
|
||||
this.lastPlayed = new Map(); // teamId -> finishedAt
|
||||
this.alerted = new Set(); // `${matchId}:${kind}` dedupe
|
||||
this.bumped = new Map(); // matchId -> extra priority (push back)
|
||||
this.alerts = [];
|
||||
}
|
||||
|
||||
// ---------- public API ----------
|
||||
start() { this.t.phase = 'live'; this.t.emit({ type: 'phase', phase: 'live' }); this.tick(); }
|
||||
|
||||
recordResult(matchId, sets, opts) {
|
||||
const m = this.t.match(matchId);
|
||||
const court = this.courts.find(c => c.matchId === matchId);
|
||||
this.t.recordResult(matchId, sets, opts);
|
||||
if (court) {
|
||||
const dur = this.now() - court.startedAt;
|
||||
if (m.status === 'final') this.durations.push(dur);
|
||||
this._freeCourt(court);
|
||||
}
|
||||
for (const tid of [m.teamA, m.teamB]) this.lastPlayed.set(tid, this.now());
|
||||
this.tick();
|
||||
}
|
||||
|
||||
withdrawTeam(teamId, opts) {
|
||||
for (const c of this.courts) {
|
||||
const m = c.matchId && this.t.match(c.matchId);
|
||||
if (m && (m.teamA === teamId || m.teamB === teamId)) this._freeCourt(c);
|
||||
}
|
||||
this.t.withdrawTeam(teamId, opts);
|
||||
// clear on-deck flags on anything that got voided/forfeited
|
||||
for (const m of this.t.matches) if (m.status !== 'pending' && m.status !== 'live') m.court = null;
|
||||
this.tick();
|
||||
}
|
||||
|
||||
pauseCourt(n, reason = '') {
|
||||
const c = this._court(n); c.status = 'paused'; c.pauseReason = reason;
|
||||
this.t.emit({ type: 'court_paused', court: n, reason });
|
||||
this.tick();
|
||||
}
|
||||
resumeCourt(n) { const c = this._court(n); c.status = 'open'; delete c.pauseReason; this.t.emit({ type: 'court_resumed', court: n }); this.tick(); }
|
||||
|
||||
/** Move a live match to another (free) court, e.g. sun/wind fairness. */
|
||||
swapToCourt(matchId, n) {
|
||||
const from = this.courts.find(c => c.matchId === matchId);
|
||||
const to = this._court(n);
|
||||
if (!from || to.matchId) throw new Error('Target court is busy or match is not live');
|
||||
to.matchId = from.matchId; to.startedAt = from.startedAt; to.freeAt = from.freeAt;
|
||||
from.matchId = null; from.startedAt = null; from.freeAt = null;
|
||||
this.t.match(matchId).court = n;
|
||||
this.t.emit({ type: 'court_swapped', match: matchId, from: from.number, to: n });
|
||||
this.tick();
|
||||
}
|
||||
|
||||
/** Push a pending match back `n` places in the queue (team not back from lunch, etc). */
|
||||
pushBack(matchId, n = 2) {
|
||||
this.bumped.set(matchId, (this.bumped.get(matchId) ?? 0) + n);
|
||||
const m = this.t.match(matchId);
|
||||
if (m.status === 'on_deck') { m.status = 'pending'; m.court = null; }
|
||||
this.t.emit({ type: 'match_pushed_back', match: matchId, places: n });
|
||||
this.tick();
|
||||
}
|
||||
|
||||
broadcast(text) { this._alert({ kind: 'broadcast', text, teams: this.t.activeTeams().map(t => t.id) }); }
|
||||
|
||||
/** The main loop. Assign free courts, refresh on-deck, emit alerts. Idempotent. */
|
||||
tick() {
|
||||
if (this.t.phase !== 'live') return;
|
||||
let assigned = true;
|
||||
while (assigned) {
|
||||
assigned = false;
|
||||
const free = this.courts.find(c => c.status === 'open' && !c.matchId);
|
||||
if (!free) break;
|
||||
const next = this._eligible(this._busyTeams()).find(m => m.status !== 'live');
|
||||
if (!next) break;
|
||||
this._assign(next, free);
|
||||
assigned = true;
|
||||
}
|
||||
this._refreshOnDeck();
|
||||
}
|
||||
|
||||
/** Public board data. */
|
||||
board() {
|
||||
const q = this.queue();
|
||||
return {
|
||||
phase: this.t.phase,
|
||||
courts: this.courts.map(c => {
|
||||
const m = c.matchId ? this.t.match(c.matchId) : null;
|
||||
return { court: c.number, status: c.status, match: m ? this._matchView(m) : null };
|
||||
}),
|
||||
upNext: q.slice(0, 3).map(x => ({ ...this._matchView(x.match), court: x.court, etaMin: Math.round(x.eta / MIN) })),
|
||||
avgMatchMin: Math.round(this.avgMatchMs() / MIN),
|
||||
};
|
||||
}
|
||||
|
||||
/** Ordered queue of pending matches with a predicted court and ETA each. */
|
||||
queue() {
|
||||
const sims = this.courts.filter(c => c.status === 'open').map(c => ({ number: c.number, freeAt: c.matchId ? this._expectedFinish(c) : this.now() }));
|
||||
if (!sims.length) return this._eligible(new Set()).map(m => ({ match: m, court: null, eta: Infinity }));
|
||||
const teamFree = new Map(this.courts.filter(c => c.matchId).flatMap(c => { const m = this.t.match(c.matchId); const f = this._expectedFinish(c); return [[m.teamA, f], [m.teamB, f]]; }));
|
||||
const out = [];
|
||||
for (const m of this._eligible(new Set())) {
|
||||
sims.sort((a, b) => a.freeAt - b.freeAt);
|
||||
const ready = Math.max(sims[0].freeAt, teamFree.get(m.teamA) ?? 0, teamFree.get(m.teamB) ?? 0);
|
||||
const start = ready + this.changeoverMs;
|
||||
out.push({ match: m, court: sims[0].number, eta: start - this.now() });
|
||||
sims[0].freeAt = start + this.avgMatchMs();
|
||||
teamFree.set(m.teamA, sims[0].freeAt); teamFree.set(m.teamB, sims[0].freeAt);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
avgMatchMs() {
|
||||
if (!this.durations.length) return this.defaultMatchMs;
|
||||
const recent = this.durations.slice(-6);
|
||||
return recent.reduce((a, b) => a + b, 0) / recent.length;
|
||||
}
|
||||
|
||||
// ---------- persistence ----------
|
||||
toJSON() {
|
||||
return {
|
||||
courts: this.courts, durations: this.durations, lastPlayed: [...this.lastPlayed], alerted: [...this.alerted],
|
||||
bumped: [...this.bumped], alerts: this.alerts.slice(-200), defaultMatchMs: this.defaultMatchMs, changeoverMs: this.changeoverMs,
|
||||
};
|
||||
}
|
||||
|
||||
static fromJSON(tournament, j, opts = {}) {
|
||||
const e = new CourtEngine(tournament, { courtCount: j.courts.length, ...opts });
|
||||
e.courts = j.courts; e.durations = j.durations ?? []; e.lastPlayed = new Map(j.lastPlayed ?? []);
|
||||
e.alerted = new Set(j.alerted ?? []); e.bumped = new Map(j.bumped ?? []); e.alerts = j.alerts ?? [];
|
||||
e.defaultMatchMs = j.defaultMatchMs ?? e.defaultMatchMs; e.changeoverMs = j.changeoverMs ?? e.changeoverMs;
|
||||
return e;
|
||||
}
|
||||
|
||||
// ---------- internals ----------
|
||||
_court(n) { const c = this.courts.find(c => c.number === n); if (!c) throw new Error(`No court ${n}`); return c; }
|
||||
_busyTeams() { return new Set(this.courts.filter(c => c.matchId).flatMap(c => { const m = this.t.match(c.matchId); return [m.teamA, m.teamB]; })); }
|
||||
_expectedFinish(c) { return Math.max(this.now(), c.startedAt + this.avgMatchMs()); }
|
||||
|
||||
/** Pending matches that could be played now, in priority order. */
|
||||
_eligible(busy) {
|
||||
const rest = tid => this.lastPlayed.get(tid) ?? 0;
|
||||
const bracketReady = m => this.t.matches.filter(x => x.feeds === m.id || x.loserFeeds === m.id).every(x => ['final','forfeit','bye','void'].includes(x.status));
|
||||
return this.t.matches
|
||||
.filter(m => (m.status === 'pending' || m.status === 'on_deck') && m.teamA && m.teamB && !busy.has(m.teamA) && !busy.has(m.teamB) && !m.conditional)
|
||||
.filter(m => m.stage === 'pool' || bracketReady(m))
|
||||
.map(m => ({ m, key: [STAGE_ORDER[m.stage], m.round, this.bumped.get(m.id) ?? 0, Math.max(rest(m.teamA), rest(m.teamB)), m.slot ?? 0] }))
|
||||
.sort((a, b) => { for (let i = 0; i < a.key.length; i++) if (a.key[i] !== b.key[i]) return a.key[i] - b.key[i]; return 0; })
|
||||
.map(x => x.m);
|
||||
}
|
||||
|
||||
_assign(m, court) {
|
||||
court.matchId = m.id; court.startedAt = this.now();
|
||||
m.status = 'live'; m.court = court.number; m.startedAt = court.startedAt;
|
||||
this.t.emit({ type: 'match_started', match: m.id, court: court.number, a: this.t.teamName(m.teamA), b: this.t.teamName(m.teamB) });
|
||||
this._alert({ kind: 'up_now', match: m.id, court: court.number, teams: [m.teamA, m.teamB] });
|
||||
}
|
||||
|
||||
_freeCourt(c) { c.matchId = null; c.startedAt = null; c.freeAt = null; }
|
||||
|
||||
_refreshOnDeck() {
|
||||
const q = this.queue();
|
||||
const deck = q.slice(0, this.courts.filter(c => c.status === 'open').length);
|
||||
const deckIds = new Set(deck.map(x => x.match.id));
|
||||
for (const m of this.t.matches) if (m.status === 'on_deck' && !deckIds.has(m.id)) { m.status = 'pending'; m.court = null; }
|
||||
for (const x of deck) {
|
||||
const m = x.match;
|
||||
if (m.status !== 'on_deck') {
|
||||
m.status = 'on_deck'; m.court = x.court;
|
||||
this._alert({ kind: 'on_deck', match: m.id, court: x.court, etaMin: Math.round(x.eta / MIN), teams: [m.teamA, m.teamB] });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_alert(a) {
|
||||
const key = a.kind === 'broadcast' ? null : `${a.match}:${a.kind}`;
|
||||
if (key && this.alerted.has(key)) return;
|
||||
if (key) this.alerted.add(key);
|
||||
const msg = this._message(a);
|
||||
this.alerts.push({ ...a, text: msg, at: this.now() });
|
||||
this.t.emit({ type: 'alert', ...a, text: msg });
|
||||
}
|
||||
|
||||
_message(a) {
|
||||
if (a.kind === 'broadcast') return a.text;
|
||||
const m = this.t.match(a.match);
|
||||
const names = [this.t.teamName(m.teamA), this.t.teamName(m.teamB)];
|
||||
if (a.kind === 'up_now') return `${names[0]} vs ${names[1]}: you're UP NOW on Court ${a.court}.`;
|
||||
return `${names[0]} vs ${names[1]}: you're ON DECK for Court ${a.court} (~${a.etaMin} min).`;
|
||||
}
|
||||
|
||||
_matchView(m) {
|
||||
return { id: m.id, stage: m.stage, round: m.round, label: m.label ?? null, a: this.t.teamName(m.teamA), b: this.t.teamName(m.teamB), status: m.status, court: m.court, score: m.sets.map(s => s.join('–')).join(', ') };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
// Bracket and pool generation. Pure functions: teams in, match objects out.
|
||||
// A match is { id, stage, round, poolId, slot, teamA, teamB, feeds, feedsSide, status, sets, winner }
|
||||
// teamA/teamB are team ids, or null when the slot is fed by another match.
|
||||
|
||||
let nextId = 1;
|
||||
export function resetIds(n = 1) { nextId = n; }
|
||||
const mid = () => `m${nextId++}`;
|
||||
|
||||
export function makeMatch(p) {
|
||||
return {
|
||||
id: mid(), stage: 'pool', round: 1, poolId: null, slot: null,
|
||||
teamA: null, teamB: null, // team ids
|
||||
feeds: null, feedsSide: null, // winner goes to match `feeds`, side 'A' | 'B'
|
||||
loserFeeds: null, loserFeedsSide: null,
|
||||
status: 'pending', // pending | on_deck | live | final | forfeit | void | bye
|
||||
sets: [], winner: null, loser: null, court: null,
|
||||
...p,
|
||||
};
|
||||
}
|
||||
|
||||
/** Split team ids into pools of ~poolSize using snake seeding (1,2,3 / 6,5,4 / 7,8,9 ...). */
|
||||
export function makePools(teamIds, poolSize = 4) {
|
||||
const n = teamIds.length;
|
||||
const poolCount = Math.max(1, Math.round(n / poolSize));
|
||||
const pools = Array.from({ length: poolCount }, (_, i) => ({ id: String.fromCharCode(65 + i), teams: [] }));
|
||||
teamIds.forEach((t, i) => {
|
||||
const lap = Math.floor(i / poolCount);
|
||||
const idx = lap % 2 === 0 ? i % poolCount : poolCount - 1 - (i % poolCount);
|
||||
pools[idx].teams.push(t);
|
||||
});
|
||||
return pools;
|
||||
}
|
||||
|
||||
/** Round robin via the circle method. Returns matches with round numbers 1..(n-1). */
|
||||
export function roundRobin(teamIds, poolId = null) {
|
||||
const ids = [...teamIds];
|
||||
if (ids.length % 2 === 1) ids.push(null); // bye marker
|
||||
const n = ids.length, rounds = n - 1, half = n / 2;
|
||||
const out = [];
|
||||
for (let r = 0; r < rounds; r++) {
|
||||
for (let i = 0; i < half; i++) {
|
||||
const a = ids[i], b = ids[n - 1 - i];
|
||||
if (a === null || b === null) continue;
|
||||
// alternate home/away so the same team isn't always listed first
|
||||
const [ta, tb] = (r + i) % 2 === 0 ? [a, b] : [b, a];
|
||||
out.push(makeMatch({ stage: 'pool', round: r + 1, poolId, teamA: ta, teamB: tb }));
|
||||
}
|
||||
ids.splice(1, 0, ids.pop()); // rotate all but the first
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Standard seeding order for a bracket of `size` (power of two): 1 v size, then 2 v size-1 etc. */
|
||||
export function seedOrder(size) {
|
||||
let order = [1];
|
||||
while (order.length < size) {
|
||||
const len = order.length * 2;
|
||||
const next = [];
|
||||
for (const s of order) next.push(s, len + 1 - s);
|
||||
order = next;
|
||||
}
|
||||
return order;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single elimination with byes. `seeded` is team ids in seed order (best first).
|
||||
* Returns { matches, size }. Byes are resolved immediately (status 'bye', winner set) and
|
||||
* propagated into the next round.
|
||||
*/
|
||||
export function singleElimination(seeded, { thirdPlace = false } = {}) {
|
||||
const size = 1 << Math.ceil(Math.log2(Math.max(2, seeded.length)));
|
||||
const rounds = Math.log2(size);
|
||||
const order = seedOrder(size);
|
||||
const byRound = [];
|
||||
// Round 1
|
||||
const r1 = [];
|
||||
for (let i = 0; i < size / 2; i++) {
|
||||
const a = seeded[order[2 * i] - 1] ?? null;
|
||||
const b = seeded[order[2 * i + 1] - 1] ?? null;
|
||||
r1.push(makeMatch({ stage: 'bracket', round: 1, slot: i + 1, teamA: a, teamB: b }));
|
||||
}
|
||||
byRound.push(r1);
|
||||
for (let r = 2; r <= rounds; r++) {
|
||||
const prev = byRound[r - 2];
|
||||
const cur = [];
|
||||
for (let i = 0; i < prev.length / 2; i++) {
|
||||
const m = makeMatch({ stage: 'bracket', round: r, slot: i + 1 });
|
||||
prev[2 * i].feeds = m.id; prev[2 * i].feedsSide = 'A';
|
||||
prev[2 * i + 1].feeds = m.id; prev[2 * i + 1].feedsSide = 'B';
|
||||
cur.push(m);
|
||||
}
|
||||
byRound.push(cur);
|
||||
}
|
||||
const matches = byRound.flat();
|
||||
if (thirdPlace && rounds >= 2) {
|
||||
const semis = byRound[rounds - 2];
|
||||
const tp = makeMatch({ stage: 'bracket', round: rounds, slot: 2, label: '3rd place' });
|
||||
semis[0].loserFeeds = tp.id; semis[0].loserFeedsSide = 'A';
|
||||
semis[1].loserFeeds = tp.id; semis[1].loserFeedsSide = 'B';
|
||||
matches.push(tp);
|
||||
}
|
||||
// Resolve byes
|
||||
const index = Object.fromEntries(matches.map(m => [m.id, m]));
|
||||
for (const m of r1) {
|
||||
if (m.teamA && !m.teamB) settleBye(m, m.teamA, index);
|
||||
else if (!m.teamA && m.teamB) settleBye(m, m.teamB, index);
|
||||
}
|
||||
return { matches, size, rounds };
|
||||
}
|
||||
|
||||
function settleBye(m, winner, index) {
|
||||
m.status = 'bye'; m.winner = winner;
|
||||
const next = index[m.feeds];
|
||||
if (next) next[m.feedsSide === 'A' ? 'teamA' : 'teamB'] = winner;
|
||||
}
|
||||
|
||||
/**
|
||||
* Double elimination for power-of-two sizes (byes are padded like single elim).
|
||||
* Winners bracket + losers bracket + grand final (+ optional reset match if the LB team wins).
|
||||
*/
|
||||
export function doubleElimination(seeded, { resetMatch = true } = {}) {
|
||||
const wb = singleElimination(seeded);
|
||||
const wbRounds = wb.rounds;
|
||||
const wbByRound = Array.from({ length: wbRounds }, (_, r) => wb.matches.filter(m => m.round === r + 1 && m.label !== '3rd place'));
|
||||
const lb = []; // array of rounds, each an array of matches
|
||||
let lbRound = 0;
|
||||
// LB round 1: losers of WB R1 pair up
|
||||
let carry = [];
|
||||
for (let r = 0; r < wbRounds; r++) {
|
||||
const losers = wbByRound[r]; // matches whose losers drop here
|
||||
if (r === 0) {
|
||||
lbRound++;
|
||||
const cur = [];
|
||||
for (let i = 0; i < losers.length / 2; i++) {
|
||||
const m = makeMatch({ stage: 'losers', round: lbRound, slot: i + 1 });
|
||||
losers[2 * i].loserFeeds = m.id; losers[2 * i].loserFeedsSide = 'A';
|
||||
losers[2 * i + 1].loserFeeds = m.id; losers[2 * i + 1].loserFeedsSide = 'B';
|
||||
cur.push(m);
|
||||
}
|
||||
lb.push(cur); carry = cur;
|
||||
} else {
|
||||
// "drop" round: WB losers of round r vs carry winners (reverse order to delay rematches)
|
||||
lbRound++;
|
||||
const cur = [];
|
||||
const rev = [...losers].reverse();
|
||||
for (let i = 0; i < carry.length; i++) {
|
||||
const m = makeMatch({ stage: 'losers', round: lbRound, slot: i + 1 });
|
||||
rev[i].loserFeeds = m.id; rev[i].loserFeedsSide = 'A';
|
||||
carry[i].feeds = m.id; carry[i].feedsSide = 'B';
|
||||
cur.push(m);
|
||||
}
|
||||
lb.push(cur); carry = cur;
|
||||
if (carry.length > 1) {
|
||||
// "consolidation" round: carry winners pair up
|
||||
lbRound++;
|
||||
const nxt = [];
|
||||
for (let i = 0; i < carry.length / 2; i++) {
|
||||
const m = makeMatch({ stage: 'losers', round: lbRound, slot: i + 1 });
|
||||
carry[2 * i].feeds = m.id; carry[2 * i].feedsSide = 'A';
|
||||
carry[2 * i + 1].feeds = m.id; carry[2 * i + 1].feedsSide = 'B';
|
||||
nxt.push(m);
|
||||
}
|
||||
lb.push(nxt); carry = nxt;
|
||||
}
|
||||
}
|
||||
}
|
||||
const wbFinal = wbByRound[wbRounds - 1][0];
|
||||
const lbFinal = carry[0];
|
||||
const gf = makeMatch({ stage: 'final', round: 1, slot: 1, label: 'Grand final' });
|
||||
wbFinal.feeds = gf.id; wbFinal.feedsSide = 'A';
|
||||
lbFinal.feeds = gf.id; lbFinal.feedsSide = 'B';
|
||||
const matches = [...wb.matches, ...lb.flat(), gf];
|
||||
if (resetMatch) {
|
||||
const reset = makeMatch({ stage: 'final', round: 2, slot: 1, label: 'Bracket reset', conditional: true });
|
||||
gf.resetMatch = reset.id;
|
||||
matches.push(reset);
|
||||
}
|
||||
// Byes in WB R1 mean LB R1 gets a null loser -> treat as bye there too.
|
||||
const index = Object.fromEntries(matches.map(m => [m.id, m]));
|
||||
for (const m of wbByRound[0]) if (m.status === 'bye') {
|
||||
const l = index[m.loserFeeds];
|
||||
l[m.loserFeedsSide === 'A' ? 'teamA' : 'teamB'] = '__bye__';
|
||||
}
|
||||
for (const m of lb[0]) {
|
||||
if (m.teamA === '__bye__' && m.teamB === '__bye__') { m.status = 'void'; }
|
||||
else if (m.teamA === '__bye__') { m.teamA = null; m.pendingBye = 'A'; }
|
||||
else if (m.teamB === '__bye__') { m.teamB = null; m.pendingBye = 'B'; }
|
||||
}
|
||||
return { matches, size: wb.size, wbRounds, lbRounds: lbRound };
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
// Tournament state: teams, matches, results, standings, withdrawals, bracket propagation.
|
||||
import { makePools, roundRobin, singleElimination, doubleElimination, makeMatch } from './formats.js';
|
||||
|
||||
export const DEFAULT_RULES = {
|
||||
teamSize: 2,
|
||||
pointsTo: 21, winBy: 2, cap: 25, bestOf: 1,
|
||||
tiebreaks: ['headToHead', 'setRatio', 'pointRatio', 'pointDiff', 'seed'],
|
||||
};
|
||||
|
||||
export class Tournament {
|
||||
constructor({ name, rules = {}, courtCount = 2, stages = ['pool', 'bracket'] } = {}) {
|
||||
this.name = name;
|
||||
this.stages = stages; // which stages the day has: ['pool'], ['bracket'], or both
|
||||
this.rules = { ...DEFAULT_RULES, ...rules };
|
||||
this.courtCount = courtCount;
|
||||
this.phase = 'checkin'; // checkin | closed | live | final
|
||||
this.teams = new Map(); // id -> { id, name, seed, status, players, poolId }
|
||||
this.matches = []; // ordered list
|
||||
this.pools = [];
|
||||
this.log = [];
|
||||
this._seq = 1;
|
||||
this.listeners = [];
|
||||
}
|
||||
|
||||
on(fn) { this.listeners.push(fn); return () => { this.listeners = this.listeners.filter(f => f !== fn); }; }
|
||||
emit(evt) { this.log.push({ ts: this.log.length, ...evt }); for (const f of this.listeners) f(evt, this); }
|
||||
|
||||
// ---------- registration ----------
|
||||
registerTeam({ name, players = 2, captain = null, phone = null, playerNames = [] }, { force = false } = {}) {
|
||||
if (this.phase !== 'checkin' && !force) throw new Error('Registration is closed');
|
||||
if (force && this.matches.length) throw new Error('Pools or bracket already generated; regenerate after adding a team');
|
||||
name = String(name ?? '').trim();
|
||||
if (!name) throw new Error('Team name is required');
|
||||
if (name.length > 40) throw new Error('Team name must be 40 characters or fewer');
|
||||
players = Number(players);
|
||||
if (!Number.isInteger(players) || players < 1 || players > 20) throw new Error('Player count must be a whole number between 1 and 20');
|
||||
if ([...this.teams.values()].some(t => t.name.toLowerCase() === name.toLowerCase())) throw new Error(`Team name "${name}" already taken`);
|
||||
if (players < this.rules.teamSize) throw new Error(`Need at least ${this.rules.teamSize} players for ${this.rules.teamSize}s`);
|
||||
const id = `t${this._seq++}`;
|
||||
const team = { id, name, players, captain, phone, playerNames: playerNames.filter(Boolean), seed: this.teams.size + 1, status: 'registered', poolId: null, code: code4(), registeredAt: this.now?.() ?? Date.now() };
|
||||
this.teams.set(id, team);
|
||||
this.emit({ type: 'team_registered', team: id, name });
|
||||
return team;
|
||||
}
|
||||
|
||||
updateTeam(id, patch) {
|
||||
const t = this.teams.get(id);
|
||||
if (!t) throw new Error('No such team');
|
||||
if (patch.name !== undefined) {
|
||||
const name = String(patch.name).trim();
|
||||
if (!name) throw new Error('Team name is required');
|
||||
if ([...this.teams.values()].some(x => x.id !== id && x.name.toLowerCase() === name.toLowerCase())) throw new Error(`Team name "${name}" already taken`);
|
||||
t.name = name;
|
||||
}
|
||||
for (const k of ['captain', 'phone']) if (patch[k] !== undefined) t[k] = patch[k];
|
||||
if (patch.players !== undefined) { const p = Number(patch.players); if (!Number.isInteger(p) || p < 1) throw new Error('Bad player count'); t.players = p; }
|
||||
if (patch.playerNames !== undefined) t.playerNames = patch.playerNames.filter(Boolean);
|
||||
this.emit({ type: 'team_updated', team: id, name: t.name });
|
||||
return t;
|
||||
}
|
||||
|
||||
removeTeam(id) {
|
||||
if (this.matches.length) throw new Error('Use withdraw once play is generated');
|
||||
this.teams.delete(id);
|
||||
this.activeTeams().forEach((t, i) => { t.seed = i + 1; });
|
||||
this.emit({ type: 'team_removed', team: id });
|
||||
}
|
||||
|
||||
teamByCode(code) { return [...this.teams.values()].find(t => t.code === String(code).toUpperCase()) ?? null; }
|
||||
|
||||
closeRegistration() { this.phase = 'closed'; this.emit({ type: 'phase', phase: 'closed' }); }
|
||||
reopenRegistration() { if (this.matches.length) throw new Error('Play already generated'); this.phase = 'checkin'; this.emit({ type: 'phase', phase: 'checkin' }); }
|
||||
|
||||
setSeeds(orderedIds) { orderedIds.forEach((id, i) => { this.teams.get(id).seed = i + 1; }); }
|
||||
|
||||
activeTeams() { return [...this.teams.values()].filter(t => t.status === 'registered').sort((a, b) => a.seed - b.seed); }
|
||||
|
||||
// ---------- generation ----------
|
||||
generatePools(poolSize = 4) {
|
||||
if (this.matches.some(m => m.stage === 'pool' && m.status === 'final')) throw new Error('Pool play already has results; cannot regenerate');
|
||||
this.matches = this.matches.filter(m => m.stage !== 'pool');
|
||||
this.pools = makePools(this.activeTeams().map(t => t.id), poolSize);
|
||||
for (const p of this.pools) {
|
||||
for (const tid of p.teams) this.teams.get(tid).poolId = p.id;
|
||||
this.matches.push(...roundRobin(p.teams, p.id));
|
||||
}
|
||||
this.emit({ type: 'pools_generated', pools: this.pools.map(p => ({ id: p.id, teams: p.teams.map(t => this.teams.get(t).name) })) });
|
||||
return this.pools;
|
||||
}
|
||||
|
||||
/** Top `perPool` from each pool plus `wildcards` best remaining, ranked by pool position then record. */
|
||||
advanceFromPools({ perPool = 2, wildcards = 0 } = {}) {
|
||||
const qualified = [];
|
||||
const rest = [];
|
||||
for (const p of this.pools) {
|
||||
const table = this.standings(p.id);
|
||||
table.slice(0, perPool).forEach((row, i) => qualified.push({ ...row, rank: i + 1 }));
|
||||
table.slice(perPool).forEach((row, i) => rest.push({ ...row, rank: perPool + i + 1 }));
|
||||
}
|
||||
// seed qualifiers: pool winners first, then runners-up, ordered within a rank by win pct / point ratio
|
||||
const byRank = (a, b) => a.rank - b.rank || b.winPct - a.winPct || b.pointRatio - a.pointRatio;
|
||||
qualified.sort(byRank);
|
||||
rest.sort(byRank);
|
||||
return [...qualified, ...rest.slice(0, wildcards)].map(r => r.teamId);
|
||||
}
|
||||
|
||||
generateBracket(seededIds, { type = 'single', thirdPlace = false } = {}) {
|
||||
if (this.matches.some(m => m.stage !== 'pool' && m.status === 'final')) throw new Error('Bracket already has results');
|
||||
this.matches = this.matches.filter(m => m.stage === 'pool');
|
||||
const res = type === 'double' ? doubleElimination(seededIds) : singleElimination(seededIds, { thirdPlace });
|
||||
this.matches.push(...res.matches);
|
||||
this.emit({ type: 'bracket_generated', bracket: type, size: res.size, teams: seededIds.map(id => this.teams.get(id).name) });
|
||||
return res;
|
||||
}
|
||||
|
||||
// ---------- results ----------
|
||||
match(id) { return this.matches.find(m => m.id === id); }
|
||||
teamName(id) { return id ? this.teams.get(id)?.name ?? id : 'TBD'; }
|
||||
|
||||
/** sets: [[a,b], ...]. Validates against rules, sets winner, propagates into bracket. */
|
||||
recordResult(matchId, sets, { actor = 'organizer', forfeit = null } = {}) {
|
||||
const m = this.match(matchId);
|
||||
if (!m) throw new Error(`No match ${matchId}`);
|
||||
if (!m.teamA || !m.teamB) throw new Error(`Match ${matchId} does not have both teams yet`);
|
||||
if (forfeit) {
|
||||
m.status = 'forfeit'; m.sets = [];
|
||||
m.winner = forfeit === m.teamA ? m.teamB : m.teamA; m.loser = forfeit;
|
||||
} else {
|
||||
let wa = 0, wb = 0;
|
||||
for (const [a, b] of sets) {
|
||||
validateSet(a, b, this.rules);
|
||||
if (a > b) wa++; else wb++;
|
||||
}
|
||||
const need = Math.ceil(this.rules.bestOf / 2);
|
||||
if (wa < need && wb < need) throw new Error(`Best of ${this.rules.bestOf}: no side has ${need} sets`);
|
||||
m.sets = sets; m.status = 'final';
|
||||
m.winner = wa > wb ? m.teamA : m.teamB; m.loser = wa > wb ? m.teamB : m.teamA;
|
||||
}
|
||||
m.finishedAt = this.now?.() ?? Date.now();
|
||||
this.emit({ type: 'result', match: m.id, actor, winner: m.winner, score: sets.map(s => s.join('-')).join(', ') || 'forfeit' });
|
||||
this._propagate(m);
|
||||
return m;
|
||||
}
|
||||
|
||||
_place(matchId, side, teamId) {
|
||||
if (!matchId) return;
|
||||
const next = this.match(matchId);
|
||||
if (!next) return;
|
||||
next[side === 'A' ? 'teamA' : 'teamB'] = teamId;
|
||||
// losers-bracket bye: the other side was a bye, so this team walks through
|
||||
if (next.pendingBye && next.status === 'pending') {
|
||||
next.status = 'bye'; next.winner = teamId; delete next.pendingBye;
|
||||
this._propagate(next);
|
||||
}
|
||||
}
|
||||
|
||||
_propagate(m) {
|
||||
if (m.feeds) this._place(m.feeds, m.feedsSide, m.winner);
|
||||
if (m.loserFeeds && m.loser) this._place(m.loserFeeds, m.loserFeedsSide, m.loser);
|
||||
// double-elim grand final: if LB side (B) wins, activate the reset match
|
||||
if (m.label === 'Grand final' && m.resetMatch) {
|
||||
const reset = this.match(m.resetMatch);
|
||||
if (m.winner === m.teamB) { reset.teamA = m.teamA; reset.teamB = m.teamB; reset.conditional = false; }
|
||||
else { reset.status = 'void'; }
|
||||
}
|
||||
if (this.isComplete()) { this.phase = 'final'; this.emit({ type: 'phase', phase: 'final', champion: this.champion() }); }
|
||||
}
|
||||
|
||||
/** Complete when every configured stage has matches and all of them are decided. */
|
||||
isComplete() {
|
||||
for (const st of this.stages) {
|
||||
const group = st === 'pool' ? ['pool'] : ['bracket', 'losers', 'final'];
|
||||
if (!this.matches.some(m => group.includes(m.stage))) return false;
|
||||
}
|
||||
return this.matches.every(m => ['final', 'forfeit', 'void', 'bye'].includes(m.status) || m.conditional);
|
||||
}
|
||||
|
||||
champion() {
|
||||
const finals = this.matches.filter(m => m.stage !== 'pool' && m.stage !== 'losers' && m.winner && !m.conditional && m.label !== '3rd place');
|
||||
if (!finals.length) return null;
|
||||
// the last decided non-losers match with no `feeds` is the title match
|
||||
const last = finals.filter(m => !m.feeds || !this.match(m.feeds) || this.match(m.feeds).status === 'void').pop();
|
||||
return last ? this.teamName(last.winner) : null;
|
||||
}
|
||||
|
||||
// ---------- withdrawal ----------
|
||||
/** mode: 'forfeit' (remaining games lost 0-pointsTo) | 'void' (all of the team's pool games removed from standings). */
|
||||
withdrawTeam(teamId, { mode = 'forfeit', actor = 'organizer' } = {}) {
|
||||
const t = this.teams.get(teamId);
|
||||
t.status = 'withdrawn'; t.withdrawalMode = mode;
|
||||
for (const m of this.matches) {
|
||||
if (m.teamA !== teamId && m.teamB !== teamId) continue;
|
||||
if (m.stage === 'pool') {
|
||||
if (m.status === 'final' && mode === 'void') { m.status = 'void'; m.winner = null; m.loser = null; }
|
||||
else if (['pending', 'on_deck', 'live'].includes(m.status)) {
|
||||
if (mode === 'void') { m.status = 'void'; }
|
||||
else this.recordResult(m.id, [], { actor, forfeit: teamId });
|
||||
m.court = null;
|
||||
}
|
||||
} else if (['pending', 'on_deck', 'live'].includes(m.status)) {
|
||||
// bracket: opponent walks over, if known; otherwise the slot becomes a bye
|
||||
const opp = m.teamA === teamId ? m.teamB : m.teamA;
|
||||
if (opp) this.recordResult(m.id, [], { actor, forfeit: teamId });
|
||||
else { m[m.teamA === teamId ? 'teamA' : 'teamB'] = null; m.pendingBye = m.teamA === null ? 'A' : 'B'; }
|
||||
m.court = null;
|
||||
}
|
||||
}
|
||||
this.emit({ type: 'team_withdrawn', team: teamId, name: t.name, mode });
|
||||
}
|
||||
|
||||
// ---------- standings ----------
|
||||
standings(poolId) {
|
||||
const pool = this.pools.find(p => p.id === poolId);
|
||||
const rows = new Map();
|
||||
for (const tid of pool.teams) {
|
||||
const t = this.teams.get(tid);
|
||||
rows.set(tid, { teamId: tid, name: t.name, seed: t.seed, status: t.status, w: 0, l: 0, setsW: 0, setsL: 0, pf: 0, pa: 0, played: 0 });
|
||||
}
|
||||
const played = this.matches.filter(m => m.poolId === poolId && ['final', 'forfeit'].includes(m.status));
|
||||
for (const m of played) {
|
||||
const A = rows.get(m.teamA), B = rows.get(m.teamB);
|
||||
const sets = m.status === 'forfeit'
|
||||
? Array.from({ length: Math.ceil(this.rules.bestOf / 2) }, () => (m.winner === m.teamA ? [this.rules.pointsTo, 0] : [0, this.rules.pointsTo]))
|
||||
: m.sets;
|
||||
for (const [a, b] of sets) {
|
||||
A.pf += a; A.pa += b; B.pf += b; B.pa += a;
|
||||
if (a > b) { A.setsW++; B.setsL++; } else { B.setsW++; A.setsL++; }
|
||||
}
|
||||
A.played++; B.played++;
|
||||
if (m.winner === m.teamA) { A.w++; B.l++; } else { B.w++; A.l++; }
|
||||
}
|
||||
const list = [...rows.values()].map(r => ({
|
||||
...r,
|
||||
winPct: r.played ? r.w / r.played : 0,
|
||||
setRatio: r.setsL ? r.setsW / r.setsL : (r.setsW ? Infinity : 0),
|
||||
pointRatio: r.pa ? r.pf / r.pa : (r.pf ? Infinity : 0),
|
||||
pointDiff: r.pf - r.pa,
|
||||
decidedBy: null,
|
||||
}));
|
||||
// Withdrawn teams sink to the bottom. Active teams are grouped by win% and each tied
|
||||
// group is broken by the ordered tiebreak list; head-to-head is computed as a
|
||||
// mini-league among only the tied teams, so a circular 3-way tie falls through.
|
||||
const active = list.filter(r => r.status !== 'withdrawn');
|
||||
const withdrawn = list.filter(r => r.status === 'withdrawn').sort((a, b) => b.winPct - a.winPct);
|
||||
const byPct = groupBy(active.sort((a, b) => b.winPct - a.winPct), r => r.winPct);
|
||||
const ranked = byPct.flatMap(g => this._breakTie(g, played, 0));
|
||||
return [...ranked, ...withdrawn];
|
||||
}
|
||||
|
||||
_breakTie(group, played, ruleIdx) {
|
||||
if (group.length < 2) return group;
|
||||
if (ruleIdx >= this.rules.tiebreaks.length) return group;
|
||||
const rule = this.rules.tiebreaks[ruleIdx];
|
||||
const ids = new Set(group.map(r => r.teamId));
|
||||
let key;
|
||||
if (rule === 'headToHead') {
|
||||
const wins = new Map(group.map(r => [r.teamId, 0]));
|
||||
for (const m of played) if (ids.has(m.teamA) && ids.has(m.teamB) && m.winner) wins.set(m.winner, wins.get(m.winner) + 1);
|
||||
key = r => wins.get(r.teamId);
|
||||
} else if (rule === 'setRatio') key = r => r.setRatio;
|
||||
else if (rule === 'pointRatio') key = r => r.pointRatio;
|
||||
else if (rule === 'pointDiff') key = r => r.pointDiff;
|
||||
else if (rule === 'seed') key = r => -r.seed;
|
||||
else throw new Error(`Unknown tiebreak ${rule}`);
|
||||
const sorted = [...group].sort((a, b) => key(b) - key(a));
|
||||
const sub = groupBy(sorted, key);
|
||||
if (sub.length === 1) return this._breakTie(group, played, ruleIdx + 1); // rule didn't separate anyone
|
||||
const sameRecord = group.every(r => r.w === group[0].w && r.l === group[0].l);
|
||||
return sub.flatMap(g => {
|
||||
if (g.length < group.length && sameRecord) for (const r of g) r.decidedBy = r.decidedBy ?? rule;
|
||||
return this._breakTie(g, played, ruleIdx + 1);
|
||||
});
|
||||
}
|
||||
|
||||
bracketMatches(stage = 'bracket') { return this.matches.filter(m => m.stage === stage); }
|
||||
|
||||
// ---------- persistence ----------
|
||||
toJSON() {
|
||||
return {
|
||||
name: this.name, slug: this.slug ?? null, date: this.date ?? null, notes: this.notes ?? null,
|
||||
rules: this.rules, courtCount: this.courtCount, stages: this.stages, phase: this.phase,
|
||||
teams: [...this.teams.values()], matches: this.matches, pools: this.pools, log: this.log.slice(-500), seq: this._seq,
|
||||
banner: this.banner ?? null, createdAt: this.createdAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
static fromJSON(j) {
|
||||
const t = new Tournament({ name: j.name, rules: j.rules, courtCount: j.courtCount, stages: j.stages });
|
||||
Object.assign(t, { slug: j.slug, date: j.date, notes: j.notes, phase: j.phase, matches: j.matches, pools: j.pools, log: j.log ?? [], _seq: j.seq ?? 1, banner: j.banner ?? null, createdAt: j.createdAt ?? null });
|
||||
t.teams = new Map((j.teams ?? []).map(x => [x.id, x]));
|
||||
return t;
|
||||
}
|
||||
|
||||
/** Public-safe team view (no phone numbers, no private codes). */
|
||||
publicTeam(id) { const t = this.teams.get(id); return t ? { id: t.id, name: t.name, players: t.players, seed: t.seed, status: t.status, poolId: t.poolId } : null; }
|
||||
}
|
||||
|
||||
export function validateSet(a, b, rules) {
|
||||
const { pointsTo, winBy, cap } = rules;
|
||||
const hi = Math.max(a, b), lo = Math.min(a, b);
|
||||
if (a === b) throw new Error(`Set cannot end tied ${a}-${b}`);
|
||||
if (hi < pointsTo) throw new Error(`Winner must reach ${pointsTo} (got ${hi}-${lo})`);
|
||||
if (cap && hi === cap) { if (hi - lo < 1) throw new Error('bad cap score'); return true; }
|
||||
if (cap && hi > cap) throw new Error(`Score exceeds cap ${cap}`);
|
||||
if (hi - lo < winBy) throw new Error(`Must win by ${winBy} (got ${hi}-${lo})`);
|
||||
if (hi > pointsTo && hi - lo > winBy) throw new Error(`${hi}-${lo}: set should have ended at ${lo + winBy}-${lo}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
function groupBy(sortedList, key) {
|
||||
const out = [];
|
||||
for (const item of sortedList) {
|
||||
const k = key(item);
|
||||
if (out.length && key(out[out.length - 1][0]) === k) out[out.length - 1].push(item);
|
||||
else out.push([item]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function code4() {
|
||||
const s = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
return Array.from({ length: 4 }, () => s[Math.floor(Math.random() * s.length)]).join('');
|
||||
}
|
||||
Generated
+343
@@ -0,0 +1,343 @@
|
||||
{
|
||||
"name": "courtside",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "courtside",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"qrcode": "^1.5.4",
|
||||
"ws": "^8.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.0",
|
||||
"wrap-ansi": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/decamelize": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dijkstrajs": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/find-up": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^5.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-locate": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/p-limit": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-try": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-locate": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-limit": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/p-try": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dijkstrajs": "^1.0.1",
|
||||
"pngjs": "^5.0.0",
|
||||
"yargs": "^15.3.1"
|
||||
},
|
||||
"bin": {
|
||||
"qrcode": "bin/qrcode"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-main-filename": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/which-module": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
|
||||
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "15.4.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^6.0.0",
|
||||
"decamelize": "^1.2.0",
|
||||
"find-up": "^4.1.0",
|
||||
"get-caller-file": "^2.0.1",
|
||||
"require-directory": "^2.1.1",
|
||||
"require-main-filename": "^2.0.0",
|
||||
"set-blocking": "^2.0.0",
|
||||
"string-width": "^4.2.0",
|
||||
"which-module": "^2.0.0",
|
||||
"y18n": "^4.0.0",
|
||||
"yargs-parser": "^18.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "18.1.3",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"camelcase": "^5.0.0",
|
||||
"decamelize": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"name":"courtside","type":"module","version":"0.1.0","scripts":{"test":"node --test test/*.test.js","sim":"node simulate.js","start":"node --disable-warning=ExperimentalWarning server/index.js","dev":"node --disable-warning=ExperimentalWarning --watch server/index.js","demo":"node --disable-warning=ExperimentalWarning scripts/seed-demo.js","smoke":"bash test/smoke.sh"},"description":"Self-hosted volleyball tournament desk: one QR code from check-in to live scores","main":"server/index.js","directories":{"test":"test"},"keywords":[],"author":"","license":"MIT","dependencies":{"qrcode":"^1.5.4","ws":"^8.21.3"},"engines":{"node":">=22.13"},"repository":{"type":"git","url":"https://gitea.cloudfreeiot.com/bkvargyas/courtside.git"}}
|
||||
@@ -0,0 +1,41 @@
|
||||
// Creates a demo tournament in the database so a fresh install has something to look at.
|
||||
// Run with the server STOPPED (the server keeps state in memory and writes it back):
|
||||
// DB_PATH=./data/courtside.sqlite node scripts/seed-demo.js
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { Store } from '../server/store.js';
|
||||
import { Registry } from '../server/state.js';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const DB_PATH = process.env.DB_PATH ?? join(here, '..', 'data', 'courtside.sqlite');
|
||||
const store = new Store(DB_PATH);
|
||||
const registry = new Registry(store);
|
||||
|
||||
if (registry.get('demo-labor-day-2s')) { console.log('Demo tournament already exists at /t/demo-labor-day-2s'); process.exit(0); }
|
||||
|
||||
const { t, engine } = registry.create({ slug: 'demo-labor-day-2s', name: 'Demo Labor Day 2s', date: '2026-09-07', courtCount: 2, rules: { teamSize: 2, pointsTo: 21, winBy: 2, cap: 25 }, notes: 'Demo data. Scores are made up.' });
|
||||
|
||||
const names = ['Net Gains', 'Block Party', 'Sunburnt', 'Dig It', 'Kiss My Ace', 'Setting Ducks', 'The Spikers', 'Sandbaggers', 'Serves You Right', 'Bump Chumps', 'Ace Holes', 'Beach Please'];
|
||||
for (const n of names) t.registerTeam({ name: n, players: 2, captain: 'Captain' });
|
||||
t.closeRegistration();
|
||||
t.generatePools(4);
|
||||
let clock = Date.now() - 90 * 60_000;
|
||||
engine.now = () => clock; t.now = engine.now;
|
||||
engine.start();
|
||||
// play the first seven matches so there are standings, then leave two live with a running score
|
||||
let seed = 11;
|
||||
const rnd = () => ((seed = (seed * 1664525 + 1013904223) >>> 0) / 2 ** 32);
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const c = engine.courts.find(c => c.matchId);
|
||||
clock += (12 + rnd() * 6) * 60_000;
|
||||
const hi = 21, lo = Math.floor(8 + rnd() * 12);
|
||||
engine.recordResult(c.matchId, [rnd() < 0.5 ? [hi, lo] : [lo, hi]]);
|
||||
}
|
||||
const live = engine.courts.filter(c => c.matchId).map(c => t.match(c.matchId));
|
||||
if (live[0]) live[0].live = [14, 11];
|
||||
if (live[1]) live[1].live = [7, 9];
|
||||
t.banner = 'Demo tournament — scores are simulated';
|
||||
engine.now = () => Date.now(); t.now = engine.now;
|
||||
registry.persist(t.slug);
|
||||
store.close();
|
||||
console.log(`Seeded demo at /t/${t.slug}. Team codes: ${[...t.teams.values()].slice(0, 3).map(x => `${x.name}=${x.code}`).join(', ')}`);
|
||||
@@ -0,0 +1,44 @@
|
||||
// Organizer authentication. Two options, both simple:
|
||||
// 1. ADMIN_PASSWORD — a login form sets a signed cookie for 12 hours.
|
||||
// 2. Cloudflare Access — if TRUST_CF_ACCESS=true, a request that carries the
|
||||
// Cf-Access-Authenticated-User-Email header (set by Cloudflare after the user passes
|
||||
// the Access policy on /admin) is treated as an organizer. Only enable this when the
|
||||
// app is reachable exclusively through the tunnel, otherwise the header can be forged.
|
||||
import { timingSafeEqual, randomBytes } from 'node:crypto';
|
||||
import { parseCookies, sign, verify } from './http.js';
|
||||
|
||||
const COOKIE = 'courtside_admin';
|
||||
const TTL_MS = 12 * 3600 * 1000;
|
||||
|
||||
export class Auth {
|
||||
constructor({ password, secret, trustCfAccess = false }) {
|
||||
this.password = password || null;
|
||||
this.secret = secret || randomBytes(32).toString('hex'); // random secret = sessions reset on restart; fine for a fallback
|
||||
this.trustCfAccess = trustCfAccess;
|
||||
if (!this.password && !this.trustCfAccess) console.warn('[auth] ADMIN_PASSWORD is not set and TRUST_CF_ACCESS is off: the organizer desk is unreachable.');
|
||||
}
|
||||
|
||||
/** Returns the organizer identity or null. */
|
||||
identify(req) {
|
||||
if (this.trustCfAccess && req.headers['cf-access-authenticated-user-email']) return String(req.headers['cf-access-authenticated-user-email']);
|
||||
const c = parseCookies(req)[COOKIE];
|
||||
const v = verify(c, this.secret);
|
||||
if (!v) return null;
|
||||
const [exp, who] = v.split('|');
|
||||
if (Number(exp) < Date.now()) return null;
|
||||
return who || 'organizer';
|
||||
}
|
||||
|
||||
checkPassword(candidate) {
|
||||
if (!this.password) return false;
|
||||
const a = Buffer.from(String(candidate)), b = Buffer.from(this.password);
|
||||
return a.length === b.length && timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
cookie(who = 'organizer', secure = false) {
|
||||
const v = sign(`${Date.now() + TTL_MS}|${who}`, this.secret);
|
||||
return `${COOKIE}=${encodeURIComponent(v)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${TTL_MS / 1000}${secure ? '; Secure' : ''}`;
|
||||
}
|
||||
|
||||
clearCookie() { return `${COOKIE}=; Path=/; HttpOnly; Max-Age=0`; }
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// A very small router and helpers over node:http. No framework so the deploy has no
|
||||
// dependency surface beyond `ws` and `qrcode`.
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||
import { readFile, stat } from 'node:fs/promises';
|
||||
import { extname, join, normalize } from 'node:path';
|
||||
|
||||
export class Router {
|
||||
constructor() { this.routes = []; }
|
||||
add(method, pattern, handler) {
|
||||
const keys = [];
|
||||
const re = new RegExp('^' + pattern.replace(/\//g, '\\/').replace(/:(\w+)/g, (_, k) => { keys.push(k); return '([^\\/]+)'; }) + '\\/?$');
|
||||
this.routes.push({ method, re, keys, handler });
|
||||
return this;
|
||||
}
|
||||
get(p, h) { return this.add('GET', p, h); }
|
||||
post(p, h) { return this.add('POST', p, h); }
|
||||
match(method, path) {
|
||||
for (const r of this.routes) {
|
||||
if (r.method !== method) continue;
|
||||
const m = path.match(r.re);
|
||||
if (m) return { handler: r.handler, params: Object.fromEntries(r.keys.map((k, i) => [k, decodeURIComponent(m[i + 1])])) };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export class HttpError extends Error { constructor(status, message) { super(message); this.status = status; } }
|
||||
|
||||
export async function readBody(req, limit = 64 * 1024) {
|
||||
const chunks = []; let size = 0;
|
||||
for await (const c of req) { size += c.length; if (size > limit) throw new HttpError(413, 'Body too large'); chunks.push(c); }
|
||||
const raw = Buffer.concat(chunks).toString('utf8');
|
||||
const ct = req.headers['content-type'] ?? '';
|
||||
if (ct.includes('application/json')) return raw ? JSON.parse(raw) : {};
|
||||
if (ct.includes('application/x-www-form-urlencoded')) return Object.fromEntries(new URLSearchParams(raw));
|
||||
return raw;
|
||||
}
|
||||
|
||||
export function parseCookies(req) {
|
||||
return Object.fromEntries((req.headers.cookie ?? '').split(';').map(s => s.trim()).filter(Boolean).map(s => { const i = s.indexOf('='); return [s.slice(0, i), decodeURIComponent(s.slice(i + 1))]; }));
|
||||
}
|
||||
|
||||
export const send = {
|
||||
html(res, body, status = 200, headers = {}) { res.writeHead(status, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', ...headers }); res.end(body); },
|
||||
json(res, obj, status = 200, headers = {}) { res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', ...headers }); res.end(JSON.stringify(obj)); },
|
||||
redirect(res, to, headers = {}) { res.writeHead(303, { location: to, ...headers }); res.end(); },
|
||||
text(res, body, status = 200) { res.writeHead(status, { 'content-type': 'text/plain; charset=utf-8' }); res.end(body); },
|
||||
};
|
||||
|
||||
const MIME = { '.css': 'text/css', '.js': 'text/javascript', '.png': 'image/png', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.webmanifest': 'application/manifest+json', '.json': 'application/json' };
|
||||
export async function serveStatic(res, root, urlPath) {
|
||||
const p = normalize(join(root, urlPath));
|
||||
if (!p.startsWith(root)) throw new HttpError(404, 'Not found');
|
||||
try {
|
||||
const s = await stat(p);
|
||||
if (!s.isFile()) throw new HttpError(404, 'Not found');
|
||||
const body = await readFile(p);
|
||||
res.writeHead(200, { 'content-type': MIME[extname(p)] ?? 'application/octet-stream', 'cache-control': 'public, max-age=300' });
|
||||
res.end(body);
|
||||
} catch (e) { if (e instanceof HttpError) throw e; throw new HttpError(404, 'Not found'); }
|
||||
}
|
||||
|
||||
/** Signed cookie values: `payload.signature`. */
|
||||
export function sign(value, secret) { return `${value}.${createHmac('sha256', secret).update(value).digest('base64url')}`; }
|
||||
export function verify(signed, secret) {
|
||||
if (!signed || !signed.includes('.')) return null;
|
||||
const i = signed.lastIndexOf('.');
|
||||
const value = signed.slice(0, i), sig = signed.slice(i + 1);
|
||||
const expected = createHmac('sha256', secret).update(value).digest('base64url');
|
||||
if (sig.length !== expected.length || !timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return null;
|
||||
return value;
|
||||
}
|
||||
|
||||
export const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
@@ -0,0 +1,70 @@
|
||||
// Courtside server entry point. One process: HTTP pages + WebSocket fan-out + SQLite.
|
||||
import { createServer } from 'node:http';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import { Store } from './store.js';
|
||||
import { Registry } from './state.js';
|
||||
import { Auth } from './auth.js';
|
||||
import { buildRouter } from './routes.js';
|
||||
import { HttpError, send, serveStatic } from './http.js';
|
||||
import { publicState } from './public-state.js';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const env = process.env;
|
||||
const PORT = Number(env.PORT ?? 3000);
|
||||
const HOST = env.HOST ?? '0.0.0.0';
|
||||
const DB_PATH = env.DB_PATH ?? join(here, '..', 'data', 'courtside.sqlite');
|
||||
|
||||
const store = new Store(DB_PATH);
|
||||
const registry = new Registry(store);
|
||||
const auth = new Auth({ password: env.ADMIN_PASSWORD, secret: env.SESSION_SECRET, trustCfAccess: env.TRUST_CF_ACCESS === 'true' });
|
||||
const router = buildRouter({ registry, auth, store });
|
||||
const staticRoot = join(here, 'public');
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
const url = new URL(req.url, 'http://x');
|
||||
try {
|
||||
if (url.pathname.startsWith('/static/')) return await serveStatic(res, staticRoot, url.pathname.slice('/static'.length));
|
||||
const m = router.match(req.method, url.pathname);
|
||||
if (!m) throw new HttpError(404, 'Not found');
|
||||
await m.handler(req, res, m.params);
|
||||
} catch (e) {
|
||||
const status = e instanceof HttpError ? e.status : 500;
|
||||
if (status === 500) console.error(`[${new Date().toISOString()}] ${req.method} ${req.url}`, e);
|
||||
if (!res.headersSent) send.html(res, `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>${status}</title><body style="font-family:sans-serif;padding:40px"><h1>${status}</h1><p>${escape(e.message)}</p><p><a href="/">Home</a></p>`, status);
|
||||
else res.end();
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- WebSocket: /ws/<slug> pushes the public state on every change ----------
|
||||
const wss = new WebSocketServer({ noServer: true, maxPayload: 1024 });
|
||||
server.on('upgrade', (req, socket, head) => {
|
||||
const m = req.url.match(/^\/ws\/([a-z0-9-]+)\/?$/);
|
||||
const item = m && registry.get(m[1]);
|
||||
if (!item) { socket.write('HTTP/1.1 404 Not Found\r\n\r\n'); socket.destroy(); return; }
|
||||
wss.handleUpgrade(req, socket, head, ws => {
|
||||
ws.isAlive = true;
|
||||
ws.on('pong', () => { ws.isAlive = true; });
|
||||
ws.on('message', () => { /* clients never send; ignore */ });
|
||||
const unsub = registry.subscribe(m[1], state => { if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(state)); });
|
||||
ws.on('close', unsub);
|
||||
ws.send(JSON.stringify(publicState(item)));
|
||||
});
|
||||
});
|
||||
const heartbeat = setInterval(() => { for (const ws of wss.clients) { if (!ws.isAlive) return ws.terminate(); ws.isAlive = false; ws.ping(); } }, 30000);
|
||||
|
||||
// ---------- ETA refresh: queue ETAs drift as time passes even with no results ----------
|
||||
const etaTimer = setInterval(() => { for (const [slug, { t }] of registry.items) if (t.phase === 'live' && registry.subscribers.get(slug)?.size) registry.notify(slug); }, 60000);
|
||||
|
||||
server.listen(PORT, HOST, () => console.log(`Courtside listening on http://${HOST}:${PORT} db=${DB_PATH} tournaments=${registry.items.size}`));
|
||||
|
||||
for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => {
|
||||
console.log(`\n${sig}: shutting down`);
|
||||
clearInterval(heartbeat); clearInterval(etaTimer);
|
||||
for (const ws of wss.clients) ws.close();
|
||||
server.close(() => { store.close(); process.exit(0); });
|
||||
setTimeout(() => process.exit(0), 3000).unref();
|
||||
});
|
||||
|
||||
function escape(s) { return String(s).replace(/[&<>]/g, c => ({ '&': '&', '<': '<', '>': '>' }[c])); }
|
||||
@@ -0,0 +1,30 @@
|
||||
// The one JSON document every public page renders from. No phone numbers, no team codes.
|
||||
export function publicState({ t, engine }) {
|
||||
const name = id => t.teamName(id);
|
||||
const view = m => ({
|
||||
id: m.id, stage: m.stage, round: m.round, slot: m.slot, label: m.label ?? null, status: m.status, court: m.court,
|
||||
a: m.teamA ? { id: m.teamA, name: name(m.teamA) } : null,
|
||||
b: m.teamB ? { id: m.teamB, name: name(m.teamB) } : null,
|
||||
winner: m.winner, sets: m.sets, live: m.live ?? null, feeds: m.feeds, feedsSide: m.feedsSide, conditional: !!m.conditional,
|
||||
});
|
||||
const board = t.phase === 'live' ? engine.board() : null;
|
||||
const pools = t.pools.map(p => ({
|
||||
id: p.id,
|
||||
standings: t.standings(p.id).map(r => ({ teamId: r.teamId, name: r.name, w: r.w, l: r.l, setsW: r.setsW, setsL: r.setsL, pf: r.pf, pa: r.pa, pointDiff: r.pointDiff, decidedBy: r.decidedBy, status: r.status })),
|
||||
matches: t.matches.filter(m => m.poolId === p.id).map(view),
|
||||
}));
|
||||
const bracket = t.matches.filter(m => m.stage !== 'pool').map(view);
|
||||
const teams = t.activeTeams().map(x => t.publicTeam(x.id));
|
||||
const withdrawn = [...t.teams.values()].filter(x => x.status === 'withdrawn').map(x => t.publicTeam(x.id));
|
||||
return {
|
||||
slug: t.slug, name: t.name, date: t.date, notes: t.notes ?? '', phase: t.phase, banner: t.banner ?? null,
|
||||
rules: t.rules, stages: t.stages, courtCount: t.courtCount,
|
||||
teams, withdrawn, pools, bracket,
|
||||
courts: board ? board.courts.map(c => ({ ...c, match: c.match ? view(t.match(c.match.id)) : null })) : [],
|
||||
upNext: board ? board.upNext.map(u => ({ ...view(t.match(u.id)), court: u.court, etaMin: u.etaMin })) : [],
|
||||
avgMatchMin: board?.avgMatchMin ?? null,
|
||||
champion: t.phase === 'final' ? t.champion() : null,
|
||||
recentAlerts: engine.alerts.slice(-20).map(a => ({ kind: a.kind, text: a.text, at: a.at, teams: a.teams ?? [], match: a.match ?? null })),
|
||||
generatedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/* Organizer desk: live score pad. Each tap posts the running score so the public board
|
||||
shows it; "Mark final" submits the set as the result. Plain fetch, no framework. */
|
||||
(() => {
|
||||
const slug = location.pathname.split('/')[3];
|
||||
const post = (path, body) => fetch(`/admin/t/${slug}/${path}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }).then(async r => { const j = await r.json().catch(() => ({})); if (!r.ok) throw new Error(j.error || r.statusText); return j; });
|
||||
|
||||
document.querySelectorAll('.scorepad').forEach(pad => {
|
||||
const id = pad.dataset.match;
|
||||
const a = pad.querySelector('[data-side=a]'), b = pad.querySelector('[data-side=b]');
|
||||
let timer = null;
|
||||
const push = () => { clearTimeout(timer); timer = setTimeout(() => post('live', { match: id, a: +a.textContent, b: +b.textContent }).catch(e => alert(e.message)), 150); };
|
||||
pad.querySelectorAll('[data-op]').forEach(btn => btn.addEventListener('click', () => {
|
||||
const [side, op] = btn.dataset.op;
|
||||
const el = side === 'a' ? a : b;
|
||||
el.textContent = Math.max(0, +el.textContent + (op === '+' ? 1 : -1));
|
||||
push();
|
||||
}));
|
||||
const finalBtn = pad.nextElementSibling.querySelector('[data-final]');
|
||||
finalBtn.addEventListener('click', () => {
|
||||
const sa = +a.textContent, sb = +b.textContent;
|
||||
if (!confirm(`Final: ${sa}–${sb}?`)) return;
|
||||
post('score', { match: id, sets: `${sa}-${sb}` }).then(() => location.reload()).catch(e => alert(e.message));
|
||||
});
|
||||
});
|
||||
|
||||
// keep the desk fresh when another organizer enters a score
|
||||
const ws = new WebSocket(`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/${slug}`);
|
||||
let last = null;
|
||||
ws.onmessage = ev => {
|
||||
const s = JSON.parse(ev.data);
|
||||
const sig = JSON.stringify([s.phase, s.courts.map(c => [c.match?.id, c.match?.status, c.status]), s.upNext.map(u => u.id)]);
|
||||
if (last && sig !== last && !document.querySelector('.scorepad:hover')) location.reload();
|
||||
last = sig;
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,95 @@
|
||||
:root{
|
||||
--bg:#F7F8F6;--surface:#fff;--ink:#17202A;--muted:#5B6875;--line:#DCE1DE;
|
||||
--accent:#1D6FB5;--accent-ink:#fff;--accent-soft:#E4EFF9;--sand:#B98F3F;--sand-soft:#F6EBD2;
|
||||
--good:#2E8B57;--warn:#C97B1C;--crit:#C0392B;--court:#1A6FB5;--court-ink:#fff;
|
||||
}
|
||||
@media (prefers-color-scheme:dark){:root{--bg:#12181F;--surface:#1A222B;--ink:#E8ECEF;--muted:#9AA6B1;--line:#2B3640;--accent:#5AA3E6;--accent-ink:#0E1A26;--accent-soft:#1B3040;--sand:#D9B26E;--sand-soft:#3A3120;--good:#5FCB8A;--warn:#E39A3B;--crit:#E5655A;--court:#1D5F99}}
|
||||
*{box-sizing:border-box}
|
||||
html{-webkit-text-size-adjust:100%}
|
||||
body{margin:0;background:var(--bg);color:var(--ink);font-family:"Source Sans 3",-apple-system,"Segoe UI",Helvetica,Arial,sans-serif;font-size:17px;line-height:1.5}
|
||||
a{color:var(--accent)}
|
||||
h1,h2,h3{font-family:"Barlow Condensed","Arial Narrow",Arial,sans-serif;line-height:1.1;margin:0;text-wrap:balance}
|
||||
h1{font-size:44px;font-weight:700}h2{font-size:26px;font-weight:600}h3{font-size:20px;font-weight:600;color:var(--muted)}
|
||||
.mono{font-family:"JetBrains Mono",ui-monospace,Menlo,monospace;font-variant-numeric:tabular-nums}
|
||||
.muted{color:var(--muted)}.small{font-size:14px}.tbd{color:var(--muted);font-style:italic}
|
||||
main{max-width:960px;margin:0 auto;padding:16px 16px 64px}
|
||||
.top{display:flex;justify-content:space-between;align-items:center;padding:10px 16px;border-bottom:1px solid var(--line);background:var(--surface)}
|
||||
.brand{font-family:"Barlow Condensed",sans-serif;font-weight:700;font-size:22px;letter-spacing:.04em;text-transform:uppercase;color:var(--ink);text-decoration:none}
|
||||
.top nav a{margin-left:16px;font-family:"Barlow Condensed",sans-serif;text-transform:uppercase;letter-spacing:.06em;font-size:15px;text-decoration:none}
|
||||
.foot{text-align:center;color:var(--muted);font-size:13px;padding:24px}
|
||||
.eyebrow{font-family:"Barlow Condensed",sans-serif;text-transform:uppercase;letter-spacing:.12em;font-size:13px;font-weight:600;color:var(--muted)}
|
||||
.hero{padding:18px 0 8px}.hero.compact{padding:8px 0}.lede{color:var(--muted);margin:6px 0 0}.notes{white-space:pre-line}
|
||||
.pill{display:inline-block;font-family:"Barlow Condensed",sans-serif;text-transform:uppercase;letter-spacing:.08em;font-size:12px;font-weight:600;padding:2px 8px;border-radius:3px;line-height:1.5;vertical-align:middle;background:var(--accent-soft);color:var(--accent)}
|
||||
.pill.live{background:var(--good);color:#fff}.pill.final{background:var(--sand-soft);color:var(--sand)}.pill.checkin{background:var(--accent);color:var(--accent-ink)}
|
||||
.card{background:var(--surface);border:1px solid var(--line);border-radius:8px;padding:16px;margin:14px 0}
|
||||
.card h2{margin-bottom:8px}
|
||||
.list .row{display:grid;grid-template-columns:auto 1fr auto;gap:12px;align-items:center;padding:12px 14px;margin:8px 0;background:var(--surface);border:1px solid var(--line);border-radius:8px;color:var(--ink);text-decoration:none}
|
||||
.flash{padding:10px 14px;border-radius:6px;margin:12px 0;background:var(--accent-soft)}.flash.error{background:var(--sand-soft);color:var(--crit);border-left:3px solid var(--crit)}
|
||||
.banner{background:var(--sand-soft);border-left:3px solid var(--sand);padding:10px 14px;border-radius:0 6px 6px 0;margin:12px 0;font-weight:600}
|
||||
.champion{text-align:center;padding:20px;margin:14px 0;border:2px solid var(--sand);border-radius:8px;background:var(--sand-soft)}.champion h2{font-size:40px}
|
||||
/* forms */
|
||||
.form label{display:block;margin:12px 0;font-weight:600}
|
||||
.form input,.form textarea,.form select{display:block;width:100%;margin-top:4px;padding:10px 12px;font:inherit;font-weight:400;border:1px solid var(--line);border-radius:6px;background:var(--bg);color:var(--ink)}
|
||||
button,.button{font-family:"Barlow Condensed",sans-serif;text-transform:uppercase;letter-spacing:.06em;font-size:17px;font-weight:600;padding:10px 18px;border-radius:6px;border:1px solid var(--line);background:var(--surface);color:var(--ink);cursor:pointer;text-decoration:none;display:inline-block;line-height:1.2}
|
||||
button.primary,.button.primary{background:var(--accent);color:var(--accent-ink);border-color:var(--accent)}
|
||||
button.danger{color:var(--crit);border-color:var(--crit)}button.small{font-size:14px;padding:6px 10px}
|
||||
button:focus-visible,a:focus-visible,input:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
||||
.linkbox{word-break:break-all;background:var(--bg);padding:10px;border-radius:6px}
|
||||
/* courts */
|
||||
.courts{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:12px;margin:14px 0}
|
||||
.court{background:var(--court);color:var(--court-ink);border-radius:8px;padding:12px 14px;position:relative;overflow:hidden;min-height:96px}
|
||||
.court:before{content:"";position:absolute;left:50%;top:0;bottom:0;border-left:2px dashed rgba(255,255,255,.35)}
|
||||
.court.idle,.court.paused{background:var(--surface);color:var(--muted);border:1px dashed var(--line)}.court.idle:before,.court.paused:before{display:none}
|
||||
.court.mine{outline:3px solid var(--sand)}
|
||||
.court .lbl{font-family:"Barlow Condensed",sans-serif;font-size:12px;letter-spacing:.12em;text-transform:uppercase;opacity:.85;position:relative}
|
||||
.court .teams{display:grid;grid-template-columns:1fr auto 1fr;align-items:center;gap:10px;margin-top:6px;position:relative}
|
||||
.court .t{font-family:"Barlow Condensed",sans-serif;font-size:24px;font-weight:600;line-height:1.05}
|
||||
.court .t:last-child{text-align:right}
|
||||
.court .s{font-size:30px;background:#0F1418;color:#fff;padding:2px 10px;border-radius:4px}
|
||||
.queue{background:#0F1418;color:#F2F5F7;border-radius:8px;padding:12px 16px;margin:12px 0;display:grid;grid-template-columns:auto 1fr auto;gap:6px 14px;align-items:center}
|
||||
.queue .eyebrow{grid-column:1/-1;color:#8E9BA6}
|
||||
.qrow{display:contents}.qrow .k{font-family:"Barlow Condensed",sans-serif;font-size:12px;letter-spacing:.12em;text-transform:uppercase;color:#8E9BA6}
|
||||
.qrow:first-of-type span:nth-child(2){color:#F0C46B;font-weight:600}.qrow.mine span:nth-child(2){text-decoration:underline}.qrow .eta{font-size:13px;color:#8E9BA6}
|
||||
/* pools */
|
||||
.pools{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:12px}
|
||||
table{width:100%;border-collapse:collapse;font-size:15px}th{text-align:left;font-family:"Barlow Condensed",sans-serif;text-transform:uppercase;letter-spacing:.08em;font-size:12px;color:var(--muted);padding:4px 6px;border-bottom:1px solid var(--ink)}
|
||||
td{padding:6px;border-bottom:1px solid var(--line)}.num{text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap}
|
||||
tr.withdrawn td{color:var(--muted);text-decoration:line-through}tr.mine td{background:var(--sand-soft)}.tb{color:var(--sand);margin-left:3px}
|
||||
details{margin-top:8px}summary{cursor:pointer;color:var(--muted);font-size:14px}
|
||||
ul.plain,ol.plain{list-style:none;padding:0;margin:8px 0}ol.plain{counter-reset:n}ol.plain li{padding:4px 0}
|
||||
.cols{columns:2;column-gap:24px}
|
||||
.matches li,.sched li{display:grid;grid-template-columns:auto 1fr auto 1fr;gap:8px;padding:5px 0;border-bottom:1px solid var(--line);font-size:15px;align-items:center}
|
||||
.sched li{grid-template-columns:auto 1fr auto}.matches li span:first-child{color:var(--muted);font-size:12px}.matches li span:last-child{text-align:right}
|
||||
.matches li.live,.sched li.live{background:var(--accent-soft)}.won{font-weight:600}.matches li.mine{outline:1px solid var(--sand)}
|
||||
.mine.card{border-color:var(--sand);border-width:2px}.big{font-size:20px}
|
||||
/* bracket */
|
||||
.bracketwrap{overflow-x:auto}.bstage{margin-top:12px}
|
||||
.bracket{display:flex;gap:16px;align-items:stretch;min-width:max-content;padding-bottom:6px}
|
||||
.round{display:flex;flex-direction:column;justify-content:space-around;gap:10px;min-width:190px}
|
||||
.rlbl{font-family:"Barlow Condensed",sans-serif;text-transform:uppercase;letter-spacing:.1em;font-size:12px;color:var(--muted);text-align:center}
|
||||
.bm{border:1px solid var(--line);border-radius:6px;background:var(--bg);font-size:15px}
|
||||
.bm>div{display:flex;justify-content:space-between;padding:5px 10px;gap:8px}.bm>div+div{border-top:1px solid var(--line)}
|
||||
.bm .mlbl{font-family:"Barlow Condensed",sans-serif;text-transform:uppercase;letter-spacing:.08em;font-size:11px;color:var(--muted);padding:3px 10px;justify-content:center}
|
||||
.bm .mlbl.live{background:var(--good);color:#fff}.bm.live{border-color:var(--good)}.bm.mine{border-color:var(--sand);border-width:2px}
|
||||
.toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);background:#0F1418;color:#fff;padding:12px 18px;border-radius:8px;max-width:90vw;box-shadow:0 8px 30px rgba(0,0,0,.3);z-index:9;font-weight:600}
|
||||
.loading{color:var(--muted);padding:24px}
|
||||
/* display (TV) mode */
|
||||
body.display{font-size:22px;background:#0F1418;color:#F2F5F7}
|
||||
body.display .top,body.display .foot{display:none}body.display main{max-width:none;padding:20px 28px}
|
||||
body.display .display-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:10px}body.display h1{font-size:56px}
|
||||
body.display .court .t{font-size:40px}body.display .court .s{font-size:52px}body.display .court{min-height:150px}
|
||||
body.display .card{background:#1A222B;border-color:#2B3640;color:#F2F5F7}body.display td{border-color:#2B3640}body.display .bm{background:#12181F;border-color:#2B3640}
|
||||
body.display .queue{font-size:26px}body.display details{display:none}
|
||||
/* admin */
|
||||
.grid2{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:12px}
|
||||
.inline{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:8px 0}.inline input{width:auto}
|
||||
.phases{display:flex;gap:6px;flex-wrap:wrap}.phases .cur{background:var(--accent);color:var(--accent-ink);border-color:var(--accent)}
|
||||
.scorepad{display:grid;grid-template-columns:1fr auto 1fr;gap:10px;align-items:center;margin:10px 0}
|
||||
.scorepad .team{font-family:"Barlow Condensed",sans-serif;font-size:20px;font-weight:600}
|
||||
.scorepad .pts{font-size:44px;text-align:center}
|
||||
.scorepad .btns{display:flex;gap:6px;justify-content:center}.scorepad .btns button{font-size:24px;padding:6px 18px}
|
||||
.adm-table td form{display:inline}
|
||||
.qr{text-align:center}.qr img{width:min(70vw,360px);image-rendering:pixelated;border:8px solid #fff;border-radius:6px}
|
||||
@media (max-width:640px){h1{font-size:34px}.cols{columns:1}.court .t{font-size:20px}.court .s{font-size:24px}}
|
||||
@media (prefers-reduced-motion:reduce){*{scroll-behavior:auto!important}}
|
||||
@media print{.top,.foot,button{display:none}body{background:#fff;color:#000}}
|
||||
@@ -0,0 +1,138 @@
|
||||
/* Courtside public board. Renders entirely from the state JSON embedded in the page,
|
||||
then keeps it fresh over a WebSocket. No framework, no build step. */
|
||||
(() => {
|
||||
const app = document.getElementById('app');
|
||||
let state = JSON.parse(document.getElementById('state').textContent);
|
||||
const myTeam = app.dataset.team || null;
|
||||
const display = app.dataset.display === '1';
|
||||
const seenAlerts = new Set();
|
||||
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
const name = t => t ? esc(t.name) : '<span class="tbd">TBD</span>';
|
||||
const isMine = m => myTeam && (m.a?.id === myTeam || m.b?.id === myTeam);
|
||||
const phaseLabel = { checkin: 'Registration open', closed: 'Registration closed', live: 'Live', final: 'Final' };
|
||||
const score = m => m.status === 'final' ? m.sets.map(s => `${s[0]}–${s[1]}`).join(', ')
|
||||
: m.status === 'forfeit' ? 'forfeit' : m.live ? `${m.live[0]}–${m.live[1]}` : m.status === 'live' ? '0–0' : '';
|
||||
const rulesLine = r => `${r.teamSize}s · to ${r.pointsTo}, win by ${r.winBy}${r.cap ? `, cap ${r.cap}` : ''}${r.bestOf > 1 ? `, best of ${r.bestOf}` : ''}`;
|
||||
|
||||
function render() {
|
||||
const s = state;
|
||||
const mine = myTeam ? [...s.teams, ...s.withdrawn].find(t => t.id === myTeam) : null;
|
||||
const parts = [];
|
||||
if (!display) parts.push(`<section class="hero compact"><div class="eyebrow">${esc(s.date ?? '')} · <span class="pill ${s.phase}">${phaseLabel[s.phase]}</span></div><h1>${esc(s.name)}</h1><p class="lede">${rulesLine(s.rules)}${s.avgMatchMin ? ` · matches running ~${s.avgMatchMin} min` : ''}</p></section>`);
|
||||
else parts.push(`<div class="display-head"><h1>${esc(s.name)}</h1><span class="pill ${s.phase}">${phaseLabel[s.phase]}</span></div>`);
|
||||
if (s.banner) parts.push(`<div class="banner">${esc(s.banner)}</div>`);
|
||||
if (s.champion) parts.push(`<section class="champion"><div class="eyebrow">Champion</div><h2>${esc(s.champion)}</h2></section>`);
|
||||
if (mine) parts.push(renderMyTeam(mine));
|
||||
if (s.phase === 'live') { parts.push(renderCourts()); parts.push(renderUpNext()); }
|
||||
if (s.phase === 'closed' && !s.pools.length) parts.push(`<section class="card"><h2>Schedule coming</h2><p>Registration is closed with ${s.teams.length} teams. The organizer is generating pools now; this page will update on its own.</p></section>`);
|
||||
if (s.bracket.length) parts.push(renderBracket());
|
||||
if (s.pools.length) parts.push(renderPools());
|
||||
if (!s.pools.length && !s.bracket.length) parts.push(renderRoster());
|
||||
if (s.withdrawn.length && !display) parts.push(`<p class="muted small">Withdrawn: ${s.withdrawn.map(t => esc(t.name)).join(', ')}</p>`);
|
||||
app.innerHTML = parts.join('');
|
||||
notifyMine();
|
||||
}
|
||||
|
||||
function renderMyTeam(t) {
|
||||
const s = state;
|
||||
const my = [...s.pools.flatMap(p => p.matches), ...s.bracket].filter(isMine);
|
||||
const live = my.find(m => m.status === 'live');
|
||||
const next = s.upNext.find(isMine) ?? my.find(m => m.status === 'on_deck' || (m.status === 'pending' && m.a && m.b));
|
||||
const played = my.filter(m => m.status === 'final' || m.status === 'forfeit');
|
||||
const w = played.filter(m => m.winner === t.id).length;
|
||||
let now;
|
||||
if (t.status === 'withdrawn') now = `<p>Your team has withdrawn from the tournament.</p>`;
|
||||
else if (live) now = `<p class="big">You're on <b>Court ${live.court}</b> now vs ${name(live.a?.id === t.id ? live.b : live.a)}</p>`;
|
||||
else if (next) now = `<p class="big">Next: vs ${name(next.a?.id === t.id ? next.b : next.a)}${next.court ? ` on <b>Court ${next.court}</b>` : ''}${next.etaMin != null ? ` <span class="muted">in about ${next.etaMin} min</span>` : ''}</p>`;
|
||||
else if (s.phase === 'final') now = `<p>Tournament over. Thanks for playing.</p>`;
|
||||
else if (s.phase === 'live') now = `<p>No match scheduled yet. Waiting on results from other courts.</p>`;
|
||||
else now = `<p>Waiting for play to start.</p>`;
|
||||
return `<section class="card mine"><div class="eyebrow">Your team</div><h2>${esc(t.name)}</h2>${now}<p class="muted">${played.length ? `Record ${w}–${played.length - w}` : 'No results yet'}${t.poolId ? ` · Pool ${esc(t.poolId)}` : ''}</p>
|
||||
${my.length ? `<ul class="plain sched">${my.map(m => `<li class="${m.status}"><span>${m.stage === 'pool' ? `Pool ${m.round}` : (m.label ?? `Bracket R${m.round}`)}</span><span>vs ${name(m.a?.id === t.id ? m.b : m.a)}</span><span class="mono">${score(m) || (m.court ? `Court ${m.court}` : '')}${m.status === 'final' || m.status === 'forfeit' ? (m.winner === t.id ? ' W' : ' L') : ''}</span></li>`).join('')}</ul>` : ''}
|
||||
${!display && 'Notification' in window && Notification.permission === 'default' ? `<button type="button" class="button" id="notify">Alert me when we're up</button>` : ''}
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function renderCourts() {
|
||||
return `<section class="courts">${state.courts.map(c => {
|
||||
const m = c.match;
|
||||
if (c.status === 'paused') return `<div class="court paused"><div class="lbl">Court ${c.court}</div><div class="teams"><div class="t">Paused</div></div></div>`;
|
||||
if (!m) return `<div class="court idle"><div class="lbl">Court ${c.court}</div><div class="teams"><div class="t muted">Open</div></div></div>`;
|
||||
return `<div class="court ${isMine(m) ? 'mine' : ''}"><div class="lbl">Court ${c.court} · ${m.stage === 'pool' ? `Pool ${esc(m.a && state.teams.find(t => t.id === m.a.id)?.poolId || '')} R${m.round}` : (m.label ?? `Bracket R${m.round}`)}</div>
|
||||
<div class="teams"><div class="t">${name(m.a)}</div><div class="s mono">${score(m) || '0–0'}</div><div class="t">${name(m.b)}</div></div></div>`;
|
||||
}).join('')}</section>`;
|
||||
}
|
||||
|
||||
function renderUpNext() {
|
||||
if (!state.upNext.length) return '';
|
||||
return `<section class="queue"><div class="eyebrow">Up next</div>${state.upNext.map((u, i) => `<div class="qrow ${isMine(u) ? 'mine' : ''}"><span class="k">${i === 0 ? 'On deck' : 'Then'}</span><span>${name(u.a)} vs ${name(u.b)}</span><span class="eta mono">Court ${u.court} · ~${u.etaMin} min</span></div>`).join('')}</section>`;
|
||||
}
|
||||
|
||||
function renderPools() {
|
||||
return `<section class="pools">${state.pools.map(p => `<div class="card pool"><h2>Pool ${esc(p.id)}</h2>
|
||||
<table><thead><tr><th>Team</th><th class="num">W</th><th class="num">L</th><th class="num">Pts</th><th class="num">+/−</th></tr></thead><tbody>
|
||||
${p.standings.map((r, i) => `<tr class="${r.status === 'withdrawn' ? 'withdrawn' : ''} ${r.teamId === myTeam ? 'mine' : ''}"><td>${i + 1}. ${esc(r.name)}${r.decidedBy ? `<span class="tb" title="Tie broken by ${r.decidedBy}">*</span>` : ''}</td><td class="num">${r.w}</td><td class="num">${r.l}</td><td class="num">${r.pf}–${r.pa}</td><td class="num">${r.pointDiff > 0 ? '+' : ''}${r.pointDiff}</td></tr>`).join('')}
|
||||
</tbody></table>
|
||||
<details${display ? '' : ''}><summary>Matches</summary><ul class="plain matches">${p.matches.map(m => `<li class="${m.status} ${isMine(m) ? 'mine' : ''}"><span>R${m.round}</span><span class="${m.winner && m.winner === m.a?.id ? 'won' : ''}">${name(m.a)}</span><span class="mono">${score(m) || (m.court ? `C${m.court}` : '·')}</span><span class="${m.winner && m.winner === m.b?.id ? 'won' : ''}">${name(m.b)}</span></li>`).join('')}</ul></details>
|
||||
</div>`).join('')}</section>`;
|
||||
}
|
||||
|
||||
function renderBracket() {
|
||||
const groups = [['bracket', 'Bracket'], ['losers', 'Losers bracket'], ['final', 'Finals']];
|
||||
return `<section class="card bracketwrap"><h2>${state.bracket.some(m => m.stage === 'losers') ? 'Double elimination' : 'Bracket'}</h2>${groups.map(([stage, title]) => {
|
||||
const ms = state.bracket.filter(m => m.stage === stage && !(m.conditional && m.status !== 'final' && !m.a));
|
||||
if (!ms.length) return '';
|
||||
const rounds = [...new Set(ms.map(m => m.round))].sort((a, b) => a - b);
|
||||
return `<div class="bstage"><h3>${title}</h3><div class="bracket">${rounds.map(r => `<div class="round"><div class="rlbl">${roundName(stage, r, rounds.length, ms)}</div>${ms.filter(m => m.round === r).map(m => `<div class="bm ${m.status} ${isMine(m) ? 'mine' : ''}">${m.label ? `<div class="mlbl">${esc(m.label)}</div>` : ''}<div class="${m.winner && m.winner === m.a?.id ? 'won' : ''}">${name(m.a)}<span class="mono">${m.sets[0] ? m.sets.map(s => s[0]).join(' ') : ''}</span></div><div class="${m.winner && m.winner === m.b?.id ? 'won' : ''}">${name(m.b)}<span class="mono">${m.sets[0] ? m.sets.map(s => s[1]).join(' ') : ''}</span></div>${m.status === 'live' ? `<div class="mlbl live">Court ${m.court}${m.live ? ` · ${m.live[0]}–${m.live[1]}` : ''}</div>` : m.status === 'forfeit' ? '<div class="mlbl">forfeit</div>' : ''}</div>`).join('')}</div>`).join('')}</div></div>`;
|
||||
}).join('')}</section>`;
|
||||
}
|
||||
function roundName(stage, r, total, ms) {
|
||||
if (stage === 'final') return r === 1 ? 'Grand final' : 'Reset';
|
||||
if (stage === 'losers') return `LB round ${r}`;
|
||||
const left = total - r;
|
||||
const inRound = ms.filter(m => m.round === r && m.label !== '3rd place').length;
|
||||
return left === 0 ? 'Final' : left === 1 ? 'Semifinals' : left === 2 ? 'Quarterfinals' : `Round of ${inRound * 2}`;
|
||||
}
|
||||
|
||||
function renderRoster() {
|
||||
return `<section class="card"><h2>Teams (${state.teams.length})</h2><ol class="plain cols">${state.teams.map(t => `<li class="${t.id === myTeam ? 'mine' : ''}">${esc(t.name)} <span class="muted">(${t.players})</span></li>`).join('')}</ol></section>`;
|
||||
}
|
||||
|
||||
// ---- alerts for the team page ----
|
||||
function notifyMine() {
|
||||
if (!myTeam) return;
|
||||
for (const a of state.recentAlerts) {
|
||||
const key = `${a.kind}:${a.match ?? a.at}`;
|
||||
if (seenAlerts.has(key)) continue;
|
||||
seenAlerts.add(key);
|
||||
if (!(a.kind === 'broadcast' || a.teams.includes(myTeam))) continue;
|
||||
if (Date.now() - a.at > 10 * 60 * 1000) continue; // stale on first load
|
||||
toast(a.text);
|
||||
try { navigator.vibrate?.([200, 100, 200]); } catch {}
|
||||
if ('Notification' in window && Notification.permission === 'granted') { try { new Notification('Courtside', { body: a.text, tag: key }); } catch {} }
|
||||
}
|
||||
}
|
||||
function toast(text) {
|
||||
const el = document.createElement('div'); el.className = 'toast'; el.textContent = text;
|
||||
document.body.appendChild(el); setTimeout(() => el.remove(), 8000);
|
||||
}
|
||||
document.addEventListener('click', e => {
|
||||
if (e.target.id === 'notify') Notification.requestPermission().then(render);
|
||||
});
|
||||
// first paint: skip toasts for alerts that already happened
|
||||
for (const a of state.recentAlerts) seenAlerts.add(`${a.kind}:${a.match ?? a.at}`);
|
||||
|
||||
// ---- live updates ----
|
||||
let retry = 1000;
|
||||
function connect() {
|
||||
const ws = new WebSocket(`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/${state.slug}`);
|
||||
ws.onmessage = ev => { state = JSON.parse(ev.data); retry = 1000; render(); };
|
||||
ws.onclose = () => { setTimeout(connect, retry); retry = Math.min(retry * 2, 15000); };
|
||||
ws.onerror = () => ws.close();
|
||||
}
|
||||
render();
|
||||
connect();
|
||||
// belt and braces: a full refresh every 2 minutes in case a proxy drops the socket silently
|
||||
setInterval(() => fetch(`/t/${state.slug}/state.json`).then(r => r.json()).then(s => { state = s; render(); }).catch(() => {}), 120000);
|
||||
if (display) setInterval(() => document.documentElement.scrollTo({ top: (Date.now() / 1000 | 0) % 2 ? 0 : document.body.scrollHeight, behavior: 'smooth' }), 15000);
|
||||
})();
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="12" fill="#1A6FB5"/><circle cx="32" cy="32" r="20" fill="none" stroke="#fff" stroke-width="4"/><path d="M32 12v40M12 32h40" stroke="#fff" stroke-width="3" stroke-dasharray="4 4" fill="none"/></svg>
|
||||
|
After Width: | Height: | Size: 291 B |
@@ -0,0 +1 @@
|
||||
{ "name": "Courtside", "short_name": "Courtside", "start_url": "/", "display": "standalone", "background_color": "#0F1418", "theme_color": "#0F1418", "icons": [{ "src": "/static/favicon.svg", "sizes": "any", "type": "image/svg+xml" }] }
|
||||
@@ -0,0 +1,160 @@
|
||||
import QRCode from 'qrcode';
|
||||
import { Router, HttpError, readBody, send, esc } from './http.js';
|
||||
import { publicState } from './public-state.js';
|
||||
import * as pub from './views/public.js';
|
||||
import * as adm from './views/admin.js';
|
||||
|
||||
export function buildRouter({ registry, auth, store }) {
|
||||
const r = new Router();
|
||||
const origin = req => `${(req.headers['x-forwarded-proto'] ?? 'http').split(',')[0]}://${req.headers['x-forwarded-host'] ?? req.headers.host}`;
|
||||
const item = slug => { const it = registry.get(slug); if (!it) throw new HttpError(404, 'No such tournament'); return it; };
|
||||
const q = req => new URL(req.url, 'http://x').searchParams;
|
||||
|
||||
// ---------- public ----------
|
||||
r.get('/', (req, res) => send.html(res, pub.homePage(registry.list())));
|
||||
r.get('/healthz', (req, res) => send.json(res, { ok: true, tournaments: registry.items.size, uptime: process.uptime() }));
|
||||
|
||||
r.get('/t/:slug', (req, res, { slug }) => {
|
||||
const it = item(slug);
|
||||
const state = publicState(it);
|
||||
if (state.phase === 'checkin') return send.html(res, pub.registerPage(state, { error: q(req).get('error') }));
|
||||
send.html(res, pub.boardPage(state));
|
||||
});
|
||||
r.get('/t/:slug/state.json', (req, res, { slug }) => send.json(res, publicState(item(slug))));
|
||||
r.get('/t/:slug/display', (req, res, { slug }) => send.html(res, pub.boardPage(publicState(item(slug)), { display: true })));
|
||||
r.get('/t/:slug/team/:code', (req, res, { slug, code }) => {
|
||||
const it = item(slug);
|
||||
const team = it.t.teamByCode(code);
|
||||
if (!team) throw new HttpError(404, 'No team with that code');
|
||||
const state = publicState(it);
|
||||
if (state.phase === 'checkin' && q(req).get('new')) return send.html(res, pub.registeredPage(state, team, origin(req)));
|
||||
send.html(res, pub.boardPage(state, { teamId: team.id }));
|
||||
});
|
||||
r.post('/t/:slug/register', async (req, res, { slug }) => {
|
||||
const it = item(slug);
|
||||
const body = await readBody(req);
|
||||
try {
|
||||
const team = registry.mutate(slug, t => t.registerTeam({
|
||||
name: body.name, captain: String(body.captain ?? '').trim().slice(0, 60), phone: String(body.phone ?? '').trim().slice(0, 30),
|
||||
players: body.players, playerNames: String(body.playerNames ?? '').split('\n').map(s => s.trim()).filter(Boolean).slice(0, 20),
|
||||
}));
|
||||
send.redirect(res, `/t/${slug}/team/${team.code}?new=1`);
|
||||
} catch (e) {
|
||||
send.html(res, pub.registerPage(publicState(it), { error: e.message, values: body }), 400);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- organizer ----------
|
||||
const requireAdmin = (req, res) => {
|
||||
const who = auth.identify(req);
|
||||
if (!who) { send.redirect(res, `/admin/login?next=${encodeURIComponent(req.url)}`); return null; }
|
||||
return who;
|
||||
};
|
||||
r.get('/admin/login', (req, res) => send.html(res, adm.loginPage(q(req).get('error'), q(req).get('next') ?? '/admin')));
|
||||
r.post('/admin/login', async (req, res) => {
|
||||
const body = await readBody(req);
|
||||
if (!auth.checkPassword(body.password)) return send.redirect(res, '/admin/login?error=' + encodeURIComponent('Wrong password'));
|
||||
const secure = (req.headers['x-forwarded-proto'] ?? '').includes('https');
|
||||
send.redirect(res, safeNext(body.next), { 'set-cookie': auth.cookie('organizer', secure) });
|
||||
});
|
||||
r.get('/admin/logout', (req, res) => send.redirect(res, '/', { 'set-cookie': auth.clearCookie() }));
|
||||
|
||||
r.get('/admin', (req, res) => { const who = requireAdmin(req, res); if (who) send.html(res, adm.adminHome(registry.list(), who, q(req).get('msg'))); });
|
||||
r.post('/admin/new', async (req, res) => {
|
||||
const who = requireAdmin(req, res); if (!who) return;
|
||||
const b = await readBody(req);
|
||||
const rules = { teamSize: +b.teamSize || 2, pointsTo: +b.pointsTo || 21, winBy: +b.winBy || 2, cap: +b.cap || 0, bestOf: +b.bestOf || 1 };
|
||||
const it = registry.create({ name: b.name, date: b.date, courtCount: +b.courtCount || 2, rules, stages: String(b.stages ?? 'pool,bracket').split(','), notes: b.notes ?? '' });
|
||||
send.redirect(res, `/admin/t/${it.t.slug}?msg=${encodeURIComponent('Created. Print the QR code and open registration when ready.')}`);
|
||||
});
|
||||
|
||||
r.get('/admin/t/:slug', (req, res, { slug }) => {
|
||||
const who = requireAdmin(req, res); if (!who) return;
|
||||
send.html(res, adm.adminTournament(item(slug), who, origin(req), q(req).get('msg'), q(req).get('error'), store.events(slug, 40)));
|
||||
});
|
||||
r.get('/admin/t/:slug/qr', async (req, res, { slug }) => {
|
||||
const who = requireAdmin(req, res); if (!who) return;
|
||||
const it = item(slug);
|
||||
const dataUrl = await QRCode.toDataURL(`${origin(req)}/t/${slug}`, { width: 720, margin: 2, errorCorrectionLevel: 'M' });
|
||||
send.html(res, adm.qrPage(it.t, origin(req), dataUrl, who));
|
||||
});
|
||||
r.get('/admin/t/:slug/qr.png', async (req, res, { slug }) => {
|
||||
const who = requireAdmin(req, res); if (!who) return;
|
||||
item(slug);
|
||||
const buf = await QRCode.toBuffer(`${origin(req)}/t/${slug}`, { width: 1200, margin: 2 });
|
||||
res.writeHead(200, { 'content-type': 'image/png' }); res.end(buf);
|
||||
});
|
||||
|
||||
// Every organizer action: POST, mutate, redirect back with a message (or JSON for fetch callers).
|
||||
const action = (path, fn) => r.post(`/admin/t/:slug${path}`, async (req, res, params) => {
|
||||
const who = requireAdmin(req, res); if (!who) return;
|
||||
const body = await readBody(req);
|
||||
const wantsJson = (req.headers['content-type'] ?? '').includes('json');
|
||||
try {
|
||||
const msg = registry.mutate(params.slug, (t, engine) => fn({ t, engine, body, who, params })) ?? 'Done';
|
||||
if (wantsJson) send.json(res, { ok: true, msg }); else send.redirect(res, `/admin/t/${params.slug}?msg=${encodeURIComponent(msg)}`);
|
||||
} catch (e) {
|
||||
if (e instanceof HttpError) throw e;
|
||||
if (wantsJson) send.json(res, { ok: false, error: e.message }, 400); else send.redirect(res, `/admin/t/${params.slug}?error=${encodeURIComponent(e.message)}`);
|
||||
}
|
||||
});
|
||||
|
||||
action('/phase', ({ t, engine, body }) => {
|
||||
const p = body.phase;
|
||||
if (p === 'checkin') { t.reopenRegistration(); return 'Registration reopened'; }
|
||||
if (p === 'closed') { if (t.phase === 'live') throw new Error('Already live; use Final or reopen is not possible'); t.closeRegistration(); return 'Registration closed'; }
|
||||
if (p === 'live') { if (!t.matches.length) throw new Error('Generate pools or a bracket first'); engine.start(); return 'Live. Courts assigned.'; }
|
||||
if (p === 'final') { t.phase = 'final'; t.emit({ type: 'phase', phase: 'final', champion: t.champion() }); return 'Marked final'; }
|
||||
throw new Error('Unknown phase');
|
||||
});
|
||||
action('/banner', ({ t, body }) => { t.banner = String(body.banner ?? '').trim().slice(0, 200) || null; t.emit({ type: 'banner', banner: t.banner }); return t.banner ? 'Banner set' : 'Banner cleared'; });
|
||||
action('/broadcast', ({ t, engine, body }) => { const text = String(body.text ?? '').trim().slice(0, 300); if (!text) throw new Error('Nothing to send'); engine.broadcast(text); return 'Broadcast sent'; });
|
||||
action('/generate', ({ t, body }) => {
|
||||
if (body.what === 'pools') { t.generatePools(+body.poolSize || 4); return `Pools generated: ${t.pools.map(p => `${p.id} (${p.teams.length})`).join(', ')}`; }
|
||||
const seeded = t.pools.length ? t.advanceFromPools({ perPool: +body.perPool || 2, wildcards: +body.wildcards || 0 }) : t.activeTeams().map(x => x.id);
|
||||
if (seeded.length < 2) throw new Error('Need at least two teams');
|
||||
const res = t.generateBracket(seeded, { type: body.type === 'double' ? 'double' : 'single', thirdPlace: !!body.thirdPlace });
|
||||
return `${body.type === 'double' ? 'Double' : 'Single'} elimination bracket of ${res.size} generated with ${seeded.length} teams`;
|
||||
});
|
||||
action('/regenerate', ({ t }) => { if (t.matches.some(m => ['final', 'forfeit'].includes(m.status))) throw new Error('Scores already entered'); t.matches = []; t.pools = []; for (const x of t.teams.values()) x.poolId = null; if (t.phase === 'live') t.phase = 'closed'; t.emit({ type: 'play_cleared' }); return 'Generated play cleared'; });
|
||||
action('/score', ({ t, engine, body, who }) => {
|
||||
const sets = parseSets(body.sets);
|
||||
const m = t.match(body.match); if (!m) throw new Error('No such match');
|
||||
const wasLive = m.status === 'live';
|
||||
if (wasLive) engine.recordResult(m.id, sets, { actor: who });
|
||||
else { t.recordResult(m.id, sets, { actor: who }); m.court = null; }
|
||||
delete m.live;
|
||||
return `Saved ${t.teamName(m.teamA)} vs ${t.teamName(m.teamB)}: ${sets.map(s => s.join('-')).join(', ')}`;
|
||||
});
|
||||
action('/live', ({ t, body }) => { const m = t.match(body.match); if (!m || m.status !== 'live') throw new Error('Match is not live'); m.live = [Math.max(0, +body.a | 0), Math.max(0, +body.b | 0)]; return 'ok'; });
|
||||
action('/forfeit', ({ t, engine, body, who }) => { const m = t.match(body.match); if (!m) throw new Error('No such match'); if (m.status === 'live') engine.recordResult(m.id, [], { actor: who, forfeit: body.team }); else t.recordResult(m.id, [], { actor: who, forfeit: body.team }); return `${t.teamName(body.team)} forfeits`; });
|
||||
action('/swap', ({ engine, body }) => { engine.swapToCourt(body.match, +body.court); return `Moved to court ${body.court}`; });
|
||||
action('/pushback', ({ engine, body }) => { engine.pushBack(body.match, 2); return 'Pushed back'; });
|
||||
action('/court', ({ engine, body }) => { if (body.op === 'pause') engine.pauseCourt(+body.court, String(body.reason ?? '').slice(0, 60)); else engine.resumeCourt(+body.court); return `Court ${body.court} ${body.op}d`; });
|
||||
action('/team', ({ t, body }) => {
|
||||
if (body.op === 'add') { const team = t.registerTeam({ name: body.name, captain: body.captain, players: body.players }, { force: true }); return `Added ${team.name} (code ${team.code})`; }
|
||||
if (body.op === 'remove') { t.removeTeam(body.id); return 'Team removed'; }
|
||||
if (body.op === 'seed') { const list = t.activeTeams().filter(x => x.id !== body.id); list.splice(Math.max(0, +body.seed - 1), 0, t.teams.get(body.id)); t.setSeeds(list.map(x => x.id)); return 'Seeds updated'; }
|
||||
t.updateTeam(body.id, { name: body.name, captain: body.captain, players: body.players }); return 'Team saved';
|
||||
});
|
||||
action('/withdraw', ({ t, engine, body, who }) => { const team = t.teams.get(body.id); if (!team) throw new Error('No such team'); if (t.phase === 'live') engine.withdrawTeam(body.id, { mode: body.mode, actor: who }); else t.withdrawTeam(body.id, { mode: body.mode, actor: who }); return `${team.name} withdrawn (${body.mode})`; });
|
||||
r.post('/admin/t/:slug/delete', async (req, res, { slug }) => {
|
||||
const who = requireAdmin(req, res); if (!who) return;
|
||||
const body = await readBody(req);
|
||||
if (body.confirm !== 'yes') return send.redirect(res, `/admin/t/${slug}?error=Not+confirmed`);
|
||||
item(slug); registry.remove(slug);
|
||||
send.redirect(res, '/admin?msg=' + encodeURIComponent('Tournament deleted'));
|
||||
});
|
||||
|
||||
return r;
|
||||
}
|
||||
|
||||
function parseSets(str) {
|
||||
const sets = String(str ?? '').split(/[,;]/).map(s => s.trim()).filter(Boolean).map(s => {
|
||||
const m = s.match(/^(\d+)\s*[-–:]\s*(\d+)$/); if (!m) throw new Error(`Can't read set "${s}" — use 21-18`);
|
||||
return [+m[1], +m[2]];
|
||||
});
|
||||
if (!sets.length) throw new Error('Enter at least one set, like 21-18');
|
||||
return sets;
|
||||
}
|
||||
const safeNext = n => (typeof n === 'string' && n.startsWith('/') && !n.startsWith('//')) ? n : '/admin';
|
||||
@@ -0,0 +1,73 @@
|
||||
// In-memory registry of tournaments. Every mutation goes through `mutate()`, which
|
||||
// persists the new state and notifies subscribers (the WebSocket layer) with the public view.
|
||||
import { Tournament } from '../engine/tournament.js';
|
||||
import { CourtEngine } from '../engine/courts.js';
|
||||
import { publicState } from './public-state.js';
|
||||
|
||||
const slugify = s => String(s).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40) || 'tournament';
|
||||
|
||||
export class Registry {
|
||||
constructor(store) {
|
||||
this.store = store;
|
||||
this.items = new Map(); // slug -> { t, engine }
|
||||
this.subscribers = new Map(); // slug -> Set<fn(state)>
|
||||
for (const row of store.loadAll()) {
|
||||
const t = Tournament.fromJSON(row.tournament);
|
||||
t.slug = row.slug;
|
||||
const engine = row.engine ? CourtEngine.fromJSON(t, row.engine) : new CourtEngine(t, { courtCount: t.courtCount });
|
||||
this._attach(row.slug, t, engine);
|
||||
}
|
||||
}
|
||||
|
||||
list() { return [...this.items.entries()].map(([slug, { t }]) => ({ slug, name: t.name, date: t.date, phase: t.phase, teams: t.activeTeams().length, createdAt: t.createdAt })); }
|
||||
get(slug) { return this.items.get(slug) ?? null; }
|
||||
|
||||
create({ name, date, courtCount = 2, rules = {}, stages = ['pool', 'bracket'], notes = '', slug: wanted = null }) {
|
||||
const base = slugify(wanted ?? name);
|
||||
let slug = base, n = 2;
|
||||
while (this.items.has(slug)) slug = `${base}-${n++}`;
|
||||
const t = new Tournament({ name, courtCount: Number(courtCount), rules, stages });
|
||||
t.slug = slug; t.date = date || null; t.notes = notes; t.createdAt = Date.now();
|
||||
const engine = new CourtEngine(t, { courtCount: t.courtCount });
|
||||
this._attach(slug, t, engine);
|
||||
this.persist(slug);
|
||||
return this.items.get(slug);
|
||||
}
|
||||
|
||||
remove(slug) { this.items.delete(slug); this.subscribers.delete(slug); this.store.remove(slug); }
|
||||
|
||||
_attach(slug, t, engine) {
|
||||
this.items.set(slug, { t, engine });
|
||||
t.on(evt => { try { this.store.logEvent(slug, evt); } catch { /* audit log is best-effort */ } });
|
||||
}
|
||||
|
||||
/** Run fn against a tournament, then persist and broadcast. Throws propagate to the caller. */
|
||||
mutate(slug, fn) {
|
||||
const item = this.get(slug);
|
||||
if (!item) throw new Error('No such tournament');
|
||||
const result = fn(item.t, item.engine);
|
||||
// keep the queue/on-deck fresh after any change during live play
|
||||
if (item.t.phase === 'live') item.engine.tick();
|
||||
this.persist(slug);
|
||||
this.notify(slug);
|
||||
return result;
|
||||
}
|
||||
|
||||
persist(slug) {
|
||||
const { t, engine } = this.get(slug);
|
||||
this.store.save(slug, t.toJSON(), engine.toJSON());
|
||||
}
|
||||
|
||||
subscribe(slug, fn) {
|
||||
if (!this.subscribers.has(slug)) this.subscribers.set(slug, new Set());
|
||||
this.subscribers.get(slug).add(fn);
|
||||
return () => this.subscribers.get(slug)?.delete(fn);
|
||||
}
|
||||
|
||||
notify(slug) {
|
||||
const subs = this.subscribers.get(slug);
|
||||
if (!subs?.size) return;
|
||||
const state = publicState(this.get(slug));
|
||||
for (const fn of subs) { try { fn(state); } catch { /* a dead socket must not break the others */ } }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// SQLite persistence using Node's built-in node:sqlite (Node 22.13+). No native modules.
|
||||
// Each tournament is stored as two JSON documents (tournament state + court engine state),
|
||||
// rewritten on every mutation. Data is tiny (a few hundred KB for a big day), so this is
|
||||
// simpler and more robust than a normalized schema, and a backup is one file copy.
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
|
||||
export class Store {
|
||||
constructor(path) {
|
||||
if (path !== ':memory:') mkdirSync(dirname(path), { recursive: true });
|
||||
this.db = new DatabaseSync(path);
|
||||
this.db.exec(`
|
||||
PRAGMA journal_mode = WAL;
|
||||
CREATE TABLE IF NOT EXISTS tournaments (
|
||||
slug TEXT PRIMARY KEY,
|
||||
tournament_json TEXT NOT NULL,
|
||||
engine_json TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
slug TEXT NOT NULL,
|
||||
ts INTEGER NOT NULL,
|
||||
actor TEXT,
|
||||
type TEXT NOT NULL,
|
||||
json TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS events_slug ON events(slug, id);
|
||||
`);
|
||||
this.stmts = {
|
||||
all: this.db.prepare('SELECT slug, tournament_json, engine_json FROM tournaments ORDER BY created_at DESC'),
|
||||
upsert: this.db.prepare(`INSERT INTO tournaments (slug, tournament_json, engine_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(slug) DO UPDATE SET tournament_json = excluded.tournament_json, engine_json = excluded.engine_json, updated_at = excluded.updated_at`),
|
||||
del: this.db.prepare('DELETE FROM tournaments WHERE slug = ?'),
|
||||
event: this.db.prepare('INSERT INTO events (slug, ts, actor, type, json) VALUES (?, ?, ?, ?, ?)'),
|
||||
events: this.db.prepare('SELECT ts, actor, type, json FROM events WHERE slug = ? ORDER BY id DESC LIMIT ?'),
|
||||
};
|
||||
}
|
||||
|
||||
loadAll() {
|
||||
return this.stmts.all.all().map(r => ({ slug: r.slug, tournament: JSON.parse(r.tournament_json), engine: r.engine_json ? JSON.parse(r.engine_json) : null }));
|
||||
}
|
||||
|
||||
save(slug, tournamentJson, engineJson) {
|
||||
const now = Date.now();
|
||||
this.stmts.upsert.run(slug, JSON.stringify(tournamentJson), engineJson ? JSON.stringify(engineJson) : null, tournamentJson.createdAt ?? now, now);
|
||||
}
|
||||
|
||||
remove(slug) { this.stmts.del.run(slug); }
|
||||
|
||||
logEvent(slug, evt) { this.stmts.event.run(slug, Date.now(), evt.actor ?? null, evt.type, JSON.stringify(evt)); }
|
||||
|
||||
events(slug, limit = 200) { return this.stmts.events.all(slug, limit).map(r => ({ ts: r.ts, ...JSON.parse(r.json) })); }
|
||||
|
||||
close() { this.db.close(); }
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { esc } from '../http.js';
|
||||
import { layout, flash } from './layout.js';
|
||||
import { phaseLabel } from './public.js';
|
||||
|
||||
export function loginPage(error = null, next = '/admin') {
|
||||
return layout({ title: 'Organizer sign in', body: `
|
||||
<section class="card form" style="max-width:420px;margin:40px auto">
|
||||
<h1>Organizer desk</h1>${flash(error, 'error')}
|
||||
<form method="post" action="/admin/login"><input type="hidden" name="next" value="${esc(next)}">
|
||||
<label>Password <input type="password" name="password" required autofocus></label>
|
||||
<button class="primary" type="submit">Sign in</button></form>
|
||||
</section>` });
|
||||
}
|
||||
|
||||
export function adminHome(list, who, msg) {
|
||||
return layout({ title: 'Desk', admin: who, body: `
|
||||
<h1>Organizer desk</h1>${flash(msg)}
|
||||
<div class="grid2">
|
||||
<section class="card"><h2>Tournaments</h2>${list.length ? `<ul class="plain">${list.map(x => `<li><a href="/admin/t/${esc(x.slug)}"><b>${esc(x.name)}</b></a> <span class="pill ${esc(x.phase)}">${esc(phaseLabel(x.phase))}</span> <span class="muted small">${esc(x.date ?? '')} · ${x.teams} teams</span></li>`).join('')}</ul>` : '<p class="muted">None yet.</p>'}</section>
|
||||
<section class="card form"><h2>New tournament</h2>
|
||||
<form method="post" action="/admin/new">
|
||||
<label>Name <input name="name" required maxlength="60" placeholder="Labor Day 2s"></label>
|
||||
<label>Date <input name="date" type="date"></label>
|
||||
<div class="inline"><label>Courts <select name="courtCount"><option>1</option><option selected>2</option><option>3</option><option>4</option></select></label>
|
||||
<label>Team size <select name="teamSize"><option value="2">2s</option><option value="3">3s</option><option value="4">4s</option><option value="6">6s</option></select></label></div>
|
||||
<div class="inline"><label>Points to <input name="pointsTo" type="number" value="21" min="5" max="50"></label>
|
||||
<label>Win by <input name="winBy" type="number" value="2" min="1" max="5"></label>
|
||||
<label>Cap <input name="cap" type="number" value="25" min="0" max="60" title="0 = no cap"></label>
|
||||
<label>Best of <select name="bestOf"><option>1</option><option>3</option></select></label></div>
|
||||
<label>Stages <select name="stages"><option value="pool,bracket">Pools, then bracket</option><option value="pool">Pools only</option><option value="bracket">Bracket only</option></select></label>
|
||||
<label>Notes for players <textarea name="notes" rows="2" placeholder="Bring water. Rec division starts at 10."></textarea></label>
|
||||
<button class="primary" type="submit">Create</button>
|
||||
</form></section>
|
||||
</div>` });
|
||||
}
|
||||
|
||||
export function adminTournament({ t, engine }, who, origin, msg, err, events) {
|
||||
const s = t.phase;
|
||||
const teams = [...t.teams.values()].sort((a, b) => a.seed - b.seed);
|
||||
const live = engine.courts.map(c => ({ c, m: c.matchId ? t.match(c.matchId) : null }));
|
||||
const queue = s === 'live' ? engine.queue().slice(0, 8) : [];
|
||||
const generated = t.matches.length > 0;
|
||||
const poolsDone = t.pools.length && t.matches.filter(m => m.stage === 'pool').every(m => ['final', 'forfeit', 'void'].includes(m.status));
|
||||
const hasBracket = t.matches.some(m => m.stage !== 'pool');
|
||||
const act = (path, label, cls = '', extra = '') => `<form method="post" action="/admin/t/${esc(t.slug)}/${path}">${extra}<button class="small ${cls}" type="submit">${label}</button></form>`;
|
||||
|
||||
const body = `
|
||||
<div class="eyebrow"><a href="/admin">Desk</a> · ${esc(t.date ?? '')}</div>
|
||||
<h1>${esc(t.name)} <span class="pill ${esc(s)}">${esc(phaseLabel(s))}</span></h1>
|
||||
<p class="muted">${t.rules.teamSize}s · to ${t.rules.pointsTo}, win by ${t.rules.winBy}${t.rules.cap ? `, cap ${t.rules.cap}` : ''}, best of ${t.rules.bestOf} · ${t.courtCount} court${t.courtCount > 1 ? 's' : ''} · <a href="/t/${esc(t.slug)}" target="_blank">public page</a> · <a href="/t/${esc(t.slug)}/display" target="_blank">TV display</a> · <a href="/admin/t/${esc(t.slug)}/qr">QR code</a></p>
|
||||
${flash(msg)}${flash(err, 'error')}
|
||||
|
||||
<section class="card"><h2>Phase</h2>
|
||||
<div class="phases">
|
||||
${act('phase', 'Check-in (registration open)', s === 'checkin' ? 'cur' : '', '<input type="hidden" name="phase" value="checkin">')}
|
||||
${act('phase', 'Close registration', s === 'closed' ? 'cur' : '', '<input type="hidden" name="phase" value="closed">')}
|
||||
${act('phase', 'Go live', s === 'live' ? 'cur' : '', '<input type="hidden" name="phase" value="live">')}
|
||||
${act('phase', 'Final', s === 'final' ? 'cur' : '', '<input type="hidden" name="phase" value="final">')}
|
||||
</div>
|
||||
<p class="muted small">The public QR page follows this. Going live starts assigning courts immediately.</p>
|
||||
<form method="post" action="/admin/t/${esc(t.slug)}/banner" class="inline"><input name="banner" placeholder="Banner on every public page (leave empty to clear)" value="${esc(t.banner ?? '')}" style="flex:1;min-width:240px"><button class="small" type="submit">Set banner</button></form>
|
||||
<form method="post" action="/admin/t/${esc(t.slug)}/broadcast" class="inline"><input name="text" placeholder="One-time announcement to every team page" style="flex:1;min-width:240px"><button class="small" type="submit">Broadcast</button></form>
|
||||
</section>
|
||||
|
||||
${s === 'live' ? `
|
||||
<section class="card"><h2>Courts</h2>
|
||||
<div class="grid2">${live.map(({ c, m }) => `
|
||||
<div class="card" style="margin:0">
|
||||
<div class="inline"><b>Court ${c.number}</b> <span class="pill">${esc(c.status)}${c.pauseReason ? ` · ${esc(c.pauseReason)}` : ''}</span>
|
||||
${c.status === 'open' ? act('court', 'Pause', '', `<input type="hidden" name="court" value="${c.number}"><input type="hidden" name="op" value="pause"><input name="reason" placeholder="reason" style="width:110px">`) : act('court', 'Resume', 'primary', `<input type="hidden" name="court" value="${c.number}"><input type="hidden" name="op" value="resume">`)}
|
||||
</div>
|
||||
${m ? scorePad(t, m) : '<p class="muted">No match assigned.</p>'}
|
||||
</div>`).join('')}</div>
|
||||
</section>
|
||||
<section class="card"><h2>Queue</h2>
|
||||
${queue.length ? `<table class="adm-table"><tr><th>#</th><th>Match</th><th>Predicted</th><th></th></tr>${queue.map((q, i) => `<tr><td>${i + 1}</td><td>${esc(t.teamName(q.match.teamA))} vs ${esc(t.teamName(q.match.teamB))} <span class="muted small">${q.match.stage === 'pool' ? `Pool ${esc(q.match.poolId)} R${q.match.round}` : esc(q.match.label ?? `Bracket R${q.match.round}`)}</span></td><td class="mono">C${q.court} ~${Math.round(q.eta / 60000)}m</td><td>${act('pushback', 'Push back', '', `<input type="hidden" name="match" value="${esc(q.match.id)}">`)}</td></tr>`).join('')}</table>` : '<p class="muted">Nothing waiting.</p>'}
|
||||
</section>` : ''}
|
||||
|
||||
<section class="card"><h2>Play</h2>
|
||||
${!generated ? `
|
||||
<form method="post" action="/admin/t/${esc(t.slug)}/generate" class="inline">
|
||||
${t.stages.includes('pool') ? `<label>Pool size <select name="poolSize"><option>3</option><option selected>4</option><option>5</option><option>6</option></select></label><button class="primary small" type="submit" name="what" value="pools">Generate pools</button>` : ''}
|
||||
${!t.stages.includes('pool') ? `<label>Bracket <select name="type"><option value="single">Single elimination</option><option value="double">Double elimination</option></select></label><label><input type="checkbox" name="thirdPlace" value="1"> 3rd place</label><button class="primary small" type="submit" name="what" value="bracket">Generate bracket</button>` : ''}
|
||||
</form>
|
||||
<p class="muted small">Uses current seeds (${t.activeTeams().length} active teams). Regenerating is allowed until the first score is entered.</p>` : ''}
|
||||
${t.pools.length ? `<p>Pools: ${t.pools.map(p => `<b>${esc(p.id)}</b> (${p.teams.map(id => esc(t.teamName(id))).join(', ')})`).join(' · ')}</p>` : ''}
|
||||
${generated && !t.matches.some(m => ['final', 'forfeit'].includes(m.status)) ? act('regenerate', 'Clear generated play', 'danger') : ''}
|
||||
${t.pools.length && !hasBracket && t.stages.includes('bracket') ? `
|
||||
<form method="post" action="/admin/t/${esc(t.slug)}/generate" class="inline">
|
||||
<label>Advance per pool <input name="perPool" type="number" value="2" min="1" max="6" style="width:60px"></label>
|
||||
<label>Wildcards <input name="wildcards" type="number" value="${Math.max(0, (1 << Math.ceil(Math.log2(Math.max(2, t.pools.length * 2)))) - t.pools.length * 2)}" min="0" max="8" style="width:60px"></label>
|
||||
<label>Bracket <select name="type"><option value="single">Single elimination</option><option value="double">Double elimination</option></select></label>
|
||||
<label><input type="checkbox" name="thirdPlace" value="1" checked> 3rd place</label>
|
||||
<button class="primary small" type="submit" name="what" value="bracket">Generate bracket${poolsDone ? '' : ' (pools not finished)'}</button>
|
||||
</form>` : ''}
|
||||
${generated ? `<details><summary>All matches (${t.matches.length}) — enter or correct any score</summary><table class="adm-table"><tr><th>ID</th><th>Stage</th><th>Match</th><th>Status</th><th>Score</th><th></th></tr>
|
||||
${t.matches.filter(m => !m.conditional || m.teamA).map(m => `<tr><td class="mono">${esc(m.id)}</td><td>${m.stage === 'pool' ? `Pool ${esc(m.poolId)} R${m.round}` : esc(m.label ?? `${m.stage} R${m.round}`)}</td><td>${esc(t.teamName(m.teamA))} vs ${esc(t.teamName(m.teamB))}</td><td>${esc(m.status)}${m.court ? ` C${m.court}` : ''}</td><td class="mono">${m.sets.map(x => x.join('-')).join(', ')}</td>
|
||||
<td>${m.teamA && m.teamB && !['void', 'bye'].includes(m.status) ? `<form method="post" action="/admin/t/${esc(t.slug)}/score" class="inline"><input type="hidden" name="match" value="${esc(m.id)}"><input name="sets" placeholder="21-18" value="${esc(m.sets.map(x => x.join('-')).join(', '))}" style="width:110px"><button class="small" type="submit">Save</button></form>` : ''}</td></tr>`).join('')}</table></details>` : ''}
|
||||
</section>
|
||||
|
||||
<section class="card"><h2>Teams (${t.activeTeams().length} active)</h2>
|
||||
<table class="adm-table"><tr><th>Seed</th><th>Team</th><th>Captain</th><th>Players</th><th>Code</th><th></th></tr>
|
||||
${teams.map(x => `<tr class="${x.status === 'withdrawn' ? 'withdrawn' : ''}">
|
||||
<td><form method="post" action="/admin/t/${esc(t.slug)}/team" class="inline"><input type="hidden" name="id" value="${esc(x.id)}"><input type="hidden" name="op" value="seed"><input name="seed" type="number" value="${x.seed}" min="1" style="width:56px" ${generated ? 'disabled' : ''}>${generated ? '' : '<button class="small" type="submit">Set</button>'}</form></td>
|
||||
<td><form method="post" action="/admin/t/${esc(t.slug)}/team" class="inline"><input type="hidden" name="id" value="${esc(x.id)}"><input type="hidden" name="op" value="edit"><input name="name" value="${esc(x.name)}" maxlength="40" style="width:150px"></td>
|
||||
<td><input name="captain" value="${esc(x.captain ?? '')}" style="width:120px"> <span class="muted small">${esc(x.phone ?? '')}</span></td>
|
||||
<td><input name="players" type="number" value="${x.players}" min="1" style="width:56px"> <button class="small" type="submit">Save</button></form></td>
|
||||
<td class="mono"><a href="/t/${esc(t.slug)}/team/${esc(x.code)}" target="_blank">${esc(x.code)}</a></td>
|
||||
<td>${x.status === 'withdrawn' ? `withdrawn (${esc(x.withdrawalMode)})` : generated
|
||||
? `<form method="post" action="/admin/t/${esc(t.slug)}/withdraw" class="inline"><input type="hidden" name="id" value="${esc(x.id)}"><select name="mode"><option value="forfeit">forfeit remaining</option><option value="void">void all games</option></select><button class="small danger" type="submit">Withdraw</button></form>`
|
||||
: act('team', 'Remove', 'danger', `<input type="hidden" name="id" value="${esc(x.id)}"><input type="hidden" name="op" value="remove">`)}</td>
|
||||
</tr>`).join('')}</table>
|
||||
${!generated ? `<form method="post" action="/admin/t/${esc(t.slug)}/team" class="inline"><input type="hidden" name="op" value="add"><input name="name" placeholder="Walk-up team name" required maxlength="40"><input name="captain" placeholder="captain"><input name="players" type="number" value="${t.rules.teamSize}" min="1" style="width:64px"><button class="small primary" type="submit">Add team</button></form>` : '<p class="muted small">Play is generated: use Withdraw for teams that leave. To add a team now, clear generated play first.</p>'}
|
||||
</section>
|
||||
|
||||
<section class="card"><h2>Recent activity</h2><ul class="plain small">${events.slice(0, 40).map(e => `<li><span class="mono muted">${new Date(e.ts).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}</span> ${esc(describe(e, t))}</li>`).join('')}</ul></section>
|
||||
<section class="card"><h2>Danger zone</h2>${act('delete', 'Delete this tournament', 'danger', `<label class="small"><input type="checkbox" name="confirm" value="yes" required> I understand this cannot be undone</label> `)}</section>`;
|
||||
return layout({ title: `${t.name} desk`, admin: who, body, script: '/static/admin.js' });
|
||||
}
|
||||
|
||||
function scorePad(t, m) {
|
||||
const live = m.live ?? [0, 0];
|
||||
return `<div class="scorepad" data-match="${esc(m.id)}">
|
||||
<div class="team">${esc(t.teamName(m.teamA))}</div><div class="muted small">${m.stage === 'pool' ? `Pool ${esc(m.poolId)} R${m.round}` : esc(m.label ?? `Bracket R${m.round}`)}</div><div class="team" style="text-align:right">${esc(t.teamName(m.teamB))}</div>
|
||||
<div class="pts mono" data-side="a">${live[0]}</div><div class="muted small" style="text-align:center">to ${t.rules.pointsTo}</div><div class="pts mono" data-side="b">${live[1]}</div>
|
||||
<div class="btns"><button type="button" data-op="a-">−</button><button type="button" data-op="a+">+</button></div><div></div><div class="btns"><button type="button" data-op="b-">−</button><button type="button" data-op="b+">+</button></div>
|
||||
</div>
|
||||
<div class="inline"><button type="button" class="primary" data-final>Mark final</button>
|
||||
<form method="post" action="/admin/t/${esc(t.slug)}/score" class="inline"><input type="hidden" name="match" value="${esc(m.id)}"><input name="sets" placeholder="21-18, 19-21, 15-9" style="width:150px"><button class="small" type="submit">Save sets</button></form>
|
||||
<form method="post" action="/admin/t/${esc(t.slug)}/forfeit" class="inline"><input type="hidden" name="match" value="${esc(m.id)}"><select name="team"><option value="${esc(m.teamA)}">${esc(t.teamName(m.teamA))}</option><option value="${esc(m.teamB)}">${esc(t.teamName(m.teamB))}</option></select><button class="small danger" type="submit">Forfeits</button></form>
|
||||
${t.courtCount > 1 ? `<form method="post" action="/admin/t/${esc(t.slug)}/swap" class="inline"><input type="hidden" name="match" value="${esc(m.id)}"><select name="court">${Array.from({ length: t.courtCount }, (_, i) => i + 1).filter(n => n !== m.court).map(n => `<option>${n}</option>`).join('')}</select><button class="small" type="submit">Move to court</button></form>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function describe(e, t) {
|
||||
const n = id => t.teamName(id);
|
||||
switch (e.type) {
|
||||
case 'team_registered': return `Registered: ${e.name}`;
|
||||
case 'team_updated': return `Edited team: ${e.name}`;
|
||||
case 'team_removed': return `Removed a team`;
|
||||
case 'team_withdrawn': return `Withdrawn: ${e.name} (${e.mode})`;
|
||||
case 'phase': return `Phase → ${e.phase}${e.champion ? ` · champion ${e.champion}` : ''}`;
|
||||
case 'pools_generated': return `Pools generated: ${e.pools.map(p => `${p.id}(${p.teams.length})`).join(' ')}`;
|
||||
case 'bracket_generated': return `${e.bracket} bracket of ${e.size} generated`;
|
||||
case 'match_started': return `Court ${e.court}: ${e.a} vs ${e.b}`;
|
||||
case 'result': { const m = t.match(e.match); return `${m ? `${n(m.teamA)} vs ${n(m.teamB)}` : e.match} → ${n(e.winner)} ${e.score}${e.actor ? ` (${e.actor})` : ''}`; }
|
||||
case 'alert': return `Alert: ${e.text}`;
|
||||
case 'court_paused': return `Court ${e.court} paused${e.reason ? `: ${e.reason}` : ''}`;
|
||||
case 'court_resumed': return `Court ${e.court} resumed`;
|
||||
case 'court_swapped': return `Match moved from court ${e.from} to ${e.to}`;
|
||||
case 'match_pushed_back': return `Match pushed back`;
|
||||
default: return e.type;
|
||||
}
|
||||
}
|
||||
|
||||
export function qrPage(t, origin, dataUrl, who) {
|
||||
const url = `${origin}/t/${t.slug}`;
|
||||
return layout({ title: `${t.name} QR`, admin: who, body: `
|
||||
<section class="card qr"><h1>${esc(t.name)}</h1><p class="lede">Scan to register, then to follow the tournament live.</p><img src="${dataUrl}" alt="QR code for ${esc(url)}"><p class="mono">${esc(url)}</p>
|
||||
<p><button onclick="print()">Print</button> <a class="button" href="/admin/t/${esc(t.slug)}">Back to desk</a></p></section>` });
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { esc } from '../http.js';
|
||||
|
||||
export function layout({ title, body, state = null, script = null, bodyClass = '', admin = null, nav = '' }) {
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#0F1418">
|
||||
<title>${esc(title)} · Courtside</title>
|
||||
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml">
|
||||
<link rel="manifest" href="/static/manifest.webmanifest">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@500;600;700&family=Source+Sans+3:wght@400;600&family=JetBrains+Mono:wght@500&display=swap">
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
</head>
|
||||
<body class="${esc(bodyClass)}">
|
||||
<header class="top">
|
||||
<a class="brand" href="/">Courtside</a>
|
||||
<nav>${nav}${admin ? `<a href="/admin">Desk</a><a href="/admin/logout">Sign out</a>` : ''}</nav>
|
||||
</header>
|
||||
<main>
|
||||
${body}
|
||||
</main>
|
||||
<footer class="foot">Courtside · self-hosted tournament desk · <a href="https://gitea.cloudfreeiot.com/bkvargyas/courtside">source</a></footer>
|
||||
${state ? `<script id="state" type="application/json">${JSON.stringify(state).replace(/</g, '\\u003c')}</script>` : ''}
|
||||
${script ? `<script src="${esc(script)}" defer></script>` : ''}
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
export const flash = (msg, kind = 'ok') => msg ? `<div class="flash ${kind}">${esc(msg)}</div>` : '';
|
||||
@@ -0,0 +1,61 @@
|
||||
import { esc } from '../http.js';
|
||||
import { layout, flash } from './layout.js';
|
||||
|
||||
export function homePage(list) {
|
||||
const rows = list.length
|
||||
? list.map(x => `<a class="row" href="/t/${esc(x.slug)}"><span class="pill ${esc(x.phase)}">${esc(phaseLabel(x.phase))}</span><b>${esc(x.name)}</b><span class="muted">${esc(x.date ?? '')} · ${x.teams} teams</span></a>`).join('')
|
||||
: `<p class="muted">No tournaments yet. An organizer creates one from the <a href="/admin">desk</a>.</p>`;
|
||||
return layout({ title: 'Tournaments', body: `<h1>Tournaments</h1><div class="list">${rows}</div>` });
|
||||
}
|
||||
|
||||
export const phaseLabel = p => ({ checkin: 'Registration open', closed: 'Registration closed', live: 'Live', final: 'Final' }[p] ?? p);
|
||||
|
||||
/** Registration form, shown while phase = checkin. */
|
||||
export function registerPage(state, { error = null, values = {} } = {}) {
|
||||
const r = state.rules;
|
||||
const body = `
|
||||
<section class="hero">
|
||||
<div class="eyebrow">${esc(state.date ?? '')}</div>
|
||||
<h1>${esc(state.name)}</h1>
|
||||
<p class="lede">${esc(r.teamSize)}s · ${esc(r.pointsTo)} points, win by ${esc(r.winBy)}${r.cap ? `, cap ${esc(r.cap)}` : ''} · ${state.teams.length} team${state.teams.length === 1 ? '' : 's'} registered</p>
|
||||
${state.notes ? `<p class="notes">${esc(state.notes)}</p>` : ''}
|
||||
</section>
|
||||
${flash(error, 'error')}
|
||||
<form class="card form" method="post" action="/t/${esc(state.slug)}/register">
|
||||
<h2>Register your team</h2>
|
||||
<label>Team name <input name="name" required maxlength="40" autocomplete="off" value="${esc(values.name ?? '')}" placeholder="Net Gains"></label>
|
||||
<label>Captain's name <input name="captain" required maxlength="60" value="${esc(values.captain ?? '')}"></label>
|
||||
<label>Captain's mobile <input name="phone" type="tel" inputmode="tel" maxlength="30" value="${esc(values.phone ?? '')}" placeholder="optional, for organizer contact"></label>
|
||||
<label>Number of players <input name="players" type="number" inputmode="numeric" min="${esc(r.teamSize)}" max="20" required value="${esc(values.players ?? r.teamSize)}"></label>
|
||||
<label>Player names <textarea name="playerNames" rows="3" placeholder="one per line (optional)">${esc(values.playerNames ?? '')}</textarea></label>
|
||||
<button class="primary" type="submit">Register</button>
|
||||
<p class="muted small">After you register you'll get a private team link. Keep this page's QR code handy: once play starts it becomes the live board.</p>
|
||||
</form>
|
||||
<section class="card">
|
||||
<h2>Registered so far</h2>
|
||||
${state.teams.length ? `<ol class="plain">${state.teams.map(t => `<li>${esc(t.name)} <span class="muted">(${t.players})</span></li>`).join('')}</ol>` : '<p class="muted">Be the first.</p>'}
|
||||
</section>`;
|
||||
return layout({ title: state.name, body });
|
||||
}
|
||||
|
||||
export function registeredPage(state, team, origin) {
|
||||
const link = `${origin}/t/${state.slug}/team/${team.code}`;
|
||||
const body = `
|
||||
<section class="hero"><div class="eyebrow">You're in</div><h1>${esc(team.name)}</h1><p class="lede">${team.players} players · captain ${esc(team.captain ?? '')}</p></section>
|
||||
<section class="card">
|
||||
<h2>Your team link</h2>
|
||||
<p>This page will show your next match, which court, and your record. Bookmark it or add it to your home screen.</p>
|
||||
<p class="linkbox"><a href="${esc(link)}">${esc(link)}</a></p>
|
||||
<p><button class="primary" type="button" data-copy="${esc(link)}">Copy link</button> <a class="button" href="sms:?&body=${encodeURIComponent(`${team.name} team page: ${link}`)}">Text it to myself</a></p>
|
||||
<p class="muted small">Team code: <b class="mono">${esc(team.code)}</b>. Anyone with the code can see your team page; nobody can change scores from it.</p>
|
||||
</section>
|
||||
<p><a href="/t/${esc(state.slug)}">Back to the tournament</a></p>
|
||||
<script>document.querySelector('[data-copy]')?.addEventListener('click',e=>{navigator.clipboard?.writeText(e.target.dataset.copy).then(()=>{e.target.textContent='Copied'})});</script>`;
|
||||
return layout({ title: `${team.name} registered`, body });
|
||||
}
|
||||
|
||||
/** Everything after check-in: closed, live and final are all rendered client-side from the state JSON. */
|
||||
export function boardPage(state, { teamId = null, display = false } = {}) {
|
||||
const body = `<div id="app" class="app" data-team="${esc(teamId ?? '')}" data-display="${display ? '1' : ''}"><noscript>This page needs JavaScript to show live scores.</noscript><div class="loading">Loading ${esc(state.name)}…</div></div>`;
|
||||
return layout({ title: state.name, body, state, script: '/static/board.js', bodyClass: display ? 'display' : '' });
|
||||
}
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
// Simulated tournament day: 12 teams, 3 pools of 4, 2 courts, one withdrawal mid-pool,
|
||||
// then an 8-team single-elimination bracket with a 3rd-place match.
|
||||
import { Tournament } from './engine/tournament.js';
|
||||
import { CourtEngine } from './engine/courts.js';
|
||||
import { resetIds } from './engine/formats.js';
|
||||
|
||||
const args = Object.fromEntries(process.argv.slice(2).map(a => a.replace(/^--/, '').split('=')));
|
||||
const SEED = Number(args.seed ?? 7);
|
||||
const BRACKET = args.bracket ?? 'single';
|
||||
const VERBOSE = args.quiet === undefined;
|
||||
|
||||
// deterministic PRNG so a run is reproducible
|
||||
let s = SEED >>> 0;
|
||||
const rnd = () => ((s = (s * 1664525 + 1013904223) >>> 0) / 2 ** 32);
|
||||
const pick = arr => arr[Math.floor(rnd() * arr.length)];
|
||||
|
||||
// simulated clock: starts 9:00, advances as matches finish
|
||||
let clock = new Date('2026-09-07T09:00:00-05:00').getTime();
|
||||
const now = () => clock;
|
||||
const hhmm = ms => new Date(ms).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', timeZone: 'America/Chicago' });
|
||||
|
||||
resetIds();
|
||||
const t = new Tournament({ name: 'HOA Labor Day 2s', rules: { teamSize: 2, pointsTo: 21, winBy: 2, cap: 25 }, courtCount: 2 });
|
||||
const eng = new CourtEngine(t, { now, defaultMatchMinutes: 15, changeoverMinutes: 3 });
|
||||
|
||||
const names = ['Net Gains', 'Block Party', 'Sunburnt', 'Dig It', 'Kiss My Ace', 'Setting Ducks', 'The Spikers', 'Sandbaggers', 'Serves You Right', 'Bump Chumps', 'Ace Holes', 'Beach Please'];
|
||||
// hidden "true strength" so results look plausible rather than uniformly random
|
||||
const strength = new Map();
|
||||
|
||||
const printed = [];
|
||||
const out = (...a) => { if (VERBOSE) console.log(...a); printed.push(a.join(' ')); };
|
||||
|
||||
t.on((e) => {
|
||||
const at = hhmm(now());
|
||||
switch (e.type) {
|
||||
case 'team_registered': return;
|
||||
case 'phase': return out(`${at} PHASE → ${e.phase}${e.champion ? ` 🏆 Champion: ${e.champion}` : ''}`);
|
||||
case 'pools_generated': return out(`${at} Pools: ` + e.pools.map(p => `${p.id}[${p.teams.join(', ')}]`).join(' '));
|
||||
case 'bracket_generated': return out(`${at} Bracket (${e.bracket}, ${e.size}): ` + e.teams.map((n, i) => `${i + 1}.${n}`).join(' '));
|
||||
case 'match_started': return out(`${at} ▶ Court ${e.court}: ${e.a} vs ${e.b} (${e.match})`);
|
||||
case 'result': { const m = t.match(e.match); return out(`${at} ✓ ${m.id} ${t.teamName(m.teamA)} vs ${t.teamName(m.teamB)} → ${t.teamName(e.winner)} ${e.score}`); }
|
||||
case 'alert': return out(`${at} 📱 ${e.kind.toUpperCase().padEnd(9)} ${e.text}`);
|
||||
case 'team_withdrawn': return out(`${at} ✗ WITHDRAWN: ${e.name} (${e.mode})`);
|
||||
case 'court_paused': return out(`${at} ⏸ Court ${e.court} paused: ${e.reason}`);
|
||||
case 'court_resumed': return out(`${at} ▶ Court ${e.court} resumed`);
|
||||
default: return out(`${at} · ${e.type} ${JSON.stringify(e)}`);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- Phase 1: check-in ----------
|
||||
for (const n of names) {
|
||||
const team = t.registerTeam({ name: n, players: pick([2, 2, 2, 3]), captain: 'Captain', phone: '+1555' + String(Math.floor(rnd() * 1e7)).padStart(7, '0') });
|
||||
strength.set(team.id, 0.35 + rnd() * 0.5);
|
||||
}
|
||||
out(`Registered ${t.teams.size} teams. Team codes e.g. ${[...t.teams.values()].slice(0, 3).map(x => `${x.name}=${x.code}`).join(', ')}`);
|
||||
try { t.registerTeam({ name: 'net gains', players: 2 }); } catch (e) { out(` (rejected duplicate: ${e.message})`); }
|
||||
try { t.registerTeam({ name: 'Solo', players: 1 }); } catch (e) { out(` (rejected: ${e.message})`); }
|
||||
|
||||
// ---------- Phase 2: closed, generate ----------
|
||||
t.closeRegistration();
|
||||
t.generatePools(4);
|
||||
|
||||
// ---------- Phase 3: live ----------
|
||||
eng.start();
|
||||
|
||||
function playScore(m) {
|
||||
// simulate a set to 21, win by 2, cap 25 with the stronger team favoured
|
||||
const pa = strength.get(m.teamA), pb = strength.get(m.teamB);
|
||||
let a = 0, b = 0;
|
||||
const { pointsTo, winBy, cap } = t.rules;
|
||||
while (true) {
|
||||
if (rnd() < pa / (pa + pb)) a++; else b++;
|
||||
const hi = Math.max(a, b), lo = Math.min(a, b);
|
||||
if (hi === cap) break;
|
||||
if (hi >= pointsTo && hi - lo >= winBy) break;
|
||||
}
|
||||
return [[a, b]];
|
||||
}
|
||||
|
||||
let withdrawn = false;
|
||||
let paused = false;
|
||||
let step = 0;
|
||||
while (t.phase === 'live' && step++ < 200) {
|
||||
// pick the court whose match will finish first
|
||||
const live = eng.courts.filter(c => c.matchId);
|
||||
if (!live.length) {
|
||||
// nothing running: maybe everything is waiting on a paused court
|
||||
if (paused) { clock += 5 * 60_000; eng.resumeCourt(2); paused = false; continue; }
|
||||
out('!! stalled: no live matches and nothing assignable'); break;
|
||||
}
|
||||
const durations = live.map(c => ({ c, end: c.startedAt + (11 + rnd() * 7) * 60_000 }));
|
||||
durations.sort((x, y) => x.end - y.end);
|
||||
const { c, end } = durations[0];
|
||||
clock = Math.max(clock, end);
|
||||
const m = t.match(c.matchId);
|
||||
eng.recordResult(m.id, playScore(m));
|
||||
|
||||
const poolDone = t.matches.filter(x => x.stage === 'pool' && ['final','forfeit','void'].includes(x.status)).length;
|
||||
const poolTotal = t.matches.filter(x => x.stage === 'pool').length;
|
||||
|
||||
// mid-pool: a team leaves (kid's soccer game)
|
||||
if (!withdrawn && poolDone >= 7) {
|
||||
withdrawn = true;
|
||||
const victim = t.activeTeams().find(x => x.name === 'Sunburnt');
|
||||
clock += 60_000;
|
||||
eng.withdrawTeam(victim.id, { mode: 'forfeit' });
|
||||
eng.broadcast('Lunch is out at the shelter. Court 2 keeps running.');
|
||||
}
|
||||
// rain delay on court 2 for a bit
|
||||
if (!paused && poolDone === 12) { paused = true; eng.pauseCourt(2, 'net repair'); }
|
||||
if (paused && poolDone === 14) { paused = false; eng.resumeCourt(2); }
|
||||
|
||||
// pool play over -> bracket
|
||||
if (poolDone === poolTotal && !t.matches.some(x => x.stage !== 'pool')) {
|
||||
out('');
|
||||
for (const p of t.pools) {
|
||||
out(` Pool ${p.id} standings`);
|
||||
for (const r of t.standings(p.id)) out(` ${r.name.padEnd(18)} ${r.w}-${r.l} pts ${String(r.pf).padStart(3)}-${String(r.pa).padStart(3)} diff ${String(r.pointDiff).padStart(4)}${r.decidedBy ? ` (${r.decidedBy})` : ''}${r.status === 'withdrawn' ? ' WITHDRAWN' : ''}`);
|
||||
}
|
||||
const seeded = t.advanceFromPools({ perPool: 2, wildcards: 2 });
|
||||
out('');
|
||||
t.generateBracket(seeded, { type: BRACKET, thirdPlace: BRACKET === 'single' });
|
||||
eng.tick();
|
||||
const b = eng.board();
|
||||
out(` Board: ` + b.courts.map(c => `C${c.court}: ${c.match ? `${c.match.a} vs ${c.match.b}` : c.status}`).join(' | ') + ` ‖ up next: ` + b.upNext.map(u => `${u.a} vs ${u.b} (C${u.court}, ~${u.etaMin}m)`).join('; '));
|
||||
out('');
|
||||
}
|
||||
}
|
||||
|
||||
out('');
|
||||
out(`Day finished at ${hhmm(now())}. ${t.matches.filter(m => m.status === 'final').length} matches played, ${t.matches.filter(m => m.status === 'forfeit').length} forfeits, ${eng.alerts.length} alerts sent, avg match ${eng.board().avgMatchMin} min.`);
|
||||
const finals = t.matches.filter(m => m.stage !== 'pool').map(m => `${m.label ?? `${m.stage} R${m.round}`}: ${t.teamName(m.teamA)} vs ${t.teamName(m.teamB)} → ${m.winner ? t.teamName(m.winner) : m.status}`);
|
||||
out(finals.join('\n'));
|
||||
|
||||
// ---------- invariants ----------
|
||||
const problems = [];
|
||||
for (const m of t.matches) {
|
||||
if (m.status === 'final' && !m.winner) problems.push(`${m.id} final without winner`);
|
||||
if (m.status === 'live') problems.push(`${m.id} still live at end`);
|
||||
}
|
||||
const teamsPlayingTwice = eng.courts.filter(c => c.matchId).length;
|
||||
if (teamsPlayingTwice) problems.push('courts still occupied');
|
||||
const sunburnt = [...t.teams.values()].find(x => x.name === 'Sunburnt');
|
||||
if (t.matches.some(m => m.stage !== 'pool' && (m.teamA === sunburnt.id || m.teamB === sunburnt.id))) problems.push('withdrawn team reached bracket');
|
||||
const upNowPerMatch = eng.alerts.filter(a => a.kind === 'up_now').map(a => a.match);
|
||||
if (new Set(upNowPerMatch).size !== upNowPerMatch.length) problems.push('duplicate up_now alert');
|
||||
console.log(problems.length ? `\nINVARIANT FAILURES:\n - ${problems.join('\n - ')}` : `\nAll invariants hold.`);
|
||||
process.exitCode = problems.length ? 1 : 0;
|
||||
@@ -0,0 +1,154 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { Tournament, validateSet } from '../engine/tournament.js';
|
||||
import { CourtEngine } from '../engine/courts.js';
|
||||
import { roundRobin, makePools, singleElimination, doubleElimination, seedOrder, resetIds } from '../engine/formats.js';
|
||||
|
||||
const setup = (n, opts = {}) => {
|
||||
resetIds();
|
||||
const t = new Tournament({ name: 'test', ...opts });
|
||||
for (let i = 1; i <= n; i++) t.registerTeam({ name: `T${i}`, players: 2 });
|
||||
return t;
|
||||
};
|
||||
const ids = t => [...t.teams.keys()];
|
||||
|
||||
test('round robin: every pair once, each team once per round', () => {
|
||||
resetIds();
|
||||
const ms = roundRobin(['a', 'b', 'c', 'd', 'e']);
|
||||
assert.equal(ms.length, 10);
|
||||
const pairs = new Set(ms.map(m => [m.teamA, m.teamB].sort().join()));
|
||||
assert.equal(pairs.size, 10);
|
||||
for (let r = 1; r <= 5; r++) {
|
||||
const teams = ms.filter(m => m.round === r).flatMap(m => [m.teamA, m.teamB]);
|
||||
assert.equal(new Set(teams).size, teams.length);
|
||||
}
|
||||
});
|
||||
|
||||
test('pools: snake seeding of 12 into 3 pools', () => {
|
||||
const p = makePools(Array.from({ length: 12 }, (_, i) => `s${i + 1}`), 4);
|
||||
assert.deepEqual(p.map(x => x.teams), [['s1', 's6', 's7', 's12'], ['s2', 's5', 's8', 's11'], ['s3', 's4', 's9', 's10']]);
|
||||
});
|
||||
|
||||
test('seed order for 8 is the standard 1-8, 4-5, 2-7, 3-6', () => {
|
||||
assert.deepEqual(seedOrder(8), [1, 8, 4, 5, 2, 7, 3, 6]);
|
||||
});
|
||||
|
||||
test('single elimination with 6 teams gives top two seeds byes into round 2', () => {
|
||||
resetIds();
|
||||
const { matches, size } = singleElimination(['s1', 's2', 's3', 's4', 's5', 's6']);
|
||||
assert.equal(size, 8);
|
||||
const r2 = matches.filter(m => m.round === 2);
|
||||
assert.deepEqual(r2.map(m => [m.teamA, m.teamB]).flat().filter(Boolean).sort(), ['s1', 's2']);
|
||||
assert.equal(matches.filter(m => m.status === 'bye').length, 2);
|
||||
});
|
||||
|
||||
test('scores are validated against rally rules', () => {
|
||||
const r = { pointsTo: 21, winBy: 2, cap: 25 };
|
||||
assert.ok(validateSet(21, 19, r));
|
||||
assert.ok(validateSet(25, 24, r));
|
||||
assert.ok(validateSet(23, 21, r));
|
||||
assert.throws(() => validateSet(21, 20, r));
|
||||
assert.throws(() => validateSet(24, 20, r));
|
||||
assert.throws(() => validateSet(26, 24, r));
|
||||
assert.throws(() => validateSet(19, 17, r));
|
||||
});
|
||||
|
||||
test('withdrawal in forfeit mode: remaining games lost, standings recompute', () => {
|
||||
const t = setup(4);
|
||||
t.closeRegistration(); t.generatePools(4);
|
||||
const [a, b, c, d] = ids(t);
|
||||
const m = t.matches.find(x => [x.teamA, x.teamB].includes(a) && [x.teamA, x.teamB].includes(b));
|
||||
t.recordResult(m.id, [[21, 10]].map(s => m.teamA === a ? s : s.reverse()));
|
||||
t.withdrawTeam(a, { mode: 'forfeit' });
|
||||
const rows = t.standings('A');
|
||||
assert.equal(rows.at(-1).teamId, a);
|
||||
assert.equal(rows.at(-1).status, 'withdrawn');
|
||||
const forfeits = t.matches.filter(x => x.status === 'forfeit');
|
||||
assert.equal(forfeits.length, 2);
|
||||
assert.ok(forfeits.every(x => x.winner !== a));
|
||||
});
|
||||
|
||||
test('withdrawal in void mode removes the team\'s played games from standings', () => {
|
||||
const t = setup(4);
|
||||
t.closeRegistration(); t.generatePools(4);
|
||||
const [a, b] = ids(t);
|
||||
const m = t.matches.find(x => [x.teamA, x.teamB].includes(a) && [x.teamA, x.teamB].includes(b));
|
||||
t.recordResult(m.id, m.teamA === a ? [[21, 10]] : [[10, 21]]);
|
||||
t.withdrawTeam(a, { mode: 'void' });
|
||||
const rowB = t.standings('A').find(r => r.teamId === b);
|
||||
assert.equal(rowB.played, 0);
|
||||
assert.equal(t.matches.filter(x => x.status === 'void').length, 3);
|
||||
});
|
||||
|
||||
test('three-way circular head-to-head falls through to the next tiebreak', () => {
|
||||
const t = setup(3, { rules: { tiebreaks: ['headToHead', 'pointDiff'] } });
|
||||
t.closeRegistration(); t.generatePools(3);
|
||||
const [a, b, c] = ids(t);
|
||||
const find = (x, y) => t.matches.find(m => [m.teamA, m.teamB].includes(x) && [m.teamA, m.teamB].includes(y));
|
||||
const win = (x, y, s) => { const m = find(x, y); t.recordResult(m.id, m.teamA === x ? [s] : [[s[1], s[0]]]); };
|
||||
win(a, b, [21, 10]); win(b, c, [21, 15]); win(c, a, [21, 19]);
|
||||
const rows = t.standings('A');
|
||||
assert.deepEqual(rows.map(r => r.w), [1, 1, 1]);
|
||||
assert.equal(rows[0].teamId, a); // best point diff (+9)
|
||||
assert.equal(rows[0].decidedBy, 'pointDiff');
|
||||
});
|
||||
|
||||
test('court engine: two courts, no team on two courts, alerts once per match', () => {
|
||||
let clock = 0;
|
||||
const t = setup(8, { stages: ['pool'] });
|
||||
t.closeRegistration(); t.generatePools(4);
|
||||
const eng = new CourtEngine(t, { now: () => clock });
|
||||
eng.start();
|
||||
assert.equal(eng.courts.filter(c => c.matchId).length, 2);
|
||||
let guard = 0;
|
||||
while (t.phase === 'live' && guard++ < 100) {
|
||||
const busy = eng.courts.filter(c => c.matchId).flatMap(c => { const m = t.match(c.matchId); return [m.teamA, m.teamB]; });
|
||||
assert.equal(new Set(busy).size, busy.length, 'a team is on two courts');
|
||||
const c = eng.courts.find(c => c.matchId);
|
||||
clock += 12 * 60_000;
|
||||
eng.recordResult(c.matchId, [[21, 15]]);
|
||||
}
|
||||
assert.equal(t.phase, 'final');
|
||||
const upNow = eng.alerts.filter(a => a.kind === 'up_now');
|
||||
assert.equal(upNow.length, 12);
|
||||
assert.equal(new Set(upNow.map(a => a.match)).size, 12);
|
||||
});
|
||||
|
||||
test('court engine: paused court is skipped, resume picks up the queue', () => {
|
||||
let clock = 0;
|
||||
const t = setup(4, { stages: ['pool'] });
|
||||
t.closeRegistration(); t.generatePools(4);
|
||||
const eng = new CourtEngine(t, { now: () => clock });
|
||||
eng.start();
|
||||
eng.pauseCourt(2, 'rain');
|
||||
clock += 10 * 60_000;
|
||||
eng.recordResult(eng.courts[0].matchId, [[21, 5]]);
|
||||
eng.recordResult(eng.courts[1].matchId, [[21, 5]]);
|
||||
assert.equal(eng.courts[1].matchId, null);
|
||||
assert.ok(eng.courts[0].matchId);
|
||||
eng.resumeCourt(2);
|
||||
assert.ok(eng.courts[1].matchId);
|
||||
});
|
||||
|
||||
test('double elimination with 6 teams (byes) plays to a champion', () => {
|
||||
const t = setup(6, { stages: ['bracket'] });
|
||||
t.closeRegistration();
|
||||
t.generateBracket(ids(t), { type: 'double' });
|
||||
let clock = 0;
|
||||
const eng = new CourtEngine(t, { now: () => clock });
|
||||
eng.start();
|
||||
let guard = 0;
|
||||
while (t.phase === 'live' && guard++ < 100) {
|
||||
const c = eng.courts.find(c => c.matchId);
|
||||
assert.ok(c, 'engine stalled');
|
||||
clock += 10 * 60_000;
|
||||
const m = t.match(c.matchId);
|
||||
// lower seed (later id) always loses, except grand final where LB side wins to force the reset
|
||||
const lbWins = m.label === 'Grand final';
|
||||
eng.recordResult(m.id, lbWins ? [[10, 21]] : [[21, 10]]);
|
||||
}
|
||||
assert.equal(t.phase, 'final');
|
||||
const reset = t.matches.find(m => m.label === 'Bracket reset');
|
||||
assert.equal(reset.status, 'final');
|
||||
assert.equal(t.champion(), t.teamName(reset.winner));
|
||||
});
|
||||
Executable
+64
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env bash
|
||||
# End-to-end smoke test against a running server. Usage: BASE=http://localhost:3000 ADMIN_PASSWORD=... test/smoke.sh
|
||||
set -euo pipefail
|
||||
BASE=${BASE:-http://localhost:3123}
|
||||
PW=${ADMIN_PASSWORD:-test}
|
||||
J=$(mktemp); trap 'rm -f $J' EXIT
|
||||
c() { curl -s -b "$J" -c "$J" "$@"; }
|
||||
fail() { echo "FAIL: $*"; exit 1; }
|
||||
|
||||
echo "· login"
|
||||
c -o /dev/null -w '%{http_code}' -X POST "$BASE/admin/login" --data-urlencode "password=$PW" --data-urlencode "next=/admin" | grep -q 303 || fail login
|
||||
echo "· create tournament"
|
||||
LOC=$(c -o /dev/null -w '%{redirect_url}' -X POST "$BASE/admin/new" --data-urlencode "name=Smoke Test 2s" --data-urlencode "courtCount=2" --data-urlencode "teamSize=2" --data-urlencode "pointsTo=21" --data-urlencode "winBy=2" --data-urlencode "cap=25" --data-urlencode "bestOf=1" --data-urlencode "stages=pool,bracket")
|
||||
SLUG=$(echo "$LOC" | sed -E 's#.*/admin/t/([^?]+).*#\1#'); echo " slug=$SLUG"
|
||||
echo "· public register 8 teams"
|
||||
for n in "Net Gains" "Block Party" "Dig It" "Kiss My Ace" "Setting Ducks" "The Spikers" "Sandbaggers" "Beach Please"; do
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/t/$SLUG/register" --data-urlencode "name=$n" --data-urlencode "captain=Cap" --data-urlencode "players=2")
|
||||
[ "$code" = 303 ] || fail "register $n -> $code"
|
||||
done
|
||||
curl -s -o /dev/null -w '%{http_code}\n' -X POST "$BASE/t/$SLUG/register" --data-urlencode "name=net gains" --data-urlencode "players=2" | grep -q 400 || fail "duplicate accepted"
|
||||
curl -s "$BASE/t/$SLUG/state.json" | node -e 'const s=JSON.parse(require("fs").readFileSync(0));if(s.teams.length!==8)process.exit(1);if(JSON.stringify(s).includes("phone"))process.exit(2)' || fail "state.json teams/privacy"
|
||||
echo "· close, pools, live"
|
||||
c -o /dev/null -X POST "$BASE/admin/t/$SLUG/phase" --data-urlencode "phase=closed"
|
||||
c -o /dev/null -w '%{redirect_url}\n' -X POST "$BASE/admin/t/$SLUG/generate" --data-urlencode "what=pools" --data-urlencode "poolSize=4" | grep -q msg= || fail pools
|
||||
c -o /dev/null -w '%{redirect_url}\n' -X POST "$BASE/admin/t/$SLUG/phase" --data-urlencode "phase=live" | grep -q msg= || fail live
|
||||
S=$(curl -s "$BASE/t/$SLUG/state.json")
|
||||
echo "$S" | node -e 'const s=JSON.parse(require("fs").readFileSync(0));if(s.phase!=="live"||s.courts.filter(c=>c.match).length!==2)process.exit(1);console.log(" courts:",s.courts.map(c=>`${c.court}: ${c.match.a.name} v ${c.match.b.name}`).join(" | "));console.log(" up next:",s.upNext.map(u=>`${u.a.name} v ${u.b.name} C${u.court} ~${u.etaMin}m`).join("; "))' || fail "live state"
|
||||
echo "· live score then final on court 1"
|
||||
M=$(echo "$S" | node -e 'const s=JSON.parse(require("fs").readFileSync(0));console.log(s.courts[0].match.id)')
|
||||
c -o /dev/null -H 'content-type: application/json' -X POST "$BASE/admin/t/$SLUG/live" -d "{\"match\":\"$M\",\"a\":18,\"b\":14}"
|
||||
curl -s "$BASE/t/$SLUG/state.json" | grep -q '"live":\[18,14\]' || fail "live score not on board"
|
||||
c -H 'content-type: application/json' -X POST "$BASE/admin/t/$SLUG/score" -d "{\"match\":\"$M\",\"sets\":\"21-14\"}" | grep -q '"ok":true' || fail "final score"
|
||||
c -H 'content-type: application/json' -X POST "$BASE/admin/t/$SLUG/score" -d "{\"match\":\"$M\",\"sets\":\"21-20\"}" | grep -q 'win by 2' || fail "bad score accepted"
|
||||
echo "· play out all pool matches"
|
||||
for i in $(seq 1 20); do
|
||||
NEXT=$(curl -s "$BASE/t/$SLUG/state.json" | node -e 'const s=JSON.parse(require("fs").readFileSync(0));const c=s.courts.find(c=>c.match);console.log(c?c.match.id:"")')
|
||||
[ -z "$NEXT" ] && break
|
||||
c -o /dev/null -H 'content-type: application/json' -X POST "$BASE/admin/t/$SLUG/score" -d "{\"match\":\"$NEXT\",\"sets\":\"21-$((10+i%9))\"}"
|
||||
done
|
||||
echo "· withdraw a team, bracket, finish"
|
||||
TID=$(curl -s "$BASE/t/$SLUG/state.json" | node -e 'const s=JSON.parse(require("fs").readFileSync(0));console.log(s.teams[7].id)')
|
||||
c -o /dev/null -X POST "$BASE/admin/t/$SLUG/withdraw" --data-urlencode "id=$TID" --data-urlencode "mode=forfeit"
|
||||
c -o /dev/null -w '%{redirect_url}\n' -X POST "$BASE/admin/t/$SLUG/generate" --data-urlencode "what=bracket" --data-urlencode "perPool=2" --data-urlencode "wildcards=0" --data-urlencode "type=single" --data-urlencode "thirdPlace=1" | grep -q msg= || fail bracket
|
||||
for i in $(seq 1 12); do
|
||||
NEXT=$(curl -s "$BASE/t/$SLUG/state.json" | node -e 'const s=JSON.parse(require("fs").readFileSync(0));const c=s.courts.find(c=>c.match);console.log(c?c.match.id:"")')
|
||||
[ -z "$NEXT" ] && break
|
||||
c -o /dev/null -H 'content-type: application/json' -X POST "$BASE/admin/t/$SLUG/score" -d "{\"match\":\"$NEXT\",\"sets\":\"21-$((12+i%8))\"}"
|
||||
done
|
||||
curl -s "$BASE/t/$SLUG/state.json" | node -e 'const s=JSON.parse(require("fs").readFileSync(0));if(s.phase!=="final"||!s.champion)process.exit(1);console.log(" champion:",s.champion,"| withdrawn:",s.withdrawn.map(t=>t.name).join(","))' || fail "did not reach final"
|
||||
echo "· pages render"
|
||||
for p in "/" "/t/$SLUG" "/t/$SLUG/display" "/admin/t/$SLUG" "/admin/t/$SLUG/qr"; do
|
||||
code=$(c -o /dev/null -w '%{http_code}' "$BASE$p"); [ "$code" = 200 ] || fail "$p -> $code"
|
||||
done
|
||||
CODE=$(c "$BASE/admin/t/$SLUG" | grep -oE '/team/[A-Z2-9]{4}' | head -1)
|
||||
[ "$(curl -s -o /dev/null -w '%{http_code}' "$BASE/t/$SLUG$CODE")" = 200 ] || fail "team page"
|
||||
echo "· websocket delivers state"
|
||||
node -e '
|
||||
const WebSocket=require("ws");const ws=new WebSocket(process.argv[1].replace("http","ws")+"/ws/"+process.argv[2]);
|
||||
ws.on("message",m=>{const s=JSON.parse(m);console.log(" ws ok, phase",s.phase);ws.close();process.exit(0);});
|
||||
ws.on("error",e=>{console.log("ws error",e.message);process.exit(1)});setTimeout(()=>process.exit(1),3000)' "$BASE" "$SLUG"
|
||||
echo "· delete"
|
||||
c -o /dev/null -X POST "$BASE/admin/t/$SLUG/delete" --data-urlencode "confirm=yes"
|
||||
[ "$(curl -s -o /dev/null -w '%{http_code}' "$BASE/t/$SLUG")" = 404 ] || fail "not deleted"
|
||||
echo "SMOKE OK"
|
||||
Reference in New Issue
Block a user