3 Commits
Author SHA1 Message Date
bkvargyasandClaude Fable 5.1 594247ef56 Deep gold champion banner and Final badge; 'bye' labels in round 1
Release 0.5.2.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01MB7nCCAscYsb3zzkT6LHZi
2026-09-05 16:19:28 +00:00
bkvargyasandClaude Fable 5.1 4ce7e48551 Withdrawn teams forfeit visibly through the bracket instead of becoming byes
A withdrawn team stays in any slot whose opponent is not known yet and forfeits
when they arrive; the board strikes the name through and tags the match
'Forfeit · Team'. Release 0.5.1.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01MB7nCCAscYsb3zzkT6LHZi
2026-09-05 15:50:49 +00:00
bkvargyasandClaude Fable 5.1 a4bee22d96 Fix double elimination with byes never finishing; hand-built brackets
Byes now cascade through the whole bracket (a slot nobody can fill is a bye or
a void), re-run on server start so stuck tournaments recover. New manual
bracket builder on the desk: pick round-1 pairings per match. Tests at 5-23
teams for both bracket types. Release 0.5.0.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01MB7nCCAscYsb3zzkT6LHZi
2026-09-05 15:33:36 +00:00
15 changed files with 281 additions and 35 deletions

No files matched your search

+34
View File
@@ -9,6 +9,40 @@ so you can always tell what a deployment is actually serving.
## [Unreleased]
## [0.5.2] - 2026-09-03
### Changed
- Champion banner and the Final badge are deep gold instead of beige.
- Round-1 bye slots in the bracket read "bye" instead of "TBD"; no stray dot before the
phase badge when a tournament has no date.
## [0.5.1] - 2026-09-03
### Changed
- A team that withdraws during a bracket now shows as a **forfeit** in every match it was in
or would have reached, with its name struck through and a "Forfeit · Team" tag on the match,
instead of quietly becoming a bye. If the opponent isn't known yet, the withdrawn team stays
in its slot and forfeits the moment the opponent arrives; in double elimination it forfeits
its losers-bracket match too. Play continues and the tournament still goes Final on its own.
- Pool match lists mark forfeits as "FF".
## [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
+1 -1
View File
@@ -78,7 +78,7 @@ All organizer routes require the session cookie set by `/admin/login` (or Cloudf
| 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). |
-1
View File
@@ -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.
+2 -1
View File
@@ -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
@@ -45,7 +46,7 @@ A keeper link can only do two things: update the running score of the live match
- *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.
In a bracket the opponent advances by forfeit either way: the bracket shows the withdrawn team struck through with a "Forfeit" tag on each match it was in, including a later match whose opponent wasn't known yet at the time (it forfeits as soon as that opponent arrives). Play carries on and the tournament still finishes on its own.
**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.
+2 -11
View File
@@ -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 };
}
+87 -11
View File
@@ -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,14 +175,15 @@ 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);
// a withdrawn team was waiting here: it forfeits to the team that just arrived
if (next.autoForfeit && next.teamA && next.teamB && ['pending', 'on_deck'].includes(next.status)) {
const f = next.autoForfeit; delete next.autoForfeit;
if (this.teams.get(teamId)?.status === 'withdrawn') { next.status = 'void'; return; } // both gone
this.recordResult(next.id, [], { actor: 'system', forfeit: f });
}
}
@@ -163,7 +196,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. */
@@ -198,13 +270,17 @@ export class Tournament {
m.court = null;
}
} else if (['pending', 'on_deck', 'live'].includes(m.status)) {
// bracket: opponent walks over, if known; otherwise the slot becomes a bye
// bracket: opponent walks over by forfeit. If the opponent isn't known yet, the withdrawn
// team stays in its slot marked autoForfeit and forfeits the moment the opponent arrives,
// so the bracket shows the forfeit rather than a silent 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 m.autoForfeit = teamId;
m.court = null;
}
}
this.settleByes();
this._checkComplete();
this.emit({ type: 'team_withdrawn', team: teamId, name: t.name, mode });
}
+1 -1
View File
@@ -1 +1 @@
{"name":"courtside","type":"module","version":"0.4.2","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.2","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"}}
+1 -1
View File
@@ -5,7 +5,7 @@ export function publicState({ t, engine }) {
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, liveSets: m.liveSets ?? null, startedAt: m.startedAt ?? null, feeds: m.feeds, feedsSide: m.feedsSide, loserFeeds: m.loserFeeds ?? null, loserFeedsSide: m.loserFeedsSide ?? null, conditional: !!m.conditional,
winner: m.winner, sets: m.sets, forfeitBy: m.status === 'forfeit' ? m.loser : (m.autoForfeit ?? null), live: m.live ?? null, liveSets: m.liveSets ?? null, startedAt: m.startedAt ?? null, feeds: m.feeds, feedsSide: m.feedsSide, loserFeeds: m.loserFeeds ?? null, loserFeedsSide: m.loserFeedsSide ?? null, conditional: !!m.conditional,
});
const board = t.phase === 'live' ? engine.board() : null;
const pools = t.pools.map(p => ({
+11
View File
@@ -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;
+8 -3
View File
@@ -2,6 +2,7 @@
--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;
--gold:#B8860B;--gold-light:#D4A422;--gold-deep:#8A6508;
}
@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}
@@ -20,13 +21,13 @@ main{max-width:960px;margin:0 auto;padding:16px 16px 64px}
.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)}
.pill.live{background:var(--good);color:#fff}.pill.final{background:var(--gold);color:#fff}.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}
.champion{text-align:center;padding:22px 20px;margin:14px 0;border:2px solid var(--gold-deep);border-radius:8px;background:linear-gradient(160deg,var(--gold-light),var(--gold) 55%,var(--gold-deep));color:#fff;text-shadow:0 1px 2px rgba(0,0,0,.25)}.champion .eyebrow{color:#FFF3C4}.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)}
@@ -84,7 +85,8 @@ ul.plain,ol.plain{list-style:none;padding:0;margin:8px 0}ol.plain{counter-reset:
.brow.won .bn{font-weight:600}.brow.won .bs{color:var(--ink);font-weight:600}.brow.mine{background:var(--sand-soft)}
.bm.live{border-color:var(--good);box-shadow:0 0 0 1px var(--good)}.bm.on_deck{border-style:dashed}.bm.mine{border-color:var(--sand);border-width:2px}
.btag{position:absolute;left:50%;transform:translateX(-50%);bottom:-9px;white-space:nowrap;font-family:"Barlow Condensed",sans-serif;text-transform:uppercase;letter-spacing:.08em;font-size:10px;padding:0 6px;border-radius:3px;background:var(--bg);border:1px solid var(--line);color:var(--muted);line-height:1.5}
.btag.live{background:var(--good);color:#fff;border-color:var(--good)}
.btag.live{background:var(--good);color:#fff;border-color:var(--good)}.btag.ff{background:var(--sand-soft);color:var(--sand);border-color:var(--sand)}
.brow.wd .bn,.wd{text-decoration:line-through;color:var(--muted)}.brow.wd .bn a{color:var(--muted)}
.bm.list .btag{position:static;transform:none;display:inline-block;align-self:flex-start;margin:4px 9px 6px}
.blist{margin-top:10px}.blist .rlbl{position:static;text-align:left}
.bracketwrap.fit .btree-scroll{overflow:hidden}
@@ -115,6 +117,9 @@ body.display .queue{font-size:26px}body.display details{display:none}
.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}
+9 -5
View File
@@ -10,6 +10,9 @@
// 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 withdrawnIds = () => new Set(state.withdrawn.map(t => t.id));
const wdClass = t => t && withdrawnIds().has(t.id) ? 'wd' : '';
const forfeitTag = m => m.forfeitBy ? `<span class="btag ff">Forfeit · ${esc(state.withdrawn.concat(state.teams).find(t => t.id === m.forfeitBy)?.name ?? '')}</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);
@@ -31,7 +34,7 @@
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>`);
if (!display) parts.push(`<section class="hero compact"><div class="eyebrow">${s.date ? `${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>`);
@@ -102,7 +105,7 @@
<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}. ${name({ id: r.teamId, name: 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>
<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' : ''} ${wdClass(m.a)}">${name(m.a)}</span><span class="mono">${m.status === 'forfeit' ? 'FF' : score(m) || (m.court ? `C${m.court}` : '·')}</span><span class="${m.winner && m.winner === m.b?.id ? 'won' : ''} ${wdClass(m.b)}">${name(m.b)}</span></li>`).join('')}</ul></details>
</div>`).join('')}</section>`;
}
@@ -162,13 +165,14 @@
const row = (t, side) => {
const won = m.winner && t && m.winner === t.id;
const pts = m.sets[0] ? m.sets.map(s => s[side]).join(' ') : (m.live && m.status === 'live' ? m.live[side] : '');
return `<div class="brow ${won ? 'won' : ''} ${t && isMineId(t.id) ? 'mine' : ''}"><span class="bn">${t ? name(t) : `<span class="tbd">${feedLabel(m, side)}</span>`}</span><span class="bs mono">${pts}</span></div>`;
return `<div class="brow ${won ? 'won' : ''} ${t && isMineId(t.id) ? 'mine' : ''} ${wdClass(t)}"><span class="bn">${t ? name(t) : `<span class="tbd">${feedLabel(m, side)}</span>`}</span><span class="bs mono">${pts}</span></div>`;
};
const tag = m.status === 'live' ? `<span class="btag live">Court ${m.court}</span>` : m.status === 'on_deck' ? `<span class="btag">On deck${m.court ? ` · C${m.court}` : ''}</span>` : m.status === 'forfeit' ? '<span class="btag">Forfeit</span>' : m.label ? `<span class="btag">${esc(m.label)}</span>` : '';
const tag = m.status === 'live' ? `<span class="btag live">Court ${m.court}</span>` : m.status === 'on_deck' ? `<span class="btag">On deck${m.court ? ` · C${m.court}` : ''}</span>` : m.forfeitBy ? forfeitTag(m) : m.label ? `<span class="btag">${esc(m.label)}</span>` : '';
return `<div class="bm ${m.status} ${isMine(m) ? 'mine' : ''}" style="left:${p.x}px;top:${p.y}px;width:${BOX_W}px;height:${BOX_H}px">${row(m.a, 0)}${row(m.b, 1)}${tag}</div>`;
}
function feedLabel(m, side) {
if (m.status === 'bye') return 'bye';
// what fills this slot later: winner/loser of which match
const wantSide = side === 0 ? 'A' : 'B';
const w = state.bracket.find(f => f.feeds === m.id && f.feedsSide === wantSide);
@@ -181,7 +185,7 @@
function bracketList(stage, ms) {
const rounds = [...new Set(ms.map(m => m.round))].sort((a, b) => a - b);
return rounds.map(r => `<div class="blist"><div class="rlbl">${roundName(stage, r, rounds.length, ms)}</div>${ms.filter(m => m.round === r).map(m => `<div class="bm list ${m.status} ${isMine(m) ? 'mine' : ''}">${['a', 'b'].map((k, i) => { const t = m[k]; const won = m.winner && t && m.winner === t.id; return `<div class="brow ${won ? 'won' : ''} ${t && isMineId(t.id) ? 'mine' : ''}"><span class="bn">${t ? name(t) : `<span class="tbd">${feedLabel(m, i)}</span>`}</span><span class="bs mono">${m.sets[0] ? m.sets.map(s => s[i]).join(' ') : (m.live && m.status === 'live' ? m.live[i] : '')}</span></div>`; }).join('')}${m.status === 'live' ? `<span class="btag live">Court ${m.court}</span>` : m.status === 'on_deck' ? `<span class="btag">On deck${m.court ? ` · C${m.court}` : ''}</span>` : m.status === 'forfeit' ? '<span class="btag">Forfeit</span>' : m.label ? `<span class="btag">${esc(m.label)}</span>` : ''}</div>`).join('')}</div>`).join('');
return rounds.map(r => `<div class="blist"><div class="rlbl">${roundName(stage, r, rounds.length, ms)}</div>${ms.filter(m => m.round === r).map(m => `<div class="bm list ${m.status} ${isMine(m) ? 'mine' : ''}">${['a', 'b'].map((k, i) => { const t = m[k]; const won = m.winner && t && m.winner === t.id; return `<div class="brow ${won ? 'won' : ''} ${t && isMineId(t.id) ? 'mine' : ''} ${wdClass(t)}"><span class="bn">${t ? name(t) : `<span class="tbd">${feedLabel(m, i)}</span>`}</span><span class="bs mono">${m.sets[0] ? m.sets.map(s => s[i]).join(' ') : (m.live && m.status === 'live' ? m.live[i] : '')}</span></div>`; }).join('')}${m.status === 'live' ? `<span class="btag live">Court ${m.court}</span>` : m.status === 'on_deck' ? `<span class="btag">On deck${m.court ? ` · C${m.court}` : ''}</span>` : m.forfeitBy ? forfeitTag(m) : m.label ? `<span class="btag">${esc(m.label)}</span>` : ''}</div>`).join('')}</div>`).join('');
}
const isMineId = id => myTeam && id === myTeam;
+6
View File
@@ -170,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 });
+8
View File
@@ -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); }
}
}
}
+21
View File
@@ -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)}">
+90
View File
@@ -152,3 +152,93 @@ 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/);
});
test('withdrawal mid-bracket shows as forfeits (not byes) and play continues to a champion', () => {
const t = setup(8, { stages: ['bracket'] });
t.closeRegistration();
t.generateBracket(ids(t), { type: 'double' });
let clock = 0;
const eng = new CourtEngine(t, { now: () => clock });
eng.start();
// play two matches, then withdraw a team that is live on a court
for (let i = 0; i < 2; i++) { const c = eng.courts.find(c => c.matchId); clock += 10 * 60_000; eng.recordResult(c.matchId, [[21, 10]]); }
const liveMatch = t.match(eng.courts.find(c => c.matchId).matchId);
const quitter = liveMatch.teamA;
eng.withdrawTeam(quitter, { mode: 'forfeit' });
// the live match is now a forfeit by the quitter, and any later match they land in forfeits too
assert.equal(liveMatch.status, 'forfeit'); assert.equal(liveMatch.loser, quitter);
let guard = 0;
while (t.phase === 'live' && guard++ < 100) {
const c = eng.courts.find(c => c.matchId); assert.ok(c, 'stalled');
clock += 10 * 60_000; eng.recordResult(c.matchId, [[21, 10]]);
}
assert.equal(t.phase, 'final');
const theirs = t.matches.filter(m => [m.teamA, m.teamB].includes(quitter) && m.stage !== 'pool');
assert.ok(theirs.length >= 2, 'quitter appears in WB and LB');
assert.ok(theirs.every(m => m.status === 'forfeit' && m.loser === quitter), 'every match of the quitter is a forfeit against them');
assert.equal(t.matches.filter(m => m.status === 'bye' && m.round > 1).length, 0, 'no silent byes created by the withdrawal');
});