Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62efa4d127 | ||
|
|
3d241949f0 | ||
|
|
1bf2298f3f |
@@ -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
|
||||
|
||||
+1
-1
@@ -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.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"}}
|
||||
@@ -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];
|
||||
|
||||
@@ -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 => ({
|
||||
|
||||
+21
-9
@@ -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}
|
||||
|
||||
+98
-7
@@ -30,6 +30,7 @@
|
||||
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>`);
|
||||
app.innerHTML = parts.join('');
|
||||
fitBracket();
|
||||
notifyMine();
|
||||
}
|
||||
|
||||
@@ -77,17 +78,107 @@
|
||||
</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() {
|
||||
const groups = [['bracket', 'Bracket'], ['losers', 'Losers bracket'], ['final', 'Finals']];
|
||||
return `<section class="card bracketwrap"><h2>${state.bracket.some(m => m.stage === 'losers') ? 'Double elimination' : 'Bracket'}</h2>${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 ? '' : `<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 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 `<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('')}</section>`;
|
||||
return `<div class="bstage">${isDouble ? `<h3>${title}</h3>` : ''}${bracketView === 'list' ? bracketList(stage, ms) : bracketTree(stage, ms)}</div>`;
|
||||
}).join('');
|
||||
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 ? esc(t.name) : `<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 ? esc(t.name) : `<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) {
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user