Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fb31c5eec | ||
|
|
110077314d | ||
|
|
103c221a12 | ||
|
|
3f60a39e45 | ||
|
|
594247ef56 | ||
|
|
4ce7e48551 | ||
|
|
a4bee22d96 |
No files matched your search
@@ -9,6 +9,74 @@ so you can always tell what a deployment is actually serving.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.6.2] - 2026-09-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Scoring rules (points to, win by, cap, best of) can be changed from the desk's Settings
|
||||||
|
area until the first score is entered, e.g. switching a set-up tournament to 15, win by 2,
|
||||||
|
cap 17. The tournament name is editable the same way.
|
||||||
|
|
||||||
|
## [0.6.1] - 2026-09-03
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Clock times (estimated finish, "ready ~", desk activity log) are shown in the tournament's
|
||||||
|
time zone rather than the viewer's device or the server's zone. Each tournament has a time
|
||||||
|
zone (Central by default, `DEFAULT_TIMEZONE` to change the default), chosen on creation and
|
||||||
|
editable from the desk's new Settings row along with the date and notes.
|
||||||
|
|
||||||
|
## [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
|
||||||
|
|
||||||
|
### 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
|
## [0.4.2] - 2026-09-03
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
+4
-3
@@ -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. |
|
||||||
@@ -71,14 +71,15 @@ All organizer routes require the session cookie set by `/admin/login` (or Cloudf
|
|||||||
| POST | `/admin/login` | `password`, `next` | Sets cookie, redirects to `next`. |
|
| POST | `/admin/login` | `password`, `next` | Sets cookie, redirects to `next`. |
|
||||||
| GET | `/admin/logout` | | Clears cookie. |
|
| GET | `/admin/logout` | | Clears cookie. |
|
||||||
| GET | `/admin` | | Desk home: tournament list and creation form. |
|
| GET | `/admin` | | Desk home: tournament list and creation form. |
|
||||||
| POST | `/admin/new` | `name`, `date`, `courtCount`, `teamSize`, `pointsTo`, `winBy`, `cap` (0 = none), `bestOf`, `stages` (`pool,bracket` / `pool` / `bracket`), `notes` | Creates a tournament. |
|
| POST | `/admin/new` | `name`, `date`, `courtCount`, `teamSize`, `pointsTo`, `winBy`, `cap` (0 = none), `bestOf`, `stages` (`pool,bracket` / `pool` / `bracket`), `timezone`, `notes` | Creates a tournament. |
|
||||||
| GET | `/admin/t/<slug>` | | Tournament desk. |
|
| GET | `/admin/t/<slug>` | | Tournament desk. |
|
||||||
| GET | `/admin/t/<slug>/qr` | | Printable QR page. `qr.png` returns the PNG. |
|
| 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. |
|
| 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>/phase` | `phase` = `checkin` / `closed` / `live` / `final` | Change phase. `live` requires generated play and starts court assignment. |
|
||||||
|
| POST | `/admin/t/<slug>/settings` | `date`, `timezone` (IANA name, e.g. `America/Chicago`), `notes`, `name`; and until the first score: `pointsTo`, `winBy`, `cap` (0 = none), `bestOf`, `teamSize` | Edit tournament settings and scoring rules. All clock times are shown in the zone. |
|
||||||
| 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` | Generate pools or bracket. |
|
| 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). |
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ Environment variables the app reads:
|
|||||||
| `ADMIN_PASSWORD` | unset | Organizer password for `/admin/login`. |
|
| `ADMIN_PASSWORD` | unset | Organizer password for `/admin/login`. |
|
||||||
| `SESSION_SECRET` | random per start | HMAC key for the organizer cookie. Set it so logins survive restarts. |
|
| `SESSION_SECRET` | random per start | HMAC key for the organizer cookie. Set it so logins survive restarts. |
|
||||||
| `TRUST_CF_ACCESS` | `false` | Accept Cloudflare Access identity header as organizer login. |
|
| `TRUST_CF_ACCESS` | `false` | Accept Cloudflare Access identity header as organizer login. |
|
||||||
|
| `DEFAULT_TIMEZONE` | `America/Chicago` | Time zone used for clock times when a tournament doesn't set its own. |
|
||||||
|
|
||||||
The app trusts `X-Forwarded-Proto` and `X-Forwarded-Host` (which cloudflared sets) to build the absolute URLs used in QR codes and team links. If you put a different reverse proxy in front, make sure it sets those headers.
|
The app trusts `X-Forwarded-Proto` and `X-Forwarded-Host` (which cloudflared sets) to build the absolute URLs used in QR codes and team links. If you put a different reverse proxy in front, make sure it sets those headers.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
- **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).
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
||||||
|
|||||||
@@ -12,14 +12,15 @@ 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.
|
||||||
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.
|
||||||
|
|
||||||
## Scoring
|
## 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.
|
- *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.
|
- *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.
|
**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
@@ -175,16 +175,7 @@ export function doubleElimination(seeded, { resetMatch = true } = {}) {
|
|||||||
gf.resetMatch = reset.id;
|
gf.resetMatch = reset.id;
|
||||||
matches.push(reset);
|
matches.push(reset);
|
||||||
}
|
}
|
||||||
// Byes in WB R1 mean LB R1 gets a null loser -> treat as bye there too.
|
// Byes cascade through the losers bracket (a bye has no loser; a void match has no winner);
|
||||||
const index = Object.fromEntries(matches.map(m => [m.id, m]));
|
// Tournament.settleByes() resolves them after generation and after every result.
|
||||||
for (const m of wbByRound[0]) if (m.status === 'bye') {
|
|
||||||
const l = index[m.loserFeeds];
|
|
||||||
l[m.loserFeedsSide === 'A' ? 'teamA' : 'teamB'] = '__bye__';
|
|
||||||
}
|
|
||||||
for (const m of lb[0]) {
|
|
||||||
if (m.teamA === '__bye__' && m.teamB === '__bye__') { m.status = 'void'; }
|
|
||||||
else if (m.teamA === '__bye__') { m.teamA = null; m.pendingBye = 'A'; }
|
|
||||||
else if (m.teamB === '__bye__') { m.teamB = null; m.pendingBye = 'B'; }
|
|
||||||
}
|
|
||||||
return { matches, size: wb.size, wbRounds, lbRounds: lbRound };
|
return { matches, size: wb.size, wbRounds, lbRounds: lbRound };
|
||||||
}
|
}
|
||||||
+132
-15
@@ -1,5 +1,5 @@
|
|||||||
// Tournament state: teams, matches, results, standings, withdrawals, bracket propagation.
|
// 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 = {
|
export const DEFAULT_RULES = {
|
||||||
teamSize: 2,
|
teamSize: 2,
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -104,15 +106,73 @@ export class Tournament {
|
|||||||
return [...qualified, ...rest.slice(0, wildcards)].map(r => r.teamId);
|
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');
|
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');
|
this.matches = this.matches.filter(m => m.stage === 'pool');
|
||||||
const res = type === 'double' ? doubleElimination(seededIds) : singleElimination(seededIds, { thirdPlace });
|
const res = type === 'double' ? doubleElimination(seededIds) : singleElimination(seededIds, { thirdPlace });
|
||||||
this.matches.push(...res.matches);
|
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;
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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. */
|
||||||
|
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 ----------
|
// ---------- results ----------
|
||||||
match(id) { return this.matches.find(m => m.id === id); }
|
match(id) { return this.matches.find(m => m.id === id); }
|
||||||
teamName(id) { return id ? this.teams.get(id)?.name ?? id : 'TBD'; }
|
teamName(id) { return id ? this.teams.get(id)?.name ?? id : 'TBD'; }
|
||||||
@@ -143,14 +203,15 @@ export class Tournament {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_place(matchId, side, teamId) {
|
_place(matchId, side, teamId) {
|
||||||
if (!matchId) return;
|
if (!matchId || !teamId) return;
|
||||||
const next = this.match(matchId);
|
const next = this.match(matchId);
|
||||||
if (!next) return;
|
if (!next) return;
|
||||||
next[side === 'A' ? 'teamA' : 'teamB'] = teamId;
|
next[side === 'A' ? 'teamA' : 'teamB'] = teamId;
|
||||||
// losers-bracket bye: the other side was a bye, so this team walks through
|
// a withdrawn team was waiting here: it forfeits to the team that just arrived
|
||||||
if (next.pendingBye && next.status === 'pending') {
|
if (next.autoForfeit && next.teamA && next.teamB && ['pending', 'on_deck'].includes(next.status)) {
|
||||||
next.status = 'bye'; next.winner = teamId; delete next.pendingBye;
|
const f = next.autoForfeit; delete next.autoForfeit;
|
||||||
this._propagate(next);
|
if (this.teams.get(teamId)?.status === 'withdrawn') { next.status = 'void'; return; } // both gone
|
||||||
|
this.recordResult(next.id, [], { actor: 'system', forfeit: f });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,7 +224,46 @@ export class Tournament {
|
|||||||
if (m.winner === m.teamB) { reset.teamA = m.teamA; reset.teamB = m.teamB; reset.conditional = false; }
|
if (m.winner === m.teamB) { reset.teamA = m.teamA; reset.teamB = m.teamB; reset.conditional = false; }
|
||||||
else { reset.status = 'void'; }
|
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. */
|
/** Complete when every configured stage has matches and all of them are decided. */
|
||||||
@@ -198,13 +298,17 @@ export class Tournament {
|
|||||||
m.court = null;
|
m.court = null;
|
||||||
}
|
}
|
||||||
} else if (['pending', 'on_deck', 'live'].includes(m.status)) {
|
} 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;
|
const opp = m.teamA === teamId ? m.teamB : m.teamA;
|
||||||
if (opp) this.recordResult(m.id, [], { actor, forfeit: teamId });
|
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;
|
m.court = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
this.settleByes();
|
||||||
|
this._checkComplete();
|
||||||
this.emit({ type: 'team_withdrawn', team: teamId, name: t.name, mode });
|
this.emit({ type: 'team_withdrawn', team: teamId, name: t.name, mode });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,7 +382,7 @@ export class Tournament {
|
|||||||
toJSON() {
|
toJSON() {
|
||||||
return {
|
return {
|
||||||
name: this.name, slug: this.slug ?? null, date: this.date ?? null, notes: this.notes ?? null,
|
name: this.name, slug: this.slug ?? null, date: this.date ?? null, notes: this.notes ?? null,
|
||||||
rules: this.rules, courtCount: this.courtCount, stages: this.stages, phase: this.phase,
|
rules: this.rules, courtCount: this.courtCount, stages: this.stages, phase: this.phase, timezone: this.timezone ?? null,
|
||||||
teams: [...this.teams.values()], matches: this.matches, pools: this.pools, log: this.log.slice(-500), seq: this._seq,
|
teams: [...this.teams.values()], matches: this.matches, pools: this.pools, log: this.log.slice(-500), seq: this._seq,
|
||||||
banner: this.banner ?? null, createdAt: this.createdAt ?? null,
|
banner: this.banner ?? null, createdAt: this.createdAt ?? null,
|
||||||
};
|
};
|
||||||
@@ -286,7 +390,7 @@ export class Tournament {
|
|||||||
|
|
||||||
static fromJSON(j) {
|
static fromJSON(j) {
|
||||||
const t = new Tournament({ name: j.name, rules: j.rules, courtCount: j.courtCount, stages: j.stages });
|
const t = new Tournament({ name: j.name, rules: j.rules, courtCount: j.courtCount, stages: j.stages });
|
||||||
Object.assign(t, { slug: j.slug, date: j.date, notes: j.notes, phase: j.phase, matches: j.matches, pools: j.pools, log: j.log ?? [], _seq: j.seq ?? 1, banner: j.banner ?? null, createdAt: j.createdAt ?? null });
|
Object.assign(t, { slug: j.slug, date: j.date, notes: j.notes, timezone: j.timezone ?? null, phase: j.phase, matches: j.matches, pools: j.pools, log: j.log ?? [], _seq: j.seq ?? 1, banner: j.banner ?? null, createdAt: j.createdAt ?? null });
|
||||||
t.teams = new Map((j.teams ?? []).map(x => [x.id, x]));
|
t.teams = new Map((j.teams ?? []).map(x => [x.id, x]));
|
||||||
return t;
|
return t;
|
||||||
}
|
}
|
||||||
@@ -307,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
@@ -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.6.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,11 +1,15 @@
|
|||||||
// The one JSON document every public page renders from. No phone numbers, no team codes.
|
// The one JSON document every public page renders from. No phone numbers, no team codes.
|
||||||
|
export const DEFAULT_TZ = process.env.DEFAULT_TIMEZONE || 'America/Chicago';
|
||||||
|
export function validTz(tz) { try { new Intl.DateTimeFormat('en-US', { timeZone: tz }); return tz; } catch { return null; } }
|
||||||
|
export const tzOf = t => validTz(t.timezone) ?? DEFAULT_TZ;
|
||||||
|
export const fmtTime = (ms, tz) => new Date(ms).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', timeZone: tz });
|
||||||
export function publicState({ t, engine }) {
|
export function publicState({ t, engine }) {
|
||||||
const name = id => t.teamName(id);
|
const name = id => t.teamName(id);
|
||||||
const view = m => ({
|
const view = m => ({
|
||||||
id: m.id, stage: m.stage, round: m.round, slot: m.slot, label: m.label ?? null, status: m.status, court: m.court,
|
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,
|
a: m.teamA ? { id: m.teamA, name: name(m.teamA) } : null,
|
||||||
b: m.teamB ? { id: m.teamB, name: name(m.teamB) } : 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 board = t.phase === 'live' ? engine.board() : null;
|
||||||
const pools = t.pools.map(p => ({
|
const pools = t.pools.map(p => ({
|
||||||
@@ -17,7 +21,7 @@ export function publicState({ t, engine }) {
|
|||||||
const teams = t.activeTeams().map(x => t.publicTeam(x.id));
|
const teams = t.activeTeams().map(x => t.publicTeam(x.id));
|
||||||
const withdrawn = [...t.teams.values()].filter(x => x.status === 'withdrawn').map(x => t.publicTeam(x.id));
|
const withdrawn = [...t.teams.values()].filter(x => x.status === 'withdrawn').map(x => t.publicTeam(x.id));
|
||||||
return {
|
return {
|
||||||
slug: t.slug, name: t.name, date: t.date, notes: t.notes ?? '', phase: t.phase, banner: t.banner ?? null,
|
slug: t.slug, name: t.name, date: t.date, notes: t.notes ?? '', timezone: tzOf(t), phase: t.phase, banner: t.banner ?? null,
|
||||||
rules: t.rules, stages: t.stages, courtCount: t.courtCount,
|
rules: t.rules, stages: t.stages, courtCount: t.courtCount,
|
||||||
teams, withdrawn, pools, bracket,
|
teams, withdrawn, pools, bracket,
|
||||||
courts: board ? board.courts.map(c => ({ ...c, match: c.match ? view(t.match(c.match.id)) : null })) : [],
|
courts: board ? board.courts.map(c => ({ ...c, match: c.match ? view(t.match(c.match.id)) : null })) : [],
|
||||||
|
|||||||
@@ -25,6 +25,17 @@
|
|||||||
|
|
||||||
document.querySelectorAll('[data-copy]').forEach(b => b.addEventListener('click', () => navigator.clipboard?.writeText(b.dataset.copy).then(() => { b.textContent = 'Copied'; })));
|
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
|
// keep the desk fresh when another organizer enters a score
|
||||||
const ws = new WebSocket(`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/${slug}`);
|
const ws = new WebSocket(`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/${slug}`);
|
||||||
let last = null;
|
let last = null;
|
||||||
|
|||||||
+11
-4
@@ -2,6 +2,7 @@
|
|||||||
--bg:#F7F8F6;--surface:#fff;--ink:#17202A;--muted:#5B6875;--line:#DCE1DE;
|
--bg:#F7F8F6;--surface:#fff;--ink:#17202A;--muted:#5B6875;--line:#DCE1DE;
|
||||||
--accent:#1D6FB5;--accent-ink:#fff;--accent-soft:#E4EFF9;--sand:#B98F3F;--sand-soft:#F6EBD2;
|
--accent:#1D6FB5;--accent-ink:#fff;--accent-soft:#E4EFF9;--sand:#B98F3F;--sand-soft:#F6EBD2;
|
||||||
--good:#2E8B57;--warn:#C97B1C;--crit:#C0392B;--court:#1A6FB5;--court-ink:#fff;
|
--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}}
|
@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}
|
*{box-sizing:border-box}
|
||||||
@@ -20,14 +21,15 @@ 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)}
|
.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}
|
.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{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{background:var(--surface);border:1px solid var(--line);border-radius:8px;padding:16px;margin:14px 0}
|
||||||
.card h2{margin-bottom:8px}
|
.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}
|
.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)}
|
.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}
|
.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 */
|
/* 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}
|
||||||
@@ -84,7 +86,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)}
|
.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}
|
.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{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}
|
.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}
|
.blist{margin-top:10px}.blist .rlbl{position:static;text-align:left}
|
||||||
.bracketwrap.fit .btree-scroll{overflow:hidden}
|
.bracketwrap.fit .btree-scroll{overflow:hidden}
|
||||||
@@ -92,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}
|
||||||
@@ -115,6 +119,9 @@ body.display .queue{font-size:26px}body.display details{display:none}
|
|||||||
.keeperbox .button{margin-top:6px}
|
.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)}
|
.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}}
|
@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) */
|
/* score keeper (Courtside Manager) */
|
||||||
body.keeper-body{background:#0F1418;color:#F2F5F7}body.keeper-body .top,body.keeper-body .foot{display:none}
|
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}
|
body.keeper-body main{max-width:720px;padding:12px 12px 40px}
|
||||||
|
|||||||
+26
-8
@@ -10,8 +10,12 @@
|
|||||||
// every team name on the board links to that team's page (schedule, record, next match)
|
// 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 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 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 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' });
|
// clock times are shown in the tournament's time zone, not the viewer's device zone
|
||||||
|
const clock = ms => { try { return new Date(ms).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', timeZone: state.timezone || undefined }); } catch { return new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); } };
|
||||||
const isMine = m => myTeam && (m.a?.id === myTeam || m.b?.id === myTeam);
|
const isMine = m => myTeam && (m.a?.id === myTeam || m.b?.id === myTeam);
|
||||||
const phaseLabel = { checkin: 'Registration open', closed: 'Registration closed', live: 'Live', final: 'Final' };
|
const phaseLabel = { checkin: 'Registration open', closed: 'Registration closed', live: 'Live', final: 'Final' };
|
||||||
const score = m => m.status === 'final' ? m.sets.map(s => `${s[0]}–${s[1]}`).join(', ')
|
const score = m => m.status === 'final' ? m.sets.map(s => `${s[0]}–${s[1]}`).join(', ')
|
||||||
@@ -31,7 +35,7 @@
|
|||||||
const s = state;
|
const s = state;
|
||||||
const mine = myTeam ? [...s.teams, ...s.withdrawn].find(t => t.id === myTeam) : null;
|
const mine = myTeam ? [...s.teams, ...s.withdrawn].find(t => t.id === myTeam) : null;
|
||||||
const parts = [];
|
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>`);
|
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.banner) parts.push(`<div class="banner">${esc(s.banner)}</div>`);
|
||||||
if (s.champion) parts.push(`<section class="champion"><div class="eyebrow">Champion</div><h2>${esc(s.champion)}</h2></section>`);
|
if (s.champion) parts.push(`<section class="champion"><div class="eyebrow">Champion</div><h2>${esc(s.champion)}</h2></section>`);
|
||||||
@@ -42,7 +46,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();
|
||||||
}
|
}
|
||||||
@@ -102,7 +114,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>
|
<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('')}
|
${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>
|
</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>`;
|
</div>`).join('')}</section>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,13 +174,14 @@
|
|||||||
const row = (t, side) => {
|
const row = (t, side) => {
|
||||||
const won = m.winner && t && m.winner === t.id;
|
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] : '');
|
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>`;
|
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) {
|
function feedLabel(m, side) {
|
||||||
|
if (m.status === 'bye') return 'bye';
|
||||||
// what fills this slot later: winner/loser of which match
|
// what fills this slot later: winner/loser of which match
|
||||||
const wantSide = side === 0 ? 'A' : 'B';
|
const wantSide = side === 0 ? 'A' : 'B';
|
||||||
const w = state.bracket.find(f => f.feeds === m.id && f.feedsSide === wantSide);
|
const w = state.bracket.find(f => f.feeds === m.id && f.feedsSide === wantSide);
|
||||||
@@ -181,7 +194,7 @@
|
|||||||
|
|
||||||
function bracketList(stage, ms) {
|
function bracketList(stage, ms) {
|
||||||
const rounds = [...new Set(ms.map(m => m.round))].sort((a, b) => a - b);
|
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;
|
const isMineId = id => myTeam && id === myTeam;
|
||||||
|
|
||||||
@@ -253,5 +266,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);
|
||||||
})();
|
})();
|
||||||
+40
-7
@@ -1,6 +1,6 @@
|
|||||||
import QRCode from 'qrcode';
|
import QRCode from 'qrcode';
|
||||||
import { Router, HttpError, readBody, send, esc, sign, verify } from './http.js';
|
import { Router, HttpError, readBody, send, esc, sign, verify } from './http.js';
|
||||||
import { publicState } from './public-state.js';
|
import { publicState, validTz } from './public-state.js';
|
||||||
import { VERSION } from './version.js';
|
import { VERSION } from './version.js';
|
||||||
import * as pub from './views/public.js';
|
import * as pub from './views/public.js';
|
||||||
import * as adm from './views/admin.js';
|
import * as adm from './views/admin.js';
|
||||||
@@ -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) {
|
||||||
@@ -113,7 +113,7 @@ export function buildRouter({ registry, auth, store }) {
|
|||||||
const who = requireAdmin(req, res); if (!who) return;
|
const who = requireAdmin(req, res); if (!who) return;
|
||||||
const b = await readBody(req);
|
const b = await readBody(req);
|
||||||
const rules = { teamSize: +b.teamSize || 2, pointsTo: +b.pointsTo || 21, winBy: +b.winBy || 2, cap: +b.cap || 0, bestOf: +b.bestOf || 1 };
|
const rules = { teamSize: +b.teamSize || 2, pointsTo: +b.pointsTo || 21, winBy: +b.winBy || 2, cap: +b.cap || 0, bestOf: +b.bestOf || 1 };
|
||||||
const it = registry.create({ name: b.name, date: b.date, courtCount: +b.courtCount || 2, rules, stages: String(b.stages ?? 'pool,bracket').split(','), notes: b.notes ?? '' });
|
const it = registry.create({ name: b.name, date: b.date, courtCount: +b.courtCount || 2, rules, stages: String(b.stages ?? 'pool,bracket').split(','), notes: b.notes ?? '', timezone: validTz(b.timezone) });
|
||||||
send.redirect(res, `/admin/t/${it.t.slug}?msg=${encodeURIComponent('Created. Print the QR code and open registration when ready.')}`);
|
send.redirect(res, `/admin/t/${it.t.slug}?msg=${encodeURIComponent('Created. Print the QR code and open registration when ready.')}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -166,14 +166,47 @@ export function buildRouter({ registry, auth, store }) {
|
|||||||
if (p === 'final') { t.phase = 'final'; t.emit({ type: 'phase', phase: 'final', champion: t.champion() }); return 'Marked final'; }
|
if (p === 'final') { t.phase = 'final'; t.emit({ type: 'phase', phase: 'final', champion: t.champion() }); return 'Marked final'; }
|
||||||
throw new Error('Unknown phase');
|
throw new Error('Unknown phase');
|
||||||
});
|
});
|
||||||
|
action('/settings', ({ t, body }) => {
|
||||||
|
if (body.timezone !== undefined) { if (body.timezone && !validTz(body.timezone)) throw new Error('Unknown time zone'); t.timezone = body.timezone || null; }
|
||||||
|
if (body.date !== undefined) t.date = String(body.date).trim() || null;
|
||||||
|
if (body.notes !== undefined) t.notes = String(body.notes).slice(0, 500);
|
||||||
|
if (body.name !== undefined && String(body.name).trim()) t.name = String(body.name).trim().slice(0, 60);
|
||||||
|
// scoring rules can change until the first score is on the books
|
||||||
|
if (['pointsTo', 'winBy', 'cap', 'bestOf', 'teamSize'].some(k => body[k] !== undefined)) {
|
||||||
|
if (t.matches.some(m => ['final', 'forfeit'].includes(m.status))) throw new Error('Scores have been entered; rules are locked');
|
||||||
|
const r = { ...t.rules };
|
||||||
|
if (body.pointsTo !== undefined) r.pointsTo = +body.pointsTo;
|
||||||
|
if (body.winBy !== undefined) r.winBy = +body.winBy;
|
||||||
|
if (body.cap !== undefined) r.cap = +body.cap || 0;
|
||||||
|
if (body.bestOf !== undefined) r.bestOf = +body.bestOf;
|
||||||
|
if (body.teamSize !== undefined) r.teamSize = +body.teamSize;
|
||||||
|
if (!(r.pointsTo >= 5 && r.pointsTo <= 50) || !(r.winBy >= 1 && r.winBy <= 5) || ![1, 3, 5].includes(r.bestOf) || !(r.teamSize >= 1 && r.teamSize <= 6)) throw new Error('Rules out of range');
|
||||||
|
if (r.cap && r.cap < r.pointsTo) throw new Error('Cap must be at least points-to (or 0 for no cap)');
|
||||||
|
t.rules = r;
|
||||||
|
}
|
||||||
|
t.emit({ type: 'settings', timezone: t.timezone, date: t.date, rules: t.rules });
|
||||||
|
return 'Settings saved';
|
||||||
|
});
|
||||||
action('/banner', ({ t, body }) => { t.banner = String(body.banner ?? '').trim().slice(0, 200) || null; t.emit({ type: 'banner', banner: t.banner }); return t.banner ? 'Banner set' : 'Banner cleared'; });
|
action('/banner', ({ t, body }) => { t.banner = String(body.banner ?? '').trim().slice(0, 200) || null; t.emit({ type: 'banner', banner: t.banner }); return t.banner ? 'Banner set' : 'Banner cleared'; });
|
||||||
action('/broadcast', ({ t, engine, body }) => { const text = String(body.text ?? '').trim().slice(0, 300); if (!text) throw new Error('Nothing to send'); engine.broadcast(text); return 'Broadcast sent'; });
|
action('/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 }) => {
|
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 === '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 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 }) => {
|
||||||
@@ -191,10 +224,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 }) => {
|
||||||
|
|||||||
+10
-2
@@ -16,18 +16,26 @@ export class Registry {
|
|||||||
t.slug = row.slug;
|
t.slug = row.slug;
|
||||||
const engine = row.engine ? CourtEngine.fromJSON(t, row.engine) : new CourtEngine(t, { courtCount: t.courtCount });
|
const engine = row.engine ? CourtEngine.fromJSON(t, row.engine) : new CourtEngine(t, { courtCount: t.courtCount });
|
||||||
this._attach(row.slug, t, engine);
|
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); }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
list() { return [...this.items.entries()].map(([slug, { t }]) => ({ slug, name: t.name, date: t.date, phase: t.phase, teams: t.activeTeams().length, createdAt: t.createdAt })); }
|
list() { return [...this.items.entries()].map(([slug, { t }]) => ({ slug, name: t.name, date: t.date, phase: t.phase, teams: t.activeTeams().length, createdAt: t.createdAt })); }
|
||||||
get(slug) { return this.items.get(slug) ?? null; }
|
get(slug) { return this.items.get(slug) ?? null; }
|
||||||
|
|
||||||
create({ name, date, courtCount = 2, rules = {}, stages = ['pool', 'bracket'], notes = '', slug: wanted = null }) {
|
create({ name, date, courtCount = 2, rules = {}, stages = ['pool', 'bracket'], notes = '', timezone = null, slug: wanted = null }) {
|
||||||
const base = slugify(wanted ?? name);
|
const base = slugify(wanted ?? name);
|
||||||
let slug = base, n = 2;
|
let slug = base, n = 2;
|
||||||
while (this.items.has(slug)) slug = `${base}-${n++}`;
|
while (this.items.has(slug)) slug = `${base}-${n++}`;
|
||||||
const t = new Tournament({ name, courtCount: Number(courtCount), rules, stages });
|
const t = new Tournament({ name, courtCount: Number(courtCount), rules, stages });
|
||||||
t.slug = slug; t.date = date || null; t.notes = notes; t.createdAt = Date.now();
|
t.slug = slug; t.date = date || null; t.notes = notes; t.timezone = timezone || null; t.createdAt = Date.now();
|
||||||
const engine = new CourtEngine(t, { courtCount: t.courtCount });
|
const engine = new CourtEngine(t, { courtCount: t.courtCount });
|
||||||
this._attach(slug, t, engine);
|
this._attach(slug, t, engine);
|
||||||
this.persist(slug);
|
this.persist(slug);
|
||||||
|
|||||||
+35
-5
@@ -1,6 +1,11 @@
|
|||||||
import { esc } from '../http.js';
|
import { esc } from '../http.js';
|
||||||
import { layout, flash } from './layout.js';
|
import { layout, flash } from './layout.js';
|
||||||
import { phaseLabel } from './public.js';
|
import { phaseLabel } from './public.js';
|
||||||
|
import { tzOf, fmtTime } from '../public-state.js';
|
||||||
|
|
||||||
|
const TZS = ['America/New_York', 'America/Chicago', 'America/Denver', 'America/Phoenix', 'America/Los_Angeles', 'America/Anchorage', 'Pacific/Honolulu', 'Europe/London', 'Europe/Riga', 'UTC'];
|
||||||
|
const tzLabel = z => ({ 'America/New_York': 'Eastern', 'America/Chicago': 'Central', 'America/Denver': 'Mountain', 'America/Phoenix': 'Arizona', 'America/Los_Angeles': 'Pacific', 'America/Anchorage': 'Alaska', 'Pacific/Honolulu': 'Hawaii', 'Europe/London': 'UK', 'Europe/Riga': 'Latvia', UTC: 'UTC' }[z] ?? z);
|
||||||
|
export const tzSelect = (cur) => `<select name="timezone">${TZS.map(z => `<option value="${z}" ${z === cur ? 'selected' : ''}>${tzLabel(z)} (${z})</option>`).join('')}</select>`;
|
||||||
|
|
||||||
export function loginPage(error = null, next = '/admin') {
|
export function loginPage(error = null, next = '/admin') {
|
||||||
return layout({ title: 'Organizer sign in', body: `
|
return layout({ title: 'Organizer sign in', body: `
|
||||||
@@ -28,6 +33,7 @@ export function adminHome(list, who, msg) {
|
|||||||
<label>Cap <input name="cap" type="number" value="25" min="0" max="60" title="0 = no cap"></label>
|
<label>Cap <input name="cap" type="number" value="25" min="0" max="60" title="0 = no cap"></label>
|
||||||
<label>Best of <select name="bestOf"><option>1</option><option>3</option></select></label></div>
|
<label>Best of <select name="bestOf"><option>1</option><option>3</option></select></label></div>
|
||||||
<label>Stages <select name="stages"><option value="pool,bracket">Pools, then bracket</option><option value="pool">Pools only</option><option value="bracket">Bracket only</option></select></label>
|
<label>Stages <select name="stages"><option value="pool,bracket">Pools, then bracket</option><option value="pool">Pools only</option><option value="bracket">Bracket only</option></select></label>
|
||||||
|
<label>Time zone ${tzSelect('America/Chicago')}</label>
|
||||||
<label>Notes for players <textarea name="notes" rows="2" placeholder="Bring water. Rec division starts at 10."></textarea></label>
|
<label>Notes for players <textarea name="notes" rows="2" placeholder="Bring water. Rec division starts at 10."></textarea></label>
|
||||||
<button class="primary" type="submit">Create</button>
|
<button class="primary" type="submit">Create</button>
|
||||||
</form></section>
|
</form></section>
|
||||||
@@ -74,6 +80,8 @@ export function adminTournament({ t, engine }, who, origin, msg, err, events, ke
|
|||||||
${act('phase', 'Final', s === 'final' ? 'cur' : '', '<input type="hidden" name="phase" value="final">')}
|
${act('phase', 'Final', s === 'final' ? 'cur' : '', '<input type="hidden" name="phase" value="final">')}
|
||||||
</div>
|
</div>
|
||||||
<p class="muted small">The public QR page follows this. Going live starts assigning courts immediately.</p>
|
<p class="muted small">The public QR page follows this. Going live starts assigning courts immediately.</p>
|
||||||
|
<form method="post" action="/admin/t/${esc(t.slug)}/settings" class="inline"><label>Date <input name="date" type="date" value="${esc(t.date ?? '')}"></label><label>Time zone ${tzSelect(tzOf(t))}</label><input name="notes" placeholder="Notes shown on the registration page" value="${esc(t.notes ?? '')}" style="flex:1;min-width:200px"><button class="small" type="submit">Save settings</button></form>
|
||||||
|
${!t.matches.some(m => ['final', 'forfeit'].includes(m.status)) ? `<form method="post" action="/admin/t/${esc(t.slug)}/settings" class="inline"><span class="muted small">Scoring</span><label>Points to <input name="pointsTo" type="number" value="${t.rules.pointsTo}" min="5" max="50" style="width:64px"></label><label>Win by <input name="winBy" type="number" value="${t.rules.winBy}" min="1" max="5" style="width:56px"></label><label>Cap <input name="cap" type="number" value="${t.rules.cap || 0}" min="0" max="60" style="width:64px" title="0 = no cap"></label><label>Best of <select name="bestOf">${[1, 3, 5].map(n => `<option ${t.rules.bestOf === n ? 'selected' : ''}>${n}</option>`).join('')}</select></label><button class="small" type="submit">Save scoring</button><span class="muted small">Locked once the first score is entered.</span></form>` : ''}
|
||||||
<form method="post" action="/admin/t/${esc(t.slug)}/banner" class="inline"><input name="banner" placeholder="Banner on every public page (leave empty to clear)" value="${esc(t.banner ?? '')}" style="flex:1;min-width:240px"><button class="small" type="submit">Set banner</button></form>
|
<form method="post" action="/admin/t/${esc(t.slug)}/banner" class="inline"><input name="banner" placeholder="Banner on every public page (leave empty to clear)" value="${esc(t.banner ?? '')}" style="flex:1;min-width:240px"><button class="small" type="submit">Set banner</button></form>
|
||||||
<form method="post" action="/admin/t/${esc(t.slug)}/broadcast" class="inline"><input name="text" placeholder="One-time announcement to every team page" style="flex:1;min-width:240px"><button class="small" type="submit">Broadcast</button></form>
|
<form method="post" action="/admin/t/${esc(t.slug)}/broadcast" class="inline"><input name="text" placeholder="One-time announcement to every team page" style="flex:1;min-width:240px"><button class="small" type="submit">Broadcast</button></form>
|
||||||
</section>
|
</section>
|
||||||
@@ -82,7 +90,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>` : ''}
|
||||||
@@ -95,31 +103,53 @@ export function adminTournament({ t, engine }, who, origin, msg, err, events, ke
|
|||||||
<label><input type="checkbox" name="thirdPlace" value="1" checked> 3rd place</label>
|
<label><input type="checkbox" name="thirdPlace" value="1" checked> 3rd place</label>
|
||||||
<button class="primary small" type="submit" name="what" value="bracket">Generate bracket${poolsDone ? '' : ' (pools not finished)'}</button>
|
<button class="primary small" type="submit" name="what" value="bracket">Generate bracket${poolsDone ? '' : ' (pools not finished)'}</button>
|
||||||
</form>` : ''}
|
</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>
|
${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>
|
${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>` : ''}
|
<td>${m.teamA && m.teamB && !['void', 'bye'].includes(m.status) ? `<form method="post" action="/admin/t/${esc(t.slug)}/score" class="inline"><input type="hidden" name="match" value="${esc(m.id)}"><input name="sets" placeholder="21-18" value="${esc(m.sets.map(x => x.join('-')).join(', '))}" style="width:110px"><button class="small" type="submit">Save</button></form>` : ''}</td></tr>`).join('')}</table></details>` : ''}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<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">${fmtTime(e.ts, tzOf(t))}</span> ${esc(describe(e, t))}</li>`).join('')}</ul></section>
|
||||||
<section class="card"><h2>Danger zone</h2>${act('delete', 'Delete this tournament', 'danger', `<label class="small"><input type="checkbox" name="confirm" value="yes" required> I understand this cannot be undone</label> `)}</section>`;
|
<section class="card"><h2>Danger zone</h2>${act('delete', 'Delete this tournament', 'danger', `<label class="small"><input type="checkbox" name="confirm" value="yes" required> I understand this cannot be undone</label> `)}</section>`;
|
||||||
return layout({ title: `${t.name} desk`, admin: who, body, script: '/static/admin.js' });
|
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.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.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>
|
||||||
|
<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">${!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>
|
||||||
|
</form></details>`;
|
||||||
|
}
|
||||||
|
|
||||||
function scorePad(t, m) {
|
function scorePad(t, m) {
|
||||||
const live = m.live ?? [0, 0];
|
const live = m.live ?? [0, 0];
|
||||||
return `<div class="scorepad" data-match="${esc(m.id)}">
|
return `<div class="scorepad" data-match="${esc(m.id)}">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -152,3 +152,126 @@ test('double elimination with 6 teams (byes) plays to a champion', () => {
|
|||||||
assert.equal(reset.status, 'final');
|
assert.equal(reset.status, 'final');
|
||||||
assert.equal(t.champion(), t.teamName(reset.winner));
|
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');
|
||||||
|
});
|
||||||
|
|
||||||
|
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' });
|
||||||
|
});
|
||||||
Reference in new issue
Block a user