Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4bee22d96 | ||
|
|
a5222953dd | ||
|
|
ae44014e22 |
No files matched your search
@@ -9,6 +9,42 @@ so you can always tell what a deployment is actually serving.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.5.0] - 2026-09-03
|
||||
|
||||
### Fixed
|
||||
- **Double elimination with byes could never finish.** With many byes (e.g. 18 teams in a
|
||||
32 bracket) losers-bracket slots fed by matches that never happen were left waiting forever;
|
||||
the tournament stayed live with both courts open. Byes now cascade through the whole bracket:
|
||||
a slot nobody can ever fill is a bye for the team on the other side, or a void match if both
|
||||
sides are empty. This is re-run when the server starts, so a stuck tournament unsticks itself
|
||||
on the first deploy of this version and goes Final if everything else is played.
|
||||
- Tested at 5, 6, 9, 12, 17, 18 and 23 teams, single and double elimination.
|
||||
|
||||
### Added
|
||||
- **Build the bracket by hand.** On the desk, "Build the bracket by hand" lists every round-1
|
||||
match with a team picker on each side, pre-filled with standard seeding. Pick who plays whom,
|
||||
leave a side as "bye" for a walkover, choose single or double elimination, and build. Teams
|
||||
picked twice are flagged before you submit and rejected by the server.
|
||||
|
||||
## [0.4.2] - 2026-09-03
|
||||
|
||||
### Fixed
|
||||
- Stylesheet and scripts are referenced with `?v=<version>`, so a new release can no longer be
|
||||
served with a cached old `app.css` (which made the score keeper page render unstyled on
|
||||
phones after 0.4.0). Versioned assets are cached for a year; unversioned for a minute.
|
||||
|
||||
## [0.4.1] - 2026-09-03
|
||||
|
||||
### Added
|
||||
- Bracket matches that are waiting on a live match now appear in the queue and under each
|
||||
court as "Net Gains vs winner on Court 2 · ready ~2:33", so double elimination and late
|
||||
single elimination rounds still show a next-up time. These are tentative (no on-deck
|
||||
alert until both teams are known).
|
||||
- Score keeper access is a QR code per court, shown on each court card of the desk, plus a
|
||||
printable page with all courts' codes (`/admin/t/<slug>/keeper-qr`). The copy-link button
|
||||
is gone; "Open on this device" remains for the organizer's own phone.
|
||||
- Idle courts show their next-up match too.
|
||||
|
||||
## [0.4.0] - 2026-09-03
|
||||
|
||||
### Added
|
||||
|
||||
+2
-1
@@ -74,10 +74,11 @@ All organizer routes require the session cookie set by `/admin/login` (or Cloudf
|
||||
| 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. |
|
||||
| GET | `/admin/t/<slug>/keeper-qr` | | Printable page with one score keeper QR code per court. |
|
||||
| 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>/generate` | `what=pools`, `poolSize` — or `what=bracket`, `perPool`, `wildcards`, `type` (`single`/`double`), `thirdPlace` — or `what=manual`, `type`, `thirdPlace`, `a1`,`b1`,`a2`,`b2`,… (team ids per round-1 match; empty = bye) | Generate pools or bracket, automatically or from hand-picked pairs. |
|
||||
| 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). |
|
||||
|
||||
@@ -4,7 +4,6 @@ Honest list of what the MVP does not do well yet. None of these block running a
|
||||
|
||||
- **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.
|
||||
|
||||
@@ -20,6 +20,7 @@ From the desk you can rename a team, fix the captain or player count, remove a t
|
||||
|
||||
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.
|
||||
To decide the first-round matchups yourself, open **Build the bracket by hand**: one row per round-1 match with a team picker on each side, pre-filled with standard seeding. Leave a side as "bye" to give the other team a walkover. Every team must appear exactly once; winners of matches 1 and 2 meet next, 3 and 4 meet, and so on down the sheet.
|
||||
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
|
||||
@@ -34,7 +35,7 @@ For best-of-3, or if you'd rather type, use "Save sets" with `21-18, 19-21, 15-9
|
||||
|
||||
## Score keepers (Courtside Manager)
|
||||
|
||||
You don't have to score every court yourself. Each court card on the desk has a **Score keeper** link (and a Copy link button). Hand that link to whoever is sitting at that court; it opens Courtside Manager, a full-screen page with two big + buttons, a − for a mis-tap, Undo, and a Finalize button that appears once a side has won the set under your rules. The page always shows whatever match is on that court and switches to the next one by itself when the previous is finalized, so one link lasts the whole day.
|
||||
You don't have to score every court yourself. Each court card on the desk shows a **score keeper QR code**; "Score keeper QR codes" at the top of the desk prints one large code per court to tape at the scorer's table. Whoever is sitting at that court scans it and gets Courtside Manager, a full-screen page with two big + buttons, a − for a mis-tap, Undo, and a Finalize button that appears once a side has won the set under your rules. The page always shows whatever match is on that court and switches to the next one by itself when the previous is finalized, so one link lasts the whole day.
|
||||
|
||||
A keeper link can only do two things: update the running score of the live match on its court, and finalize it. It cannot change a finished score, forfeit, or touch anything else; corrections are made from the desk ("All matches"). Links are signed with the server's `SESSION_SECRET`, so if that changes the old links stop working and you copy new ones.
|
||||
|
||||
|
||||
+18
-2
@@ -102,7 +102,7 @@ export class CourtEngine {
|
||||
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) })),
|
||||
upNext: q.slice(0, 3).map(x => ({ ...this._matchView(x.match), court: x.court, etaMin: Math.round(x.eta / MIN), tentative: !!x.tentative, aFrom: x.aFrom ?? null, bFrom: x.bFrom ?? null })),
|
||||
avgMatchMin: Math.round(this.avgMatchMs() / MIN),
|
||||
};
|
||||
}
|
||||
@@ -121,6 +121,22 @@ export class CourtEngine {
|
||||
sims[0].freeAt = start + this.avgMatchMs();
|
||||
teamFree.set(m.teamA, sims[0].freeAt); teamFree.set(m.teamB, sims[0].freeAt);
|
||||
}
|
||||
// Bracket matches still waiting on a live match (one or both sides TBD) get a tentative slot too,
|
||||
// so late in a bracket the board still says "Net Gains vs winner on Court 2 · ready ~2:10".
|
||||
const decided = x => ['final', 'forfeit', 'bye', 'void'].includes(x.status);
|
||||
const liveCourt = x => this.courts.find(c => c.matchId === x.id)?.number ?? null;
|
||||
for (const m of this.t.matches) {
|
||||
if (m.status !== 'pending' || m.conditional || m.stage === 'pool' || (m.teamA && m.teamB)) continue;
|
||||
const feeders = this.t.matches.filter(x => x.feeds === m.id || x.loserFeeds === m.id);
|
||||
if (!feeders.length || !feeders.every(x => decided(x) || x.status === 'live') || !feeders.some(x => x.status === 'live')) continue;
|
||||
const side = (which) => { const f = feeders.find(x => (x.feeds === m.id && x.feedsSide === which) || (x.loserFeeds === m.id && x.loserFeedsSide === which)); return f && f.status === 'live' ? { court: liveCourt(f), loser: f.loserFeeds === m.id } : null; };
|
||||
const waitOn = feeders.filter(x => x.status === 'live').map(x => this._expectedFinish(this.courts.find(c => c.matchId === x.id)));
|
||||
sims.sort((a, b) => a.freeAt - b.freeAt);
|
||||
const ready = Math.max(sims[0].freeAt, ...waitOn, m.teamA ? teamFree.get(m.teamA) ?? 0 : 0, m.teamB ? teamFree.get(m.teamB) ?? 0 : 0);
|
||||
const start = ready + this.changeoverMs;
|
||||
out.push({ match: m, court: sims[0].number, eta: start - this.now(), tentative: true, aFrom: m.teamA ? null : side('A'), bFrom: m.teamB ? null : side('B') });
|
||||
sims[0].freeAt = start + this.avgMatchMs();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -173,7 +189,7 @@ export class CourtEngine {
|
||||
_freeCourt(c) { c.matchId = null; c.startedAt = null; c.freeAt = null; }
|
||||
|
||||
_refreshOnDeck() {
|
||||
const q = this.queue();
|
||||
const q = this.queue().filter(x => !x.tentative);
|
||||
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; }
|
||||
|
||||
+2
-11
@@ -175,16 +175,7 @@ export function doubleElimination(seeded, { resetMatch = 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'; }
|
||||
}
|
||||
// Byes cascade through the losers bracket (a bye has no loser; a void match has no winner);
|
||||
// Tournament.settleByes() resolves them after generation and after every result.
|
||||
return { matches, size: wb.size, wbRounds, lbRounds: lbRound };
|
||||
}
|
||||
+79
-11
@@ -1,5 +1,5 @@
|
||||
// Tournament state: teams, matches, results, standings, withdrawals, bracket propagation.
|
||||
import { makePools, roundRobin, singleElimination, doubleElimination, makeMatch } from './formats.js';
|
||||
import { makePools, roundRobin, singleElimination, doubleElimination, makeMatch, seedOrder } from './formats.js';
|
||||
|
||||
export const DEFAULT_RULES = {
|
||||
teamSize: 2,
|
||||
@@ -104,15 +104,47 @@ export class Tournament {
|
||||
return [...qualified, ...rest.slice(0, wildcards)].map(r => r.teamId);
|
||||
}
|
||||
|
||||
generateBracket(seededIds, { type = 'single', thirdPlace = false } = {}) {
|
||||
generateBracket(seededIds, { type = 'single', thirdPlace = false, manual = 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) });
|
||||
this.settleByes();
|
||||
this.emit({ type: 'bracket_generated', bracket: type, size: res.size, manual, teams: seededIds.filter(Boolean).map(id => this.teams.get(id)?.name ?? id) });
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a bracket from hand-picked first-round pairs: pairs = [[teamIdA|null, teamIdB|null], ...]
|
||||
* in the order the matches appear top to bottom. null = bye. Every active team must appear once.
|
||||
*/
|
||||
generateBracketFromPairs(pairs, opts = {}) {
|
||||
const size = pairs.length * 2;
|
||||
if (size < 2 || (size & (size - 1)) !== 0) throw new Error(`Need a power-of-two number of slots (got ${size})`);
|
||||
const used = new Set();
|
||||
for (const [a, b] of pairs) for (const id of [a, b]) {
|
||||
if (!id) continue;
|
||||
if (!this.teams.get(id) || this.teams.get(id).status !== 'registered') throw new Error(`Unknown or withdrawn team in bracket: ${id}`);
|
||||
if (used.has(id)) throw new Error(`${this.teamName(id)} is placed twice`);
|
||||
used.add(id);
|
||||
}
|
||||
const missing = this.activeTeams().filter(t => !used.has(t.id));
|
||||
if (missing.length) throw new Error(`Not placed: ${missing.map(t => t.name).join(', ')}`);
|
||||
if (pairs.some(([a, b]) => !a && !b)) throw new Error('A match needs at least one team; leave at most one side empty for a bye');
|
||||
// singleElimination lays out by seed order; invert that so pair i lands in round-1 match i
|
||||
const order = seedOrder(size);
|
||||
const seeded = new Array(size).fill(null);
|
||||
pairs.forEach(([a, b], i) => { seeded[order[2 * i] - 1] = a ?? null; seeded[order[2 * i + 1] - 1] = b ?? null; });
|
||||
return this.generateBracket(seeded, { ...opts, manual: true });
|
||||
}
|
||||
|
||||
/** Default first-round pairs (standard seeding) for `seededIds`, for pre-filling the manual builder. */
|
||||
defaultPairs(seededIds) {
|
||||
const size = 1 << Math.ceil(Math.log2(Math.max(2, seededIds.length)));
|
||||
const order = seedOrder(size);
|
||||
return Array.from({ length: size / 2 }, (_, i) => [seededIds[order[2 * i] - 1] ?? null, seededIds[order[2 * i + 1] - 1] ?? null]);
|
||||
}
|
||||
|
||||
// ---------- results ----------
|
||||
match(id) { return this.matches.find(m => m.id === id); }
|
||||
teamName(id) { return id ? this.teams.get(id)?.name ?? id : 'TBD'; }
|
||||
@@ -143,15 +175,10 @@ export class Tournament {
|
||||
}
|
||||
|
||||
_place(matchId, side, teamId) {
|
||||
if (!matchId) return;
|
||||
if (!matchId || !teamId) 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) {
|
||||
@@ -163,7 +190,46 @@ export class Tournament {
|
||||
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() }); }
|
||||
this.settleByes();
|
||||
this._checkComplete();
|
||||
}
|
||||
|
||||
_checkComplete() {
|
||||
if (this.phase !== 'final' && this.isComplete()) { this.phase = 'final'; this.emit({ type: 'phase', phase: 'final', champion: this.champion() }); }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve byes anywhere in a bracket. A slot is "dead" when nobody can ever fill it: its feeder
|
||||
* is void (both of that match's slots were dead), or the slot takes the loser of a bye/void match.
|
||||
* Dead + dead = void; dead + team = bye (team advances); dead + unknown = wait (pendingBye).
|
||||
* Loops until stable, so a bye in round 1 cascades through the losers bracket correctly.
|
||||
*/
|
||||
settleByes() {
|
||||
let changed = true, guard = 0;
|
||||
while (changed && guard++ < 50) {
|
||||
changed = false;
|
||||
for (const m of this.matches) {
|
||||
if (m.stage === 'pool' || m.conditional || !['pending', 'on_deck'].includes(m.status)) continue;
|
||||
const deadA = !m.teamA && (m.pendingBye === 'A' || this._sideDead(m, 'A'));
|
||||
const deadB = !m.teamB && (m.pendingBye === 'B' || this._sideDead(m, 'B'));
|
||||
if (deadA && deadB) { m.status = 'void'; m.court = null; delete m.pendingBye; changed = true; }
|
||||
else if ((deadA && m.teamB) || (deadB && m.teamA)) {
|
||||
m.status = 'bye'; m.winner = m.teamA ?? m.teamB; m.court = null; delete m.pendingBye; changed = true;
|
||||
if (m.feeds) this._place(m.feeds, m.feedsSide, m.winner);
|
||||
} else if (deadA || deadB) {
|
||||
const side = deadA ? 'A' : 'B';
|
||||
if (m.pendingBye !== side) { m.pendingBye = side; changed = true; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_sideDead(m, side) {
|
||||
const w = this.matches.find(x => x.feeds === m.id && x.feedsSide === side);
|
||||
if (w) return w.status === 'void';
|
||||
const l = this.matches.find(x => x.loserFeeds === m.id && x.loserFeedsSide === side);
|
||||
if (l) return l.status === 'void' || l.status === 'bye';
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Complete when every configured stage has matches and all of them are decided. */
|
||||
@@ -201,10 +267,12 @@ export class Tournament {
|
||||
// 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'; }
|
||||
else { const side = m.teamA === teamId ? 'A' : 'B'; m[side === 'A' ? 'teamA' : 'teamB'] = null; m.pendingBye = side; }
|
||||
m.court = null;
|
||||
}
|
||||
}
|
||||
this.settleByes();
|
||||
this._checkComplete();
|
||||
this.emit({ type: 'team_withdrawn', team: teamId, name: t.name, mode });
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"name":"courtside","type":"module","version":"0.4.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"}}
|
||||
{"name":"courtside","type":"module","version":"0.5.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"}}
|
||||
+4
-2
@@ -48,14 +48,16 @@ export const send = {
|
||||
};
|
||||
|
||||
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) {
|
||||
export async function serveStatic(res, root, urlPath, versioned = false) {
|
||||
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' });
|
||||
// Assets are referenced with ?v=<version>, so a new release is a new URL: cache the versioned
|
||||
// URL for a year, and keep unversioned requests short-lived.
|
||||
res.writeHead(200, { 'content-type': MIME[extname(p)] ?? 'application/octet-stream', 'cache-control': versioned ? 'public, max-age=31536000, immutable' : 'public, max-age=60, must-revalidate' });
|
||||
res.end(body);
|
||||
} catch (e) { if (e instanceof HttpError) throw e; throw new HttpError(404, 'Not found'); }
|
||||
}
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ 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));
|
||||
if (url.pathname.startsWith('/static/')) return await serveStatic(res, staticRoot, url.pathname.slice('/static'.length), url.searchParams.has('v'));
|
||||
const m = router.match(req.method, url.pathname);
|
||||
if (!m) throw new HttpError(404, 'Not found');
|
||||
await m.handler(req, res, m.params);
|
||||
|
||||
@@ -21,7 +21,7 @@ export function publicState({ t, engine }) {
|
||||
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 })) : [],
|
||||
upNext: board ? board.upNext.map(u => ({ ...view(t.match(u.id)), court: u.court, etaMin: u.etaMin, tentative: u.tentative, aFrom: u.aFrom, bFrom: u.bFrom })) : [],
|
||||
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 })),
|
||||
|
||||
@@ -25,6 +25,17 @@
|
||||
|
||||
document.querySelectorAll('[data-copy]').forEach(b => b.addEventListener('click', () => navigator.clipboard?.writeText(b.dataset.copy).then(() => { b.textContent = 'Copied'; })));
|
||||
|
||||
// manual bracket builder: highlight a team picked twice before the form is submitted
|
||||
const pairsForm = document.querySelector('.manualform');
|
||||
if (pairsForm) {
|
||||
const check = () => {
|
||||
const seen = new Map();
|
||||
pairsForm.querySelectorAll('select[name^=a],select[name^=b]').forEach(sel => { if (sel.value) seen.set(sel.value, (seen.get(sel.value) ?? 0) + 1); });
|
||||
pairsForm.querySelectorAll('tr').forEach(tr => tr.classList.toggle('dup', [...tr.querySelectorAll('select')].some(sel => sel.value && seen.get(sel.value) > 1)));
|
||||
};
|
||||
pairsForm.addEventListener('change', check); check();
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
@@ -54,7 +54,7 @@ a.tl{color:inherit;text-decoration:none;border-bottom:1px dotted currentColor}a.
|
||||
.court .timing{position:relative;font-family:"Barlow Condensed",sans-serif;font-size:12px;letter-spacing:.06em;text-transform:uppercase;opacity:.85;margin-top:6px}
|
||||
.court .nextup{position:relative;margin-top:8px;padding-top:8px;border-top:1px solid rgba(255,255,255,.3);font-size:15px}
|
||||
.court .nextup .k{font-family:"Barlow Condensed",sans-serif;font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:#F0C46B;margin-right:4px}
|
||||
.court .nextup .eta{font-size:13px;opacity:.85;margin-left:4px}.court .nextup.mine{font-weight:600}
|
||||
.court .nextup .eta{font-size:13px;opacity:.85;margin-left:4px}.court .nextup .tbd{color:inherit;opacity:.85}.court.idle .nextup{border-top-color:var(--line);color:var(--ink)}.court.idle .nextup .k{color:var(--sand)}.court .nextup.mine{font-weight:600}
|
||||
/* 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)}
|
||||
@@ -110,6 +110,14 @@ body.display .queue{font-size:26px}body.display details{display:none}
|
||||
@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}}
|
||||
.keeperbox{display:flex;gap:12px;align-items:flex-start;margin:6px 0 10px;padding:10px;border:1px dashed var(--line);border-radius:8px}
|
||||
.keeperbox img{flex:none;border-radius:4px;background:#fff;image-rendering:pixelated}
|
||||
.keeperbox .button{margin-top:6px}
|
||||
.qrgrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:16px}.qrgrid .qr img{width:min(80vw,300px)}
|
||||
@media print{.qrgrid{grid-template-columns:1fr 1fr}.qrgrid .card{break-inside:avoid;border:1px solid #000}}
|
||||
.manual{margin-top:10px}.manual summary{font-weight:600;color:var(--accent)}
|
||||
.pairs select{max-width:220px;font:inherit;padding:4px 6px;border:1px solid var(--line);border-radius:6px;background:var(--bg);color:var(--ink)}
|
||||
.pairs td{padding:4px 6px}.pairs tr.dup select{border-color:var(--crit)}
|
||||
/* score keeper (Courtside Manager) */
|
||||
body.keeper-body{background:#0F1418;color:#F2F5F7}body.keeper-body .top,body.keeper-body .foot{display:none}
|
||||
body.keeper-body main{max-width:720px;padding:12px 12px 40px}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
// every team name on the board links to that team's page (schedule, record, next match)
|
||||
const teamHref = t => `/t/${state.slug}/teams/${encodeURIComponent(t.id)}`;
|
||||
const name = t => t ? `<a class="tl" href="${teamHref(t)}">${esc(t.name)}</a>` : '<span class="tbd">TBD</span>';
|
||||
const fromName = (t, from) => t ? name(t) : from ? `<span class="tbd">${from.loser ? 'loser' : 'winner'} on Court ${from.court}</span>` : '<span class="tbd">TBD</span>';
|
||||
const clock = ms => new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
|
||||
const isMine = m => myTeam && (m.a?.id === myTeam || m.b?.id === myTeam);
|
||||
const phaseLabel = { checkin: 'Registration open', closed: 'Registration closed', live: 'Live', final: 'Final' };
|
||||
@@ -70,7 +71,7 @@
|
||||
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>`;
|
||||
if (!m) return `<div class="court idle"><div class="lbl">Court ${c.court}</div><div class="teams"><div class="t muted">Open</div></div>${nextForCourt(c.court)}</div>`;
|
||||
const avg = state.avgMatchMin;
|
||||
const elapsed = m.startedAt ? Math.max(0, Math.round((Date.now() - m.startedAt) / 60000)) : null;
|
||||
const left = avg != null && elapsed != null ? Math.max(0, avg - elapsed) : null;
|
||||
@@ -88,12 +89,12 @@
|
||||
const u = state.upNext.find(x => x.court === courtNo);
|
||||
if (!u) return '';
|
||||
const ready = clock(state.generatedAt + u.etaMin * 60000);
|
||||
return `<div class="nextup ${isMine(u) ? 'mine' : ''}"><span class="k">Next up</span> ${name(u.a)} vs ${name(u.b)} <span class="eta mono">ready ~${ready}</span></div>`;
|
||||
return `<div class="nextup ${isMine(u) ? 'mine' : ''}"><span class="k">Next up</span> ${fromName(u.a, u.aFrom)} vs ${fromName(u.b, u.bFrom)} <span class="eta mono">ready ~${ready}</span></div>`;
|
||||
}
|
||||
|
||||
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} · ~${clock(state.generatedAt + u.etaMin * 60000)} (${u.etaMin} min)</span></div>`).join('')}</section>`;
|
||||
return `<section class="queue"><div class="eyebrow">Up next</div>${state.upNext.map((u, i) => `<div class="qrow ${isMine(u) ? 'mine' : ''}"><span class="k">${u.tentative ? 'Pending' : i === 0 ? 'On deck' : 'Then'}</span><span>${fromName(u.a, u.aFrom)} vs ${fromName(u.b, u.bFrom)}</span><span class="eta mono">Court ${u.court} · ~${clock(state.generatedAt + u.etaMin * 60000)} (${u.etaMin} min)</span></div>`).join('')}</section>`;
|
||||
}
|
||||
|
||||
function renderPools() {
|
||||
|
||||
+18
-2
@@ -117,9 +117,19 @@ export function buildRouter({ registry, auth, store }) {
|
||||
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 }) => {
|
||||
r.get('/admin/t/:slug', async (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), (t, c) => keeperUrl(req, t.slug, c.number)));
|
||||
const it = item(slug);
|
||||
const keeper = { url: c => keeperUrl(req, slug, c.number), qr: {} };
|
||||
if (it.t.phase === 'live') for (const c of it.engine.courts) keeper.qr[c.number] = await QRCode.toDataURL(keeperUrl(req, slug, c.number), { width: 220, margin: 1 });
|
||||
send.html(res, adm.adminTournament(it, who, origin(req), q(req).get('msg'), q(req).get('error'), store.events(slug, 40), keeper));
|
||||
});
|
||||
r.get('/admin/t/:slug/keeper-qr', async (req, res, { slug }) => {
|
||||
const who = requireAdmin(req, res); if (!who) return;
|
||||
const it = item(slug);
|
||||
const cards = [];
|
||||
for (const c of it.engine.courts) cards.push({ court: c.number, url: keeperUrl(req, slug, c.number), qr: await QRCode.toDataURL(keeperUrl(req, slug, c.number), { width: 640, margin: 2 }) });
|
||||
send.html(res, adm.keeperQrPage(it.t, cards, who));
|
||||
});
|
||||
r.get('/admin/t/:slug/qr', async (req, res, { slug }) => {
|
||||
const who = requireAdmin(req, res); if (!who) return;
|
||||
@@ -160,6 +170,12 @@ export function buildRouter({ registry, auth, store }) {
|
||||
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(', ')}`; }
|
||||
if (body.what === 'manual') {
|
||||
const pairs = [];
|
||||
for (let i = 1; body[`a${i}`] !== undefined || body[`b${i}`] !== undefined; i++) pairs.push([body[`a${i}`] || null, body[`b${i}`] || null]);
|
||||
const res = t.generateBracketFromPairs(pairs, { type: body.type === 'double' ? 'double' : 'single', thirdPlace: !!body.thirdPlace });
|
||||
return `${body.type === 'double' ? 'Double' : 'Single'} elimination bracket of ${res.size} built from your pairings`;
|
||||
}
|
||||
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 });
|
||||
|
||||
@@ -16,6 +16,14 @@ export class Registry {
|
||||
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);
|
||||
// Re-run bye settling on load: a tournament saved by an older version may have bracket slots
|
||||
// that could never be filled (the 18-team double-elim case). Cheap and idempotent.
|
||||
if (t.matches.some(m => m.stage !== 'pool') && t.phase !== 'final') {
|
||||
const before = JSON.stringify(t.matches.map(m => [m.status, m.teamA, m.teamB]));
|
||||
t.settleByes(); t._checkComplete();
|
||||
if (t.phase === 'live') engine.tick();
|
||||
if (before !== JSON.stringify(t.matches.map(m => [m.status, m.teamA, m.teamB]))) { console.log(`[load] ${row.slug}: settled bracket byes`); this.persist(row.slug); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+34
-4
@@ -34,7 +34,7 @@ export function adminHome(list, who, msg) {
|
||||
</div>` });
|
||||
}
|
||||
|
||||
export function adminTournament({ t, engine }, who, origin, msg, err, events, keeperUrl = () => '#') {
|
||||
export function adminTournament({ t, engine }, who, origin, msg, err, events, keeper = { url: () => '#', qr: {} }) {
|
||||
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 }));
|
||||
@@ -47,7 +47,7 @@ export function adminTournament({ t, engine }, who, origin, msg, err, events, ke
|
||||
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>
|
||||
<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>${s === 'live' ? ` · <a href="/admin/t/${esc(t.slug)}/keeper-qr">Score keeper QR codes</a>` : ''}</p>
|
||||
${flash(msg)}${flash(err, 'error')}
|
||||
|
||||
|
||||
@@ -58,12 +58,12 @@ export function adminTournament({ t, engine }, who, origin, msg, err, events, ke
|
||||
<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>
|
||||
<p class="small"><a class="button small" href="${esc(keeperUrl(t, c))}" target="_blank">Score keeper</a> <button type="button" class="small" data-copy="${esc(keeperUrl(t, c))}">Copy link</button> <span class="muted">Hand this to whoever is scoring Court ${c.number}: it scores whatever match is on this court and finalizes it. Corrections stay here on the desk.</span></p>
|
||||
<div class="keeperbox">${keeper.qr[c.number] ? `<img src="${keeper.qr[c.number]}" alt="Score keeper QR for court ${c.number}" width="110" height="110">` : ''}<div><b>Score keeper for Court ${c.number}</b><br><span class="muted small">Whoever is scoring this court scans this. The page follows whatever match is on the court and can finalize it; corrections stay here on the desk.</span><br><a class="button small" href="${esc(keeper.url(c))}" target="_blank">Open on this device</a></div></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>'}
|
||||
${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>${q.tentative ? '<span class="pill">pending</span> ' : ''}${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>${q.tentative ? '' : 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>Phase</h2>
|
||||
@@ -95,6 +95,7 @@ export function adminTournament({ t, engine }, who, origin, msg, err, events, ke
|
||||
<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>` : ''}
|
||||
${!hasBracket && t.stages.includes('bracket') && (t.pools.length || !t.stages.includes('pool')) ? manualBuilder(t) : ''}
|
||||
${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>` : ''}
|
||||
@@ -120,6 +121,26 @@ export function adminTournament({ t, engine }, who, origin, msg, err, events, ke
|
||||
return layout({ title: `${t.name} desk`, admin: who, body, script: '/static/admin.js' });
|
||||
}
|
||||
|
||||
/** Hand-built bracket: one row per first-round match with a team picker on each side. */
|
||||
function manualBuilder(t) {
|
||||
const teams = t.activeTeams();
|
||||
const seeded = t.pools.length ? t.advanceFromPools({ perPool: 2, wildcards: Math.max(0, (1 << Math.ceil(Math.log2(Math.max(2, t.pools.length * 2)))) - t.pools.length * 2) }) : teams.map(x => x.id);
|
||||
const pairs = t.defaultPairs(seeded);
|
||||
const opt = sel => `<option value="">— bye —</option>` + teams.map(x => `<option value="${esc(x.id)}" ${x.id === sel ? 'selected' : ''}>${esc(x.name)}${x.seed ? ` (${x.seed})` : ''}</option>`).join('');
|
||||
return `<details class="manual"><summary>Build the bracket by hand — choose who plays whom in round 1</summary>
|
||||
<form method="post" action="/admin/t/${esc(t.slug)}/generate" class="manualform">
|
||||
<input type="hidden" name="what" value="manual">
|
||||
<div class="inline"><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>
|
||||
<span class="muted small">${teams.length} teams in a bracket of ${pairs.length * 2}; ${pairs.length * 2 - teams.length} bye${pairs.length * 2 - teams.length === 1 ? '' : 's'}. Every team must appear exactly once; a side left as "bye" gives the other team a walkover.</span></div>
|
||||
<table class="adm-table pairs"><tr><th>Match</th><th>Side A</th><th></th><th>Side B</th></tr>
|
||||
${pairs.map(([a, b], i) => `<tr><td>${i + 1}</td><td><select name="a${i + 1}">${opt(a)}</select></td><td class="muted">vs</td><td><select name="b${i + 1}">${opt(b)}</select></td></tr>`).join('')}
|
||||
</table>
|
||||
<p class="muted small">Pre-filled with standard seeding (1 v ${pairs.length * 2}, 2 v ${pairs.length * 2 - 1}, …). Winners of matches 1 and 2 meet, 3 and 4 meet, and so on down the sheet.</p>
|
||||
<button class="primary small" type="submit">Build bracket from these pairings</button>
|
||||
</form></details>`;
|
||||
}
|
||||
|
||||
function scorePad(t, m) {
|
||||
const live = m.live ?? [0, 0];
|
||||
return `<div class="scorepad" data-match="${esc(m.id)}">
|
||||
@@ -167,3 +188,12 @@ export function keeperPage(t, court, k, state) {
|
||||
const body = `<div id="keeper" class="keeper" data-court="${court}" data-k="${esc(k)}"><div class="loading">Loading Court ${court}…</div></div>`;
|
||||
return layout({ title: `Court ${court} score keeper`, body, state, script: '/static/keeper.js', bodyClass: 'keeper-body' });
|
||||
}
|
||||
|
||||
/** Printable page: one score keeper QR per court, to tape to the scorer's table. */
|
||||
export function keeperQrPage(t, cards, who) {
|
||||
return layout({ title: `${t.name} score keeper codes`, admin: who, body: `
|
||||
<h1>${esc(t.name)} · score keeper codes</h1>
|
||||
<p class="muted">Print this and tape each code at its court. Scanning opens Courtside Manager for that court only. Codes stop working if the server's SESSION_SECRET changes.</p>
|
||||
<div class="qrgrid">${cards.map(c => `<section class="card qr"><h2>Court ${c.court}</h2><img src="${c.qr}" alt="Score keeper QR for court ${c.court}"><p class="small muted">Score keeper · ${esc(t.name)}</p></section>`).join('')}</div>
|
||||
<p><button onclick="print()">Print</button> <a class="button" href="/admin/t/${esc(t.slug)}">Back to desk</a></p>` });
|
||||
}
|
||||
@@ -13,7 +13,7 @@ export function layout({ title, body, state = null, script = null, bodyClass = '
|
||||
<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">
|
||||
<link rel="stylesheet" href="/static/app.css?v=${VERSION}">
|
||||
</head>
|
||||
<body class="${esc(bodyClass)}">
|
||||
<header class="top">
|
||||
@@ -25,7 +25,7 @@ ${body}
|
||||
</main>
|
||||
<footer class="foot">Courtside v${VERSION} · self-hosted tournament desk · <a href="https://gitea.cloudfreeiot.com/bkvargyas/courtside">source</a> · <a href="/admin">organizer desk</a></footer>
|
||||
${state ? `<script id="state" type="application/json">${JSON.stringify(state).replace(/</g, '\\u003c')}</script>` : ''}
|
||||
${script ? `<script src="${esc(script)}" defer></script>` : ''}
|
||||
${script ? `<script src="${esc(script)}?v=${VERSION}" defer></script>` : ''}
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
@@ -152,3 +152,67 @@ test('double elimination with 6 teams (byes) plays to a champion', () => {
|
||||
assert.equal(reset.status, 'final');
|
||||
assert.equal(t.champion(), t.teamName(reset.winner));
|
||||
});
|
||||
|
||||
for (const n of [5, 9, 12, 17, 18, 23]) {
|
||||
test(`double elimination with ${n} teams (many byes) plays to a champion with no stuck matches`, () => {
|
||||
const t = setup(n, { 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++ < 400) {
|
||||
const c = eng.courts.find(c => c.matchId);
|
||||
assert.ok(c, `engine stalled with ${t.matches.filter(m => m.status === 'pending').length} pending matches`);
|
||||
clock += 10 * 60_000;
|
||||
eng.recordResult(c.matchId, [[21, 10]]);
|
||||
}
|
||||
assert.equal(t.phase, 'final');
|
||||
assert.ok(t.champion());
|
||||
assert.equal(t.matches.filter(m => m.status === 'pending').length, 0);
|
||||
});
|
||||
test(`single elimination with ${n} teams plays to a champion`, () => {
|
||||
const t = setup(n, { stages: ['bracket'] });
|
||||
t.closeRegistration();
|
||||
t.generateBracket(ids(t), { type: 'single', thirdPlace: true });
|
||||
let clock = 0;
|
||||
const eng = new CourtEngine(t, { now: () => clock });
|
||||
eng.start();
|
||||
let guard = 0;
|
||||
while (t.phase === 'live' && guard++ < 200) {
|
||||
const c = eng.courts.find(c => c.matchId);
|
||||
assert.ok(c, 'engine stalled');
|
||||
clock += 10 * 60_000;
|
||||
eng.recordResult(c.matchId, [[21, 10]]);
|
||||
}
|
||||
assert.equal(t.phase, 'final');
|
||||
});
|
||||
}
|
||||
|
||||
test('withdrawal from a bracket before the opponent is known becomes a bye for the opponent', () => {
|
||||
const t = setup(4, { stages: ['bracket'] });
|
||||
t.closeRegistration();
|
||||
const [a, b, c, d] = ids(t);
|
||||
t.generateBracket([a, b, c, d], { type: 'single' });
|
||||
const r1 = t.matches.filter(m => m.round === 1);
|
||||
const mA = r1.find(m => [m.teamA, m.teamB].includes(a));
|
||||
t.recordResult(mA.id, mA.teamA === a ? [[21, 5]] : [[5, 21]]); // a advances to final
|
||||
t.withdrawTeam(a, { mode: 'forfeit' }); // a leaves before the final is set
|
||||
const other = r1.find(m => m !== mA);
|
||||
t.recordResult(other.id, [[21, 5]]);
|
||||
assert.equal(t.phase, 'final');
|
||||
assert.equal(t.champion(), t.teamName(other.teamA));
|
||||
});
|
||||
|
||||
test('manual bracket: hand-picked round-1 pairs land in the chosen matches', () => {
|
||||
const t = setup(6, { stages: ['bracket'] });
|
||||
t.closeRegistration();
|
||||
const [a, b, c, d, e, f] = ids(t);
|
||||
t.generateBracketFromPairs([[a, f], [b, null], [c, d], [null, e]], { type: 'single' });
|
||||
const r1 = t.matches.filter(m => m.round === 1).sort((x, y) => x.slot - y.slot);
|
||||
assert.deepEqual(r1.map(m => [m.teamA, m.teamB]), [[a, f], [b, null], [c, d], [null, e]]);
|
||||
assert.equal(r1[1].status, 'bye'); assert.equal(r1[3].status, 'bye');
|
||||
assert.throws(() => t.generateBracketFromPairs([[a, a], [b, c], [d, e], [f, null]]), /placed twice/);
|
||||
assert.throws(() => t.generateBracketFromPairs([[a, b], [c, d], [e, null], [null, null]]), /Not placed/);
|
||||
});
|
||||
Reference in new issue
Block a user