From ae44014e22d19362b95d1e58f5e3e706b42ed63f Mon Sep 17 00:00:00 2001 From: Brian Vargyas Date: Fri, 4 Sep 2026 02:28:04 +0000 Subject: [PATCH] Tentative next-up for bracket matches waiting on live courts; per-court score keeper QR codes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01MB7nCCAscYsb3zzkT6LHZi --- CHANGELOG.md | 12 ++++++++++++ docs/API.md | 1 + docs/ORGANIZER-GUIDE.md | 2 +- engine/courts.js | 20 ++++++++++++++++++-- package.json | 2 +- server/public-state.js | 2 +- server/public/app.css | 7 ++++++- server/public/board.js | 7 ++++--- server/routes.js | 14 ++++++++++++-- server/views/admin.js | 17 +++++++++++++---- 10 files changed, 69 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 676588c..70e005a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,18 @@ so you can always tell what a deployment is actually serving. ## [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//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 diff --git a/docs/API.md b/docs/API.md index 7f5c7f8..9dff612 100644 --- a/docs/API.md +++ b/docs/API.md @@ -74,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/` | | Tournament desk. | | GET | `/admin/t//qr` | | Printable QR page. `qr.png` returns the PNG. | +| GET | `/admin/t//keeper-qr` | | Printable page with one score keeper QR code per court. | | POST | `/admin/t//phase` | `phase` = `checkin` / `closed` / `live` / `final` | Change phase. `live` requires generated play and starts court assignment. | | POST | `/admin/t//banner` | `banner` | Set (or clear with empty) the persistent banner. | | POST | `/admin/t//broadcast` | `text` | One-time alert to all team pages. | diff --git a/docs/ORGANIZER-GUIDE.md b/docs/ORGANIZER-GUIDE.md index 1dff28a..dc935e9 100644 --- a/docs/ORGANIZER-GUIDE.md +++ b/docs/ORGANIZER-GUIDE.md @@ -34,7 +34,7 @@ For best-of-3, or if you'd rather type, use "Save sets" with `21-18, 19-21, 15-9 ## 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. +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. diff --git a/engine/courts.js b/engine/courts.js index f280f3f..56b59da 100644 --- a/engine/courts.js +++ b/engine/courts.js @@ -102,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), }; } @@ -121,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; } @@ -173,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; } diff --git a/package.json b/package.json index f945507..e60d638 100644 --- a/package.json +++ b/package.json @@ -1 +1 @@ -{"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 +{"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"}} \ No newline at end of file diff --git a/server/public-state.js b/server/public-state.js index 463f74c..c03d5fc 100644 --- a/server/public-state.js +++ b/server/public-state.js @@ -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 })), diff --git a/server/public/app.css b/server/public/app.css index 93a4d91..f87b4e2 100644 --- a/server/public/app.css +++ b/server/public/app.css @@ -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,6 +110,11 @@ 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} diff --git a/server/public/board.js b/server/public/board.js index 6b0fd3d..b80e6fd 100644 --- a/server/public/board.js +++ b/server/public/board.js @@ -10,6 +10,7 @@ // 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 ? `${esc(t.name)}` : 'TBD'; + const fromName = (t, from) => t ? name(t) : from ? `${from.loser ? 'loser' : 'winner'} on Court ${from.court}` : 'TBD'; 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' }; @@ -70,7 +71,7 @@ return `
${state.courts.map(c => { const m = c.match; if (c.status === 'paused') return `
Court ${c.court}
Paused
`; - if (!m) return `
Court ${c.court}
Open
`; + if (!m) return `
Court ${c.court}
Open
${nextForCourt(c.court)}
`; 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; @@ -88,12 +89,12 @@ const u = state.upNext.find(x => x.court === courtNo); if (!u) return ''; const ready = clock(state.generatedAt + u.etaMin * 60000); - return `
Next up ${name(u.a)} vs ${name(u.b)} ready ~${ready}
`; + return `
Next up ${fromName(u.a, u.aFrom)} vs ${fromName(u.b, u.bFrom)} ready ~${ready}
`; } function renderUpNext() { if (!state.upNext.length) return ''; - return `
Up next
${state.upNext.map((u, i) => `
${i === 0 ? 'On deck' : 'Then'}${name(u.a)} vs ${name(u.b)}Court ${u.court} · ~${clock(state.generatedAt + u.etaMin * 60000)} (${u.etaMin} min)
`).join('')}
`; + return `
Up next
${state.upNext.map((u, i) => `
${u.tentative ? 'Pending' : i === 0 ? 'On deck' : 'Then'}${fromName(u.a, u.aFrom)} vs ${fromName(u.b, u.bFrom)}Court ${u.court} · ~${clock(state.generatedAt + u.etaMin * 60000)} (${u.etaMin} min)
`).join('')}
`; } function renderPools() { diff --git a/server/routes.js b/server/routes.js index 05d4c66..8d0f15b 100644 --- a/server/routes.js +++ b/server/routes.js @@ -117,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), (t, c) => keeperUrl(req, t.slug, c.number))); + 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; diff --git a/server/views/admin.js b/server/views/admin.js index 01753ce..36f1c37 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, keeperUrl = () => '#') { +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,7 +47,7 @@ export function adminTournament({ t, engine }, who, origin, msg, err, events, ke const body = `
Desk · ${esc(t.date ?? '')}

${esc(t.name)} ${esc(phaseLabel(s))}

-

${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

+

${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${s === 'live' ? ` · Score keeper QR codes` : ''}

${flash(msg)}${flash(err, 'error')} @@ -58,12 +58,12 @@ export function adminTournament({ t, engine }, who, origin, msg, err, events, ke
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.

+
${keeper.qr[c.number] ? `Score keeper QR for court ${c.number}` : ''}
Score keeper for Court ${c.number}
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.
Open on this device
${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.

'} + ${queue.length ? `${queue.map((q, i) => ``).join('')}
#MatchPredicted
${i + 1}${q.tentative ? 'pending ' : ''}${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${q.tentative ? '' : act('pushback', 'Push back', '', ``)}
` : '

Nothing waiting.

'}
` : ''}

Phase

@@ -167,3 +167,12 @@ 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' }); } + +/** 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: ` +

${esc(t.name)} · score keeper codes

+

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.

+
${cards.map(c => `

Court ${c.court}

Score keeper QR for court ${c.court}

Score keeper · ${esc(t.name)}

`).join('')}
+

Back to desk

` }); +}