5 Commits
Author SHA1 Message Date
bkvargyas fe4f2a8f55 docs: document /t/<slug>/teams/<id> 2026-09-04 01:49:00 +00:00
bkvargyasandClaude Fable 5.1 1956d25061 Team-name links to team pages, court timing and next-up per court, desk link in footer
Every team name on the board links to /t/<slug>/teams/<id>. Court cards show
average game time, minutes played and the next match predicted for that court
with its expected ready time; the queue shows clock times too. Team schedules
show scores from the team's own perspective. Release 0.3.1.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01MB7nCCAscYsb3zzkT6LHZi
2026-09-04 01:48:54 +00:00
bkvargyasandClaude Fable 5.1 62efa4d127 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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01MB7nCCAscYsb3zzkT6LHZi
2026-09-03 21:41:35 +00:00
bkvargyas 3d241949f0 Trigger deploy: verify push-to-deploy with force_deploy enabled 2026-09-03 17:32:49 -04:00
bkvargyas 1bf2298f3f Trigger deploy: verify push-to-deploy webhook 2026-09-03 17:32:11 -04:00
9 changed files with 220 additions and 26 deletions
+31
View File
@@ -9,6 +9,37 @@ so you can always tell what a deployment is actually serving.
## [Unreleased] ## [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/<slug>/teams/<id>`, 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
- 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 ## [0.2.0] - 2026-09-03
First deployed release. Courtside now runs as a managed stack rather than a First deployed release. Courtside now runs as a managed stack rather than a
+1
View File
@@ -10,6 +10,7 @@ All pages are server-rendered HTML except where noted. `<slug>` is the URL-safe
| GET | `/healthz` | `{ ok, tournaments, uptime }` JSON. Used by the Docker healthcheck. | | GET | `/healthz` | `{ ok, tournaments, uptime }` JSON. Used by the Docker healthcheck. |
| GET | `/t/<slug>` | The QR landing page. Registration form while phase is `checkin`; otherwise the live board / results (client-rendered from state JSON). | | GET | `/t/<slug>` | The QR landing page. Registration form while phase is `checkin`; otherwise the live board / results (client-rendered from state JSON). |
| POST | `/t/<slug>/register` | Form fields `name`, `captain`, `phone`, `players`, `playerNames` (newline-separated). 303 to the team page on success, 400 with the form re-rendered on error. | | POST | `/t/<slug>/register` | Form fields `name`, `captain`, `phone`, `players`, `playerNames` (newline-separated). 303 to the team page on success, 400 with the form re-rendered on error. |
| GET | `/t/<slug>/teams/<id>` | The same personalized board addressed by public team id; this is what team-name links use. |
| GET | `/t/<slug>/team/<code>` | Personalized board for the team with that 4-character code. `?new=1` shows the post-registration confirmation instead. | | GET | `/t/<slug>/team/<code>` | Personalized board for the team with that 4-character code. `?new=1` shows the post-registration confirmation instead. |
| GET | `/t/<slug>/display` | TV display mode of the board. | | GET | `/t/<slug>/display` | TV display mode of the board. |
| GET | `/t/<slug>/state.json` | The public state document (below). | | GET | `/t/<slug>/state.json` | The public state document (below). |
+1 -1
View File
@@ -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"}} {"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"}}
+25 -2
View File
@@ -1,12 +1,16 @@
// Creates a demo tournament in the database so a fresh install has something to look at. // 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): // 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 // 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 { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
import { Store } from '../server/store.js'; import { Store } from '../server/store.js';
import { Registry } from '../server/state.js'; import { Registry } from '../server/state.js';
const here = dirname(fileURLToPath(import.meta.url)); 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 DB_PATH = process.env.DB_PATH ?? join(here, '..', 'data', 'courtside.sqlite');
const store = new Store(DB_PATH); const store = new Store(DB_PATH);
const registry = new Registry(store); const registry = new Registry(store);
@@ -31,9 +35,28 @@ for (let i = 0; i < 7; i++) {
const hi = 21, lo = Math.floor(8 + rnd() * 12); const hi = 21, lo = Math.floor(8 + rnd() * 12);
engine.recordResult(c.matchId, [rnd() < 0.5 ? [hi, lo] : [lo, hi]]); 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)); const live = engine.courts.filter(c => c.matchId).map(c => t.match(c.matchId));
if (live[0]) live[0].live = [14, 11]; if (live[0]) { live[0].live = [14, 11]; live[0].startedAt = Date.now() - 9 * 60_000; }
if (live[1]) live[1].live = [7, 9]; 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'; t.banner = 'Demo tournament — scores are simulated';
engine.now = () => Date.now(); t.now = engine.now; engine.now = () => Date.now(); t.now = engine.now;
registry.persist(t.slug); registry.persist(t.slug);
+1 -1
View File
@@ -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, 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, a: m.teamA ? { id: m.teamA, name: name(m.teamA) } : null,
b: m.teamB ? { id: m.teamB, name: name(m.teamB) } : 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, 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 board = t.phase === 'live' ? engine.board() : null;
const pools = t.pools.map(p => ({ const pools = t.pools.map(p => ({
+26 -9
View File
@@ -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} .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{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} .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 */
.pools{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:12px} .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)} 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)}
@@ -63,14 +68,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)} .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} .mine.card{border-color:var(--sand);border-width:2px}.big{font-size:20px}
/* bracket */ /* bracket */
.bracketwrap{overflow-x:auto}.bstage{margin-top:12px} .bracketwrap .bhead{display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px}
.bracket{display:flex;gap:16px;align-items:stretch;min-width:max-content;padding-bottom:6px} .bviews{display:flex;gap:4px}.bviews .cur{background:var(--accent);color:var(--accent-ink);border-color:var(--accent)}
.round{display:flex;flex-direction:column;justify-content:space-around;gap:10px;min-width:190px} .bstage{margin-top:14px}.bhint{margin:8px 0 0}
.rlbl{font-family:"Barlow Condensed",sans-serif;text-transform:uppercase;letter-spacing:.1em;font-size:12px;color:var(--muted);text-align:center} .btree-scroll{overflow-x:auto;overflow-y:hidden;-webkit-overflow-scrolling:touch;padding-bottom:6px}
.bm{border:1px solid var(--line);border-radius:6px;background:var(--bg);font-size:15px} .btree{position:relative}.rlbls{position:absolute;top:0;left:0;right:0;height:22px}
.bm>div{display:flex;justify-content:space-between;padding:5px 10px;gap:8px}.bm>div+div{border-top:1px solid var(--line)} .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}
.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} .bcanvas{position:absolute;left:0}.bcanvas svg{position:absolute;left:0;top:0;overflow:visible}
.bm .mlbl.live{background:var(--good);color:#fff}.bm.live{border-color:var(--good)}.bm.mine{border-color:var(--sand);border-width:2px} .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} .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} .loading{color:var(--muted);padding:24px}
/* display (TV) mode */ /* display (TV) mode */
@@ -78,7 +95,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 .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 .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 .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} body.display .queue{font-size:26px}body.display details{display:none}
/* admin */ /* admin */
.grid2{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:12px} .grid2{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:12px}
+129 -12
View File
@@ -7,11 +7,23 @@
const display = app.dataset.display === '1'; const display = app.dataset.display === '1';
const seenAlerts = new Set(); const seenAlerts = new Set();
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])); const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const name = t => t ? esc(t.name) : '<span class="tbd">TBD</span>'; // 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 clock = ms => new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
const isMine = m => myTeam && (m.a?.id === myTeam || m.b?.id === myTeam); 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 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(', ') 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' ? '00' : ''; : m.status === 'forfeit' ? 'forfeit' : m.live ? `${m.live[0]}${m.live[1]}` : m.status === 'live' ? '00' : '';
// 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' ? '00' : '';
};
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}` : ''}`; 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() { function render() {
@@ -30,6 +42,7 @@
if (!s.pools.length && !s.bracket.length) parts.push(renderRoster()); if (!s.pools.length && !s.bracket.length) parts.push(renderRoster());
if (s.withdrawn.length && !display) parts.push(`<p class="muted small">Withdrawn: ${s.withdrawn.map(t => esc(t.name)).join(', ')}</p>`); if (s.withdrawn.length && !display) parts.push(`<p class="muted small">Withdrawn: ${s.withdrawn.map(t => esc(t.name)).join(', ')}</p>`);
app.innerHTML = parts.join(''); app.innerHTML = parts.join('');
fitBracket();
notifyMine(); notifyMine();
} }
@@ -48,7 +61,7 @@
else if (s.phase === 'live') now = `<p>No match scheduled yet. Waiting on results from other courts.</p>`; else if (s.phase === 'live') now = `<p>No match scheduled yet. Waiting on results from other courts.</p>`;
else now = `<p>Waiting for play to start.</p>`; else now = `<p>Waiting for play to start.</p>`;
return `<section class="card mine"><div class="eyebrow">Your team</div><h2>${esc(t.name)}</h2>${now}<p class="muted">${played.length ? `Record ${w}${played.length - w}` : 'No results yet'}${t.poolId ? ` · Pool ${esc(t.poolId)}` : ''}</p> return `<section class="card mine"><div class="eyebrow">Your team</div><h2>${esc(t.name)}</h2>${now}<p class="muted">${played.length ? `Record ${w}${played.length - w}` : 'No results yet'}${t.poolId ? ` · Pool ${esc(t.poolId)}` : ''}</p>
${my.length ? `<ul class="plain sched">${my.map(m => `<li class="${m.status}"><span>${m.stage === 'pool' ? `Pool ${m.round}` : (m.label ?? `Bracket R${m.round}`)}</span><span>vs ${name(m.a?.id === t.id ? m.b : m.a)}</span><span class="mono">${score(m) || (m.court ? `Court ${m.court}` : '')}${m.status === 'final' || m.status === 'forfeit' ? (m.winner === t.id ? ' W' : ' L') : ''}</span></li>`).join('')}</ul>` : ''} ${my.length ? `<ul class="plain sched">${my.map(m => `<li class="${m.status}"><span>${m.stage === 'pool' ? `Pool ${m.round}` : (m.label ?? `Bracket R${m.round}`)}</span><span>vs ${name(m.a?.id === t.id ? m.b : m.a)}</span><span class="mono">${scoreFor(m, t.id) || (m.court ? `Court ${m.court}` : '')}${m.status === 'final' || m.status === 'forfeit' ? (m.winner === t.id ? ' W' : ' L') : ''}</span></li>`).join('')}</ul>` : ''}
${!display && 'Notification' in window && Notification.permission === 'default' ? `<button type="button" class="button" id="notify">Alert me when we're up</button>` : ''} ${!display && 'Notification' in window && Notification.permission === 'default' ? `<button type="button" class="button" id="notify">Alert me when we're up</button>` : ''}
</section>`; </section>`;
} }
@@ -58,36 +71,140 @@
const m = c.match; 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 (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></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;
const timing = avg != null ? `avg game ${avg} min${elapsed != null ? ` · ${elapsed} min played${left ? `, ~${left} left` : ''}` : ''}` : '';
return `<div class="court ${isMine(m) ? 'mine' : ''}"><div class="lbl">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}`)}</div> return `<div class="court ${isMine(m) ? 'mine' : ''}"><div class="lbl">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}`)}</div>
<div class="teams"><div class="t">${name(m.a)}</div><div class="s mono">${score(m) || '00'}</div><div class="t">${name(m.b)}</div></div></div>`; <div class="teams"><div class="t">${name(m.a)}</div><div class="s mono">${score(m) || '00'}</div><div class="t">${name(m.b)}</div></div>
${timing ? `<div class="timing">${timing}</div>` : ''}
${nextForCourt(c.court)}</div>`;
}).join('')}</section>`; }).join('')}</section>`;
} }
// 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 `<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>`;
}
function renderUpNext() { function renderUpNext() {
if (!state.upNext.length) return ''; 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} · ~${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">${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>`;
} }
function renderPools() { function renderPools() {
return `<section class="pools">${state.pools.map(p => `<div class="card pool"><h2>Pool ${esc(p.id)}</h2> return `<section class="pools">${state.pools.map(p => `<div class="card pool"><h2>Pool ${esc(p.id)}</h2>
<table><thead><tr><th>Team</th><th class="num">W</th><th class="num">L</th><th class="num">Pts</th><th class="num">+/</th></tr></thead><tbody> <table><thead><tr><th>Team</th><th class="num">W</th><th class="num">L</th><th class="num">Pts</th><th class="num">+/</th></tr></thead><tbody>
${p.standings.map((r, i) => `<tr class="${r.status === 'withdrawn' ? 'withdrawn' : ''} ${r.teamId === myTeam ? 'mine' : ''}"><td>${i + 1}. ${esc(r.name)}${r.decidedBy ? `<span class="tb" title="Tie broken by ${r.decidedBy}">*</span>` : ''}</td><td class="num">${r.w}</td><td class="num">${r.l}</td><td class="num">${r.pf}${r.pa}</td><td class="num">${r.pointDiff > 0 ? '+' : ''}${r.pointDiff}</td></tr>`).join('')} ${p.standings.map((r, i) => `<tr class="${r.status === 'withdrawn' ? 'withdrawn' : ''} ${r.teamId === myTeam ? 'mine' : ''}"><td>${i + 1}. ${name({ id: r.teamId, name: r.name })}${r.decidedBy ? `<span class="tb" title="Tie broken by ${r.decidedBy}">*</span>` : ''}</td><td class="num">${r.w}</td><td class="num">${r.l}</td><td class="num">${r.pf}${r.pa}</td><td class="num">${r.pointDiff > 0 ? '+' : ''}${r.pointDiff}</td></tr>`).join('')}
</tbody></table> </tbody></table>
<details${display ? '' : ''}><summary>Matches</summary><ul class="plain matches">${p.matches.map(m => `<li class="${m.status} ${isMine(m) ? 'mine' : ''}"><span>R${m.round}</span><span class="${m.winner && m.winner === m.a?.id ? 'won' : ''}">${name(m.a)}</span><span class="mono">${score(m) || (m.court ? `C${m.court}` : '·')}</span><span class="${m.winner && m.winner === m.b?.id ? 'won' : ''}">${name(m.b)}</span></li>`).join('')}</ul></details> <details${display ? '' : ''}><summary>Matches</summary><ul class="plain matches">${p.matches.map(m => `<li class="${m.status} ${isMine(m) ? 'mine' : ''}"><span>R${m.round}</span><span class="${m.winner && m.winner === m.a?.id ? 'won' : ''}">${name(m.a)}</span><span class="mono">${score(m) || (m.court ? `C${m.court}` : '·')}</span><span class="${m.winner && m.winner === m.b?.id ? 'won' : ''}">${name(m.b)}</span></li>`).join('')}</ul></details>
</div>`).join('')}</section>`; </div>`).join('')}</section>`;
} }
// ---- 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() { function renderBracket() {
const groups = [['bracket', 'Bracket'], ['losers', 'Losers bracket'], ['final', 'Finals']]; const isDouble = state.bracket.some(m => m.stage === 'losers');
return `<section class="card bracketwrap"><h2>${state.bracket.some(m => m.stage === 'losers') ? 'Double elimination' : 'Bracket'}</h2>${groups.map(([stage, title]) => { const toggle = display ? '' : `<div class="bviews" role="tablist">${[['tree', 'Bracket'], ['fit', 'Fit to screen'], ['list', 'List']].map(([v, l]) => `<button type="button" class="small ${bracketView === v ? 'cur' : ''}" data-bview="${v}">${l}</button>`).join('')}</div>`;
const ms = state.bracket.filter(m => m.stage === stage && !(m.conditional && m.status !== 'final' && !m.a)); 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 ''; if (!ms.length) return '';
const rounds = [...new Set(ms.map(m => m.round))].sort((a, b) => a - b); return `<div class="bstage">${isDouble ? `<h3>${title}</h3>` : ''}${bracketView === 'list' ? bracketList(stage, ms) : bracketTree(stage, ms)}</div>`;
return `<div class="bstage"><h3>${title}</h3><div class="bracket">${rounds.map(r => `<div class="round"><div class="rlbl">${roundName(stage, r, rounds.length, ms)}</div>${ms.filter(m => m.round === r).map(m => `<div class="bm ${m.status} ${isMine(m) ? 'mine' : ''}">${m.label ? `<div class="mlbl">${esc(m.label)}</div>` : ''}<div class="${m.winner && m.winner === m.a?.id ? 'won' : ''}">${name(m.a)}<span class="mono">${m.sets[0] ? m.sets.map(s => s[0]).join(' ') : ''}</span></div><div class="${m.winner && m.winner === m.b?.id ? 'won' : ''}">${name(m.b)}<span class="mono">${m.sets[0] ? m.sets.map(s => s[1]).join(' ') : ''}</span></div>${m.status === 'live' ? `<div class="mlbl live">Court ${m.court}${m.live ? ` · ${m.live[0]}${m.live[1]}` : ''}</div>` : m.status === 'forfeit' ? '<div class="mlbl">forfeit</div>' : ''}</div>`).join('')}</div>`).join('')}</div></div>`; }).join('');
}).join('')}</section>`; return `<section class="card bracketwrap ${bracketView === 'fit' ? 'fit' : ''}"><div class="bhead"><h2>${isDouble ? 'Double elimination' : 'Bracket'}</h2>${toggle}</div>${body}${bracketView === 'tree' && !display ? '<p class="muted small bhint">Swipe sideways to see later rounds.</p>' : ''}</section>`;
} }
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 `<path d="M${x1} ${y1} H${xm} V${y2} H${x2}" class="${m.winner ? 'decided' : ''} ${hot ? 'mine' : ''}"/>`;
}).join('');
const boxes = [...pos.entries()].map(([id, p]) => matchBox(ms.find(m => m.id === id), p)).join('');
const labels = rounds.map((r, ri) => `<div class="rlbl" style="left:${ri * (BOX_W + COL_GAP)}px;width:${BOX_W}px">${roundName(stage, r, rounds.length, tree)}</div>`).join('');
return `<div class="btree-scroll"><div class="btree" style="width:${width}px;height:${height + 22}px"><div class="rlbls">${labels}</div><div class="bcanvas" style="top:22px"><svg width="${width}" height="${height}" aria-hidden="true">${paths}</svg>${boxes}</div></div></div>`;
}
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 `<div class="brow ${won ? 'won' : ''} ${t && isMineId(t.id) ? 'mine' : ''}"><span class="bn">${t ? name(t) : `<span class="tbd">${feedLabel(m, side)}</span>`}</span><span class="bs mono">${pts}</span></div>`;
};
const tag = m.status === 'live' ? `<span class="btag live">Court ${m.court}</span>` : m.status === 'on_deck' ? `<span class="btag">On deck${m.court ? ` · C${m.court}` : ''}</span>` : m.status === 'forfeit' ? '<span class="btag">Forfeit</span>' : m.label ? `<span class="btag">${esc(m.label)}</span>` : '';
return `<div class="bm ${m.status} ${isMine(m) ? 'mine' : ''}" style="left:${p.x}px;top:${p.y}px;width:${BOX_W}px;height:${BOX_H}px">${row(m.a, 0)}${row(m.b, 1)}${tag}</div>`;
}
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 => `<div class="blist"><div class="rlbl">${roundName(stage, r, rounds.length, ms)}</div>${ms.filter(m => m.round === r).map(m => `<div class="bm list ${m.status} ${isMine(m) ? 'mine' : ''}">${['a', 'b'].map((k, i) => { const t = m[k]; const won = m.winner && t && m.winner === t.id; return `<div class="brow ${won ? 'won' : ''} ${t && isMineId(t.id) ? 'mine' : ''}"><span class="bn">${t ? name(t) : `<span class="tbd">${feedLabel(m, i)}</span>`}</span><span class="bs mono">${m.sets[0] ? m.sets.map(s => s[i]).join(' ') : (m.live && m.status === 'live' ? m.live[i] : '')}</span></div>`; }).join('')}${m.status === 'live' ? `<span class="btag live">Court ${m.court}</span>` : m.status === 'on_deck' ? `<span class="btag">On deck${m.court ? ` · C${m.court}` : ''}</span>` : m.status === 'forfeit' ? '<span class="btag">Forfeit</span>' : m.label ? `<span class="btag">${esc(m.label)}</span>` : ''}</div>`).join('')}</div>`).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) { 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}`; if (stage === 'losers') return `LB round ${r}`;
const left = total - r; const left = total - r;
const inRound = ms.filter(m => m.round === r && m.label !== '3rd place').length; const inRound = ms.filter(m => m.round === r && m.label !== '3rd place').length;
+5
View File
@@ -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))); 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 })); 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 }) => { r.post('/t/:slug/register', async (req, res, { slug }) => {
const it = item(slug); const it = item(slug);
const body = await readBody(req); const body = await readBody(req);
+1 -1
View File
@@ -23,7 +23,7 @@ export function layout({ title, body, state = null, script = null, bodyClass = '
<main> <main>
${body} ${body}
</main> </main>
<footer class="foot">Courtside v${VERSION} · self-hosted tournament desk · <a href="https://gitea.cloudfreeiot.com/bkvargyas/courtside">source</a></footer> <footer class="foot">Courtside v${VERSION} · self-hosted tournament desk · <a href="https://gitea.cloudfreeiot.com/bkvargyas/courtside">source</a> · <a href="/admin">organizer desk</a></footer>
${state ? `<script id="state" type="application/json">${JSON.stringify(state).replace(/</g, '\\u003c')}</script>` : ''} ${state ? `<script id="state" type="application/json">${JSON.stringify(state).replace(/</g, '\\u003c')}</script>` : ''}
${script ? `<script src="${esc(script)}" defer></script>` : ''} ${script ? `<script src="${esc(script)}" defer></script>` : ''}
</body> </body>