4 Commits
Author SHA1 Message Date
bkvargyasandClaude Fable 5.1 a5222953dd Version static asset URLs so releases bust the CSS/JS cache
Cached app.css from before 0.4.0 left the score keeper page unstyled on phones.
Assets are now /static/x?v=<version> with a one-year immutable cache; unversioned
requests get 60s. Release 0.4.2.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01MB7nCCAscYsb3zzkT6LHZi
2026-09-04 02:41:15 +00:00
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
18 changed files with 342 additions and 39 deletions
+35
View File
@@ -9,6 +9,41 @@ so you can always tell what a deployment is actually serving.
## [Unreleased]
## [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 290 minutes are ignored for the ETA average.
## [0.3.1] - 2026-09-03
### 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
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
MIT. See [LICENSE](LICENSE).
+11
View File
@@ -51,6 +51,16 @@ A match object:
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
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,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. |
| GET | `/admin/t/<slug>` | | Tournament desk. |
| 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>/banner` | `banner` | Set (or clear with empty) the persistent banner. |
| 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.
- **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.
- **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.
- **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.
+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).
## 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
**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);
if (court) {
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);
}
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;
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),
};
}
@@ -120,6 +121,22 @@ export class CourtEngine {
sims[0].freeAt = start + this.avgMatchMs();
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;
}
@@ -172,7 +189,7 @@ export class CourtEngine {
_freeCourt(c) { c.matchId = null; c.startedAt = null; c.freeAt = null; }
_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 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; }
+1 -1
View File
@@ -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.4.2","scripts":{"test":"node --test test/*.test.js","sim":"node simulate.js","start":"node --disable-warning=ExperimentalWarning server/index.js","dev":"node --disable-warning=ExperimentalWarning --watch server/index.js","demo":"node --disable-warning=ExperimentalWarning scripts/seed-demo.js","smoke":"bash test/smoke.sh"},"description":"Self-hosted volleyball tournament desk: one QR code from check-in to live scores","main":"server/index.js","directories":{"test":"test"},"keywords":[],"author":"","license":"MIT","dependencies":{"qrcode":"^1.5.4","ws":"^8.21.3"},"engines":{"node":">=22.13"},"repository":{"type":"git","url":"https://gitea.cloudfreeiot.com/bkvargyas/courtside.git"}}
+4 -2
View File
@@ -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' };
export async function serveStatic(res, root, urlPath) {
export async function serveStatic(res, root, urlPath, versioned = false) {
const p = normalize(join(root, urlPath));
if (!p.startsWith(root)) throw new HttpError(404, 'Not found');
try {
const s = await stat(p);
if (!s.isFile()) throw new HttpError(404, 'Not found');
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);
} catch (e) { if (e instanceof HttpError) throw e; throw new HttpError(404, 'Not found'); }
}
+1 -1
View File
@@ -25,7 +25,7 @@ const staticRoot = join(here, 'public');
const server = createServer(async (req, res) => {
const url = new URL(req.url, 'http://x');
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);
if (!m) throw new HttpError(404, 'Not found');
await m.handler(req, res, m.params);
+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,
a: m.teamA ? { id: m.teamA, name: name(m.teamA) } : null,
b: m.teamB ? { id: m.teamB, name: name(m.teamB) } : null,
winner: m.winner, sets: m.sets, live: m.live ?? null, 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, live: m.live ?? null, liveSets: m.liveSets ?? null, startedAt: m.startedAt ?? null, feeds: m.feeds, feedsSide: m.feedsSide, loserFeeds: m.loserFeeds ?? null, loserFeedsSide: m.loserFeedsSide ?? null, conditional: !!m.conditional,
});
const board = t.phase === 'live' ? engine.board() : null;
const pools = t.pools.map(p => ({
@@ -21,7 +21,7 @@ export function publicState({ t, engine }) {
rules: t.rules, stages: t.stages, courtCount: t.courtCount,
teams, withdrawn, pools, bracket,
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,
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 })),
+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
const ws = new WebSocket(`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/${slug}`);
let last = null;
+28 -1
View File
@@ -54,7 +54,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 .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.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{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)}
@@ -110,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 (prefers-reduced-motion:reduce){*{scroll-behavior:auto!important}}
@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}}
+8 -6
View File
@@ -10,18 +10,19 @@
// 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 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(', ')
: 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 pair(m.live);
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}` : ''}`;
@@ -70,11 +71,12 @@
return `<section class="courts">${state.courts.map(c => {
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 (!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 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>
<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>` : ''}
@@ -87,12 +89,12 @@
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> ${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() {
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() {
+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(() => {}); });
})();
+57 -4
View File
@@ -1,5 +1,5 @@
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 { VERSION } from './version.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 ----------
const requireAdmin = (req, res) => {
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.')}`);
});
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;
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 }) => {
const who = requireAdmin(req, res); if (!who) return;
@@ -129,7 +182,7 @@ export function buildRouter({ registry, auth, store }) {
const wasLive = m.status === 'live';
if (wasLive) engine.recordResult(m.id, sets, { actor: who });
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(', ')}`;
});
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>` });
}
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 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 }));
@@ -47,9 +47,25 @@ export function adminTournament({ t, engine }, who, origin, msg, err, events) {
const body = `
<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>
<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')}
${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>
<div class="phases">
${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>
</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>
${!generated ? `
<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>
<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>` });
}
+2 -2
View File
@@ -13,7 +13,7 @@ export function layout({ title, body, state = null, script = null, bodyClass = '
<link rel="manifest" href="/static/manifest.webmanifest">
<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="/static/app.css">
<link rel="stylesheet" href="/static/app.css?v=${VERSION}">
</head>
<body class="${esc(bodyClass)}">
<header class="top">
@@ -25,7 +25,7 @@ ${body}
</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>
${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>
</html>`;
}
+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"
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"
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"
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:"")')