From 62efa4d127a714964e8a79d26310b9976546659d Mon Sep 17 00:00:00 2001 From: Brian Vargyas Date: Thu, 3 Sep 2026 21:41:35 +0000 Subject: [PATCH] Draw the bracket as a real tree with connectors; fit-to-screen and list views for phones Matches are positioned per round with SVG 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 team highlighted along its path. Double elimination renders winners, losers and finals trees. Narrow screens default to a scaled fit-to-width view; a round-by-round list is also available. Seed script gains --stage=bracket|double. Release 0.3.0. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01MB7nCCAscYsb3zzkT6LHZi --- CHANGELOG.md | 17 +++++++ package.json | 2 +- scripts/seed-demo.js | 22 +++++++++ server/public-state.js | 2 +- server/public/app.css | 30 ++++++++---- server/public/board.js | 105 ++++++++++++++++++++++++++++++++++++++--- 6 files changed, 160 insertions(+), 18 deletions(-) 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;