2 Commits
Author SHA1 Message Date
bkvargyasandClaude Fable 5.1 103c221a12 Average player age at registration; age-matched round 1 for bracket-only tournaments
Optional age on signup and the desk. Auto-generated brackets pair neighbours by
age with byes spread down the sheet; teams without an age are placed at random,
fully random when no ages exist. Release 0.6.0.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01MB7nCCAscYsb3zzkT6LHZi
2026-09-05 16:28:12 +00:00
bkvargyasandClaude Fable 5.1 3f60a39e45 TV display: courts pinned at top, lower pane scrolls and pages itself
Release 0.5.3.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01MB7nCCAscYsb3zzkT6LHZi
2026-09-05 16:20:55 +00:00
11 changed files with 138 additions and 22 deletions

No files matched your search

+19
View File
@@ -9,6 +9,25 @@ so you can always tell what a deployment is actually serving.
## [Unreleased] ## [Unreleased]
## [0.6.0] - 2026-09-03
### Added
- Registration asks for the team's **average player age** (optional), with a note that it is
used to match teams of similar age groups in round 1 and is not shown publicly. Editable on
the desk's team list and when adding a walk-up team.
- **Age-matched round 1** for bracket-only tournaments: teams are sorted by average age and
neighbours play each other; teams that gave no age are slotted in at random, and if nobody
gave an age the first round is fully random. Byes are spread down the sheet so round-2
opponents are also close in age. On by default when generating; untick to use seed order.
The hand-built bracket form is pre-filled the same way.
## [0.5.3] - 2026-09-03
### Changed
- TV display: the courts (and banner) stay pinned at the top of the screen; up next, brackets
and standings scroll in a pane underneath, paging automatically every 12 seconds and
wrapping back to the top. Idle courts and the banner now use dark-theme colors on the TV.
## [0.5.2] - 2026-09-03 ## [0.5.2] - 2026-09-03
### Changed ### Changed
+2 -2
View File
@@ -9,7 +9,7 @@ All pages are server-rendered HTML except where noted. `<slug>` is the URL-safe
| GET | `/` | List of tournaments. | | GET | `/` | List of tournaments. |
| GET | `/healthz` | `{ ok, tournaments, uptime }` JSON. Used by the Docker healthcheck. | | GET | `/healthz` | `{ ok, tournaments, uptime }` JSON. Used by the Docker healthcheck. |
| GET | `/t/<slug>` | The QR landing page. Registration form while phase is `checkin`; otherwise the live board / results (client-rendered from state JSON). | | GET | `/t/<slug>` | The QR landing page. Registration form while phase is `checkin`; otherwise the live board / results (client-rendered from state JSON). |
| POST | `/t/<slug>/register` | Form fields `name`, `captain`, `phone`, `players`, `playerNames` (newline-separated). 303 to the team page on success, 400 with the form re-rendered on error. | | POST | `/t/<slug>/register` | Form fields `name`, `captain`, `phone`, `players`, `avgAge` (optional, 5–99), `playerNames` (newline-separated). 303 to the team page on success, 400 with the form re-rendered on error. |
| GET | `/t/<slug>/teams/<id>` | The same personalized board addressed by public team id; this is what team-name links use. | | GET | `/t/<slug>/teams/<id>` | The same personalized board addressed by public team id; this is what team-name links use. |
| GET | `/t/<slug>/team/<code>` | Personalized board for the team with that 4-character code. `?new=1` shows the post-registration confirmation instead. | | GET | `/t/<slug>/team/<code>` | Personalized board for the team with that 4-character code. `?new=1` shows the post-registration confirmation instead. |
| GET | `/t/<slug>/display` | TV display mode of the board. | | GET | `/t/<slug>/display` | TV display mode of the board. |
@@ -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>/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>/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>/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` — 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>/generate` | `what=pools`, `poolSize` — or `what=bracket`, `perPool`, `wildcards`, `type` (`single`/`double`), `thirdPlace`, `byAge` (bracket-only: age-matched round 1, random where no age) — 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>/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>/score` | `match`, `sets` (`"21-18, 19-21, 15-9"`) | Record or correct a result. Validated against the rules. |
| POST | `/admin/t/<slug>/live` | `match`, `a`, `b` | Update the running score of a live match (JSON, used by the score pad). | | POST | `/admin/t/<slug>/live` | `match`, `a`, `b` | Update the running score of a live match (JSON, used by the score pad). |
+2 -2
View File
@@ -12,14 +12,14 @@ Everything an organizer does happens at `/admin` on a phone or laptop. Sign in w
## Check-in ## Check-in
Teams scan the code and fill in team name, captain, mobile, number of players, optional player names. They get a private team link on the confirmation screen with a "Text it to myself" button. The team link shows their schedule, record, and alerts all day. Teams scan the code and fill in team name, captain, mobile, number of players, an optional average player age, and optional player names. They get a private team link on the confirmation screen with a "Text it to myself" button. The team link shows their schedule, record, and alerts all day.
From the desk you can rename a team, fix the captain or player count, remove a team, add a walk-up team, and set seeds (1 = strongest). Seeds decide pool placement (snake order) and bracket placement. If you don't seed, registration order is used. From the desk you can rename a team, fix the captain or player count, remove a team, add a walk-up team, and set seeds (1 = strongest). Seeds decide pool placement (snake order) and bracket placement. If you don't seed, registration order is used.
## Starting play ## Starting play
1. **Close registration.** The public page switches to the roster and "schedule coming." 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. 2. **Generate pools** (pick the pool size) or, for bracket-only, generate the bracket. For bracket-only days, "Match round 1 by similar age" (on by default) sorts teams by the average age they gave at registration and pairs neighbours, drops teams without an age in at random, and spreads byes so second-round opponents are close in age too; untick it to use seed order. 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. 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. 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.
+43 -2
View File
@@ -26,7 +26,7 @@ export class Tournament {
emit(evt) { this.log.push({ ts: this.log.length, ...evt }); for (const f of this.listeners) f(evt, this); } emit(evt) { this.log.push({ ts: this.log.length, ...evt }); for (const f of this.listeners) f(evt, this); }
// ---------- registration ---------- // ---------- registration ----------
registerTeam({ name, players = 2, captain = null, phone = null, playerNames = [] }, { force = false } = {}) { registerTeam({ name, players = 2, captain = null, phone = null, playerNames = [], avgAge = null }, { force = false } = {}) {
if (this.phase !== 'checkin' && !force) throw new Error('Registration is closed'); if (this.phase !== 'checkin' && !force) throw new Error('Registration is closed');
if (force && this.matches.length) throw new Error('Pools or bracket already generated; regenerate after adding a team'); if (force && this.matches.length) throw new Error('Pools or bracket already generated; regenerate after adding a team');
name = String(name ?? '').trim(); name = String(name ?? '').trim();
@@ -36,8 +36,9 @@ export class Tournament {
if (!Number.isInteger(players) || players < 1 || players > 20) throw new Error('Player count must be a whole number between 1 and 20'); if (!Number.isInteger(players) || players < 1 || players > 20) throw new Error('Player count must be a whole number between 1 and 20');
if ([...this.teams.values()].some(t => t.name.toLowerCase() === name.toLowerCase())) throw new Error(`Team name "${name}" already taken`); if ([...this.teams.values()].some(t => t.name.toLowerCase() === name.toLowerCase())) throw new Error(`Team name "${name}" already taken`);
if (players < this.rules.teamSize) throw new Error(`Need at least ${this.rules.teamSize} players for ${this.rules.teamSize}s`); if (players < this.rules.teamSize) throw new Error(`Need at least ${this.rules.teamSize} players for ${this.rules.teamSize}s`);
avgAge = parseAge(avgAge);
const id = `t${this._seq++}`; const id = `t${this._seq++}`;
const team = { id, name, players, captain, phone, playerNames: playerNames.filter(Boolean), seed: this.teams.size + 1, status: 'registered', poolId: null, code: code4(), registeredAt: this.now?.() ?? Date.now() }; const team = { id, name, players, captain, phone, avgAge, playerNames: playerNames.filter(Boolean), seed: this.teams.size + 1, status: 'registered', poolId: null, code: code4(), registeredAt: this.now?.() ?? Date.now() };
this.teams.set(id, team); this.teams.set(id, team);
this.emit({ type: 'team_registered', team: id, name }); this.emit({ type: 'team_registered', team: id, name });
return team; return team;
@@ -55,6 +56,7 @@ export class Tournament {
for (const k of ['captain', 'phone']) if (patch[k] !== undefined) t[k] = patch[k]; for (const k of ['captain', 'phone']) if (patch[k] !== undefined) t[k] = patch[k];
if (patch.players !== undefined) { const p = Number(patch.players); if (!Number.isInteger(p) || p < 1) throw new Error('Bad player count'); t.players = p; } if (patch.players !== undefined) { const p = Number(patch.players); if (!Number.isInteger(p) || p < 1) throw new Error('Bad player count'); t.players = p; }
if (patch.playerNames !== undefined) t.playerNames = patch.playerNames.filter(Boolean); if (patch.playerNames !== undefined) t.playerNames = patch.playerNames.filter(Boolean);
if (patch.avgAge !== undefined) t.avgAge = parseAge(patch.avgAge);
this.emit({ type: 'team_updated', team: id, name: t.name }); this.emit({ type: 'team_updated', team: id, name: t.name });
return t; return t;
} }
@@ -138,6 +140,32 @@ export class Tournament {
return this.generateBracket(seeded, { ...opts, manual: true }); return this.generateBracket(seeded, { ...opts, manual: true });
} }
/**
* Round-1 pairs that put teams of similar average age against each other. Teams are sorted by
* age (teams without an age are slotted in at random) and laid down the sheet in order; the byes needed to fill the bracket
* are spread evenly so each bye team's round-2 opponent is also close in age. Returns pairs in
* sheet order, ready for generateBracketFromPairs.
*/
pairsByAge(teamIds = this.activeTeams().map(t => t.id), rnd = Math.random) {
const teams = teamIds.map(id => this.teams.get(id));
const known = teams.filter(t => t.avgAge != null).sort((a, b) => a.avgAge - b.avgAge);
// teams that gave no age are dropped in at random positions (fully random when nobody gave one)
const ordered = known.map(t => t.id);
for (const t of shuffle(teams.filter(t => t.avgAge == null), rnd)) ordered.splice(Math.floor(rnd() * (ordered.length + 1)), 0, t.id);
const size = 1 << Math.ceil(Math.log2(Math.max(2, ordered.length)));
const matches = size / 2, full = ordered.length - matches; // matches with two teams; the rest are byes
// choose which match positions are full, spread evenly down the sheet
const fullAt = new Set(Array.from({ length: full }, (_, i) => Math.floor((i + 0.5) * matches / full)));
const pairs = []; let k = 0;
for (let i = 0; i < matches; i++) {
if (fullAt.has(i)) pairs.push([ordered[k++], ordered[k++]]);
else pairs.push([ordered[k++] ?? null, null]);
}
return pairs;
}
hasAges() { return this.activeTeams().some(t => t.avgAge != null); }
/** Default first-round pairs (standard seeding) for `seededIds`, for pre-filling the manual builder. */ /** Default first-round pairs (standard seeding) for `seededIds`, for pre-filling the manual builder. */
defaultPairs(seededIds) { defaultPairs(seededIds) {
const size = 1 << Math.ceil(Math.log2(Math.max(2, seededIds.length))); const size = 1 << Math.ceil(Math.log2(Math.max(2, seededIds.length)));
@@ -383,6 +411,19 @@ export function validateSet(a, b, rules) {
return true; return true;
} }
function shuffle(arr, rnd = Math.random) {
const a = [...arr];
for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(rnd() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; }
return a;
}
function parseAge(v) {
if (v === null || v === undefined || v === '') return null;
const n = Number(v);
if (!Number.isFinite(n) || n < 5 || n > 99) throw new Error('Average age should be a number between 5 and 99');
return Math.round(n);
}
function groupBy(sortedList, key) { function groupBy(sortedList, key) {
const out = []; const out = [];
for (const item of sortedList) { for (const item of sortedList) {
+1 -1
View File
@@ -1 +1 @@
{"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"}} {"name":"courtside","type":"module","version":"0.6.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"}}
+3 -1
View File
@@ -29,6 +29,7 @@ main{max-width:960px;margin:0 auto;padding:16px 16px 64px}
.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} .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: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} .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 */ /* forms */
.form .hint{display:block;font-weight:400;font-size:14px;color:var(--muted);margin-top:4px}
.form label{display:block;margin:12px 0;font-weight:600} .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)} .form input,.form textarea,.form select{display:block;width:100%;margin-top:4px;padding:10px 12px;font:inherit;font-weight:400;border:1px solid var(--line);border-radius:6px;background:var(--bg);color:var(--ink)}
button,.button{font-family:"Barlow Condensed",sans-serif;text-transform:uppercase;letter-spacing:.06em;font-size:17px;font-weight:600;padding:10px 18px;border-radius:6px;border:1px solid var(--line);background:var(--surface);color:var(--ink);cursor:pointer;text-decoration:none;display:inline-block;line-height:1.2} button,.button{font-family:"Barlow Condensed",sans-serif;text-transform:uppercase;letter-spacing:.06em;font-size:17px;font-weight:600;padding:10px 18px;border-radius:6px;border:1px solid var(--line);background:var(--surface);color:var(--ink);cursor:pointer;text-decoration:none;display:inline-block;line-height:1.2}
@@ -94,7 +95,8 @@ ul.plain,ol.plain{list-style:none;padding:0;margin:8px 0}ol.plain{counter-reset:
.loading{color:var(--muted);padding:24px} .loading{color:var(--muted);padding:24px}
/* display (TV) mode */ /* display (TV) mode */
body.display{font-size:22px;background:#0F1418;color:#F2F5F7} body.display{font-size:22px;background:#0F1418;color:#F2F5F7}
body.display .top,body.display .foot{display:none}body.display main{max-width:none;padding:20px 28px} body.display .top,body.display .foot{display:none}body.display main{max-width:none;padding:14px 24px 0;height:100vh;height:100dvh;box-sizing:border-box}
body.display .app{display:flex;flex-direction:column;height:100%}body.display .pin{flex:none;padding-bottom:10px;border-bottom:1px solid #2B3640}body.display .scrollpane{flex:1;min-height:0;overflow-y:auto;padding:10px 0 24px;scrollbar-width:none}body.display .scrollpane::-webkit-scrollbar{display:none}body.display .courts{margin-bottom:0}body.display .court.idle,body.display .court.paused{background:#1A222B;color:#9AA6B1;border-color:#2B3640}body.display .court.idle .nextup{color:#F2F5F7;border-top-color:#2B3640}body.display .banner{background:#3A3120;color:#F0C46B;border-left-color:#D9B26E}body.display .brow .bs{color:#9AA6B1}body.display .brow.won .bs{color:#F2F5F7}
body.display .display-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:10px}body.display h1{font-size:56px} body.display .display-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:10px}body.display h1{font-size:56px}
body.display .court .t{font-size:40px}body.display .court .s{font-size:52px}body.display .court{min-height:150px} body.display .court .t{font-size:40px}body.display .court .s{font-size:52px}body.display .court{min-height:150px}
body.display .card{background:#1A222B;border-color:#2B3640;color:#F2F5F7}body.display td{border-color:#2B3640}body.display .bm{background:#12181F;border-color:#2B3640}body.display .brow+.brow{border-color:#2B3640}body.display .bcanvas path{stroke:#3A4652}body.display .btag{background:#1A222B;border-color:#2B3640} body.display .card{background:#1A222B;border-color:#2B3640;color:#F2F5F7}body.display td{border-color:#2B3640}body.display .bm{background:#12181F;border-color:#2B3640}body.display .brow+.brow{border-color:#2B3640}body.display .bcanvas path{stroke:#3A4652}body.display .btag{background:#1A222B;border-color:#2B3640}
+15 -2
View File
@@ -45,7 +45,15 @@
if (s.pools.length) parts.push(renderPools()); if (s.pools.length) parts.push(renderPools());
if (!s.pools.length && !s.bracket.length) parts.push(renderRoster()); if (!s.pools.length && !s.bracket.length) parts.push(renderRoster());
if (s.withdrawn.length && !display) parts.push(`<p class="muted small">Withdrawn: ${s.withdrawn.map(t => esc(t.name)).join(', ')}</p>`); if (s.withdrawn.length && !display) parts.push(`<p class="muted small">Withdrawn: ${s.withdrawn.map(t => esc(t.name)).join(', ')}</p>`);
app.innerHTML = parts.join(''); if (display) {
// TV: courts stay pinned at the top; everything else scrolls underneath
const pane = app.querySelector('.scrollpane');
const keep = pane ? pane.scrollTop : 0;
const pinCount = parts.findIndex(p => p.startsWith('<section class="queue"') || p.startsWith('<section class="card bracketwrap') || p.startsWith('<section class="pools"') || p.startsWith('<section class="card"'));
const cut = pinCount === -1 ? parts.length : pinCount;
app.innerHTML = `<div class="pin">${parts.slice(0, cut).join('')}</div><div class="scrollpane">${parts.slice(cut).join('')}</div>`;
const np = app.querySelector('.scrollpane'); if (np) np.scrollTop = keep;
} else app.innerHTML = parts.join('');
fitBracket(); fitBracket();
notifyMine(); notifyMine();
} }
@@ -257,5 +265,10 @@
connect(); connect();
// belt and braces: a full refresh every 2 minutes in case a proxy drops the socket silently // belt and braces: a full refresh every 2 minutes in case a proxy drops the socket silently
setInterval(() => fetch(`/t/${state.slug}/state.json`).then(r => r.json()).then(s => { state = s; render(); }).catch(() => {}), 120000); setInterval(() => fetch(`/t/${state.slug}/state.json`).then(r => r.json()).then(s => { state = s; render(); }).catch(() => {}), 120000);
if (display) setInterval(() => document.documentElement.scrollTo({ top: (Date.now() / 1000 | 0) % 2 ? 0 : document.body.scrollHeight, behavior: 'smooth' }), 15000); // TV: page the lower pane (up next, brackets, standings) every 12 s and wrap to the top; courts never move
if (display) setInterval(() => {
const pane = app.querySelector('.scrollpane'); if (!pane || pane.scrollHeight <= pane.clientHeight) return;
const atEnd = pane.scrollTop + pane.clientHeight >= pane.scrollHeight - 4;
pane.scrollTo({ top: atEnd ? 0 : pane.scrollTop + pane.clientHeight * 0.85, behavior: 'smooth' });
}, 12000);
})(); })();
+11 -5
View File
@@ -42,7 +42,7 @@ export function buildRouter({ registry, auth, store }) {
try { try {
const team = registry.mutate(slug, t => t.registerTeam({ const team = registry.mutate(slug, t => t.registerTeam({
name: body.name, captain: String(body.captain ?? '').trim().slice(0, 60), phone: String(body.phone ?? '').trim().slice(0, 30), name: body.name, captain: String(body.captain ?? '').trim().slice(0, 60), phone: String(body.phone ?? '').trim().slice(0, 30),
players: body.players, playerNames: String(body.playerNames ?? '').split('\n').map(s => s.trim()).filter(Boolean).slice(0, 20), players: body.players, avgAge: body.avgAge, playerNames: String(body.playerNames ?? '').split('\n').map(s => s.trim()).filter(Boolean).slice(0, 20),
})); }));
send.redirect(res, `/t/${slug}/team/${team.code}?new=1`); send.redirect(res, `/t/${slug}/team/${team.code}?new=1`);
} catch (e) { } catch (e) {
@@ -176,10 +176,16 @@ export function buildRouter({ registry, auth, store }) {
const res = t.generateBracketFromPairs(pairs, { type: body.type === 'double' ? 'double' : 'single', thirdPlace: !!body.thirdPlace }); 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`; return `${body.type === 'double' ? 'Double' : 'Single'} elimination bracket of ${res.size} built from your pairings`;
} }
const opts = { type: body.type === 'double' ? 'double' : 'single', thirdPlace: !!body.thirdPlace };
if (!t.pools.length && body.byAge) {
if (t.activeTeams().length < 2) throw new Error('Need at least two teams');
const res = t.generateBracketFromPairs(t.pairsByAge(), opts);
return `${opts.type === 'double' ? 'Double' : 'Single'} elimination bracket of ${res.size} generated; ${t.hasAges() ? 'round 1 matched by similar average age (teams without an age placed at random)' : 'no ages were given, so round 1 is random'}`;
}
const seeded = t.pools.length ? t.advanceFromPools({ perPool: +body.perPool || 2, wildcards: +body.wildcards || 0 }) : t.activeTeams().map(x => x.id); 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'); 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 }); const res = t.generateBracket(seeded, opts);
return `${body.type === 'double' ? 'Double' : 'Single'} elimination bracket of ${res.size} generated with ${seeded.length} teams`; return `${opts.type === 'double' ? 'Double' : 'Single'} elimination bracket of ${res.size} generated with ${seeded.length} teams`;
}); });
action('/regenerate', ({ t }) => { if (t.matches.some(m => ['final', 'forfeit'].includes(m.status))) throw new Error('Scores already entered'); t.matches = []; t.pools = []; for (const x of t.teams.values()) x.poolId = null; if (t.phase === 'live') t.phase = 'closed'; t.emit({ type: 'play_cleared' }); return 'Generated play cleared'; }); action('/regenerate', ({ t }) => { if (t.matches.some(m => ['final', 'forfeit'].includes(m.status))) throw new Error('Scores already entered'); t.matches = []; t.pools = []; for (const x of t.teams.values()) x.poolId = null; if (t.phase === 'live') t.phase = 'closed'; t.emit({ type: 'play_cleared' }); return 'Generated play cleared'; });
action('/score', ({ t, engine, body, who }) => { action('/score', ({ t, engine, body, who }) => {
@@ -197,10 +203,10 @@ export function buildRouter({ registry, auth, store }) {
action('/pushback', ({ engine, body }) => { engine.pushBack(body.match, 2); return 'Pushed back'; }); action('/pushback', ({ engine, body }) => { engine.pushBack(body.match, 2); return 'Pushed back'; });
action('/court', ({ engine, body }) => { if (body.op === 'pause') engine.pauseCourt(+body.court, String(body.reason ?? '').slice(0, 60)); else engine.resumeCourt(+body.court); return `Court ${body.court} ${body.op}d`; }); action('/court', ({ engine, body }) => { if (body.op === 'pause') engine.pauseCourt(+body.court, String(body.reason ?? '').slice(0, 60)); else engine.resumeCourt(+body.court); return `Court ${body.court} ${body.op}d`; });
action('/team', ({ t, body }) => { action('/team', ({ t, body }) => {
if (body.op === 'add') { const team = t.registerTeam({ name: body.name, captain: body.captain, players: body.players }, { force: true }); return `Added ${team.name} (code ${team.code})`; } if (body.op === 'add') { const team = t.registerTeam({ name: body.name, captain: body.captain, players: body.players, avgAge: body.avgAge }, { force: true }); return `Added ${team.name} (code ${team.code})`; }
if (body.op === 'remove') { t.removeTeam(body.id); return 'Team removed'; } if (body.op === 'remove') { t.removeTeam(body.id); return 'Team removed'; }
if (body.op === 'seed') { const list = t.activeTeams().filter(x => x.id !== body.id); list.splice(Math.max(0, +body.seed - 1), 0, t.teams.get(body.id)); t.setSeeds(list.map(x => x.id)); return 'Seeds updated'; } if (body.op === 'seed') { const list = t.activeTeams().filter(x => x.id !== body.id); list.splice(Math.max(0, +body.seed - 1), 0, t.teams.get(body.id)); t.setSeeds(list.map(x => x.id)); return 'Seeds updated'; }
t.updateTeam(body.id, { name: body.name, captain: body.captain, players: body.players }); return 'Team saved'; t.updateTeam(body.id, { name: body.name, captain: body.captain, players: body.players, avgAge: body.avgAge }); return 'Team saved';
}); });
action('/withdraw', ({ t, engine, body, who }) => { const team = t.teams.get(body.id); if (!team) throw new Error('No such team'); if (t.phase === 'live') engine.withdrawTeam(body.id, { mode: body.mode, actor: who }); else t.withdrawTeam(body.id, { mode: body.mode, actor: who }); return `${team.name} withdrawn (${body.mode})`; }); action('/withdraw', ({ t, engine, body, who }) => { const team = t.teams.get(body.id); if (!team) throw new Error('No such team'); if (t.phase === 'live') engine.withdrawTeam(body.id, { mode: body.mode, actor: who }); else t.withdrawTeam(body.id, { mode: body.mode, actor: who }); return `${team.name} withdrawn (${body.mode})`; });
r.post('/admin/t/:slug/delete', async (req, res, { slug }) => { r.post('/admin/t/:slug/delete', async (req, res, { slug }) => {
+8 -7
View File
@@ -82,7 +82,7 @@ export function adminTournament({ t, engine }, who, origin, msg, err, events, ke
${!generated ? ` ${!generated ? `
<form method="post" action="/admin/t/${esc(t.slug)}/generate" class="inline"> <form method="post" action="/admin/t/${esc(t.slug)}/generate" class="inline">
${t.stages.includes('pool') ? `<label>Pool size <select name="poolSize"><option>3</option><option selected>4</option><option>5</option><option>6</option></select></label><button class="primary small" type="submit" name="what" value="pools">Generate pools</button>` : ''} ${t.stages.includes('pool') ? `<label>Pool size <select name="poolSize"><option>3</option><option selected>4</option><option>5</option><option>6</option></select></label><button class="primary small" type="submit" name="what" value="pools">Generate pools</button>` : ''}
${!t.stages.includes('pool') ? `<label>Bracket <select name="type"><option value="single">Single elimination</option><option value="double">Double elimination</option></select></label><label><input type="checkbox" name="thirdPlace" value="1"> 3rd place</label><button class="primary small" type="submit" name="what" value="bracket">Generate bracket</button>` : ''} ${!t.stages.includes('pool') ? `<label>Bracket <select name="type"><option value="single">Single elimination</option><option value="double">Double elimination</option></select></label><label><input type="checkbox" name="thirdPlace" value="1"> 3rd place</label><label title="Sorts teams by the average age given at registration and pairs neighbours in round 1; teams without an age are placed at random; byes are spread so round-2 opponents are close in age too. Untick to use seed order instead."><input type="checkbox" name="byAge" value="1" checked> Match round 1 by similar age${t.hasAges() ? '' : ' (no ages yet: fully random)'}</label><button class="primary small" type="submit" name="what" value="bracket">Generate bracket</button>` : ''}
</form> </form>
<p class="muted small">Uses current seeds (${t.activeTeams().length} active teams). Regenerating is allowed until the first score is entered.</p>` : ''} <p class="muted small">Uses current seeds (${t.activeTeams().length} active teams). Regenerating is allowed until the first score is entered.</p>` : ''}
${t.pools.length ? `<p>Pools: ${t.pools.map(p => `<b>${esc(p.id)}</b> (${p.teams.map(id => esc(t.teamName(id))).join(', ')})`).join(' · ')}</p>` : ''} ${t.pools.length ? `<p>Pools: ${t.pools.map(p => `<b>${esc(p.id)}</b> (${p.teams.map(id => esc(t.teamName(id))).join(', ')})`).join(' · ')}</p>` : ''}
@@ -102,18 +102,19 @@ export function adminTournament({ t, engine }, who, origin, msg, err, events, ke
</section> </section>
<section class="card"><h2>Teams (${t.activeTeams().length} active)</h2> <section class="card"><h2>Teams (${t.activeTeams().length} active)</h2>
<table class="adm-table"><tr><th>Seed</th><th>Team</th><th>Captain</th><th>Players</th><th>Code</th><th></th></tr> <table class="adm-table"><tr><th>Seed</th><th>Team</th><th>Captain</th><th>Players</th><th>Avg age</th><th>Code</th><th></th></tr>
${teams.map(x => `<tr class="${x.status === 'withdrawn' ? 'withdrawn' : ''}"> ${teams.map(x => `<tr class="${x.status === 'withdrawn' ? 'withdrawn' : ''}">
<td><form method="post" action="/admin/t/${esc(t.slug)}/team" class="inline"><input type="hidden" name="id" value="${esc(x.id)}"><input type="hidden" name="op" value="seed"><input name="seed" type="number" value="${x.seed}" min="1" style="width:56px" ${generated ? 'disabled' : ''}>${generated ? '' : '<button class="small" type="submit">Set</button>'}</form></td> <td><form method="post" action="/admin/t/${esc(t.slug)}/team" class="inline"><input type="hidden" name="id" value="${esc(x.id)}"><input type="hidden" name="op" value="seed"><input name="seed" type="number" value="${x.seed}" min="1" style="width:56px" ${generated ? 'disabled' : ''}>${generated ? '' : '<button class="small" type="submit">Set</button>'}</form></td>
<td><form method="post" action="/admin/t/${esc(t.slug)}/team" class="inline"><input type="hidden" name="id" value="${esc(x.id)}"><input type="hidden" name="op" value="edit"><input name="name" value="${esc(x.name)}" maxlength="40" style="width:150px"></td> <td><form method="post" action="/admin/t/${esc(t.slug)}/team" class="inline"><input type="hidden" name="id" value="${esc(x.id)}"><input type="hidden" name="op" value="edit"><input name="name" value="${esc(x.name)}" maxlength="40" style="width:150px"></td>
<td><input name="captain" value="${esc(x.captain ?? '')}" style="width:120px"> <span class="muted small">${esc(x.phone ?? '')}</span></td> <td><input name="captain" value="${esc(x.captain ?? '')}" style="width:120px"> <span class="muted small">${esc(x.phone ?? '')}</span></td>
<td><input name="players" type="number" value="${x.players}" min="1" style="width:56px"> <button class="small" type="submit">Save</button></form></td> <td><input name="players" type="number" value="${x.players}" min="1" style="width:56px"></td>
<td><input name="avgAge" type="number" value="${x.avgAge ?? ''}" min="5" max="99" style="width:56px" placeholder="—"> <button class="small" type="submit">Save</button></form></td>
<td class="mono"><a href="/t/${esc(t.slug)}/team/${esc(x.code)}" target="_blank">${esc(x.code)}</a></td> <td class="mono"><a href="/t/${esc(t.slug)}/team/${esc(x.code)}" target="_blank">${esc(x.code)}</a></td>
<td>${x.status === 'withdrawn' ? `withdrawn (${esc(x.withdrawalMode)})` : generated <td>${x.status === 'withdrawn' ? `withdrawn (${esc(x.withdrawalMode)})` : generated
? `<form method="post" action="/admin/t/${esc(t.slug)}/withdraw" class="inline"><input type="hidden" name="id" value="${esc(x.id)}"><select name="mode"><option value="forfeit">forfeit remaining</option><option value="void">void all games</option></select><button class="small danger" type="submit">Withdraw</button></form>` ? `<form method="post" action="/admin/t/${esc(t.slug)}/withdraw" class="inline"><input type="hidden" name="id" value="${esc(x.id)}"><select name="mode"><option value="forfeit">forfeit remaining</option><option value="void">void all games</option></select><button class="small danger" type="submit">Withdraw</button></form>`
: act('team', 'Remove', 'danger', `<input type="hidden" name="id" value="${esc(x.id)}"><input type="hidden" name="op" value="remove">`)}</td> : act('team', 'Remove', 'danger', `<input type="hidden" name="id" value="${esc(x.id)}"><input type="hidden" name="op" value="remove">`)}</td>
</tr>`).join('')}</table> </tr>`).join('')}</table>
${!generated ? `<form method="post" action="/admin/t/${esc(t.slug)}/team" class="inline"><input type="hidden" name="op" value="add"><input name="name" placeholder="Walk-up team name" required maxlength="40"><input name="captain" placeholder="captain"><input name="players" type="number" value="${t.rules.teamSize}" min="1" style="width:64px"><button class="small primary" type="submit">Add team</button></form>` : '<p class="muted small">Play is generated: use Withdraw for teams that leave. To add a team now, clear generated play first.</p>'} ${!generated ? `<form method="post" action="/admin/t/${esc(t.slug)}/team" class="inline"><input type="hidden" name="op" value="add"><input name="name" placeholder="Walk-up team name" required maxlength="40"><input name="captain" placeholder="captain"><input name="players" type="number" value="${t.rules.teamSize}" min="1" style="width:64px"><input name="avgAge" type="number" placeholder="avg age" min="5" max="99" style="width:84px"><button class="small primary" type="submit">Add team</button></form>` : '<p class="muted small">Play is generated: use Withdraw for teams that leave. To add a team now, clear generated play first.</p>'}
</section> </section>
<section class="card"><h2>Recent activity</h2><ul class="plain small">${events.slice(0, 40).map(e => `<li><span class="mono muted">${new Date(e.ts).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}</span> ${esc(describe(e, t))}</li>`).join('')}</ul></section> <section class="card"><h2>Recent activity</h2><ul class="plain small">${events.slice(0, 40).map(e => `<li><span class="mono muted">${new Date(e.ts).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}</span> ${esc(describe(e, t))}</li>`).join('')}</ul></section>
@@ -125,8 +126,8 @@ export function adminTournament({ t, engine }, who, origin, msg, err, events, ke
function manualBuilder(t) { function manualBuilder(t) {
const teams = t.activeTeams(); 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 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 pairs = !t.pools.length ? t.pairsByAge() : 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(''); const opt = sel => `<option value="">— bye —</option>` + teams.map(x => `<option value="${esc(x.id)}" ${x.id === sel ? 'selected' : ''}>${esc(x.name)}${x.avgAge != null ? ` · age ${x.avgAge}` : ''}${x.seed ? ` (${x.seed})` : ''}</option>`).join('');
return `<details class="manual"><summary>Build the bracket by hand — choose who plays whom in round 1</summary> 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"> <form method="post" action="/admin/t/${esc(t.slug)}/generate" class="manualform">
<input type="hidden" name="what" value="manual"> <input type="hidden" name="what" value="manual">
@@ -136,7 +137,7 @@ function manualBuilder(t) {
<table class="adm-table pairs"><tr><th>Match</th><th>Side A</th><th></th><th>Side B</th></tr> <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('')} ${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> </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> <p class="muted small">${!t.pools.length ? (t.hasAges() ? 'Pre-filled by similar average age (youngest at the top; teams without an age placed at random).' : 'Pre-filled at random (no ages were given).') : `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> <button class="primary small" type="submit">Build bracket from these pairings</button>
</form></details>`; </form></details>`;
} }
+1
View File
@@ -27,6 +27,7 @@ export function registerPage(state, { error = null, values = {} } = {}) {
<label>Captain's name <input name="captain" required maxlength="60" value="${esc(values.captain ?? '')}"></label> <label>Captain's name <input name="captain" required maxlength="60" value="${esc(values.captain ?? '')}"></label>
<label>Captain's mobile <input name="phone" type="tel" inputmode="tel" maxlength="30" value="${esc(values.phone ?? '')}" placeholder="optional, for organizer contact"></label> <label>Captain's mobile <input name="phone" type="tel" inputmode="tel" maxlength="30" value="${esc(values.phone ?? '')}" placeholder="optional, for organizer contact"></label>
<label>Number of players <input name="players" type="number" inputmode="numeric" min="${esc(r.teamSize)}" max="20" required value="${esc(values.players ?? r.teamSize)}"></label> <label>Number of players <input name="players" type="number" inputmode="numeric" min="${esc(r.teamSize)}" max="20" required value="${esc(values.players ?? r.teamSize)}"></label>
<label>Average age of your players <input name="avgAge" type="number" inputmode="numeric" min="5" max="99" value="${esc(values.avgAge ?? '')}" placeholder="e.g. 34"><span class="hint">Optional. We use this to match teams of similar age groups against each other in the first round, for the best play. Teams that skip it are placed at random. Not shown publicly.</span></label>
<label>Player names <textarea name="playerNames" rows="3" placeholder="one per line (optional)">${esc(values.playerNames ?? '')}</textarea></label> <label>Player names <textarea name="playerNames" rows="3" placeholder="one per line (optional)">${esc(values.playerNames ?? '')}</textarea></label>
<button class="primary" type="submit">Register</button> <button class="primary" type="submit">Register</button>
<p class="muted small">After you register you'll get a private team link. Keep this page's QR code handy: once play starts it becomes the live board.</p> <p class="muted small">After you register you'll get a private team link. Keep this page's QR code handy: once play starts it becomes the live board.</p>
+33
View File
@@ -242,3 +242,36 @@ test('withdrawal mid-bracket shows as forfeits (not byes) and play continues to
assert.ok(theirs.every(m => m.status === 'forfeit' && m.loser === quitter), 'every match of the quitter is a forfeit against them'); 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'); assert.equal(t.matches.filter(m => m.status === 'bye' && m.round > 1).length, 0, 'no silent byes created by the withdrawal');
}); });
test('age-matched round 1: neighbours in age play each other, byes spread evenly, everyone placed once', () => {
const t = setup(0, { stages: ['bracket'] });
const ages = [52, 23, 41, 38, 19, 60, 44, 25, 33, 29, null, 47];
ages.forEach((a, i) => t.registerTeam({ name: `T${i + 1}`, players: 2, avgAge: a }));
t.closeRegistration();
const pairs = t.pairsByAge();
assert.equal(pairs.length, 8); // 12 teams -> 16 bracket -> 8 matches
assert.equal(pairs.filter(([a, b]) => a && b).length, 4); // 4 real matches, 4 byes
const placed = pairs.flat().filter(Boolean);
assert.equal(new Set(placed).size, 12);
// every real match pairs two teams within 10 years of each other
for (const [a, b] of pairs) if (a && b) {
const x = t.teams.get(a).avgAge, y = t.teams.get(b).avgAge;
if (x != null && y != null) assert.ok(Math.abs(x - y) <= 10, `${x} vs ${y}`);
}
// byes are spread, not bunched at the bottom
const byeRows = pairs.map(([a, b], i) => (!a || !b) ? i : -1).filter(i => i >= 0);
assert.ok(byeRows[0] < 4 && byeRows.at(-1) >= 4);
t.generateBracketFromPairs(pairs, { type: 'double' });
assert.throws(() => t.registerTeam({ name: 'Bad', players: 2, avgAge: 150 }, { force: true }));
});
test('no ages at all: round-1 pairing is random, still valid', () => {
const t = setup(8, { stages: ['bracket'] });
t.closeRegistration();
let seed = 3; const rnd = () => ((seed = (seed * 1664525 + 1013904223) >>> 0) / 2 ** 32);
const pairs = t.pairsByAge(undefined, rnd);
assert.equal(pairs.length, 4);
assert.equal(new Set(pairs.flat()).size, 8);
assert.notDeepEqual(pairs.flat(), ids(t), 'order should not simply be seed order');
t.generateBracketFromPairs(pairs, { type: 'single' });
});