5 Commits
Author SHA1 Message Date
bkvargyasandClaude Fable 5.1 ae44014e22 Tentative next-up for bracket matches waiting on live courts; per-court score keeper QR codes
Matches whose opponent comes from a live match now show in the queue and under
courts as 'X vs winner on Court 2 · ready ~time', which is what double
elimination needed. Score keeper access is a QR per court on the desk plus a
printable page; the copy-link button is gone. Release 0.4.1.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01MB7nCCAscYsb3zzkT6LHZi
2026-09-04 02:28:04 +00:00
bkvargyasandClaude Fable 5.1 eb58eb9ad9 Courtside Manager score keeper page per court; Phase below Courts on the desk; estimated finish on court cards
A per-court signed link from the desk opens a full-screen scoring page with
big +/- buttons, undo, best-of sets, and a confirmed Finalize step; it follows
whatever match is on the court and can never edit a finished result. Public
court cards show the estimated finish time. Release 0.4.0.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01MB7nCCAscYsb3zzkT6LHZi
2026-09-04 02:10:40 +00:00
bkvargyas acc5fdc2f3 docs: how to cut a release 2026-09-04 01:53:23 +00:00
bkvargyas fe4f2a8f55 docs: document /t/<slug>/teams/<id> 2026-09-04 01:49:00 +00:00
bkvargyasandClaude Fable 5.1 1956d25061 Team-name links to team pages, court timing and next-up per court, desk link in footer
Every team name on the board links to /t/<slug>/teams/<id>. Court cards show
average game time, minutes played and the next match predicted for that court
with its expected ready time; the queue shows clock times too. Team schedules
show scores from the team's own perspective. Release 0.3.1.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01MB7nCCAscYsb3zzkT6LHZi
2026-09-04 01:48:54 +00:00
17 changed files with 385 additions and 39 deletions
+42
View File
@@ -9,6 +9,48 @@ so you can always tell what a deployment is actually serving.
## [Unreleased] ## [Unreleased]
## [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 290 minutes are ignored for the ETA average.
## [0.3.1] - 2026-09-03
### Added
- Every team name on the public board (courts, up-next, bracket tree and list, pool
standings, team schedules) links to that team's page at `/t/<slug>/teams/<id>`, which
shows their next match, court, record and schedule. Team pages by private code still work.
- Court cards show the running average game time and minutes played, and a "Next up" line
with the match predicted for that court and the clock time it should be ready.
- Up-next queue shows the expected clock time as well as minutes.
- Footer links to the organizer desk.
### Fixed
- A team's schedule on its page shows scores from that team's point of view.
## [0.3.0] - 2026-09-03 ## [0.3.0] - 2026-09-03
### Added ### Added
+11
View File
@@ -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
View File
@@ -10,6 +10,7 @@ All pages are server-rendered HTML except where noted. `<slug>` is the URL-safe
| GET | `/healthz` | `{ ok, tournaments, uptime }` JSON. Used by the Docker healthcheck. | | GET | `/healthz` | `{ ok, tournaments, uptime }` JSON. Used by the Docker healthcheck. |
| GET | `/t/<slug>` | The QR landing page. Registration form while phase is `checkin`; otherwise the live board / results (client-rendered from state JSON). | | GET | `/t/<slug>` | The QR landing page. Registration form while phase is `checkin`; otherwise the live board / results (client-rendered from state JSON). |
| POST | `/t/<slug>/register` | Form fields `name`, `captain`, `phone`, `players`, `playerNames` (newline-separated). 303 to the team page on success, 400 with the form re-rendered on error. | | POST | `/t/<slug>/register` | Form fields `name`, `captain`, `phone`, `players`, `playerNames` (newline-separated). 303 to the team page on success, 400 with the form re-rendered on error. |
| GET | `/t/<slug>/teams/<id>` | The same personalized board addressed by public team id; this is what team-name links use. |
| GET | `/t/<slug>/team/<code>` | Personalized board for the team with that 4-character code. `?new=1` shows the post-registration confirmation instead. | | GET | `/t/<slug>/team/<code>` | Personalized board for the team with that 4-character code. `?new=1` shows the post-registration confirmation instead. |
| GET | `/t/<slug>/display` | TV display mode of the board. | | GET | `/t/<slug>/display` | TV display mode of the board. |
| GET | `/t/<slug>/state.json` | The public state document (below). | | GET | `/t/<slug>/state.json` | The public state document (below). |
@@ -50,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.
@@ -63,6 +74,7 @@ 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. |
+1 -1
View File
@@ -11,7 +11,7 @@ Honest list of what the MVP does not do well yet. None of these block running a
- **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.
+6
View File
@@ -32,6 +32,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:
+20 -3
View File
@@ -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; }
+1 -1
View File
@@ -1 +1 @@
{"name":"courtside","type":"module","version":"0.3.0","scripts":{"test":"node --test test/*.test.js","sim":"node simulate.js","start":"node --disable-warning=ExperimentalWarning server/index.js","dev":"node --disable-warning=ExperimentalWarning --watch server/index.js","demo":"node --disable-warning=ExperimentalWarning scripts/seed-demo.js","smoke":"bash test/smoke.sh"},"description":"Self-hosted volleyball tournament desk: one QR code from check-in to live scores","main":"server/index.js","directories":{"test":"test"},"keywords":[],"author":"","license":"MIT","dependencies":{"qrcode":"^1.5.4","ws":"^8.21.3"},"engines":{"node":">=22.13"},"repository":{"type":"git","url":"https://gitea.cloudfreeiot.com/bkvargyas/courtside.git"}} {"name":"courtside","type":"module","version":"0.4.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"}}
+3 -2
View File
@@ -54,8 +54,9 @@ if (STAGE !== 'pool') {
} }
} }
const live = engine.courts.filter(c => c.matchId).map(c => t.match(c.matchId)); const live = engine.courts.filter(c => c.matchId).map(c => t.match(c.matchId));
if (live[0]) live[0].live = [14, 11]; if (live[0]) { live[0].live = [14, 11]; live[0].startedAt = Date.now() - 9 * 60_000; }
if (live[1]) live[1].live = [7, 9]; if (live[1]) { live[1].live = [7, 9]; live[1].startedAt = Date.now() - 5 * 60_000; }
for (const c of engine.courts) if (c.matchId) c.startedAt = t.match(c.matchId).startedAt;
t.banner = 'Demo tournament — scores are simulated'; t.banner = 'Demo tournament — scores are simulated';
engine.now = () => Date.now(); t.now = engine.now; engine.now = () => Date.now(); t.now = engine.now;
registry.persist(t.slug); registry.persist(t.slug);
+2 -2
View File
@@ -5,7 +5,7 @@ export function publicState({ t, engine }) {
id: m.id, stage: m.stage, round: m.round, slot: m.slot, label: m.label ?? null, status: m.status, court: m.court, 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, feeds: m.feeds, feedsSide: m.feedsSide, loserFeeds: m.loserFeeds ?? null, loserFeedsSide: m.loserFeedsSide ?? null, conditional: !!m.conditional, winner: m.winner, sets: m.sets, live: m.live ?? null, liveSets: m.liveSets ?? null, startedAt: m.startedAt ?? null, feeds: m.feeds, feedsSide: m.feedsSide, loserFeeds: m.loserFeeds ?? null, loserFeedsSide: m.loserFeedsSide ?? null, conditional: !!m.conditional,
}); });
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 })),
+2
View File
@@ -23,6 +23,8 @@
}); });
}); });
document.querySelectorAll('[data-copy]').forEach(b => b.addEventListener('click', () => navigator.clipboard?.writeText(b.dataset.copy).then(() => { b.textContent = 'Copied'; })));
// 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;
+32
View File
@@ -50,6 +50,11 @@ button:focus-visible,a:focus-visible,input:focus-visible{outline:2px solid var(-
.queue .eyebrow{grid-column:1/-1;color:#8E9BA6} .queue .eyebrow{grid-column:1/-1;color:#8E9BA6}
.qrow{display:contents}.qrow .k{font-family:"Barlow Condensed",sans-serif;font-size:12px;letter-spacing:.12em;text-transform:uppercase;color:#8E9BA6} .qrow{display:contents}.qrow .k{font-family:"Barlow Condensed",sans-serif;font-size:12px;letter-spacing:.12em;text-transform:uppercase;color:#8E9BA6}
.qrow:first-of-type span:nth-child(2){color:#F0C46B;font-weight:600}.qrow.mine span:nth-child(2){text-decoration:underline}.qrow .eta{font-size:13px;color:#8E9BA6} .qrow:first-of-type span:nth-child(2){color:#F0C46B;font-weight:600}.qrow.mine span:nth-child(2){text-decoration:underline}.qrow .eta{font-size:13px;color:#8E9BA6}
a.tl{color:inherit;text-decoration:none;border-bottom:1px dotted currentColor}a.tl:hover{border-bottom-style:solid}
.court .timing{position:relative;font-family:"Barlow Condensed",sans-serif;font-size:12px;letter-spacing:.06em;text-transform:uppercase;opacity:.85;margin-top:6px}
.court .nextup{position:relative;margin-top:8px;padding-top:8px;border-top:1px solid rgba(255,255,255,.3);font-size:15px}
.court .nextup .k{font-family:"Barlow Condensed",sans-serif;font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:#F0C46B;margin-right:4px}
.court .nextup .eta{font-size:13px;opacity:.85;margin-left:4px}.court .nextup .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)}
@@ -105,3 +110,30 @@ 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}}
/* 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}}
+37 -9
View File
@@ -7,11 +7,24 @@
const display = app.dataset.display === '1'; const display = app.dataset.display === '1';
const seenAlerts = new Set(); const seenAlerts = new Set();
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])); const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const name = t => t ? esc(t.name) : '<span class="tbd">TBD</span>'; // every team name on the board links to that team's page (schedule, record, next match)
const teamHref = t => `/t/${state.slug}/teams/${encodeURIComponent(t.id)}`;
const name = t => t ? `<a class="tl" href="${teamHref(t)}">${esc(t.name)}</a>` : '<span class="tbd">TBD</span>';
const fromName = (t, from) => t ? name(t) : from ? `<span class="tbd">${from.loser ? 'loser' : 'winner'} on Court ${from.court}</span>` : '<span class="tbd">TBD</span>';
const clock = ms => new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
const isMine = m => myTeam && (m.a?.id === myTeam || m.b?.id === myTeam); const 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' ? '00' : ''; : m.status === 'forfeit' ? 'forfeit' : m.live ? [...(m.liveSets ?? []), m.live].map(s => `${s[0]}${s[1]}`).join(', ') : m.status === 'live' ? '00' : '';
// score from one team's point of view (their points first)
const scoreFor = (m, teamId) => {
const flip = m.b?.id === teamId;
const pair = ([a, b]) => flip ? `${b}${a}` : `${a}${b}`;
if (m.status === 'final') return m.sets.map(pair).join(', ');
if (m.status === 'forfeit') return 'forfeit';
if (m.live) return [...(m.liveSets ?? []), m.live].map(pair).join(', ');
return m.status === 'live' ? '00' : '';
};
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}` : ''}`;
function render() { function render() {
@@ -49,7 +62,7 @@
else if (s.phase === 'live') now = `<p>No match scheduled yet. Waiting on results from other courts.</p>`; else if (s.phase === 'live') now = `<p>No match scheduled yet. Waiting on results from other courts.</p>`;
else now = `<p>Waiting for play to start.</p>`; else now = `<p>Waiting for play to start.</p>`;
return `<section class="card mine"><div class="eyebrow">Your team</div><h2>${esc(t.name)}</h2>${now}<p class="muted">${played.length ? `Record ${w}${played.length - w}` : 'No results yet'}${t.poolId ? ` · Pool ${esc(t.poolId)}` : ''}</p> return `<section class="card mine"><div class="eyebrow">Your team</div><h2>${esc(t.name)}</h2>${now}<p class="muted">${played.length ? `Record ${w}${played.length - w}` : 'No results yet'}${t.poolId ? ` · Pool ${esc(t.poolId)}` : ''}</p>
${my.length ? `<ul class="plain sched">${my.map(m => `<li class="${m.status}"><span>${m.stage === 'pool' ? `Pool ${m.round}` : (m.label ?? `Bracket R${m.round}`)}</span><span>vs ${name(m.a?.id === t.id ? m.b : m.a)}</span><span class="mono">${score(m) || (m.court ? `Court ${m.court}` : '')}${m.status === 'final' || m.status === 'forfeit' ? (m.winner === t.id ? ' W' : ' L') : ''}</span></li>`).join('')}</ul>` : ''} ${my.length ? `<ul class="plain sched">${my.map(m => `<li class="${m.status}"><span>${m.stage === 'pool' ? `Pool ${m.round}` : (m.label ?? `Bracket R${m.round}`)}</span><span>vs ${name(m.a?.id === t.id ? m.b : m.a)}</span><span class="mono">${scoreFor(m, t.id) || (m.court ? `Court ${m.court}` : '')}${m.status === 'final' || m.status === 'forfeit' ? (m.winner === t.id ? ' W' : ' L') : ''}</span></li>`).join('')}</ul>` : ''}
${!display && 'Notification' in window && Notification.permission === 'default' ? `<button type="button" class="button" id="notify">Alert me when we're up</button>` : ''} ${!display && 'Notification' in window && Notification.permission === 'default' ? `<button type="button" class="button" id="notify">Alert me when we're up</button>` : ''}
</section>`; </section>`;
} }
@@ -58,21 +71,36 @@
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 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 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) || '00'}</div><div class="t">${name(m.b)}</div></div></div>`; <div class="teams"><div class="t">${name(m.a)}</div><div class="s mono">${score(m) || '00'}</div><div class="t">${name(m.b)}</div></div>
${timing ? `<div class="timing">${timing}</div>` : ''}
${nextForCourt(c.court)}</div>`;
}).join('')}</section>`; }).join('')}</section>`;
} }
// the first queued match predicted for this court, with the clock time it should be ready
function nextForCourt(courtNo) {
const u = state.upNext.find(x => x.court === courtNo);
if (!u) return '';
const ready = clock(state.generatedAt + u.etaMin * 60000);
return `<div class="nextup ${isMine(u) ? 'mine' : ''}"><span class="k">Next up</span> ${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} · ~${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() {
return `<section class="pools">${state.pools.map(p => `<div class="card pool"><h2>Pool ${esc(p.id)}</h2> return `<section class="pools">${state.pools.map(p => `<div class="card pool"><h2>Pool ${esc(p.id)}</h2>
<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}. ${esc(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' : ''}">${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>
</div>`).join('')}</section>`; </div>`).join('')}</section>`;
@@ -134,7 +162,7 @@
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 ? esc(t.name) : `<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' : ''}"><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.status === 'forfeit' ? '<span class="btag">Forfeit</span>' : 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>`;
@@ -153,7 +181,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 ? esc(t.name) : `<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' : ''}"><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('');
} }
const isMineId = id => myTeam && id === myTeam; const isMineId = id => myTeam && id === myTeam;
+109
View File
@@ -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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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(() => {}); });
})();
+62 -4
View File
@@ -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';
@@ -31,6 +31,11 @@ export function buildRouter({ registry, auth, store }) {
if (state.phase === 'checkin' && q(req).get('new')) return send.html(res, pub.registeredPage(state, team, origin(req))); if (state.phase === 'checkin' && q(req).get('new')) return send.html(res, pub.registeredPage(state, team, origin(req)));
send.html(res, pub.boardPage(state, { teamId: team.id })); send.html(res, pub.boardPage(state, { teamId: team.id }));
}); });
r.get('/t/:slug/teams/:id', (req, res, { slug, id }) => {
const it = item(slug);
if (!it.t.teams.has(id)) throw new HttpError(404, 'No such team');
send.html(res, pub.boardPage(publicState(it), { teamId: id }));
});
r.post('/t/:slug/register', async (req, res, { slug }) => { r.post('/t/:slug/register', async (req, res, { slug }) => {
const it = item(slug); const it = item(slug);
const body = await readBody(req); const body = await readBody(req);
@@ -45,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);
@@ -69,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;
@@ -124,7 +182,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'; });
+33 -16
View File
@@ -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">
@@ -159,3 +161,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>` });
}
+1 -1
View File
@@ -23,7 +23,7 @@ export function layout({ title, body, state = null, script = null, bodyClass = '
<main> <main>
${body} ${body}
</main> </main>
<footer class="foot">Courtside v${VERSION} · self-hosted tournament desk · <a href="https://gitea.cloudfreeiot.com/bkvargyas/courtside">source</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)}" defer></script>` : ''}
</body> </body>
+11
View File
@@ -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:"")')