diff --git a/CHANGELOG.md b/CHANGELOG.md index c19237c..d5d9daf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,20 @@ so you can always tell what a deployment is actually serving. ## [Unreleased] +## [0.3.1] - 2026-09-03 + +### Added +- Every team name on the public board (courts, up-next, bracket tree and list, pool + standings, team schedules) links to that team's page at `/t//teams/`, which + shows their next match, court, record and schedule. Team pages by private code still work. +- Court cards show the running average game time and minutes played, and a "Next up" line + with the match predicted for that court and the clock time it should be ready. +- Up-next queue shows the expected clock time as well as minutes. +- Footer links to the organizer desk. + +### Fixed +- A team's schedule on its page shows scores from that team's point of view. + ## [0.3.0] - 2026-09-03 ### Added diff --git a/package.json b/package.json index bfc24ad..bd45a9e 100644 --- a/package.json +++ b/package.json @@ -1 +1 @@ -{"name":"courtside","type":"module","version":"0.3.0","scripts":{"test":"node --test test/*.test.js","sim":"node simulate.js","start":"node --disable-warning=ExperimentalWarning server/index.js","dev":"node --disable-warning=ExperimentalWarning --watch server/index.js","demo":"node --disable-warning=ExperimentalWarning scripts/seed-demo.js","smoke":"bash test/smoke.sh"},"description":"Self-hosted volleyball tournament desk: one QR code from check-in to live scores","main":"server/index.js","directories":{"test":"test"},"keywords":[],"author":"","license":"MIT","dependencies":{"qrcode":"^1.5.4","ws":"^8.21.3"},"engines":{"node":">=22.13"},"repository":{"type":"git","url":"https://gitea.cloudfreeiot.com/bkvargyas/courtside.git"}} \ No newline at end of file +{"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 diff --git a/scripts/seed-demo.js b/scripts/seed-demo.js index 6fd7b89..02e90c2 100644 --- a/scripts/seed-demo.js +++ b/scripts/seed-demo.js @@ -54,8 +54,9 @@ if (STAGE !== 'pool') { } } const live = engine.courts.filter(c => c.matchId).map(c => t.match(c.matchId)); -if (live[0]) live[0].live = [14, 11]; -if (live[1]) live[1].live = [7, 9]; +if (live[0]) { live[0].live = [14, 11]; live[0].startedAt = Date.now() - 9 * 60_000; } +if (live[1]) { live[1].live = [7, 9]; live[1].startedAt = Date.now() - 5 * 60_000; } +for (const c of engine.courts) if (c.matchId) c.startedAt = t.match(c.matchId).startedAt; t.banner = 'Demo tournament — scores are simulated'; engine.now = () => Date.now(); t.now = engine.now; registry.persist(t.slug); diff --git a/server/public-state.js b/server/public-state.js index 52ad36a..8f049ef 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, 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, 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/app.css b/server/public/app.css index 2a69dbc..82399a2 100644 --- a/server/public/app.css +++ b/server/public/app.css @@ -50,6 +50,11 @@ button:focus-visible,a:focus-visible,input:focus-visible{outline:2px solid var(- .queue .eyebrow{grid-column:1/-1;color:#8E9BA6} .qrow{display:contents}.qrow .k{font-family:"Barlow Condensed",sans-serif;font-size:12px;letter-spacing:.12em;text-transform:uppercase;color:#8E9BA6} .qrow:first-of-type span:nth-child(2){color:#F0C46B;font-weight:600}.qrow.mine span:nth-child(2){text-decoration:underline}.qrow .eta{font-size:13px;color:#8E9BA6} +a.tl{color:inherit;text-decoration:none;border-bottom:1px dotted currentColor}a.tl:hover{border-bottom-style:solid} +.court .timing{position:relative;font-family:"Barlow Condensed",sans-serif;font-size:12px;letter-spacing:.06em;text-transform:uppercase;opacity:.85;margin-top:6px} +.court .nextup{position:relative;margin-top:8px;padding-top:8px;border-top:1px solid rgba(255,255,255,.3);font-size:15px} +.court .nextup .k{font-family:"Barlow Condensed",sans-serif;font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:#F0C46B;margin-right:4px} +.court .nextup .eta{font-size:13px;opacity:.85;margin-left:4px}.court .nextup.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)} diff --git a/server/public/board.js b/server/public/board.js index 551c696..9a7925e 100644 --- a/server/public/board.js +++ b/server/public/board.js @@ -7,11 +7,23 @@ const display = app.dataset.display === '1'; const seenAlerts = new Set(); const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); - const name = t => t ? esc(t.name) : 'TBD'; + // 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 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' ? '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); + 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}` : ''}`; function render() { @@ -49,7 +61,7 @@ else if (s.phase === 'live') now = `

No match scheduled yet. Waiting on results from other courts.

`; else now = `

Waiting for play to start.

`; return `
Your team

${esc(t.name)}

${now}

${played.length ? `Record ${w}–${played.length - w}` : 'No results yet'}${t.poolId ? ` · Pool ${esc(t.poolId)}` : ''}

- ${my.length ? `
    ${my.map(m => `
  • ${m.stage === 'pool' ? `Pool ${m.round}` : (m.label ?? `Bracket R${m.round}`)}vs ${name(m.a?.id === t.id ? m.b : m.a)}${score(m) || (m.court ? `Court ${m.court}` : '')}${m.status === 'final' || m.status === 'forfeit' ? (m.winner === t.id ? ' W' : ' L') : ''}
  • `).join('')}
` : ''} + ${my.length ? `
    ${my.map(m => `
  • ${m.stage === 'pool' ? `Pool ${m.round}` : (m.label ?? `Bracket R${m.round}`)}vs ${name(m.a?.id === t.id ? m.b : m.a)}${scoreFor(m, t.id) || (m.court ? `Court ${m.court}` : '')}${m.status === 'final' || m.status === 'forfeit' ? (m.winner === t.id ? ' W' : ' L') : ''}
  • `).join('')}
` : ''} ${!display && 'Notification' in window && Notification.permission === 'default' ? `` : ''}
`; } @@ -59,20 +71,34 @@ const m = c.match; if (c.status === 'paused') return `
Court ${c.court}
Paused
`; if (!m) return `
Court ${c.court}
Open
`; + 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` : ''}` : ''}` : ''; 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)}
`; +
${name(m.a)}
${score(m) || '0–0'}
${name(m.b)}
+ ${timing ? `
${timing}
` : ''} + ${nextForCourt(c.court)}`; }).join('')}`; } + // the first queued match predicted for this court, with the clock time it should be ready + function nextForCourt(courtNo) { + const u = state.upNext.find(x => x.court === courtNo); + if (!u) return ''; + const ready = clock(state.generatedAt + u.etaMin * 60000); + return `
Next up ${name(u.a)} vs ${name(u.b)} 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} · ~${u.etaMin} min
`).join('')}
`; + 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('')}
`; } function renderPools() { return `
${state.pools.map(p => `

Pool ${esc(p.id)}

- ${p.standings.map((r, i) => ``).join('')} + ${p.standings.map((r, i) => ``).join('')}
TeamWLPts+/−
${i + 1}. ${esc(r.name)}${r.decidedBy ? `*` : ''}${r.w}${r.l}${r.pf}–${r.pa}${r.pointDiff > 0 ? '+' : ''}${r.pointDiff}
${i + 1}. ${name({ id: r.teamId, name: r.name })}${r.decidedBy ? `*` : ''}${r.w}${r.l}${r.pf}–${r.pa}${r.pointDiff > 0 ? '+' : ''}${r.pointDiff}
Matches
    ${p.matches.map(m => `
  • R${m.round}${name(m.a)}${score(m) || (m.court ? `C${m.court}` : '·')}${name(m.b)}
  • `).join('')}
`).join('')}
`; @@ -134,7 +160,7 @@ const row = (t, side) => { const won = m.winner && t && m.winner === t.id; const pts = m.sets[0] ? m.sets.map(s => s[side]).join(' ') : (m.live && m.status === 'live' ? m.live[side] : ''); - return `
${t ? esc(t.name) : `${feedLabel(m, side)}`}${pts}
`; + return `
${t ? name(t) : `${feedLabel(m, side)}`}${pts}
`; }; const tag = m.status === 'live' ? `Court ${m.court}` : m.status === 'on_deck' ? `On deck${m.court ? ` · C${m.court}` : ''}` : m.status === 'forfeit' ? 'Forfeit' : m.label ? `${esc(m.label)}` : ''; return `
${row(m.a, 0)}${row(m.b, 1)}${tag}
`; @@ -153,7 +179,7 @@ function bracketList(stage, ms) { const rounds = [...new Set(ms.map(m => m.round))].sort((a, b) => a - b); - return rounds.map(r => `
${roundName(stage, r, rounds.length, ms)}
${ms.filter(m => m.round === r).map(m => `
${['a', 'b'].map((k, i) => { const t = m[k]; const won = m.winner && t && m.winner === t.id; return `
${t ? esc(t.name) : `${feedLabel(m, i)}`}${m.sets[0] ? m.sets.map(s => s[i]).join(' ') : (m.live && m.status === 'live' ? m.live[i] : '')}
`; }).join('')}${m.status === 'live' ? `Court ${m.court}` : m.status === 'on_deck' ? `On deck${m.court ? ` · C${m.court}` : ''}` : m.status === 'forfeit' ? 'Forfeit' : m.label ? `${esc(m.label)}` : ''}
`).join('')}
`).join(''); + return rounds.map(r => `
${roundName(stage, r, rounds.length, ms)}
${ms.filter(m => m.round === r).map(m => `
${['a', 'b'].map((k, i) => { const t = m[k]; const won = m.winner && t && m.winner === t.id; return `
${t ? name(t) : `${feedLabel(m, i)}`}${m.sets[0] ? m.sets.map(s => s[i]).join(' ') : (m.live && m.status === 'live' ? m.live[i] : '')}
`; }).join('')}${m.status === 'live' ? `Court ${m.court}` : m.status === 'on_deck' ? `On deck${m.court ? ` · C${m.court}` : ''}` : m.status === 'forfeit' ? 'Forfeit' : m.label ? `${esc(m.label)}` : ''}
`).join('')}
`).join(''); } const isMineId = id => myTeam && id === myTeam; diff --git a/server/routes.js b/server/routes.js index c50fd63..99662e4 100644 --- a/server/routes.js +++ b/server/routes.js @@ -31,6 +31,11 @@ export function buildRouter({ registry, auth, store }) { if (state.phase === 'checkin' && q(req).get('new')) return send.html(res, pub.registeredPage(state, team, origin(req))); send.html(res, pub.boardPage(state, { teamId: team.id })); }); + r.get('/t/:slug/teams/:id', (req, res, { slug, id }) => { + const it = item(slug); + if (!it.t.teams.has(id)) throw new HttpError(404, 'No such team'); + send.html(res, pub.boardPage(publicState(it), { teamId: id })); + }); r.post('/t/:slug/register', async (req, res, { slug }) => { const it = item(slug); const body = await readBody(req); diff --git a/server/views/layout.js b/server/views/layout.js index 1294f31..b336cc5 100644 --- a/server/views/layout.js +++ b/server/views/layout.js @@ -23,7 +23,7 @@ export function layout({ title, body, state = null, script = null, bodyClass = '
${body}
-
Courtside v${VERSION} · self-hosted tournament desk · source
+ ${state ? `` : ''} ${script ? `` : ''}