From eb58eb9ad99cb63c40538cbf464096032301ce49 Mon Sep 17 00:00:00 2001 From: Brian Vargyas Date: Fri, 4 Sep 2026 02:10:40 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01MB7nCCAscYsb3zzkT6LHZi --- CHANGELOG.md | 16 ++++++ docs/API.md | 10 ++++ docs/KNOWN-GAPS.md | 2 +- docs/ORGANIZER-GUIDE.md | 6 +++ engine/courts.js | 3 +- package.json | 2 +- server/public-state.js | 2 +- server/public/admin.js | 2 + server/public/app.css | 22 ++++++++ server/public/board.js | 7 +-- server/public/keeper.js | 109 ++++++++++++++++++++++++++++++++++++++++ server/routes.js | 49 ++++++++++++++++-- server/views/admin.js | 38 ++++++++------ test/smoke.sh | 11 ++++ 14 files changed, 254 insertions(+), 25 deletions(-) create mode 100644 server/public/keeper.js diff --git a/CHANGELOG.md b/CHANGELOG.md index d5d9daf..676588c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,22 @@ so you can always tell what a deployment is actually serving. ## [Unreleased] +## [0.4.0] - 2026-09-03 + +### Added +- **Courtside Manager**, a score keeper page per court (`/keeper//?k=…`). Big + +/− buttons, undo, sets for best-of-3, and a Finalize step with confirmation that appears + once a side has won under the tournament's rules. It follows whatever match is on the court + and moves to the next one automatically. The link is opened or copied from the court card + on the desk; it can only score and finalize, never edit a finished result. +- Court cards on the public board show the estimated finish time of the current match. + +### Changed +- Organizer desk: the Phase section now sits below Courts and Queue, so the score pads + are at the top during play. +- Multi-set running scores (`liveSets`) show on the public board while a keeper is scoring. +- Match durations outside 2–90 minutes are ignored for the ETA average. + ## [0.3.1] - 2026-09-03 ### Added diff --git a/docs/API.md b/docs/API.md index 1e66856..7f5c7f8 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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 `:court` under `SESSION_SECRET`; requests without a valid `k` get 403. + +| Method | Path | Body (JSON) | Effect | +| --- | --- | --- | --- | +| GET | `/keeper//?k=…` | | The score keeper page for that court. | +| POST | `/keeper///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///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. diff --git a/docs/KNOWN-GAPS.md b/docs/KNOWN-GAPS.md index 8b43858..7bff7fc 100644 --- a/docs/KNOWN-GAPS.md +++ b/docs/KNOWN-GAPS.md @@ -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. diff --git a/docs/ORGANIZER-GUIDE.md b/docs/ORGANIZER-GUIDE.md index 3148025..1dff28a 100644 --- a/docs/ORGANIZER-GUIDE.md +++ b/docs/ORGANIZER-GUIDE.md @@ -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 has a **Score keeper** link (and a Copy link button). Hand that link to whoever is sitting at that court; it opens 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: diff --git a/engine/courts.js b/engine/courts.js index 79acb5d..f280f3f 100644 --- a/engine/courts.js +++ b/engine/courts.js @@ -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()); diff --git a/package.json b/package.json index bd45a9e..f945507 100644 --- a/package.json +++ b/package.json @@ -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"}} \ No newline at end of file +{"name":"courtside","type":"module","version":"0.4.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"}} \ No newline at end of file diff --git a/server/public-state.js b/server/public-state.js index 8f049ef..463f74c 100644 --- a/server/public-state.js +++ b/server/public-state.js @@ -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 => ({ diff --git a/server/public/admin.js b/server/public/admin.js index 5f06bb1..6c90ba7 100644 --- a/server/public/admin.js +++ b/server/public/admin.js @@ -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; diff --git a/server/public/app.css b/server/public/app.css index 82399a2..93a4d91 100644 --- a/server/public/app.css +++ b/server/public/app.css @@ -110,3 +110,25 @@ 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}} +/* 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}} diff --git a/server/public/board.js b/server/public/board.js index 9a7925e..6b0fd3d 100644 --- a/server/public/board.js +++ b/server/public/board.js @@ -14,14 +14,14 @@ 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' ? '0–0' : ''; + : m.status === 'forfeit' ? 'forfeit' : m.live ? [...(m.liveSets ?? []), m.live].map(s => `${s[0]}–${s[1]}`).join(', ') : m.status === 'live' ? '0–0' : ''; // score from one team's point of view (their points first) 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' ? '0–0' : ''; }; const rulesLine = r => `${r.teamSize}s · to ${r.pointsTo}, win by ${r.winBy}${r.cap ? `, cap ${r.cap}` : ''}${r.bestOf > 1 ? `, best of ${r.bestOf}` : ''}`; @@ -74,7 +74,8 @@ 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 `
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}`)}
${name(m.a)}
${score(m) || '0–0'}
${name(m.b)}
${timing ? `
${timing}
` : ''} diff --git a/server/public/keeper.js b/server/public/keeper.js new file mode 100644 index 0000000..978e784 --- /dev/null +++ b/server/public/keeper.js @@ -0,0 +1,109 @@ +/* Courtside Manager — score keeper for one court. Big buttons, one job: keep the running score of + whatever match is on this court and finalize it. Corrections after that are the organizer's. */ +(() => { + const root = document.getElementById('keeper'); + const court = +root.dataset.court, k = root.dataset.k; + let state = JSON.parse(document.getElementById('state').textContent); + const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); + const base = `/keeper/${state.slug}/${court}`; + const post = (path, body) => fetch(`${base}/${path}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ k, ...body }) }).then(async r => { const j = await r.json().catch(() => ({})); if (!r.ok || !j.ok) throw new Error(j.error || r.statusText); return j; }); + + // local scoring state for the match currently on this court + let cur = null; // { matchId, a, b, sets: [[a,b],...], history: [] } + let busy = false, note = '', confirming = false; + + function courtMatch() { const c = state.courts.find(c => c.court === court); return c?.match && c.match.status === 'live' ? c.match : null; } + + function syncFromState() { + const m = courtMatch(); + if (!m) { cur = null; return; } + if (!cur || cur.matchId !== m.id) { + cur = { matchId: m.id, a: m.live?.[0] ?? 0, b: m.live?.[1] ?? 0, sets: m.liveSets ? m.liveSets.map(s => [...s]) : [], history: [] }; + confirming = false; note = ''; + } + } + + const rules = () => state.rules; + function setOver(a, b) { + const { pointsTo, winBy, cap } = rules(); + const hi = Math.max(a, b), lo = Math.min(a, b); + if (cap && hi >= cap && hi > lo) return true; + return hi >= pointsTo && hi - lo >= winBy; + } + const setsNeeded = () => Math.ceil(rules().bestOf / 2); + const setsWon = side => cur.sets.filter(s => (side === 0 ? s[0] > s[1] : s[1] > s[0])).length; + + function render() { + syncFromState(); + const c = state.courts.find(c => c.court === court); + const m = courtMatch(); + const head = `
Court ${court}${esc(state.name)}
`; + if (!m) { + const next = state.upNext.find(u => u.court === court); + root.innerHTML = `${head}

${c?.status === 'paused' ? 'Court paused' : 'No match on this court'}

+

${next ? `Next up: ${esc(next.a?.name)} vs ${esc(next.b?.name)} (~${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.'}

+ ${note ? `

${esc(note)}

` : ''}
`; + 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} +
${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}` : ''}
+
+
${esc(m.a?.name)}
${cur.a}
+
${esc(m.b?.name)}
${cur.b}
+
+ ${cur.sets.length ? `
Sets: ${cur.sets.map(s => `${s[0]}–${s[1]}`).join(', ')}
` : ''} +
+ ${r.bestOf > 1 && over && !matchDone ? `` : ''} + ${over && matchDone && !confirming ? `` : ''} + ${confirming ? `

Final score ${[...cur.sets, [cur.a, cur.b]].map(s => `${s[0]}–${s[1]}`).join(', ')}. Once finalized, only the organizer desk can change it.

` : ''} + ${!over ? `

Finalize appears when a side reaches ${r.pointsTo} with a ${r.winBy}-point lead${r.cap ? ` or hits the cap of ${r.cap}` : ''}.

` : ''} + +
+ ${note ? `

${esc(note)}

` : ''}`; + } + + 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(() => {}); }); +})(); diff --git a/server/routes.js b/server/routes.js index 99662e4..05d4c66 100644 --- a/server/routes.js +++ b/server/routes.js @@ -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); @@ -76,7 +119,7 @@ export function buildRouter({ registry, auth, store }) { r.get('/admin/t/:slug', (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))); + send.html(res, adm.adminTournament(item(slug), who, origin(req), q(req).get('msg'), q(req).get('error'), store.events(slug, 40), (t, c) => keeperUrl(req, t.slug, c.number))); }); r.get('/admin/t/:slug/qr', async (req, res, { slug }) => { const who = requireAdmin(req, res); if (!who) return; @@ -129,7 +172,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'; }); diff --git a/server/views/admin.js b/server/views/admin.js index e03ef04..01753ce 100644 --- a/server/views/admin.js +++ b/server/views/admin.js @@ -34,7 +34,7 @@ export function adminHome(list, who, msg) {
` }); } -export function adminTournament({ t, engine }, who, origin, msg, err, events) { +export function adminTournament({ t, engine }, who, origin, msg, err, events, keeperUrl = () => '#') { 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 })); @@ -50,6 +50,22 @@ export function adminTournament({ t, engine }, who, origin, msg, err, events) {

${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' : ''} · public page · TV display · QR code

${flash(msg)}${flash(err, 'error')} + + ${s === 'live' ? ` +

Courts

+
${live.map(({ c, m }) => ` +
+
Court ${c.number} ${esc(c.status)}${c.pauseReason ? ` · ${esc(c.pauseReason)}` : ''} + ${c.status === 'open' ? act('court', 'Pause', '', ``) : act('court', 'Resume', 'primary', ``)} +
+

Score keeper Hand this to whoever is scoring Court ${c.number}: it scores whatever match is on this court and finalizes it. Corrections stay here on the desk.

+ ${m ? scorePad(t, m) : '

No match assigned.

'} +
`).join('')}
+
+

Queue

+ ${queue.length ? `${queue.map((q, i) => ``).join('')}
#MatchPredicted
${i + 1}${esc(t.teamName(q.match.teamA))} vs ${esc(t.teamName(q.match.teamB))} ${q.match.stage === 'pool' ? `Pool ${esc(q.match.poolId)} R${q.match.round}` : esc(q.match.label ?? `Bracket R${q.match.round}`)}C${q.court} ~${Math.round(q.eta / 60000)}m${act('pushback', 'Push back', '', ``)}
` : '

Nothing waiting.

'} +
` : ''} +

Phase

${act('phase', 'Check-in (registration open)', s === 'checkin' ? 'cur' : '', '')} @@ -62,20 +78,6 @@ export function adminTournament({ t, engine }, who, origin, msg, err, events) {
- ${s === 'live' ? ` -

Courts

-
${live.map(({ c, m }) => ` -
-
Court ${c.number} ${esc(c.status)}${c.pauseReason ? ` · ${esc(c.pauseReason)}` : ''} - ${c.status === 'open' ? act('court', 'Pause', '', ``) : act('court', 'Resume', 'primary', ``)} -
- ${m ? scorePad(t, m) : '

No match assigned.

'} -
`).join('')}
-
-

Queue

- ${queue.length ? `${queue.map((q, i) => ``).join('')}
#MatchPredicted
${i + 1}${esc(t.teamName(q.match.teamA))} vs ${esc(t.teamName(q.match.teamB))} ${q.match.stage === 'pool' ? `Pool ${esc(q.match.poolId)} R${q.match.round}` : esc(q.match.label ?? `Bracket R${q.match.round}`)}C${q.court} ~${Math.round(q.eta / 60000)}m${act('pushback', 'Push back', '', ``)}
` : '

Nothing waiting.

'} -
` : ''} -

Play

${!generated ? `
@@ -159,3 +161,9 @@ export function qrPage(t, origin, dataUrl, who) {

${esc(t.name)}

Scan to register, then to follow the tournament live.

QR code for ${esc(url)}

${esc(url)}

Back to desk

` }); } + +/** 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 = `
Loading Court ${court}…
`; + return layout({ title: `Court ${court} score keeper`, body, state, script: '/static/keeper.js', bodyClass: 'keeper-body' }); +} diff --git a/test/smoke.sh b/test/smoke.sh index bf83d6c..a202985 100755 --- a/test/smoke.sh +++ b/test/smoke.sh @@ -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:"")')