Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
594247ef56 | ||
|
|
4ce7e48551 | ||
|
|
a4bee22d96 | ||
|
|
a5222953dd | ||
|
|
ae44014e22 | ||
|
|
eb58eb9ad9 | ||
|
|
acc5fdc2f3 |
No files matched your search
@@ -9,6 +9,75 @@ so you can always tell what a deployment is actually serving.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.5.2] - 2026-09-03
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Champion banner and the Final badge are deep gold instead of beige.
|
||||||
|
- Round-1 bye slots in the bracket read "bye" instead of "TBD"; no stray dot before the
|
||||||
|
phase badge when a tournament has no date.
|
||||||
|
|
||||||
|
## [0.5.1] - 2026-09-03
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- A team that withdraws during a bracket now shows as a **forfeit** in every match it was in
|
||||||
|
or would have reached, with its name struck through and a "Forfeit · Team" tag on the match,
|
||||||
|
instead of quietly becoming a bye. If the opponent isn't known yet, the withdrawn team stays
|
||||||
|
in its slot and forfeits the moment the opponent arrives; in double elimination it forfeits
|
||||||
|
its losers-bracket match too. Play continues and the tournament still goes Final on its own.
|
||||||
|
- Pool match lists mark forfeits as "FF".
|
||||||
|
|
||||||
|
## [0.5.0] - 2026-09-03
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Double elimination with byes could never finish.** With many byes (e.g. 18 teams in a
|
||||||
|
32 bracket) losers-bracket slots fed by matches that never happen were left waiting forever;
|
||||||
|
the tournament stayed live with both courts open. Byes now cascade through the whole bracket:
|
||||||
|
a slot nobody can ever fill is a bye for the team on the other side, or a void match if both
|
||||||
|
sides are empty. This is re-run when the server starts, so a stuck tournament unsticks itself
|
||||||
|
on the first deploy of this version and goes Final if everything else is played.
|
||||||
|
- Tested at 5, 6, 9, 12, 17, 18 and 23 teams, single and double elimination.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Build the bracket by hand.** On the desk, "Build the bracket by hand" lists every round-1
|
||||||
|
match with a team picker on each side, pre-filled with standard seeding. Pick who plays whom,
|
||||||
|
leave a side as "bye" for a walkover, choose single or double elimination, and build. Teams
|
||||||
|
picked twice are flagged before you submit and rejected by the server.
|
||||||
|
|
||||||
|
## [0.4.2] - 2026-09-03
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Stylesheet and scripts are referenced with `?v=<version>`, so a new release can no longer be
|
||||||
|
served with a cached old `app.css` (which made the score keeper page render unstyled on
|
||||||
|
phones after 0.4.0). Versioned assets are cached for a year; unversioned for a minute.
|
||||||
|
|
||||||
|
## [0.4.1] - 2026-09-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Bracket matches that are waiting on a live match now appear in the queue and under each
|
||||||
|
court as "Net Gains vs winner on Court 2 · ready ~2:33", so double elimination and late
|
||||||
|
single elimination rounds still show a next-up time. These are tentative (no on-deck
|
||||||
|
alert until both teams are known).
|
||||||
|
- Score keeper access is a QR code per court, shown on each court card of the desk, plus a
|
||||||
|
printable page with all courts' codes (`/admin/t/<slug>/keeper-qr`). The copy-link button
|
||||||
|
is gone; "Open on this device" remains for the organizer's own phone.
|
||||||
|
- Idle courts show their next-up match too.
|
||||||
|
|
||||||
|
## [0.4.0] - 2026-09-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Courtside Manager**, a score keeper page per court (`/keeper/<slug>/<court>?k=…`). Big
|
||||||
|
+/− buttons, undo, sets for best-of-3, and a Finalize step with confirmation that appears
|
||||||
|
once a side has won under the tournament's rules. It follows whatever match is on the court
|
||||||
|
and moves to the next one automatically. The link is opened or copied from the court card
|
||||||
|
on the desk; it can only score and finalize, never edit a finished result.
|
||||||
|
- Court cards on the public board show the estimated finish time of the current match.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Organizer desk: the Phase section now sits below Courts and Queue, so the score pads
|
||||||
|
are at the top during play.
|
||||||
|
- Multi-set running scores (`liveSets`) show on the public board while a keeper is scoring.
|
||||||
|
- Match durations outside 2–90 minutes are ignored for the ETA average.
|
||||||
|
|
||||||
## [0.3.1] - 2026-09-03
|
## [0.3.1] - 2026-09-03
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -79,6 +79,17 @@ Architecture and the reasoning behind it: [docs/ARCHITECTURE.md](docs/ARCHITECTU
|
|||||||
Versions and what changed in each: [CHANGELOG.md](CHANGELOG.md). The running
|
Versions and what changed in each: [CHANGELOG.md](CHANGELOG.md). The running
|
||||||
version is in the page footer and at `GET /healthz`.
|
version is in the page footer and at `GET /healthz`.
|
||||||
|
|
||||||
|
To cut a release: move the `[Unreleased]` notes in `CHANGELOG.md` under a new
|
||||||
|
`## [x.y.z] - date` heading, set the same version in `package.json`, commit, then tag
|
||||||
|
and publish the Gitea release with the changelog section as its notes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git tag -a vx.y.z -m "Courtside x.y.z" && git push origin --tags
|
||||||
|
# Gitea → Releases → Draft a release on tag vx.y.z, or POST /api/v1/repos/bkvargyas/courtside/releases
|
||||||
|
```
|
||||||
|
|
||||||
|
A push to `main` deploys; the tag and release record which commit each version was.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT. See [LICENSE](LICENSE).
|
MIT. See [LICENSE](LICENSE).
|
||||||
+12
-1
@@ -51,6 +51,16 @@ A match object:
|
|||||||
|
|
||||||
Phone numbers, captain names, and team codes never appear in the public document.
|
Phone numbers, captain names, and team codes never appear in the public document.
|
||||||
|
|
||||||
|
## Score keeper (Courtside Manager)
|
||||||
|
|
||||||
|
Per-court links issued from the desk. `k` is an HMAC of `<slug>:court<n>` under `SESSION_SECRET`; requests without a valid `k` get 403.
|
||||||
|
|
||||||
|
| Method | Path | Body (JSON) | Effect |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| GET | `/keeper/<slug>/<court>?k=…` | | The score keeper page for that court. |
|
||||||
|
| POST | `/keeper/<slug>/<court>/live` | `k`, `match`, `a`, `b`, `sets` (completed sets, `[[21,18]]`) | Update the running score of the live match on that court. Fails if `match` is not the one on the court. |
|
||||||
|
| POST | `/keeper/<slug>/<court>/final` | `k`, `match`, `sets` (all sets incl. the last) | Record the result, validated against the rules; the court engine then assigns the next match. Fails once the match is no longer live. |
|
||||||
|
|
||||||
## Organizer
|
## Organizer
|
||||||
|
|
||||||
All organizer routes require the session cookie set by `/admin/login` (or Cloudflare Access when `TRUST_CF_ACCESS=true`). Unauthenticated requests are redirected to the login page.
|
All organizer routes require the session cookie set by `/admin/login` (or Cloudflare Access when `TRUST_CF_ACCESS=true`). Unauthenticated requests are redirected to the login page.
|
||||||
@@ -64,10 +74,11 @@ All organizer routes require the session cookie set by `/admin/login` (or Cloudf
|
|||||||
| POST | `/admin/new` | `name`, `date`, `courtCount`, `teamSize`, `pointsTo`, `winBy`, `cap` (0 = none), `bestOf`, `stages` (`pool,bracket` / `pool` / `bracket`), `notes` | Creates a tournament. |
|
| POST | `/admin/new` | `name`, `date`, `courtCount`, `teamSize`, `pointsTo`, `winBy`, `cap` (0 = none), `bestOf`, `stages` (`pool,bracket` / `pool` / `bracket`), `notes` | Creates a tournament. |
|
||||||
| GET | `/admin/t/<slug>` | | Tournament desk. |
|
| GET | `/admin/t/<slug>` | | 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. |
|
||||||
| POST | `/admin/t/<slug>/phase` | `phase` = `checkin` / `closed` / `live` / `final` | Change phase. `live` requires generated play and starts court assignment. |
|
| POST | `/admin/t/<slug>/phase` | `phase` = `checkin` / `closed` / `live` / `final` | Change phase. `live` requires generated play and starts court assignment. |
|
||||||
| POST | `/admin/t/<slug>/banner` | `banner` | Set (or clear with empty) the persistent banner. |
|
| POST | `/admin/t/<slug>/banner` | `banner` | Set (or clear with empty) the persistent banner. |
|
||||||
| POST | `/admin/t/<slug>/broadcast` | `text` | One-time alert to all team pages. |
|
| POST | `/admin/t/<slug>/broadcast` | `text` | One-time alert to all team pages. |
|
||||||
| POST | `/admin/t/<slug>/generate` | `what=pools`, `poolSize` — or `what=bracket`, `perPool`, `wildcards`, `type` (`single`/`double`), `thirdPlace` | Generate pools or bracket. |
|
| POST | `/admin/t/<slug>/generate` | `what=pools`, `poolSize` — or `what=bracket`, `perPool`, `wildcards`, `type` (`single`/`double`), `thirdPlace` — or `what=manual`, `type`, `thirdPlace`, `a1`,`b1`,`a2`,`b2`,… (team ids per round-1 match; empty = bye) | Generate pools or bracket, automatically or from hand-picked pairs. |
|
||||||
| POST | `/admin/t/<slug>/regenerate` | | Clear all generated play (only before any score). |
|
| POST | `/admin/t/<slug>/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). |
|
||||||
|
|||||||
+1
-2
@@ -4,14 +4,13 @@ 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.
|
||||||
- **Browser notifications need HTTPS** and, on iOS, the page added to the home screen. Through the Cloudflare tunnel HTTPS is automatic.
|
- **Browser notifications need HTTPS** and, on iOS, the page added to the home screen. Through the Cloudflare tunnel HTTPS is automatic.
|
||||||
- **Registration is open to anyone with the URL** during check-in, by design. If someone registers junk teams, remove them from the desk. There is no rate limiting beyond the 64 KB body cap.
|
- **Registration is open to anyone with the URL** during check-in, by design. If someone registers junk teams, remove them from the desk. There is no rate limiting beyond the 64 KB body cap.
|
||||||
- **Team codes are 4 characters** (about a million combinations) and only reveal a personalized read-only view, so guessing is harmless. They are not a write credential.
|
- **Team codes are 4 characters** (about a million combinations) and only reveal a personalized read-only view, so guessing is harmless. They are not a write credential.
|
||||||
- **Organizer sessions reset on restart** unless `SESSION_SECRET` is set.
|
- **Organizer sessions and score keeper links reset on restart** unless `SESSION_SECRET` is set. Set it in production.
|
||||||
- **`node:sqlite` is marked experimental** by Node (stable API since 22.13, still emits a warning that the start script suppresses). If a future Node changes it, `server/store.js` is the only file that touches it and is about 50 lines.
|
- **`node:sqlite` is marked experimental** by Node (stable API since 22.13, still emits a warning that the start script suppresses). If a future Node changes it, `server/store.js` is the only file that touches it and is about 50 lines.
|
||||||
- **Single process.** Restarting the container drops WebSocket connections; clients reconnect with backoff and re-fetch state, and no data is lost since every mutation is written before it is broadcast.
|
- **Single process.** Restarting the container drops WebSocket connections; clients reconnect with backoff and re-fetch state, and no data is lost since every mutation is written before it is broadcast.
|
||||||
- **The desk reloads itself** when court or phase state changes (for multi-organizer freshness). If you are mid-typing in a form when another organizer marks a match final, the page reloads. The score pad is exempt while hovered.
|
- **The desk reloads itself** when court or phase state changes (for multi-organizer freshness). If you are mid-typing in a form when another organizer marks a match final, the page reloads. The score pad is exempt while hovered.
|
||||||
@@ -20,6 +20,7 @@ From the desk you can rename a team, fix the captain or player count, remove a t
|
|||||||
|
|
||||||
1. **Close registration.** The public page switches to the roster and "schedule coming."
|
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. 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
|
||||||
@@ -32,6 +33,12 @@ For best-of-3, or if you'd rather type, use "Save sets" with `21-18, 19-21, 15-9
|
|||||||
|
|
||||||
**Forfeits.** The "Forfeits" button next to a live match records a forfeit by the selected team (0 to points-to for standings purposes).
|
**Forfeits.** The "Forfeits" button next to a live match records a forfeit by the selected team (0 to points-to for standings purposes).
|
||||||
|
|
||||||
|
## Score keepers (Courtside Manager)
|
||||||
|
|
||||||
|
You don't have to score every court yourself. Each court card on the desk shows a **score keeper QR code**; "Score keeper QR codes" at the top of the desk prints one large code per court to tape at the scorer's table. Whoever is sitting at that court scans it and gets Courtside Manager, a full-screen page with two big + buttons, a − for a mis-tap, Undo, and a Finalize button that appears once a side has won the set under your rules. The page always shows whatever match is on that court and switches to the next one by itself when the previous is finalized, so one link lasts the whole day.
|
||||||
|
|
||||||
|
A keeper link can only do two things: update the running score of the live match on its court, and finalize it. It cannot change a finished score, forfeit, or touch anything else; corrections are made from the desk ("All matches"). Links are signed with the server's `SESSION_SECRET`, so if that changes the old links stop working and you copy new ones.
|
||||||
|
|
||||||
## Things that go wrong on the day
|
## Things that go wrong on the day
|
||||||
|
|
||||||
**A team leaves.** In the Teams list click Withdraw and choose:
|
**A team leaves.** In the Teams list click Withdraw and choose:
|
||||||
@@ -39,7 +46,7 @@ For best-of-3, or if you'd rather type, use "Save sets" with `21-18, 19-21, 15-9
|
|||||||
- *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.
|
||||||
|
|
||||||
|
|||||||
+20
-3
@@ -28,7 +28,8 @@ export class CourtEngine {
|
|||||||
this.t.recordResult(matchId, sets, opts);
|
this.t.recordResult(matchId, sets, opts);
|
||||||
if (court) {
|
if (court) {
|
||||||
const dur = this.now() - court.startedAt;
|
const dur = this.now() - court.startedAt;
|
||||||
if (m.status === 'final') this.durations.push(dur);
|
// ignore implausible durations (a match left open over lunch, a clock jump) so the ETA stays useful
|
||||||
|
if (m.status === 'final' && dur >= 2 * MIN && dur <= 90 * MIN) this.durations.push(dur);
|
||||||
this._freeCourt(court);
|
this._freeCourt(court);
|
||||||
}
|
}
|
||||||
for (const tid of [m.teamA, m.teamB]) this.lastPlayed.set(tid, this.now());
|
for (const tid of [m.teamA, m.teamB]) this.lastPlayed.set(tid, this.now());
|
||||||
@@ -101,7 +102,7 @@ export class CourtEngine {
|
|||||||
const m = c.matchId ? this.t.match(c.matchId) : null;
|
const m = c.matchId ? this.t.match(c.matchId) : null;
|
||||||
return { court: c.number, status: c.status, match: m ? this._matchView(m) : null };
|
return { court: c.number, status: c.status, match: m ? this._matchView(m) : null };
|
||||||
}),
|
}),
|
||||||
upNext: q.slice(0, 3).map(x => ({ ...this._matchView(x.match), court: x.court, etaMin: Math.round(x.eta / MIN) })),
|
upNext: q.slice(0, 3).map(x => ({ ...this._matchView(x.match), court: x.court, etaMin: Math.round(x.eta / MIN), tentative: !!x.tentative, aFrom: x.aFrom ?? null, bFrom: x.bFrom ?? null })),
|
||||||
avgMatchMin: Math.round(this.avgMatchMs() / MIN),
|
avgMatchMin: Math.round(this.avgMatchMs() / MIN),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -120,6 +121,22 @@ export class CourtEngine {
|
|||||||
sims[0].freeAt = start + this.avgMatchMs();
|
sims[0].freeAt = start + this.avgMatchMs();
|
||||||
teamFree.set(m.teamA, sims[0].freeAt); teamFree.set(m.teamB, sims[0].freeAt);
|
teamFree.set(m.teamA, sims[0].freeAt); teamFree.set(m.teamB, sims[0].freeAt);
|
||||||
}
|
}
|
||||||
|
// Bracket matches still waiting on a live match (one or both sides TBD) get a tentative slot too,
|
||||||
|
// so late in a bracket the board still says "Net Gains vs winner on Court 2 · ready ~2:10".
|
||||||
|
const decided = x => ['final', 'forfeit', 'bye', 'void'].includes(x.status);
|
||||||
|
const liveCourt = x => this.courts.find(c => c.matchId === x.id)?.number ?? null;
|
||||||
|
for (const m of this.t.matches) {
|
||||||
|
if (m.status !== 'pending' || m.conditional || m.stage === 'pool' || (m.teamA && m.teamB)) continue;
|
||||||
|
const feeders = this.t.matches.filter(x => x.feeds === m.id || x.loserFeeds === m.id);
|
||||||
|
if (!feeders.length || !feeders.every(x => decided(x) || x.status === 'live') || !feeders.some(x => x.status === 'live')) continue;
|
||||||
|
const side = (which) => { const f = feeders.find(x => (x.feeds === m.id && x.feedsSide === which) || (x.loserFeeds === m.id && x.loserFeedsSide === which)); return f && f.status === 'live' ? { court: liveCourt(f), loser: f.loserFeeds === m.id } : null; };
|
||||||
|
const waitOn = feeders.filter(x => x.status === 'live').map(x => this._expectedFinish(this.courts.find(c => c.matchId === x.id)));
|
||||||
|
sims.sort((a, b) => a.freeAt - b.freeAt);
|
||||||
|
const ready = Math.max(sims[0].freeAt, ...waitOn, m.teamA ? teamFree.get(m.teamA) ?? 0 : 0, m.teamB ? teamFree.get(m.teamB) ?? 0 : 0);
|
||||||
|
const start = ready + this.changeoverMs;
|
||||||
|
out.push({ match: m, court: sims[0].number, eta: start - this.now(), tentative: true, aFrom: m.teamA ? null : side('A'), bFrom: m.teamB ? null : side('B') });
|
||||||
|
sims[0].freeAt = start + this.avgMatchMs();
|
||||||
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,7 +189,7 @@ export class CourtEngine {
|
|||||||
_freeCourt(c) { c.matchId = null; c.startedAt = null; c.freeAt = null; }
|
_freeCourt(c) { c.matchId = null; c.startedAt = null; c.freeAt = null; }
|
||||||
|
|
||||||
_refreshOnDeck() {
|
_refreshOnDeck() {
|
||||||
const q = this.queue();
|
const q = this.queue().filter(x => !x.tentative);
|
||||||
const deck = q.slice(0, this.courts.filter(c => c.status === 'open').length);
|
const deck = q.slice(0, this.courts.filter(c => c.status === 'open').length);
|
||||||
const deckIds = new Set(deck.map(x => x.match.id));
|
const deckIds = new Set(deck.map(x => x.match.id));
|
||||||
for (const m of this.t.matches) if (m.status === 'on_deck' && !deckIds.has(m.id)) { m.status = 'pending'; m.court = null; }
|
for (const m of this.t.matches) if (m.status === 'on_deck' && !deckIds.has(m.id)) { m.status = 'pending'; m.court = null; }
|
||||||
|
|||||||
+2
-11
@@ -175,16 +175,7 @@ export function doubleElimination(seeded, { resetMatch = true } = {}) {
|
|||||||
gf.resetMatch = reset.id;
|
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 };
|
||||||
}
|
}
|
||||||
+87
-11
@@ -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,
|
||||||
@@ -104,15 +104,47 @@ 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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 +175,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 +196,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 +270,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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
{"name":"courtside","type":"module","version":"0.3.1","scripts":{"test":"node --test test/*.test.js","sim":"node simulate.js","start":"node --disable-warning=ExperimentalWarning server/index.js","dev":"node --disable-warning=ExperimentalWarning --watch server/index.js","demo":"node --disable-warning=ExperimentalWarning scripts/seed-demo.js","smoke":"bash test/smoke.sh"},"description":"Self-hosted volleyball tournament desk: one QR code from check-in to live scores","main":"server/index.js","directories":{"test":"test"},"keywords":[],"author":"","license":"MIT","dependencies":{"qrcode":"^1.5.4","ws":"^8.21.3"},"engines":{"node":">=22.13"},"repository":{"type":"git","url":"https://gitea.cloudfreeiot.com/bkvargyas/courtside.git"}}
|
{"name":"courtside","type":"module","version":"0.5.2","scripts":{"test":"node --test test/*.test.js","sim":"node simulate.js","start":"node --disable-warning=ExperimentalWarning server/index.js","dev":"node --disable-warning=ExperimentalWarning --watch server/index.js","demo":"node --disable-warning=ExperimentalWarning scripts/seed-demo.js","smoke":"bash test/smoke.sh"},"description":"Self-hosted volleyball tournament desk: one QR code from check-in to live scores","main":"server/index.js","directories":{"test":"test"},"keywords":[],"author":"","license":"MIT","dependencies":{"qrcode":"^1.5.4","ws":"^8.21.3"},"engines":{"node":">=22.13"},"repository":{"type":"git","url":"https://gitea.cloudfreeiot.com/bkvargyas/courtside.git"}}
|
||||||
+4
-2
@@ -48,14 +48,16 @@ export const send = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const MIME = { '.css': 'text/css', '.js': 'text/javascript', '.png': 'image/png', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.webmanifest': 'application/manifest+json', '.json': 'application/json' };
|
const MIME = { '.css': 'text/css', '.js': 'text/javascript', '.png': 'image/png', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.webmanifest': 'application/manifest+json', '.json': 'application/json' };
|
||||||
export async function serveStatic(res, root, urlPath) {
|
export async function serveStatic(res, root, urlPath, versioned = false) {
|
||||||
const p = normalize(join(root, urlPath));
|
const p = normalize(join(root, urlPath));
|
||||||
if (!p.startsWith(root)) throw new HttpError(404, 'Not found');
|
if (!p.startsWith(root)) throw new HttpError(404, 'Not found');
|
||||||
try {
|
try {
|
||||||
const s = await stat(p);
|
const s = await stat(p);
|
||||||
if (!s.isFile()) throw new HttpError(404, 'Not found');
|
if (!s.isFile()) throw new HttpError(404, 'Not found');
|
||||||
const body = await readFile(p);
|
const body = await readFile(p);
|
||||||
res.writeHead(200, { 'content-type': MIME[extname(p)] ?? 'application/octet-stream', 'cache-control': 'public, max-age=300' });
|
// Assets are referenced with ?v=<version>, so a new release is a new URL: cache the versioned
|
||||||
|
// URL for a year, and keep unversioned requests short-lived.
|
||||||
|
res.writeHead(200, { 'content-type': MIME[extname(p)] ?? 'application/octet-stream', 'cache-control': versioned ? 'public, max-age=31536000, immutable' : 'public, max-age=60, must-revalidate' });
|
||||||
res.end(body);
|
res.end(body);
|
||||||
} catch (e) { if (e instanceof HttpError) throw e; throw new HttpError(404, 'Not found'); }
|
} catch (e) { if (e instanceof HttpError) throw e; throw new HttpError(404, 'Not found'); }
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -25,7 +25,7 @@ const staticRoot = join(here, 'public');
|
|||||||
const server = createServer(async (req, res) => {
|
const server = createServer(async (req, res) => {
|
||||||
const url = new URL(req.url, 'http://x');
|
const url = new URL(req.url, 'http://x');
|
||||||
try {
|
try {
|
||||||
if (url.pathname.startsWith('/static/')) return await serveStatic(res, staticRoot, url.pathname.slice('/static'.length));
|
if (url.pathname.startsWith('/static/')) return await serveStatic(res, staticRoot, url.pathname.slice('/static'.length), url.searchParams.has('v'));
|
||||||
const m = router.match(req.method, url.pathname);
|
const m = router.match(req.method, url.pathname);
|
||||||
if (!m) throw new HttpError(404, 'Not found');
|
if (!m) throw new HttpError(404, 'Not found');
|
||||||
await m.handler(req, res, m.params);
|
await m.handler(req, res, m.params);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export function publicState({ t, engine }) {
|
|||||||
id: m.id, stage: m.stage, round: m.round, slot: m.slot, label: m.label ?? null, status: m.status, court: m.court,
|
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, 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 => ({
|
||||||
@@ -21,7 +21,7 @@ export function publicState({ t, engine }) {
|
|||||||
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 })) : [],
|
||||||
upNext: board ? board.upNext.map(u => ({ ...view(t.match(u.id)), court: u.court, etaMin: u.etaMin })) : [],
|
upNext: board ? board.upNext.map(u => ({ ...view(t.match(u.id)), court: u.court, etaMin: u.etaMin, tentative: u.tentative, aFrom: u.aFrom, bFrom: u.bFrom })) : [],
|
||||||
avgMatchMin: board?.avgMatchMin ?? null,
|
avgMatchMin: board?.avgMatchMin ?? null,
|
||||||
champion: t.phase === 'final' ? t.champion() : null,
|
champion: t.phase === 'final' ? t.champion() : null,
|
||||||
recentAlerts: engine.alerts.slice(-20).map(a => ({ kind: a.kind, text: a.text, at: a.at, teams: a.teams ?? [], match: a.match ?? null })),
|
recentAlerts: engine.alerts.slice(-20).map(a => ({ kind: a.kind, text: a.text, at: a.at, teams: a.teams ?? [], match: a.match ?? null })),
|
||||||
|
|||||||
@@ -23,6 +23,19 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
|
|||||||
+36
-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,13 +21,13 @@ main{max-width:960px;margin:0 auto;padding:16px 16px 64px}
|
|||||||
.eyebrow{font-family:"Barlow Condensed",sans-serif;text-transform:uppercase;letter-spacing:.12em;font-size:13px;font-weight:600;color:var(--muted)}
|
.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 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)}
|
||||||
@@ -54,7 +55,7 @@ a.tl{color:inherit;text-decoration:none;border-bottom:1px dotted currentColor}a.
|
|||||||
.court .timing{position:relative;font-family:"Barlow Condensed",sans-serif;font-size:12px;letter-spacing:.06em;text-transform:uppercase;opacity:.85;margin-top:6px}
|
.court .timing{position:relative;font-family:"Barlow Condensed",sans-serif;font-size:12px;letter-spacing:.06em;text-transform:uppercase;opacity:.85;margin-top:6px}
|
||||||
.court .nextup{position:relative;margin-top:8px;padding-top:8px;border-top:1px solid rgba(255,255,255,.3);font-size:15px}
|
.court .nextup{position:relative;margin-top:8px;padding-top:8px;border-top:1px solid rgba(255,255,255,.3);font-size:15px}
|
||||||
.court .nextup .k{font-family:"Barlow Condensed",sans-serif;font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:#F0C46B;margin-right:4px}
|
.court .nextup .k{font-family:"Barlow Condensed",sans-serif;font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:#F0C46B;margin-right:4px}
|
||||||
.court .nextup .eta{font-size:13px;opacity:.85;margin-left:4px}.court .nextup.mine{font-weight:600}
|
.court .nextup .eta{font-size:13px;opacity:.85;margin-left:4px}.court .nextup .tbd{color:inherit;opacity:.85}.court.idle .nextup{border-top-color:var(--line);color:var(--ink)}.court.idle .nextup .k{color:var(--sand)}.court .nextup.mine{font-weight:600}
|
||||||
/* pools */
|
/* pools */
|
||||||
.pools{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:12px}
|
.pools{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:12px}
|
||||||
table{width:100%;border-collapse:collapse;font-size:15px}th{text-align:left;font-family:"Barlow Condensed",sans-serif;text-transform:uppercase;letter-spacing:.08em;font-size:12px;color:var(--muted);padding:4px 6px;border-bottom:1px solid var(--ink)}
|
table{width:100%;border-collapse:collapse;font-size:15px}th{text-align:left;font-family:"Barlow Condensed",sans-serif;text-transform:uppercase;letter-spacing:.08em;font-size:12px;color:var(--muted);padding:4px 6px;border-bottom:1px solid var(--ink)}
|
||||||
@@ -84,7 +85,8 @@ ul.plain,ol.plain{list-style:none;padding:0;margin:8px 0}ol.plain{counter-reset:
|
|||||||
.brow.won .bn{font-weight:600}.brow.won .bs{color:var(--ink);font-weight:600}.brow.mine{background:var(--sand-soft)}
|
.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}
|
||||||
@@ -110,3 +112,33 @@ body.display .queue{font-size:26px}body.display details{display:none}
|
|||||||
@media (max-width:640px){h1{font-size:34px}.cols{columns:1}.court .t{font-size:20px}.court .s{font-size:24px}}
|
@media (max-width:640px){h1{font-size:34px}.cols{columns:1}.court .t{font-size:20px}.court .s{font-size:24px}}
|
||||||
@media (prefers-reduced-motion:reduce){*{scroll-behavior:auto!important}}
|
@media (prefers-reduced-motion:reduce){*{scroll-behavior:auto!important}}
|
||||||
@media print{.top,.foot,button{display:none}body{background:#fff;color:#000}}
|
@media print{.top,.foot,button{display:none}body{background:#fff;color:#000}}
|
||||||
|
.keeperbox{display:flex;gap:12px;align-items:flex-start;margin:6px 0 10px;padding:10px;border:1px dashed var(--line);border-radius:8px}
|
||||||
|
.keeperbox img{flex:none;border-radius:4px;background:#fff;image-rendering:pixelated}
|
||||||
|
.keeperbox .button{margin-top:6px}
|
||||||
|
.qrgrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:16px}.qrgrid .qr img{width:min(80vw,300px)}
|
||||||
|
@media print{.qrgrid{grid-template-columns:1fr 1fr}.qrgrid .card{break-inside:avoid;border:1px solid #000}}
|
||||||
|
.manual{margin-top:10px}.manual summary{font-weight:600;color:var(--accent)}
|
||||||
|
.pairs select{max-width:220px;font:inherit;padding:4px 6px;border:1px solid var(--line);border-radius:6px;background:var(--bg);color:var(--ink)}
|
||||||
|
.pairs td{padding:4px 6px}.pairs tr.dup select{border-color:var(--crit)}
|
||||||
|
/* score keeper (Courtside Manager) */
|
||||||
|
body.keeper-body{background:#0F1418;color:#F2F5F7}body.keeper-body .top,body.keeper-body .foot{display:none}
|
||||||
|
body.keeper-body main{max-width:720px;padding:12px 12px 40px}
|
||||||
|
.khead{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px}
|
||||||
|
.kcourt{font-family:"Barlow Condensed",sans-serif;font-size:28px;font-weight:700;letter-spacing:.04em;text-transform:uppercase}
|
||||||
|
.klbl{font-family:"Barlow Condensed",sans-serif;font-size:14px;letter-spacing:.08em;text-transform:uppercase;color:#8E9BA6;margin-bottom:10px}
|
||||||
|
.kpad{display:grid;grid-template-columns:1fr 1fr;gap:10px}
|
||||||
|
.kside{background:#1A222B;border:2px solid #2B3640;border-radius:12px;padding:12px 10px 10px;display:flex;flex-direction:column;align-items:center;gap:8px}
|
||||||
|
.kside.lead{border-color:#5AA3E6}
|
||||||
|
.kname{font-family:"Barlow Condensed",sans-serif;font-size:22px;font-weight:600;text-align:center;line-height:1.1;min-height:2.2em;display:flex;align-items:center}
|
||||||
|
.kpts{font-size:96px;line-height:1;font-weight:500}
|
||||||
|
.kbtn{width:100%;border-radius:10px;border:0;font-size:44px;line-height:1;padding:22px 0;cursor:pointer;font-family:inherit;touch-action:manipulation;-webkit-tap-highlight-color:transparent}
|
||||||
|
.kbtn.plus{background:#1D6FB5;color:#fff;padding:34px 0}.kbtn.plus:disabled{background:#2B3640;color:#5B6875;cursor:default}.kbtn.plus:active{background:#3A8AD0}
|
||||||
|
.kbtn.minus{background:#2B3640;color:#C9D2DA;font-size:28px;padding:10px 0}.kbtn.minus:active{background:#3A4652}
|
||||||
|
.ksets{margin:12px 0 0;color:#C9D2DA;text-align:center}
|
||||||
|
.kactions{margin-top:14px;display:flex;flex-direction:column;gap:10px;align-items:stretch}
|
||||||
|
.kactions button{font-size:20px;padding:14px}.kactions .big{font-size:24px;padding:18px}
|
||||||
|
.kactions button.small{font-size:15px;padding:8px;background:transparent;color:#C9D2DA;border-color:#2B3640}
|
||||||
|
.kactions button.primary{background:#2E8B57;border-color:#2E8B57;color:#fff}
|
||||||
|
.kconfirm{background:#1A222B;border:2px solid #F0C46B;border-radius:12px;padding:14px;text-align:center}.kconfirm p{margin:0 0 10px}
|
||||||
|
.kwait{text-align:center;padding:40px 10px}.kwait h1{font-size:34px}.knote{background:#3A3120;color:#F0C46B;padding:10px 14px;border-radius:8px;text-align:center}
|
||||||
|
@media (max-width:380px){.kpts{font-size:72px}.kbtn.plus{padding:26px 0}}
|
||||||
+17
-11
@@ -10,18 +10,22 @@
|
|||||||
// 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 clock = ms => new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
|
const clock = ms => new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
|
||||||
const isMine = m => myTeam && (m.a?.id === myTeam || m.b?.id === myTeam);
|
const 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(', ')
|
||||||
: m.status === 'forfeit' ? 'forfeit' : m.live ? `${m.live[0]}–${m.live[1]}` : m.status === 'live' ? '0–0' : '';
|
: m.status === 'forfeit' ? 'forfeit' : m.live ? [...(m.liveSets ?? []), m.live].map(s => `${s[0]}–${s[1]}`).join(', ') : m.status === 'live' ? '0–0' : '';
|
||||||
// score from one team's point of view (their points first)
|
// score from one team's point of view (their points first)
|
||||||
const scoreFor = (m, teamId) => {
|
const scoreFor = (m, teamId) => {
|
||||||
const flip = m.b?.id === teamId;
|
const flip = m.b?.id === teamId;
|
||||||
const pair = ([a, b]) => flip ? `${b}–${a}` : `${a}–${b}`;
|
const pair = ([a, b]) => flip ? `${b}–${a}` : `${a}–${b}`;
|
||||||
if (m.status === 'final') return m.sets.map(pair).join(', ');
|
if (m.status === 'final') return m.sets.map(pair).join(', ');
|
||||||
if (m.status === 'forfeit') return 'forfeit';
|
if (m.status === 'forfeit') return 'forfeit';
|
||||||
if (m.live) return pair(m.live);
|
if (m.live) return [...(m.liveSets ?? []), m.live].map(pair).join(', ');
|
||||||
return m.status === 'live' ? '0–0' : '';
|
return m.status === 'live' ? '0–0' : '';
|
||||||
};
|
};
|
||||||
const rulesLine = r => `${r.teamSize}s · to ${r.pointsTo}, win by ${r.winBy}${r.cap ? `, cap ${r.cap}` : ''}${r.bestOf > 1 ? `, best of ${r.bestOf}` : ''}`;
|
const rulesLine = r => `${r.teamSize}s · to ${r.pointsTo}, win by ${r.winBy}${r.cap ? `, cap ${r.cap}` : ''}${r.bestOf > 1 ? `, best of ${r.bestOf}` : ''}`;
|
||||||
@@ -30,7 +34,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>`);
|
||||||
@@ -70,11 +74,12 @@
|
|||||||
return `<section class="courts">${state.courts.map(c => {
|
return `<section class="courts">${state.courts.map(c => {
|
||||||
const m = c.match;
|
const m = c.match;
|
||||||
if (c.status === 'paused') return `<div class="court paused"><div class="lbl">Court ${c.court}</div><div class="teams"><div class="t">Paused</div></div></div>`;
|
if (c.status === 'paused') return `<div class="court paused"><div class="lbl">Court ${c.court}</div><div class="teams"><div class="t">Paused</div></div></div>`;
|
||||||
if (!m) return `<div class="court idle"><div class="lbl">Court ${c.court}</div><div class="teams"><div class="t muted">Open</div></div></div>`;
|
if (!m) return `<div class="court idle"><div class="lbl">Court ${c.court}</div><div class="teams"><div class="t muted">Open</div></div>${nextForCourt(c.court)}</div>`;
|
||||||
const avg = state.avgMatchMin;
|
const avg = state.avgMatchMin;
|
||||||
const elapsed = m.startedAt ? Math.max(0, Math.round((Date.now() - m.startedAt) / 60000)) : null;
|
const elapsed = m.startedAt ? Math.max(0, Math.round((Date.now() - m.startedAt) / 60000)) : null;
|
||||||
const left = avg != null && elapsed != null ? Math.max(0, avg - elapsed) : null;
|
const left = avg != null && elapsed != null ? Math.max(0, avg - elapsed) : null;
|
||||||
const timing = avg != null ? `avg game ${avg} min${elapsed != null ? ` · ${elapsed} min played${left ? `, ~${left} left` : ''}` : ''}` : '';
|
const finish = avg != null && m.startedAt ? clock(Math.max(Date.now(), m.startedAt + avg * 60000)) : null;
|
||||||
|
const timing = avg != null ? `avg game ${avg} min${elapsed != null ? ` · ${elapsed} min played` : ''}${finish ? ` · est. finish ~${finish}${left ? ` (${left} min)` : ''}` : ''}` : '';
|
||||||
return `<div class="court ${isMine(m) ? 'mine' : ''}"><div class="lbl">Court ${c.court} · ${m.stage === 'pool' ? `Pool ${esc(m.a && state.teams.find(t => t.id === m.a.id)?.poolId || '')} R${m.round}` : (m.label ?? `Bracket R${m.round}`)}</div>
|
return `<div class="court ${isMine(m) ? 'mine' : ''}"><div class="lbl">Court ${c.court} · ${m.stage === 'pool' ? `Pool ${esc(m.a && state.teams.find(t => t.id === m.a.id)?.poolId || '')} R${m.round}` : (m.label ?? `Bracket R${m.round}`)}</div>
|
||||||
<div class="teams"><div class="t">${name(m.a)}</div><div class="s mono">${score(m) || '0–0'}</div><div class="t">${name(m.b)}</div></div>
|
<div class="teams"><div class="t">${name(m.a)}</div><div class="s mono">${score(m) || '0–0'}</div><div class="t">${name(m.b)}</div></div>
|
||||||
${timing ? `<div class="timing">${timing}</div>` : ''}
|
${timing ? `<div class="timing">${timing}</div>` : ''}
|
||||||
@@ -87,12 +92,12 @@
|
|||||||
const u = state.upNext.find(x => x.court === courtNo);
|
const u = state.upNext.find(x => x.court === courtNo);
|
||||||
if (!u) return '';
|
if (!u) return '';
|
||||||
const ready = clock(state.generatedAt + u.etaMin * 60000);
|
const ready = clock(state.generatedAt + u.etaMin * 60000);
|
||||||
return `<div class="nextup ${isMine(u) ? 'mine' : ''}"><span class="k">Next up</span> ${name(u.a)} vs ${name(u.b)} <span class="eta mono">ready ~${ready}</span></div>`;
|
return `<div class="nextup ${isMine(u) ? 'mine' : ''}"><span class="k">Next up</span> ${fromName(u.a, u.aFrom)} vs ${fromName(u.b, u.bFrom)} <span class="eta mono">ready ~${ready}</span></div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderUpNext() {
|
function renderUpNext() {
|
||||||
if (!state.upNext.length) return '';
|
if (!state.upNext.length) return '';
|
||||||
return `<section class="queue"><div class="eyebrow">Up next</div>${state.upNext.map((u, i) => `<div class="qrow ${isMine(u) ? 'mine' : ''}"><span class="k">${i === 0 ? 'On deck' : 'Then'}</span><span>${name(u.a)} vs ${name(u.b)}</span><span class="eta mono">Court ${u.court} · ~${clock(state.generatedAt + u.etaMin * 60000)} (${u.etaMin} min)</span></div>`).join('')}</section>`;
|
return `<section class="queue"><div class="eyebrow">Up next</div>${state.upNext.map((u, i) => `<div class="qrow ${isMine(u) ? 'mine' : ''}"><span class="k">${u.tentative ? 'Pending' : i === 0 ? 'On deck' : 'Then'}</span><span>${fromName(u.a, u.aFrom)} vs ${fromName(u.b, u.bFrom)}</span><span class="eta mono">Court ${u.court} · ~${clock(state.generatedAt + u.etaMin * 60000)} (${u.etaMin} min)</span></div>`).join('')}</section>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPools() {
|
function renderPools() {
|
||||||
@@ -100,7 +105,7 @@
|
|||||||
<table><thead><tr><th>Team</th><th class="num">W</th><th class="num">L</th><th class="num">Pts</th><th class="num">+/−</th></tr></thead><tbody>
|
<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>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,13 +165,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);
|
||||||
@@ -179,7 +185,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;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
/* Courtside Manager — score keeper for one court. Big buttons, one job: keep the running score of
|
||||||
|
whatever match is on this court and finalize it. Corrections after that are the organizer's. */
|
||||||
|
(() => {
|
||||||
|
const root = document.getElementById('keeper');
|
||||||
|
const court = +root.dataset.court, k = root.dataset.k;
|
||||||
|
let state = JSON.parse(document.getElementById('state').textContent);
|
||||||
|
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||||
|
const base = `/keeper/${state.slug}/${court}`;
|
||||||
|
const post = (path, body) => fetch(`${base}/${path}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ k, ...body }) }).then(async r => { const j = await r.json().catch(() => ({})); if (!r.ok || !j.ok) throw new Error(j.error || r.statusText); return j; });
|
||||||
|
|
||||||
|
// local scoring state for the match currently on this court
|
||||||
|
let cur = null; // { matchId, a, b, sets: [[a,b],...], history: [] }
|
||||||
|
let busy = false, note = '', confirming = false;
|
||||||
|
|
||||||
|
function courtMatch() { const c = state.courts.find(c => c.court === court); return c?.match && c.match.status === 'live' ? c.match : null; }
|
||||||
|
|
||||||
|
function syncFromState() {
|
||||||
|
const m = courtMatch();
|
||||||
|
if (!m) { cur = null; return; }
|
||||||
|
if (!cur || cur.matchId !== m.id) {
|
||||||
|
cur = { matchId: m.id, a: m.live?.[0] ?? 0, b: m.live?.[1] ?? 0, sets: m.liveSets ? m.liveSets.map(s => [...s]) : [], history: [] };
|
||||||
|
confirming = false; note = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rules = () => state.rules;
|
||||||
|
function setOver(a, b) {
|
||||||
|
const { pointsTo, winBy, cap } = rules();
|
||||||
|
const hi = Math.max(a, b), lo = Math.min(a, b);
|
||||||
|
if (cap && hi >= cap && hi > lo) return true;
|
||||||
|
return hi >= pointsTo && hi - lo >= winBy;
|
||||||
|
}
|
||||||
|
const setsNeeded = () => Math.ceil(rules().bestOf / 2);
|
||||||
|
const setsWon = side => cur.sets.filter(s => (side === 0 ? s[0] > s[1] : s[1] > s[0])).length;
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
syncFromState();
|
||||||
|
const c = state.courts.find(c => c.court === court);
|
||||||
|
const m = courtMatch();
|
||||||
|
const head = `<div class="khead"><span class="kcourt">Court ${court}</span><span class="pill ${state.phase}">${esc(state.name)}</span></div>`;
|
||||||
|
if (!m) {
|
||||||
|
const next = state.upNext.find(u => u.court === court);
|
||||||
|
root.innerHTML = `${head}<div class="kwait"><h1>${c?.status === 'paused' ? 'Court paused' : 'No match on this court'}</h1>
|
||||||
|
<p class="muted">${next ? `Next up: <b>${esc(next.a?.name)} vs ${esc(next.b?.name)}</b> (~${next.etaMin} min). This page will switch to it automatically.` : state.phase === 'final' ? 'Tournament is over.' : 'Waiting for the organizer to assign the next match. This page updates on its own.'}</p>
|
||||||
|
${note ? `<p class="knote">${esc(note)}</p>` : ''}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const r = rules();
|
||||||
|
const over = setOver(cur.a, cur.b);
|
||||||
|
const matchDone = over && (setsWon(0) + (cur.a > cur.b ? 1 : 0) >= setsNeeded() || setsWon(1) + (cur.b > cur.a ? 1 : 0) >= setsNeeded());
|
||||||
|
const setNo = cur.sets.length + 1;
|
||||||
|
root.innerHTML = `${head}
|
||||||
|
<div class="klbl">${m.stage === 'pool' ? `Pool play · round ${m.round}` : esc(m.label ?? `Bracket round ${m.round}`)} · to ${r.pointsTo}, win by ${r.winBy}${r.cap ? `, cap ${r.cap}` : ''}${r.bestOf > 1 ? ` · best of ${r.bestOf}, set ${setNo}` : ''}</div>
|
||||||
|
<div class="kpad">
|
||||||
|
<div class="kside ${cur.a > cur.b ? 'lead' : ''}"><div class="kname">${esc(m.a?.name)}</div><div class="kpts mono">${cur.a}</div><button type="button" class="kbtn plus" data-op="a+" ${over ? 'disabled' : ''} aria-label="Point ${esc(m.a?.name)}">+</button><button type="button" class="kbtn minus" data-op="a-" aria-label="Undo point ${esc(m.a?.name)}">−</button></div>
|
||||||
|
<div class="kside ${cur.b > cur.a ? 'lead' : ''}"><div class="kname">${esc(m.b?.name)}</div><div class="kpts mono">${cur.b}</div><button type="button" class="kbtn plus" data-op="b+" ${over ? 'disabled' : ''} aria-label="Point ${esc(m.b?.name)}">+</button><button type="button" class="kbtn minus" data-op="b-" aria-label="Undo point ${esc(m.b?.name)}">−</button></div>
|
||||||
|
</div>
|
||||||
|
${cur.sets.length ? `<div class="ksets mono">Sets: ${cur.sets.map(s => `${s[0]}–${s[1]}`).join(', ')}</div>` : ''}
|
||||||
|
<div class="kactions">
|
||||||
|
${r.bestOf > 1 && over && !matchDone ? `<button type="button" class="primary" data-act="endset">End set ${setNo}</button>` : ''}
|
||||||
|
${over && matchDone && !confirming ? `<button type="button" class="primary big" data-act="finalize">Finalize · ${esc((cur.a > cur.b ? m.a : m.b)?.name)} wins</button>` : ''}
|
||||||
|
${confirming ? `<div class="kconfirm"><p>Final score <b class="mono">${[...cur.sets, [cur.a, cur.b]].map(s => `${s[0]}–${s[1]}`).join(', ')}</b>. Once finalized, only the organizer desk can change it.</p><button type="button" class="primary big" data-act="confirm">Yes, finalize</button> <button type="button" data-act="cancel">Not yet</button></div>` : ''}
|
||||||
|
${!over ? `<p class="muted small">Finalize appears when a side reaches ${r.pointsTo} with a ${r.winBy}-point lead${r.cap ? ` or hits the cap of ${r.cap}` : ''}.</p>` : ''}
|
||||||
|
<button type="button" class="small" data-act="undo" ${cur.history.length ? '' : 'disabled'}>Undo last</button>
|
||||||
|
</div>
|
||||||
|
${note ? `<p class="knote">${esc(note)}</p>` : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let pushTimer = null;
|
||||||
|
function push() {
|
||||||
|
clearTimeout(pushTimer);
|
||||||
|
pushTimer = setTimeout(() => post('live', { match: cur.matchId, a: cur.a, b: cur.b, sets: cur.sets }).then(() => { note = ''; }).catch(e => { note = e.message; render(); }), 120);
|
||||||
|
}
|
||||||
|
function point(side, delta) {
|
||||||
|
if (!cur) return;
|
||||||
|
cur.history.push({ a: cur.a, b: cur.b, sets: cur.sets.map(s => [...s]) });
|
||||||
|
if (side === 'a') cur.a = Math.max(0, cur.a + delta); else cur.b = Math.max(0, cur.b + delta);
|
||||||
|
confirming = false; try { navigator.vibrate?.(15); } catch {}
|
||||||
|
render(); push();
|
||||||
|
}
|
||||||
|
|
||||||
|
root.addEventListener('click', e => {
|
||||||
|
const op = e.target.closest('[data-op]')?.dataset.op; const act = e.target.closest('[data-act]')?.dataset.act;
|
||||||
|
if (op) return point(op[0], op[1] === '+' ? 1 : -1);
|
||||||
|
if (!act || !cur) return;
|
||||||
|
if (act === 'undo') { const h = cur.history.pop(); if (h) { cur.a = h.a; cur.b = h.b; cur.sets = h.sets; confirming = false; render(); push(); } }
|
||||||
|
if (act === 'endset') { cur.history.push({ a: cur.a, b: cur.b, sets: cur.sets.map(s => [...s]) }); cur.sets.push([cur.a, cur.b]); cur.a = 0; cur.b = 0; render(); push(); }
|
||||||
|
if (act === 'finalize') { confirming = true; render(); }
|
||||||
|
if (act === 'cancel') { confirming = false; render(); }
|
||||||
|
if (act === 'confirm') {
|
||||||
|
if (busy) return; busy = true;
|
||||||
|
const sets = [...cur.sets, [cur.a, cur.b]];
|
||||||
|
post('final', { match: cur.matchId, sets }).then(j => { note = j.msg; cur = null; confirming = false; busy = false; }).catch(e => { note = e.message; confirming = false; busy = false; render(); });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// live state: switch to the next match automatically, notice if the desk finalized this one
|
||||||
|
let retry = 1000;
|
||||||
|
function connect() {
|
||||||
|
const ws = new WebSocket(`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/${state.slug}`);
|
||||||
|
ws.onmessage = ev => { state = JSON.parse(ev.data); retry = 1000; render(); };
|
||||||
|
ws.onclose = () => { setTimeout(connect, retry); retry = Math.min(retry * 2, 15000); };
|
||||||
|
ws.onerror = () => ws.close();
|
||||||
|
}
|
||||||
|
render(); connect();
|
||||||
|
// keep the screen awake while scoring, where supported
|
||||||
|
navigator.wakeLock?.request?.('screen').catch(() => {});
|
||||||
|
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') navigator.wakeLock?.request?.('screen').catch(() => {}); });
|
||||||
|
})();
|
||||||
+63
-4
@@ -1,5 +1,5 @@
|
|||||||
import QRCode from 'qrcode';
|
import QRCode from 'qrcode';
|
||||||
import { Router, HttpError, readBody, send, esc } from './http.js';
|
import { Router, HttpError, readBody, send, esc, sign, verify } from './http.js';
|
||||||
import { publicState } from './public-state.js';
|
import { publicState } 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';
|
||||||
@@ -50,6 +50,49 @@ export function buildRouter({ registry, auth, store }) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------- score keeper: a per-court link the organizer hands to whoever is scoring that court ----------
|
||||||
|
// The link carries an HMAC of slug+court signed with the session secret. The page always shows
|
||||||
|
// whatever match is live on that court, can update its running score and finalize it, and moves on
|
||||||
|
// to the next match by itself. Corrections after a match is final need the organizer desk.
|
||||||
|
const keeperToken = (slug, court) => sign(`${slug}:court${court}`, auth.secret).split('.').pop();
|
||||||
|
const keeperUrl = (req, slug, court) => `${origin(req)}/keeper/${slug}/${court}?k=${keeperToken(slug, court)}`;
|
||||||
|
const keeperCheck = (slug, court, k) => { if (!k || verify(`${slug}:court${court}.${k}`, auth.secret) === null) throw new HttpError(403, 'This score keeper link is not valid (links change when the server secret changes; get a fresh one from the desk)'); };
|
||||||
|
const courtMatch = (it, court, matchId) => {
|
||||||
|
const c = it.engine.courts.find(c => c.number === +court); if (!c) throw new HttpError(404, 'No such court');
|
||||||
|
const m = c.matchId ? it.t.match(c.matchId) : null;
|
||||||
|
if (!m || m.status !== 'live') throw new Error('No live match on this court');
|
||||||
|
if (matchId && m.id !== matchId) throw new Error('A different match is on this court now; the page has been refreshed');
|
||||||
|
return m;
|
||||||
|
};
|
||||||
|
r.get('/keeper/:slug/:court', (req, res, { slug, court }) => {
|
||||||
|
const it = item(slug);
|
||||||
|
keeperCheck(slug, court, q(req).get('k'));
|
||||||
|
if (!it.engine.courts.some(c => c.number === +court)) throw new HttpError(404, 'No such court');
|
||||||
|
send.html(res, adm.keeperPage(it.t, +court, q(req).get('k'), publicState(it)));
|
||||||
|
});
|
||||||
|
r.post('/keeper/:slug/:court/live', async (req, res, { slug, court }) => {
|
||||||
|
const body = await readBody(req);
|
||||||
|
keeperCheck(slug, court, body.k);
|
||||||
|
try {
|
||||||
|
registry.mutate(slug, (t, engine) => { const m = courtMatch({ t, engine }, court, body.match); m.live = [Math.max(0, +body.a | 0), Math.max(0, +body.b | 0)]; m.liveSets = Array.isArray(body.sets) ? body.sets.map(s => [+s[0] | 0, +s[1] | 0]).slice(0, 5) : []; });
|
||||||
|
send.json(res, { ok: true });
|
||||||
|
} catch (e) { send.json(res, { ok: false, error: e.message }, 400); }
|
||||||
|
});
|
||||||
|
r.post('/keeper/:slug/:court/final', async (req, res, { slug, court }) => {
|
||||||
|
const body = await readBody(req);
|
||||||
|
keeperCheck(slug, court, body.k);
|
||||||
|
try {
|
||||||
|
const msg = registry.mutate(slug, (t, engine) => {
|
||||||
|
const m = courtMatch({ t, engine }, court, body.match);
|
||||||
|
const sets = (Array.isArray(body.sets) ? body.sets : []).map(s => [+s[0] | 0, +s[1] | 0]);
|
||||||
|
engine.recordResult(m.id, sets, { actor: `keeper:court${court}` });
|
||||||
|
delete m.live; delete m.liveSets;
|
||||||
|
return `${t.teamName(m.winner)} wins ${sets.map(s => s.join('-')).join(', ')}`;
|
||||||
|
});
|
||||||
|
send.json(res, { ok: true, msg });
|
||||||
|
} catch (e) { send.json(res, { ok: false, error: e.message }, 400); }
|
||||||
|
});
|
||||||
|
|
||||||
// ---------- organizer ----------
|
// ---------- organizer ----------
|
||||||
const requireAdmin = (req, res) => {
|
const requireAdmin = (req, res) => {
|
||||||
const who = auth.identify(req);
|
const who = auth.identify(req);
|
||||||
@@ -74,9 +117,19 @@ export function buildRouter({ registry, auth, store }) {
|
|||||||
send.redirect(res, `/admin/t/${it.t.slug}?msg=${encodeURIComponent('Created. Print the QR code and open registration when ready.')}`);
|
send.redirect(res, `/admin/t/${it.t.slug}?msg=${encodeURIComponent('Created. Print the QR code and open registration when ready.')}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
r.get('/admin/t/:slug', (req, res, { slug }) => {
|
r.get('/admin/t/:slug', async (req, res, { slug }) => {
|
||||||
const who = requireAdmin(req, res); if (!who) return;
|
const who = requireAdmin(req, res); if (!who) return;
|
||||||
send.html(res, adm.adminTournament(item(slug), who, origin(req), q(req).get('msg'), q(req).get('error'), store.events(slug, 40)));
|
const it = item(slug);
|
||||||
|
const keeper = { url: c => keeperUrl(req, slug, c.number), qr: {} };
|
||||||
|
if (it.t.phase === 'live') for (const c of it.engine.courts) keeper.qr[c.number] = await QRCode.toDataURL(keeperUrl(req, slug, c.number), { width: 220, margin: 1 });
|
||||||
|
send.html(res, adm.adminTournament(it, who, origin(req), q(req).get('msg'), q(req).get('error'), store.events(slug, 40), keeper));
|
||||||
|
});
|
||||||
|
r.get('/admin/t/:slug/keeper-qr', async (req, res, { slug }) => {
|
||||||
|
const who = requireAdmin(req, res); if (!who) return;
|
||||||
|
const it = item(slug);
|
||||||
|
const cards = [];
|
||||||
|
for (const c of it.engine.courts) cards.push({ court: c.number, url: keeperUrl(req, slug, c.number), qr: await QRCode.toDataURL(keeperUrl(req, slug, c.number), { width: 640, margin: 2 }) });
|
||||||
|
send.html(res, adm.keeperQrPage(it.t, cards, who));
|
||||||
});
|
});
|
||||||
r.get('/admin/t/:slug/qr', async (req, res, { slug }) => {
|
r.get('/admin/t/:slug/qr', async (req, res, { slug }) => {
|
||||||
const who = requireAdmin(req, res); if (!who) return;
|
const who = requireAdmin(req, res); if (!who) return;
|
||||||
@@ -117,6 +170,12 @@ export function buildRouter({ registry, auth, store }) {
|
|||||||
action('/broadcast', ({ t, engine, body }) => { const text = String(body.text ?? '').trim().slice(0, 300); if (!text) throw new Error('Nothing to send'); engine.broadcast(text); return 'Broadcast sent'; });
|
action('/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 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, { type: body.type === 'double' ? 'double' : 'single', thirdPlace: !!body.thirdPlace });
|
||||||
@@ -129,7 +188,7 @@ export function buildRouter({ registry, auth, store }) {
|
|||||||
const wasLive = m.status === 'live';
|
const wasLive = m.status === 'live';
|
||||||
if (wasLive) engine.recordResult(m.id, sets, { actor: who });
|
if (wasLive) engine.recordResult(m.id, sets, { actor: who });
|
||||||
else { t.recordResult(m.id, sets, { actor: who }); m.court = null; }
|
else { t.recordResult(m.id, sets, { actor: who }); m.court = null; }
|
||||||
delete m.live;
|
delete m.live; delete m.liveSets;
|
||||||
return `Saved ${t.teamName(m.teamA)} vs ${t.teamName(m.teamB)}: ${sets.map(s => s.join('-')).join(', ')}`;
|
return `Saved ${t.teamName(m.teamA)} vs ${t.teamName(m.teamB)}: ${sets.map(s => s.join('-')).join(', ')}`;
|
||||||
});
|
});
|
||||||
action('/live', ({ t, body }) => { const m = t.match(body.match); if (!m || m.status !== 'live') throw new Error('Match is not live'); m.live = [Math.max(0, +body.a | 0), Math.max(0, +body.b | 0)]; return 'ok'; });
|
action('/live', ({ t, body }) => { const m = t.match(body.match); if (!m || m.status !== 'live') throw new Error('Match is not live'); m.live = [Math.max(0, +body.a | 0), Math.max(0, +body.b | 0)]; return 'ok'; });
|
||||||
|
|||||||
@@ -16,6 +16,14 @@ 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); }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+54
-16
@@ -34,7 +34,7 @@ export function adminHome(list, who, msg) {
|
|||||||
</div>` });
|
</div>` });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function adminTournament({ t, engine }, who, origin, msg, err, events) {
|
export function adminTournament({ t, engine }, who, origin, msg, err, events, keeper = { url: () => '#', qr: {} }) {
|
||||||
const s = t.phase;
|
const s = t.phase;
|
||||||
const teams = [...t.teams.values()].sort((a, b) => a.seed - b.seed);
|
const teams = [...t.teams.values()].sort((a, b) => a.seed - b.seed);
|
||||||
const live = engine.courts.map(c => ({ c, m: c.matchId ? t.match(c.matchId) : null }));
|
const live = engine.courts.map(c => ({ c, m: c.matchId ? t.match(c.matchId) : null }));
|
||||||
@@ -47,9 +47,25 @@ export function adminTournament({ t, engine }, who, origin, msg, err, events) {
|
|||||||
const body = `
|
const body = `
|
||||||
<div class="eyebrow"><a href="/admin">Desk</a> · ${esc(t.date ?? '')}</div>
|
<div class="eyebrow"><a href="/admin">Desk</a> · ${esc(t.date ?? '')}</div>
|
||||||
<h1>${esc(t.name)} <span class="pill ${esc(s)}">${esc(phaseLabel(s))}</span></h1>
|
<h1>${esc(t.name)} <span class="pill ${esc(s)}">${esc(phaseLabel(s))}</span></h1>
|
||||||
<p class="muted">${t.rules.teamSize}s · to ${t.rules.pointsTo}, win by ${t.rules.winBy}${t.rules.cap ? `, cap ${t.rules.cap}` : ''}, best of ${t.rules.bestOf} · ${t.courtCount} court${t.courtCount > 1 ? 's' : ''} · <a href="/t/${esc(t.slug)}" target="_blank">public page</a> · <a href="/t/${esc(t.slug)}/display" target="_blank">TV display</a> · <a href="/admin/t/${esc(t.slug)}/qr">QR code</a></p>
|
<p class="muted">${t.rules.teamSize}s · to ${t.rules.pointsTo}, win by ${t.rules.winBy}${t.rules.cap ? `, cap ${t.rules.cap}` : ''}, best of ${t.rules.bestOf} · ${t.courtCount} court${t.courtCount > 1 ? 's' : ''} · <a href="/t/${esc(t.slug)}" target="_blank">public page</a> · <a href="/t/${esc(t.slug)}/display" target="_blank">TV display</a> · <a href="/admin/t/${esc(t.slug)}/qr">QR code</a>${s === 'live' ? ` · <a href="/admin/t/${esc(t.slug)}/keeper-qr">Score keeper QR codes</a>` : ''}</p>
|
||||||
${flash(msg)}${flash(err, 'error')}
|
${flash(msg)}${flash(err, 'error')}
|
||||||
|
|
||||||
|
|
||||||
|
${s === 'live' ? `
|
||||||
|
<section class="card"><h2>Courts</h2>
|
||||||
|
<div class="grid2">${live.map(({ c, m }) => `
|
||||||
|
<div class="card" style="margin:0">
|
||||||
|
<div class="inline"><b>Court ${c.number}</b> <span class="pill">${esc(c.status)}${c.pauseReason ? ` · ${esc(c.pauseReason)}` : ''}</span>
|
||||||
|
${c.status === 'open' ? act('court', 'Pause', '', `<input type="hidden" name="court" value="${c.number}"><input type="hidden" name="op" value="pause"><input name="reason" placeholder="reason" style="width:110px">`) : act('court', 'Resume', 'primary', `<input type="hidden" name="court" value="${c.number}"><input type="hidden" name="op" value="resume">`)}
|
||||||
|
</div>
|
||||||
|
<div class="keeperbox">${keeper.qr[c.number] ? `<img src="${keeper.qr[c.number]}" alt="Score keeper QR for court ${c.number}" width="110" height="110">` : ''}<div><b>Score keeper for Court ${c.number}</b><br><span class="muted small">Whoever is scoring this court scans this. The page follows whatever match is on the court and can finalize it; corrections stay here on the desk.</span><br><a class="button small" href="${esc(keeper.url(c))}" target="_blank">Open on this device</a></div></div>
|
||||||
|
${m ? scorePad(t, m) : '<p class="muted">No match assigned.</p>'}
|
||||||
|
</div>`).join('')}</div>
|
||||||
|
</section>
|
||||||
|
<section class="card"><h2>Queue</h2>
|
||||||
|
${queue.length ? `<table class="adm-table"><tr><th>#</th><th>Match</th><th>Predicted</th><th></th></tr>${queue.map((q, i) => `<tr><td>${i + 1}</td><td>${q.tentative ? '<span class="pill">pending</span> ' : ''}${esc(t.teamName(q.match.teamA))} vs ${esc(t.teamName(q.match.teamB))} <span class="muted small">${q.match.stage === 'pool' ? `Pool ${esc(q.match.poolId)} R${q.match.round}` : esc(q.match.label ?? `Bracket R${q.match.round}`)}</span></td><td class="mono">C${q.court} ~${Math.round(q.eta / 60000)}m</td><td>${q.tentative ? '' : act('pushback', 'Push back', '', `<input type="hidden" name="match" value="${esc(q.match.id)}">`)}</td></tr>`).join('')}</table>` : '<p class="muted">Nothing waiting.</p>'}
|
||||||
|
</section>` : ''}
|
||||||
|
|
||||||
<section class="card"><h2>Phase</h2>
|
<section class="card"><h2>Phase</h2>
|
||||||
<div class="phases">
|
<div class="phases">
|
||||||
${act('phase', 'Check-in (registration open)', s === 'checkin' ? 'cur' : '', '<input type="hidden" name="phase" value="checkin">')}
|
${act('phase', 'Check-in (registration open)', s === 'checkin' ? 'cur' : '', '<input type="hidden" name="phase" value="checkin">')}
|
||||||
@@ -62,20 +78,6 @@ export function adminTournament({ t, engine }, who, origin, msg, err, events) {
|
|||||||
<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>
|
||||||
|
|
||||||
${s === 'live' ? `
|
|
||||||
<section class="card"><h2>Courts</h2>
|
|
||||||
<div class="grid2">${live.map(({ c, m }) => `
|
|
||||||
<div class="card" style="margin:0">
|
|
||||||
<div class="inline"><b>Court ${c.number}</b> <span class="pill">${esc(c.status)}${c.pauseReason ? ` · ${esc(c.pauseReason)}` : ''}</span>
|
|
||||||
${c.status === 'open' ? act('court', 'Pause', '', `<input type="hidden" name="court" value="${c.number}"><input type="hidden" name="op" value="pause"><input name="reason" placeholder="reason" style="width:110px">`) : act('court', 'Resume', 'primary', `<input type="hidden" name="court" value="${c.number}"><input type="hidden" name="op" value="resume">`)}
|
|
||||||
</div>
|
|
||||||
${m ? scorePad(t, m) : '<p class="muted">No match assigned.</p>'}
|
|
||||||
</div>`).join('')}</div>
|
|
||||||
</section>
|
|
||||||
<section class="card"><h2>Queue</h2>
|
|
||||||
${queue.length ? `<table class="adm-table"><tr><th>#</th><th>Match</th><th>Predicted</th><th></th></tr>${queue.map((q, i) => `<tr><td>${i + 1}</td><td>${esc(t.teamName(q.match.teamA))} vs ${esc(t.teamName(q.match.teamB))} <span class="muted small">${q.match.stage === 'pool' ? `Pool ${esc(q.match.poolId)} R${q.match.round}` : esc(q.match.label ?? `Bracket R${q.match.round}`)}</span></td><td class="mono">C${q.court} ~${Math.round(q.eta / 60000)}m</td><td>${act('pushback', 'Push back', '', `<input type="hidden" name="match" value="${esc(q.match.id)}">`)}</td></tr>`).join('')}</table>` : '<p class="muted">Nothing waiting.</p>'}
|
|
||||||
</section>` : ''}
|
|
||||||
|
|
||||||
<section class="card"><h2>Play</h2>
|
<section class="card"><h2>Play</h2>
|
||||||
${!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">
|
||||||
@@ -93,6 +95,7 @@ export function adminTournament({ t, engine }, who, origin, msg, err, events) {
|
|||||||
<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>` : ''}
|
||||||
@@ -118,6 +121,26 @@ export function adminTournament({ t, engine }, who, origin, msg, err, events) {
|
|||||||
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.defaultPairs(seeded);
|
||||||
|
const opt = sel => `<option value="">— bye —</option>` + teams.map(x => `<option value="${esc(x.id)}" ${x.id === sel ? 'selected' : ''}>${esc(x.name)}${x.seed ? ` (${x.seed})` : ''}</option>`).join('');
|
||||||
|
return `<details class="manual"><summary>Build the bracket by hand — choose who plays whom in round 1</summary>
|
||||||
|
<form method="post" action="/admin/t/${esc(t.slug)}/generate" class="manualform">
|
||||||
|
<input type="hidden" name="what" value="manual">
|
||||||
|
<div class="inline"><label>Bracket <select name="type"><option value="single">Single elimination</option><option value="double">Double elimination</option></select></label>
|
||||||
|
<label><input type="checkbox" name="thirdPlace" value="1"> 3rd place</label>
|
||||||
|
<span class="muted small">${teams.length} teams in a bracket of ${pairs.length * 2}; ${pairs.length * 2 - teams.length} bye${pairs.length * 2 - teams.length === 1 ? '' : 's'}. Every team must appear exactly once; a side left as "bye" gives the other team a walkover.</span></div>
|
||||||
|
<table class="adm-table pairs"><tr><th>Match</th><th>Side A</th><th></th><th>Side B</th></tr>
|
||||||
|
${pairs.map(([a, b], i) => `<tr><td>${i + 1}</td><td><select name="a${i + 1}">${opt(a)}</select></td><td class="muted">vs</td><td><select name="b${i + 1}">${opt(b)}</select></td></tr>`).join('')}
|
||||||
|
</table>
|
||||||
|
<p class="muted small">Pre-filled with standard seeding (1 v ${pairs.length * 2}, 2 v ${pairs.length * 2 - 1}, …). Winners of matches 1 and 2 meet, 3 and 4 meet, and so on down the sheet.</p>
|
||||||
|
<button class="primary small" type="submit">Build bracket from these pairings</button>
|
||||||
|
</form></details>`;
|
||||||
|
}
|
||||||
|
|
||||||
function scorePad(t, m) {
|
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)}">
|
||||||
@@ -159,3 +182,18 @@ export function qrPage(t, origin, dataUrl, who) {
|
|||||||
<section class="card qr"><h1>${esc(t.name)}</h1><p class="lede">Scan to register, then to follow the tournament live.</p><img src="${dataUrl}" alt="QR code for ${esc(url)}"><p class="mono">${esc(url)}</p>
|
<section class="card qr"><h1>${esc(t.name)}</h1><p class="lede">Scan to register, then to follow the tournament live.</p><img src="${dataUrl}" alt="QR code for ${esc(url)}"><p class="mono">${esc(url)}</p>
|
||||||
<p><button onclick="print()">Print</button> <a class="button" href="/admin/t/${esc(t.slug)}">Back to desk</a></p></section>` });
|
<p><button onclick="print()">Print</button> <a class="button" href="/admin/t/${esc(t.slug)}">Back to desk</a></p></section>` });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Courtside Manager: the score keeper page for one court. Everything is rendered client-side from state. */
|
||||||
|
export function keeperPage(t, court, k, state) {
|
||||||
|
const body = `<div id="keeper" class="keeper" data-court="${court}" data-k="${esc(k)}"><div class="loading">Loading Court ${court}…</div></div>`;
|
||||||
|
return layout({ title: `Court ${court} score keeper`, body, state, script: '/static/keeper.js', bodyClass: 'keeper-body' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Printable page: one score keeper QR per court, to tape to the scorer's table. */
|
||||||
|
export function keeperQrPage(t, cards, who) {
|
||||||
|
return layout({ title: `${t.name} score keeper codes`, admin: who, body: `
|
||||||
|
<h1>${esc(t.name)} · score keeper codes</h1>
|
||||||
|
<p class="muted">Print this and tape each code at its court. Scanning opens Courtside Manager for that court only. Codes stop working if the server's SESSION_SECRET changes.</p>
|
||||||
|
<div class="qrgrid">${cards.map(c => `<section class="card qr"><h2>Court ${c.court}</h2><img src="${c.qr}" alt="Score keeper QR for court ${c.court}"><p class="small muted">Score keeper · ${esc(t.name)}</p></section>`).join('')}</div>
|
||||||
|
<p><button onclick="print()">Print</button> <a class="button" href="/admin/t/${esc(t.slug)}">Back to desk</a></p>` });
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ export function layout({ title, body, state = null, script = null, bodyClass = '
|
|||||||
<link rel="manifest" href="/static/manifest.webmanifest">
|
<link rel="manifest" href="/static/manifest.webmanifest">
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@500;600;700&family=Source+Sans+3:wght@400;600&family=JetBrains+Mono:wght@500&display=swap">
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@500;600;700&family=Source+Sans+3:wght@400;600&family=JetBrains+Mono:wght@500&display=swap">
|
||||||
<link rel="stylesheet" href="/static/app.css">
|
<link rel="stylesheet" href="/static/app.css?v=${VERSION}">
|
||||||
</head>
|
</head>
|
||||||
<body class="${esc(bodyClass)}">
|
<body class="${esc(bodyClass)}">
|
||||||
<header class="top">
|
<header class="top">
|
||||||
@@ -25,7 +25,7 @@ ${body}
|
|||||||
</main>
|
</main>
|
||||||
<footer class="foot">Courtside v${VERSION} · self-hosted tournament desk · <a href="https://gitea.cloudfreeiot.com/bkvargyas/courtside">source</a> · <a href="/admin">organizer desk</a></footer>
|
<footer class="foot">Courtside v${VERSION} · self-hosted tournament desk · <a href="https://gitea.cloudfreeiot.com/bkvargyas/courtside">source</a> · <a href="/admin">organizer desk</a></footer>
|
||||||
${state ? `<script id="state" type="application/json">${JSON.stringify(state).replace(/</g, '\\u003c')}</script>` : ''}
|
${state ? `<script id="state" type="application/json">${JSON.stringify(state).replace(/</g, '\\u003c')}</script>` : ''}
|
||||||
${script ? `<script src="${esc(script)}" defer></script>` : ''}
|
${script ? `<script src="${esc(script)}?v=${VERSION}" defer></script>` : ''}
|
||||||
</body>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,3 +152,93 @@ 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');
|
||||||
|
});
|
||||||
@@ -31,6 +31,17 @@ c -o /dev/null -H 'content-type: application/json' -X POST "$BASE/admin/t/$SLUG/
|
|||||||
curl -s "$BASE/t/$SLUG/state.json" | grep -q '"live":\[18,14\]' || fail "live score not on board"
|
curl -s "$BASE/t/$SLUG/state.json" | grep -q '"live":\[18,14\]' || fail "live score not on board"
|
||||||
c -H 'content-type: application/json' -X POST "$BASE/admin/t/$SLUG/score" -d "{\"match\":\"$M\",\"sets\":\"21-14\"}" | grep -q '"ok":true' || fail "final score"
|
c -H 'content-type: application/json' -X POST "$BASE/admin/t/$SLUG/score" -d "{\"match\":\"$M\",\"sets\":\"21-14\"}" | grep -q '"ok":true' || fail "final score"
|
||||||
c -H 'content-type: application/json' -X POST "$BASE/admin/t/$SLUG/score" -d "{\"match\":\"$M\",\"sets\":\"21-20\"}" | grep -q 'win by 2' || fail "bad score accepted"
|
c -H 'content-type: application/json' -X POST "$BASE/admin/t/$SLUG/score" -d "{\"match\":\"$M\",\"sets\":\"21-20\"}" | grep -q 'win by 2' || fail "bad score accepted"
|
||||||
|
echo "· score keeper link scores and finalizes court 2"
|
||||||
|
KURL=$(c "$BASE/admin/t/$SLUG" | grep -oE "/keeper/$SLUG/2\?k=[A-Za-z0-9_-]+" | head -1); [ -n "$KURL" ] || fail "no keeper link on desk"
|
||||||
|
K=${KURL#*k=}
|
||||||
|
M2=$(curl -s "$BASE/t/$SLUG/state.json" | node -e 'const s=JSON.parse(require("fs").readFileSync(0));console.log(s.courts[1].match.id)')
|
||||||
|
[ "$(curl -s -o /dev/null -w '%{http_code}' "$BASE$KURL")" = 200 ] || fail "keeper page"
|
||||||
|
[ "$(curl -s -o /dev/null -w '%{http_code}' "$BASE/keeper/$SLUG/2?k=bad")" = 403 ] || fail "bad keeper token accepted"
|
||||||
|
curl -s -H 'content-type: application/json' -X POST "$BASE/keeper/$SLUG/2/live" -d "{\"k\":\"$K\",\"match\":\"$M2\",\"a\":20,\"b\":19,\"sets\":[]}" | grep -q '"ok":true' || fail "keeper live"
|
||||||
|
curl -s "$BASE/t/$SLUG/state.json" | grep -q '"live":\[20,19\]' || fail "keeper live score not on board"
|
||||||
|
curl -s -H 'content-type: application/json' -X POST "$BASE/keeper/$SLUG/2/final" -d "{\"k\":\"$K\",\"match\":\"$M2\",\"sets\":[[21,20]]}" | grep -q 'win by 2' || fail "keeper accepted invalid final"
|
||||||
|
curl -s -H 'content-type: application/json' -X POST "$BASE/keeper/$SLUG/2/final" -d "{\"k\":\"$K\",\"match\":\"$M2\",\"sets\":[[22,20]]}" | grep -q '"ok":true' || fail "keeper final"
|
||||||
|
curl -s -H 'content-type: application/json' -X POST "$BASE/keeper/$SLUG/2/final" -d "{\"k\":\"$K\",\"match\":\"$M2\",\"sets\":[[21,10]]}" | grep -q '"ok":false' || fail "keeper could re-finalize"
|
||||||
echo "· play out all pool matches"
|
echo "· play out all pool matches"
|
||||||
for i in $(seq 1 20); do
|
for i in $(seq 1 20); do
|
||||||
NEXT=$(curl -s "$BASE/t/$SLUG/state.json" | node -e 'const s=JSON.parse(require("fs").readFileSync(0));const c=s.courts.find(c=>c.match);console.log(c?c.match.id:"")')
|
NEXT=$(curl -s "$BASE/t/$SLUG/state.json" | node -e 'const s=JSON.parse(require("fs").readFileSync(0));const c=s.courts.find(c=>c.match);console.log(c?c.match.id:"")')
|
||||||
|
|||||||
Reference in new issue
Block a user