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
This commit is contained in:
@@ -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 })),
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 ? `<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' };
|
||||
@@ -70,7 +71,7 @@
|
||||
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;
|
||||
@@ -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 `<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() {
|
||||
|
||||
+12
-2
@@ -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;
|
||||
|
||||
+13
-4
@@ -34,7 +34,7 @@ export function adminHome(list, who, msg) {
|
||||
</div>` });
|
||||
}
|
||||
|
||||
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 = `
|
||||
<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')}
|
||||
|
||||
|
||||
@@ -58,12 +58,12 @@ export function adminTournament({ t, engine }, who, origin, msg, err, events, ke
|
||||
<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>
|
||||
<p class="small"><a class="button small" href="${esc(keeperUrl(t, c))}" target="_blank">Score keeper</a> <button type="button" class="small" data-copy="${esc(keeperUrl(t, c))}">Copy link</button> <span class="muted">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.</span></p>
|
||||
<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>${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>'}
|
||||
${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>
|
||||
@@ -167,3 +167,12 @@ 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>` });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user