diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cd5e89..c19237c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,23 @@ so you can always tell what a deployment is actually serving. ## [Unreleased] +## [0.3.0] - 2026-09-03 + +### Added +- Bracket is drawn as a real tree: matches positioned per round with connector lines + between feeder and next match, winners in bold, live matches tagged with their court, + empty slots labelled with what fills them ("W R1.4", "L R2.1"), and the viewer's own + team highlighted along its path. Double elimination shows winners bracket, losers + bracket and finals as separate trees. +- Three bracket views: full tree with sideways scrolling, "Fit to screen" (scaled to the + phone's width, the default on narrow screens), and a round-by-round list. The choice is + remembered per device. +- `scripts/seed-demo.js --stage=bracket|double` seeds the demo mid-bracket. + +### Changed +- Public state now includes `loserFeeds`/`loserFeedsSide` on bracket matches so the + page can label upcoming slots. + ## [0.2.0] - 2026-09-03 First deployed release. Courtside now runs as a managed stack rather than a diff --git a/package.json b/package.json index 814807d..bfc24ad 100644 --- a/package.json +++ b/package.json @@ -1 +1 @@ -{"name":"courtside","type":"module","version":"0.2.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.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/scripts/seed-demo.js b/scripts/seed-demo.js index c5d26f8..6fd7b89 100644 --- a/scripts/seed-demo.js +++ b/scripts/seed-demo.js @@ -1,12 +1,16 @@ // Creates a demo tournament in the database so a fresh install has something to look at. // Run with the server STOPPED (the server keeps state in memory and writes it back): // DB_PATH=./data/courtside.sqlite node scripts/seed-demo.js +// Options: --stage=pool (default, mid pool play) | --stage=bracket (single elim in progress) +// | --stage=double (double elim in progress) import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import { Store } from '../server/store.js'; import { Registry } from '../server/state.js'; const here = dirname(fileURLToPath(import.meta.url)); +const args = Object.fromEntries(process.argv.slice(2).map(a => a.replace(/^--/, '').split('='))); +const STAGE = args.stage ?? 'pool'; const DB_PATH = process.env.DB_PATH ?? join(here, '..', 'data', 'courtside.sqlite'); const store = new Store(DB_PATH); const registry = new Registry(store); @@ -31,6 +35,24 @@ for (let i = 0; i < 7; i++) { const hi = 21, lo = Math.floor(8 + rnd() * 12); engine.recordResult(c.matchId, [rnd() < 0.5 ? [hi, lo] : [lo, hi]]); } +if (STAGE !== 'pool') { + // finish pool play, build the bracket, decide a few bracket matches + while (engine.courts.some(c => c.matchId) && t.matches.filter(m => m.stage === 'pool' && m.status === 'pending').length + engine.courts.filter(c => c.matchId).length > 0) { + const c = engine.courts.find(c => c.matchId); if (!c) break; + clock += (12 + rnd() * 6) * 60_000; + const lo = Math.floor(8 + rnd() * 12); + engine.recordResult(c.matchId, [rnd() < 0.5 ? [21, lo] : [lo, 21]]); + } + const seeded = t.advanceFromPools({ perPool: 2, wildcards: 2 }); + t.generateBracket(seeded, { type: STAGE === 'double' ? 'double' : 'single', thirdPlace: STAGE !== 'double' }); + engine.tick(); + for (let i = 0; i < (STAGE === 'double' ? 5 : 3); i++) { + const c = engine.courts.find(c => c.matchId); if (!c) break; + clock += (12 + rnd() * 6) * 60_000; + const lo = Math.floor(8 + rnd() * 12); + engine.recordResult(c.matchId, [rnd() < 0.5 ? [21, lo] : [lo, 21]]); + } +} 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]; diff --git a/server/public-state.js b/server/public-state.js index 47c7fb6..52ad36a 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, conditional: !!m.conditional, + 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, }); 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 ec365fa..2a69dbc 100644 --- a/server/public/app.css +++ b/server/public/app.css @@ -63,14 +63,26 @@ ul.plain,ol.plain{list-style:none;padding:0;margin:8px 0}ol.plain{counter-reset: .matches li.live,.sched li.live{background:var(--accent-soft)}.won{font-weight:600}.matches li.mine{outline:1px solid var(--sand)} .mine.card{border-color:var(--sand);border-width:2px}.big{font-size:20px} /* bracket */ -.bracketwrap{overflow-x:auto}.bstage{margin-top:12px} -.bracket{display:flex;gap:16px;align-items:stretch;min-width:max-content;padding-bottom:6px} -.round{display:flex;flex-direction:column;justify-content:space-around;gap:10px;min-width:190px} -.rlbl{font-family:"Barlow Condensed",sans-serif;text-transform:uppercase;letter-spacing:.1em;font-size:12px;color:var(--muted);text-align:center} -.bm{border:1px solid var(--line);border-radius:6px;background:var(--bg);font-size:15px} -.bm>div{display:flex;justify-content:space-between;padding:5px 10px;gap:8px}.bm>div+div{border-top:1px solid var(--line)} -.bm .mlbl{font-family:"Barlow Condensed",sans-serif;text-transform:uppercase;letter-spacing:.08em;font-size:11px;color:var(--muted);padding:3px 10px;justify-content:center} -.bm .mlbl.live{background:var(--good);color:#fff}.bm.live{border-color:var(--good)}.bm.mine{border-color:var(--sand);border-width:2px} +.bracketwrap .bhead{display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px} +.bviews{display:flex;gap:4px}.bviews .cur{background:var(--accent);color:var(--accent-ink);border-color:var(--accent)} +.bstage{margin-top:14px}.bhint{margin:8px 0 0} +.btree-scroll{overflow-x:auto;overflow-y:hidden;-webkit-overflow-scrolling:touch;padding-bottom:6px} +.btree{position:relative}.rlbls{position:absolute;top:0;left:0;right:0;height:22px} +.rlbl{position:absolute;font-family:"Barlow Condensed",sans-serif;text-transform:uppercase;letter-spacing:.1em;font-size:12px;color:var(--muted);text-align:center;white-space:nowrap} +.bcanvas{position:absolute;left:0}.bcanvas svg{position:absolute;left:0;top:0;overflow:visible} +.bcanvas path{fill:none;stroke:var(--line);stroke-width:2}.bcanvas path.decided{stroke:var(--muted)}.bcanvas path.mine{stroke:var(--sand);stroke-width:3} +.bm{position:absolute;border:1px solid var(--line);border-radius:6px;background:var(--surface);font-size:14px;display:flex;flex-direction:column;justify-content:center;overflow:visible} +.bm.list{position:static;width:auto;height:auto;margin:8px 0} +.brow{display:flex;justify-content:space-between;align-items:center;gap:8px;padding:4px 9px;line-height:1.25;min-height:50%} +.brow+.brow{border-top:1px solid var(--line)} +.brow .bn{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.brow .bs{flex:none;min-width:1.5em;text-align:right;color:var(--muted)} +.brow.won .bn{font-weight:600}.brow.won .bs{color:var(--ink);font-weight:600}.brow.mine{background:var(--sand-soft)} +.bm.live{border-color:var(--good);box-shadow:0 0 0 1px var(--good)}.bm.on_deck{border-style:dashed}.bm.mine{border-color:var(--sand);border-width:2px} +.btag{position:absolute;left:50%;transform:translateX(-50%);bottom:-9px;white-space:nowrap;font-family:"Barlow Condensed",sans-serif;text-transform:uppercase;letter-spacing:.08em;font-size:10px;padding:0 6px;border-radius:3px;background:var(--bg);border:1px solid var(--line);color:var(--muted);line-height:1.5} +.btag.live{background:var(--good);color:#fff;border-color:var(--good)} +.bm.list .btag{position:static;transform:none;display:inline-block;align-self:flex-start;margin:4px 9px 6px} +.blist{margin-top:10px}.blist .rlbl{position:static;text-align:left} +.bracketwrap.fit .btree-scroll{overflow:hidden} .toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);background:#0F1418;color:#fff;padding:12px 18px;border-radius:8px;max-width:90vw;box-shadow:0 8px 30px rgba(0,0,0,.3);z-index:9;font-weight:600} .loading{color:var(--muted);padding:24px} /* display (TV) mode */ @@ -78,7 +90,7 @@ body.display{font-size:22px;background:#0F1418;color:#F2F5F7} body.display .top,body.display .foot{display:none}body.display main{max-width:none;padding:20px 28px} body.display .display-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:10px}body.display h1{font-size:56px} body.display .court .t{font-size:40px}body.display .court .s{font-size:52px}body.display .court{min-height:150px} -body.display .card{background:#1A222B;border-color:#2B3640;color:#F2F5F7}body.display td{border-color:#2B3640}body.display .bm{background:#12181F;border-color:#2B3640} +body.display .card{background:#1A222B;border-color:#2B3640;color:#F2F5F7}body.display td{border-color:#2B3640}body.display .bm{background:#12181F;border-color:#2B3640}body.display .brow+.brow{border-color:#2B3640}body.display .bcanvas path{stroke:#3A4652}body.display .btag{background:#1A222B;border-color:#2B3640} body.display .queue{font-size:26px}body.display details{display:none} /* admin */ .grid2{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:12px} diff --git a/server/public/board.js b/server/public/board.js index c611570..551c696 100644 --- a/server/public/board.js +++ b/server/public/board.js @@ -30,6 +30,7 @@ if (!s.pools.length && !s.bracket.length) parts.push(renderRoster()); if (s.withdrawn.length && !display) parts.push(`

Withdrawn: ${s.withdrawn.map(t => esc(t.name)).join(', ')}

`); app.innerHTML = parts.join(''); + fitBracket(); notifyMine(); } @@ -77,17 +78,107 @@ `).join('')}`; } + // ---- bracket: a real tree with connector lines, plus a list view for small screens ---- + // default: full tree on wide screens, scaled-to-fit on phones; the viewer's choice is remembered + let bracketView = window.innerWidth < 700 ? 'fit' : 'tree'; + try { bracketView = localStorage.getItem('courtside.bracketView') || bracketView; } catch {} + const BOX_W = 176, BOX_H = 58, COL_GAP = 44, ROW_GAP = 14; + function renderBracket() { - const groups = [['bracket', 'Bracket'], ['losers', 'Losers bracket'], ['final', 'Finals']]; - return `

${state.bracket.some(m => m.stage === 'losers') ? 'Double elimination' : 'Bracket'}

${groups.map(([stage, title]) => { - const ms = state.bracket.filter(m => m.stage === stage && !(m.conditional && m.status !== 'final' && !m.a)); + const isDouble = state.bracket.some(m => m.stage === 'losers'); + const toggle = display ? '' : `
${[['tree', 'Bracket'], ['fit', 'Fit to screen'], ['list', 'List']].map(([v, l]) => ``).join('')}
`; + const stages = [['bracket', isDouble ? 'Winners bracket' : 'Bracket'], ['losers', 'Losers bracket'], ['final', 'Finals']]; + const body = stages.map(([stage, title]) => { + const ms = state.bracket.filter(m => m.stage === stage && !(m.conditional && !m.a)); if (!ms.length) return ''; - const rounds = [...new Set(ms.map(m => m.round))].sort((a, b) => a - b); - return `

${title}

${rounds.map(r => `
${roundName(stage, r, rounds.length, ms)}
${ms.filter(m => m.round === r).map(m => `
${m.label ? `
${esc(m.label)}
` : ''}
${name(m.a)}${m.sets[0] ? m.sets.map(s => s[0]).join(' ') : ''}
${name(m.b)}${m.sets[0] ? m.sets.map(s => s[1]).join(' ') : ''}
${m.status === 'live' ? `
Court ${m.court}${m.live ? ` · ${m.live[0]}–${m.live[1]}` : ''}
` : m.status === 'forfeit' ? '
forfeit
' : ''}
`).join('')}
`).join('')}
`; - }).join('')}
`; + return `
${isDouble ? `

${title}

` : ''}${bracketView === 'list' ? bracketList(stage, ms) : bracketTree(stage, ms)}
`; + }).join(''); + return `

${isDouble ? 'Double elimination' : 'Bracket'}

${toggle}
${body}${bracketView === 'tree' && !display ? '

Swipe sideways to see later rounds.

' : ''}
`; } + + function bracketTree(stage, ms) { + const third = ms.find(m => m.label === '3rd place'); + const tree = ms.filter(m => m !== third); + const rounds = [...new Set(tree.map(m => m.round))].sort((a, b) => a - b); + const pos = new Map(); + const feedersOf = m => tree.filter(f => f.feeds === m.id); + rounds.forEach((r, ri) => { + const inRound = tree.filter(m => m.round === r).sort((a, b) => (a.slot ?? 0) - (b.slot ?? 0)); + const x = ri * (BOX_W + COL_GAP); + let cursor = 0; + inRound.forEach(m => { + const fs = feedersOf(m).map(f => pos.get(f.id)).filter(Boolean); + let y; + if (fs.length) y = fs.reduce((a, p) => a + p.y, 0) / fs.length; + else y = cursor; + y = Math.max(y, cursor); + pos.set(m.id, { x, y }); + cursor = y + BOX_H + ROW_GAP; + }); + }); + let height = Math.max(...[...pos.values()].map(p => p.y)) + BOX_H; + let width = rounds.length * (BOX_W + COL_GAP) - COL_GAP; + if (third) { const f = pos.get(tree.find(m => m.round === rounds.at(-1))?.id) ?? { x: 0, y: 0 }; pos.set(third.id, { x: f.x, y: height + ROW_GAP * 2 }); height += ROW_GAP * 2 + BOX_H; } + const paths = tree.filter(m => m.feeds && pos.has(m.feeds)).map(m => { + const a = pos.get(m.id), b = pos.get(m.feeds); + const x1 = a.x + BOX_W, y1 = a.y + BOX_H / 2, x2 = b.x, y2 = b.y + BOX_H / 2, xm = x1 + COL_GAP / 2; + const hot = m.winner && isMineId(m.winner); + return ``; + }).join(''); + const boxes = [...pos.entries()].map(([id, p]) => matchBox(ms.find(m => m.id === id), p)).join(''); + const labels = rounds.map((r, ri) => `
${roundName(stage, r, rounds.length, tree)}
`).join(''); + return `
${labels}
${boxes}
`; + } + + function matchBox(m, p) { + 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}
`; + }; + 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}
`; + } + + function feedLabel(m, side) { + // what fills this slot later: winner/loser of which match + const wantSide = side === 0 ? 'A' : 'B'; + const w = state.bracket.find(f => f.feeds === m.id && f.feedsSide === wantSide); + if (w) return `W ${shortRef(w)}`; + const l = state.bracket.find(f => f.loserFeeds === m.id && f.loserFeedsSide === wantSide); + if (l) return `L ${shortRef(l)}`; + return 'TBD'; + } + const shortRef = m => m.stage === 'losers' ? `LB${m.round}.${m.slot}` : m.stage === 'final' ? 'final' : `R${m.round}.${m.slot}`; + + 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(''); + } + const isMineId = id => myTeam && id === myTeam; + + function fitBracket() { + if (bracketView !== 'fit') return; + document.querySelectorAll('.btree-scroll').forEach(sc => { + const tree = sc.firstElementChild; + const w = parseFloat(tree.style.width), h = parseFloat(tree.style.height); + const scale = Math.min(1, (sc.clientWidth - 2) / w); + tree.style.transform = `scale(${scale})`; + tree.style.transformOrigin = '0 0'; + sc.style.height = `${h * scale}px`; + }); + } + document.addEventListener('click', e => { + const b = e.target.closest('[data-bview]'); + if (!b) return; + bracketView = b.dataset.bview; + try { localStorage.setItem('courtside.bracketView', bracketView); } catch {} + render(); + }); + window.addEventListener('resize', fitBracket); + function roundName(stage, r, total, ms) { - if (stage === 'final') return r === 1 ? 'Grand final' : 'Reset'; + if (stage === 'final') return r === 1 ? 'Grand final' : 'Bracket reset'; if (stage === 'losers') return `LB round ${r}`; const left = total - r; const inRound = ms.filter(m => m.round === r && m.label !== '3rd place').length;