7 Commits
Author SHA1 Message Date
bkvargyasandClaude Fable 5.1 eb58eb9ad9 Courtside Manager score keeper page per court; Phase below Courts on the desk; estimated finish on court cards
A per-court signed link from the desk opens a full-screen scoring page with
big +/- buttons, undo, best-of sets, and a confirmed Finalize step; it follows
whatever match is on the court and can never edit a finished result. Public
court cards show the estimated finish time. Release 0.4.0.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01MB7nCCAscYsb3zzkT6LHZi
2026-09-04 02:10:40 +00:00
bkvargyas acc5fdc2f3 docs: how to cut a release 2026-09-04 01:53:23 +00:00
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
17 changed files with 481 additions and 47 deletions
+47
View File
@@ -9,6 +9,53 @@ so you can always tell what a deployment is actually serving.
## [Unreleased] ## [Unreleased]
## [0.4.0] - 2026-09-03
### Added
- **Courtside Manager**, a score keeper page per court (`/keeper/<slug>/<court>?k=…`). Big
+/ buttons, undo, sets for best-of-3, and a Finalize step with confirmation that appears
once a side has won under the tournament's rules. It follows whatever match is on the court
and moves to the next one automatically. The link is opened or copied from the court card
on the desk; it can only score and finalize, never edit a finished result.
- Court cards on the public board show the estimated finish time of the current match.
### Changed
- Organizer desk: the Phase section now sits below Courts and Queue, so the score pads
are at the top during play.
- Multi-set running scores (`liveSets`) show on the public board while a keeper is scoring.
- Match durations outside 290 minutes are ignored for the ETA average.
## [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
+11
View File
@@ -79,6 +79,17 @@ Architecture and the reasoning behind it: [docs/ARCHITECTURE.md](docs/ARCHITECTU
Versions and what changed in each: [CHANGELOG.md](CHANGELOG.md). The running Versions and what changed in each: [CHANGELOG.md](CHANGELOG.md). The running
version is in the page footer and at `GET /healthz`. version is in the page footer and at `GET /healthz`.
To cut a release: move the `[Unreleased]` notes in `CHANGELOG.md` under a new
`## [x.y.z] - date` heading, set the same version in `package.json`, commit, then tag
and publish the Gitea release with the changelog section as its notes:
```bash
git tag -a vx.y.z -m "Courtside x.y.z" && git push origin --tags
# Gitea → Releases → Draft a release on tag vx.y.z, or POST /api/v1/repos/bkvargyas/courtside/releases
```
A push to `main` deploys; the tag and release record which commit each version was.
## License ## License
MIT. See [LICENSE](LICENSE). MIT. See [LICENSE](LICENSE).
+11
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). |
@@ -50,6 +51,16 @@ A match object:
Phone numbers, captain names, and team codes never appear in the public document. Phone numbers, captain names, and team codes never appear in the public document.
## Score keeper (Courtside Manager)
Per-court links issued from the desk. `k` is an HMAC of `<slug>:court<n>` under `SESSION_SECRET`; requests without a valid `k` get 403.
| Method | Path | Body (JSON) | Effect |
| --- | --- | --- | --- |
| GET | `/keeper/<slug>/<court>?k=…` | | The score keeper page for that court. |
| POST | `/keeper/<slug>/<court>/live` | `k`, `match`, `a`, `b`, `sets` (completed sets, `[[21,18]]`) | Update the running score of the live match on that court. Fails if `match` is not the one on the court. |
| POST | `/keeper/<slug>/<court>/final` | `k`, `match`, `sets` (all sets incl. the last) | Record the result, validated against the rules; the court engine then assigns the next match. Fails once the match is no longer live. |
## Organizer ## Organizer
All organizer routes require the session cookie set by `/admin/login` (or Cloudflare Access when `TRUST_CF_ACCESS=true`). Unauthenticated requests are redirected to the login page. All organizer routes require the session cookie set by `/admin/login` (or Cloudflare Access when `TRUST_CF_ACCESS=true`). Unauthenticated requests are redirected to the login page.
+1 -1
View File
@@ -11,7 +11,7 @@ Honest list of what the MVP does not do well yet. None of these block running a
- **Browser notifications need HTTPS** and, on iOS, the page added to the home screen. Through the Cloudflare tunnel HTTPS is automatic. - **Browser notifications need HTTPS** and, on iOS, the page added to the home screen. Through the Cloudflare tunnel HTTPS is automatic.
- **Registration is open to anyone with the URL** during check-in, by design. If someone registers junk teams, remove them from the desk. There is no rate limiting beyond the 64 KB body cap. - **Registration is open to anyone with the URL** during check-in, by design. If someone registers junk teams, remove them from the desk. There is no rate limiting beyond the 64 KB body cap.
- **Team codes are 4 characters** (about a million combinations) and only reveal a personalized read-only view, so guessing is harmless. They are not a write credential. - **Team codes are 4 characters** (about a million combinations) and only reveal a personalized read-only view, so guessing is harmless. They are not a write credential.
- **Organizer sessions reset on restart** unless `SESSION_SECRET` is set. - **Organizer sessions and score keeper links reset on restart** unless `SESSION_SECRET` is set. Set it in production.
- **`node:sqlite` is marked experimental** by Node (stable API since 22.13, still emits a warning that the start script suppresses). If a future Node changes it, `server/store.js` is the only file that touches it and is about 50 lines. - **`node:sqlite` is marked experimental** by Node (stable API since 22.13, still emits a warning that the start script suppresses). If a future Node changes it, `server/store.js` is the only file that touches it and is about 50 lines.
- **Single process.** Restarting the container drops WebSocket connections; clients reconnect with backoff and re-fetch state, and no data is lost since every mutation is written before it is broadcast. - **Single process.** Restarting the container drops WebSocket connections; clients reconnect with backoff and re-fetch state, and no data is lost since every mutation is written before it is broadcast.
- **The desk reloads itself** when court or phase state changes (for multi-organizer freshness). If you are mid-typing in a form when another organizer marks a match final, the page reloads. The score pad is exempt while hovered. - **The desk reloads itself** when court or phase state changes (for multi-organizer freshness). If you are mid-typing in a form when another organizer marks a match final, the page reloads. The score pad is exempt while hovered.
+6
View File
@@ -32,6 +32,12 @@ For best-of-3, or if you'd rather type, use "Save sets" with `21-18, 19-21, 15-9
**Forfeits.** The "Forfeits" button next to a live match records a forfeit by the selected team (0 to points-to for standings purposes). **Forfeits.** The "Forfeits" button next to a live match records a forfeit by the selected team (0 to points-to for standings purposes).
## Score keepers (Courtside Manager)
You don't have to score every court yourself. Each court card on the desk has a **Score keeper** link (and a Copy link button). Hand that link to whoever is sitting at that court; it opens Courtside Manager, a full-screen page with two big + buttons, a for a mis-tap, Undo, and a Finalize button that appears once a side has won the set under your rules. The page always shows whatever match is on that court and switches to the next one by itself when the previous is finalized, so one link lasts the whole day.
A keeper link can only do two things: update the running score of the live match on its court, and finalize it. It cannot change a finished score, forfeit, or touch anything else; corrections are made from the desk ("All matches"). Links are signed with the server's `SESSION_SECRET`, so if that changes the old links stop working and you copy new ones.
## Things that go wrong on the day ## Things that go wrong on the day
**A team leaves.** In the Teams list click Withdraw and choose: **A team leaves.** In the Teams list click Withdraw and choose:
+2 -1
View File
@@ -28,7 +28,8 @@ export class CourtEngine {
this.t.recordResult(matchId, sets, opts); this.t.recordResult(matchId, sets, opts);
if (court) { if (court) {
const dur = this.now() - court.startedAt; const dur = this.now() - court.startedAt;
if (m.status === 'final') this.durations.push(dur); // ignore implausible durations (a match left open over lunch, a clock jump) so the ETA stays useful
if (m.status === 'final' && dur >= 2 * MIN && dur <= 90 * MIN) this.durations.push(dur);
this._freeCourt(court); this._freeCourt(court);
} }
for (const tid of [m.teamA, m.teamB]) this.lastPlayed.set(tid, this.now()); for (const tid of [m.teamA, m.teamB]) this.lastPlayed.set(tid, this.now());
+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.4.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"}}
+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, liveSets: m.liveSets ?? 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 => ({
+2
View File
@@ -23,6 +23,8 @@
}); });
}); });
document.querySelectorAll('[data-copy]').forEach(b => b.addEventListener('click', () => navigator.clipboard?.writeText(b.dataset.copy).then(() => { b.textContent = 'Copied'; })));
// keep the desk fresh when another organizer enters a score // keep the desk fresh when another organizer enters a score
const ws = new WebSocket(`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/${slug}`); const ws = new WebSocket(`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/${slug}`);
let last = null; let last = null;
+48 -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}
@@ -93,3 +110,25 @@ body.display .queue{font-size:26px}body.display details{display:none}
@media (max-width:640px){h1{font-size:34px}.cols{columns:1}.court .t{font-size:20px}.court .s{font-size:24px}} @media (max-width:640px){h1{font-size:34px}.cols{columns:1}.court .t{font-size:20px}.court .s{font-size:24px}}
@media (prefers-reduced-motion:reduce){*{scroll-behavior:auto!important}} @media (prefers-reduced-motion:reduce){*{scroll-behavior:auto!important}}
@media print{.top,.foot,button{display:none}body{background:#fff;color:#000}} @media print{.top,.foot,button{display:none}body{background:#fff;color:#000}}
/* score keeper (Courtside Manager) */
body.keeper-body{background:#0F1418;color:#F2F5F7}body.keeper-body .top,body.keeper-body .foot{display:none}
body.keeper-body main{max-width:720px;padding:12px 12px 40px}
.khead{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px}
.kcourt{font-family:"Barlow Condensed",sans-serif;font-size:28px;font-weight:700;letter-spacing:.04em;text-transform:uppercase}
.klbl{font-family:"Barlow Condensed",sans-serif;font-size:14px;letter-spacing:.08em;text-transform:uppercase;color:#8E9BA6;margin-bottom:10px}
.kpad{display:grid;grid-template-columns:1fr 1fr;gap:10px}
.kside{background:#1A222B;border:2px solid #2B3640;border-radius:12px;padding:12px 10px 10px;display:flex;flex-direction:column;align-items:center;gap:8px}
.kside.lead{border-color:#5AA3E6}
.kname{font-family:"Barlow Condensed",sans-serif;font-size:22px;font-weight:600;text-align:center;line-height:1.1;min-height:2.2em;display:flex;align-items:center}
.kpts{font-size:96px;line-height:1;font-weight:500}
.kbtn{width:100%;border-radius:10px;border:0;font-size:44px;line-height:1;padding:22px 0;cursor:pointer;font-family:inherit;touch-action:manipulation;-webkit-tap-highlight-color:transparent}
.kbtn.plus{background:#1D6FB5;color:#fff;padding:34px 0}.kbtn.plus:disabled{background:#2B3640;color:#5B6875;cursor:default}.kbtn.plus:active{background:#3A8AD0}
.kbtn.minus{background:#2B3640;color:#C9D2DA;font-size:28px;padding:10px 0}.kbtn.minus:active{background:#3A4652}
.ksets{margin:12px 0 0;color:#C9D2DA;text-align:center}
.kactions{margin-top:14px;display:flex;flex-direction:column;gap:10px;align-items:stretch}
.kactions button{font-size:20px;padding:14px}.kactions .big{font-size:24px;padding:18px}
.kactions button.small{font-size:15px;padding:8px;background:transparent;color:#C9D2DA;border-color:#2B3640}
.kactions button.primary{background:#2E8B57;border-color:#2E8B57;color:#fff}
.kconfirm{background:#1A222B;border:2px solid #F0C46B;border-radius:12px;padding:14px;text-align:center}.kconfirm p{margin:0 0 10px}
.kwait{text-align:center;padding:40px 10px}.kwait h1{font-size:34px}.knote{background:#3A3120;color:#F0C46B;padding:10px 14px;border-radius:8px;text-align:center}
@media (max-width:380px){.kpts{font-size:72px}.kbtn.plus{padding:26px 0}}
+131 -13
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.liveSets ?? []), m.live].map(s => `${s[0]}${s[1]}`).join(', ') : 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 [...(m.liveSets ?? []), m.live].map(pair).join(', ');
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,141 @@
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 finish = avg != null && m.startedAt ? clock(Math.max(Date.now(), m.startedAt + avg * 60000)) : null;
const timing = avg != null ? `avg game ${avg} min${elapsed != null ? ` · ${elapsed} min played` : ''}${finish ? ` · est. finish ~${finish}${left ? ` (${left} min)` : ''}` : ''}` : '';
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;
+109
View File
@@ -0,0 +1,109 @@
/* Courtside Manager — score keeper for one court. Big buttons, one job: keep the running score of
whatever match is on this court and finalize it. Corrections after that are the organizer's. */
(() => {
const root = document.getElementById('keeper');
const court = +root.dataset.court, k = root.dataset.k;
let state = JSON.parse(document.getElementById('state').textContent);
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const base = `/keeper/${state.slug}/${court}`;
const post = (path, body) => fetch(`${base}/${path}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ k, ...body }) }).then(async r => { const j = await r.json().catch(() => ({})); if (!r.ok || !j.ok) throw new Error(j.error || r.statusText); return j; });
// local scoring state for the match currently on this court
let cur = null; // { matchId, a, b, sets: [[a,b],...], history: [] }
let busy = false, note = '', confirming = false;
function courtMatch() { const c = state.courts.find(c => c.court === court); return c?.match && c.match.status === 'live' ? c.match : null; }
function syncFromState() {
const m = courtMatch();
if (!m) { cur = null; return; }
if (!cur || cur.matchId !== m.id) {
cur = { matchId: m.id, a: m.live?.[0] ?? 0, b: m.live?.[1] ?? 0, sets: m.liveSets ? m.liveSets.map(s => [...s]) : [], history: [] };
confirming = false; note = '';
}
}
const rules = () => state.rules;
function setOver(a, b) {
const { pointsTo, winBy, cap } = rules();
const hi = Math.max(a, b), lo = Math.min(a, b);
if (cap && hi >= cap && hi > lo) return true;
return hi >= pointsTo && hi - lo >= winBy;
}
const setsNeeded = () => Math.ceil(rules().bestOf / 2);
const setsWon = side => cur.sets.filter(s => (side === 0 ? s[0] > s[1] : s[1] > s[0])).length;
function render() {
syncFromState();
const c = state.courts.find(c => c.court === court);
const m = courtMatch();
const head = `<div class="khead"><span class="kcourt">Court ${court}</span><span class="pill ${state.phase}">${esc(state.name)}</span></div>`;
if (!m) {
const next = state.upNext.find(u => u.court === court);
root.innerHTML = `${head}<div class="kwait"><h1>${c?.status === 'paused' ? 'Court paused' : 'No match on this court'}</h1>
<p class="muted">${next ? `Next up: <b>${esc(next.a?.name)} vs ${esc(next.b?.name)}</b> (~${next.etaMin} min). This page will switch to it automatically.` : state.phase === 'final' ? 'Tournament is over.' : 'Waiting for the organizer to assign the next match. This page updates on its own.'}</p>
${note ? `<p class="knote">${esc(note)}</p>` : ''}</div>`;
return;
}
const r = rules();
const over = setOver(cur.a, cur.b);
const matchDone = over && (setsWon(0) + (cur.a > cur.b ? 1 : 0) >= setsNeeded() || setsWon(1) + (cur.b > cur.a ? 1 : 0) >= setsNeeded());
const setNo = cur.sets.length + 1;
root.innerHTML = `${head}
<div class="klbl">${m.stage === 'pool' ? `Pool play · round ${m.round}` : esc(m.label ?? `Bracket round ${m.round}`)} · to ${r.pointsTo}, win by ${r.winBy}${r.cap ? `, cap ${r.cap}` : ''}${r.bestOf > 1 ? ` · best of ${r.bestOf}, set ${setNo}` : ''}</div>
<div class="kpad">
<div class="kside ${cur.a > cur.b ? 'lead' : ''}"><div class="kname">${esc(m.a?.name)}</div><div class="kpts mono">${cur.a}</div><button type="button" class="kbtn plus" data-op="a+" ${over ? 'disabled' : ''} aria-label="Point ${esc(m.a?.name)}">+</button><button type="button" class="kbtn minus" data-op="a-" aria-label="Undo point ${esc(m.a?.name)}"></button></div>
<div class="kside ${cur.b > cur.a ? 'lead' : ''}"><div class="kname">${esc(m.b?.name)}</div><div class="kpts mono">${cur.b}</div><button type="button" class="kbtn plus" data-op="b+" ${over ? 'disabled' : ''} aria-label="Point ${esc(m.b?.name)}">+</button><button type="button" class="kbtn minus" data-op="b-" aria-label="Undo point ${esc(m.b?.name)}"></button></div>
</div>
${cur.sets.length ? `<div class="ksets mono">Sets: ${cur.sets.map(s => `${s[0]}${s[1]}`).join(', ')}</div>` : ''}
<div class="kactions">
${r.bestOf > 1 && over && !matchDone ? `<button type="button" class="primary" data-act="endset">End set ${setNo}</button>` : ''}
${over && matchDone && !confirming ? `<button type="button" class="primary big" data-act="finalize">Finalize · ${esc((cur.a > cur.b ? m.a : m.b)?.name)} wins</button>` : ''}
${confirming ? `<div class="kconfirm"><p>Final score <b class="mono">${[...cur.sets, [cur.a, cur.b]].map(s => `${s[0]}${s[1]}`).join(', ')}</b>. Once finalized, only the organizer desk can change it.</p><button type="button" class="primary big" data-act="confirm">Yes, finalize</button> <button type="button" data-act="cancel">Not yet</button></div>` : ''}
${!over ? `<p class="muted small">Finalize appears when a side reaches ${r.pointsTo} with a ${r.winBy}-point lead${r.cap ? ` or hits the cap of ${r.cap}` : ''}.</p>` : ''}
<button type="button" class="small" data-act="undo" ${cur.history.length ? '' : 'disabled'}>Undo last</button>
</div>
${note ? `<p class="knote">${esc(note)}</p>` : ''}`;
}
let pushTimer = null;
function push() {
clearTimeout(pushTimer);
pushTimer = setTimeout(() => post('live', { match: cur.matchId, a: cur.a, b: cur.b, sets: cur.sets }).then(() => { note = ''; }).catch(e => { note = e.message; render(); }), 120);
}
function point(side, delta) {
if (!cur) return;
cur.history.push({ a: cur.a, b: cur.b, sets: cur.sets.map(s => [...s]) });
if (side === 'a') cur.a = Math.max(0, cur.a + delta); else cur.b = Math.max(0, cur.b + delta);
confirming = false; try { navigator.vibrate?.(15); } catch {}
render(); push();
}
root.addEventListener('click', e => {
const op = e.target.closest('[data-op]')?.dataset.op; const act = e.target.closest('[data-act]')?.dataset.act;
if (op) return point(op[0], op[1] === '+' ? 1 : -1);
if (!act || !cur) return;
if (act === 'undo') { const h = cur.history.pop(); if (h) { cur.a = h.a; cur.b = h.b; cur.sets = h.sets; confirming = false; render(); push(); } }
if (act === 'endset') { cur.history.push({ a: cur.a, b: cur.b, sets: cur.sets.map(s => [...s]) }); cur.sets.push([cur.a, cur.b]); cur.a = 0; cur.b = 0; render(); push(); }
if (act === 'finalize') { confirming = true; render(); }
if (act === 'cancel') { confirming = false; render(); }
if (act === 'confirm') {
if (busy) return; busy = true;
const sets = [...cur.sets, [cur.a, cur.b]];
post('final', { match: cur.matchId, sets }).then(j => { note = j.msg; cur = null; confirming = false; busy = false; }).catch(e => { note = e.message; confirming = false; busy = false; render(); });
}
});
// live state: switch to the next match automatically, notice if the desk finalized this one
let retry = 1000;
function connect() {
const ws = new WebSocket(`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/${state.slug}`);
ws.onmessage = ev => { state = JSON.parse(ev.data); retry = 1000; render(); };
ws.onclose = () => { setTimeout(connect, retry); retry = Math.min(retry * 2, 15000); };
ws.onerror = () => ws.close();
}
render(); connect();
// keep the screen awake while scoring, where supported
navigator.wakeLock?.request?.('screen').catch(() => {});
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') navigator.wakeLock?.request?.('screen').catch(() => {}); });
})();
+51 -3
View File
@@ -1,5 +1,5 @@
import QRCode from 'qrcode'; import QRCode from 'qrcode';
import { Router, HttpError, readBody, send, esc } from './http.js'; import { Router, HttpError, readBody, send, esc, sign, verify } from './http.js';
import { publicState } from './public-state.js'; import { publicState } from './public-state.js';
import { VERSION } from './version.js'; import { VERSION } from './version.js';
import * as pub from './views/public.js'; import * as pub from './views/public.js';
@@ -31,6 +31,11 @@ export function buildRouter({ registry, auth, store }) {
if (state.phase === 'checkin' && q(req).get('new')) return send.html(res, pub.registeredPage(state, team, origin(req))); 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);
@@ -45,6 +50,49 @@ export function buildRouter({ registry, auth, store }) {
} }
}); });
// ---------- score keeper: a per-court link the organizer hands to whoever is scoring that court ----------
// The link carries an HMAC of slug+court signed with the session secret. The page always shows
// whatever match is live on that court, can update its running score and finalize it, and moves on
// to the next match by itself. Corrections after a match is final need the organizer desk.
const keeperToken = (slug, court) => sign(`${slug}:court${court}`, auth.secret).split('.').pop();
const keeperUrl = (req, slug, court) => `${origin(req)}/keeper/${slug}/${court}?k=${keeperToken(slug, court)}`;
const keeperCheck = (slug, court, k) => { if (!k || verify(`${slug}:court${court}.${k}`, auth.secret) === null) throw new HttpError(403, 'This score keeper link is not valid (links change when the server secret changes; get a fresh one from the desk)'); };
const courtMatch = (it, court, matchId) => {
const c = it.engine.courts.find(c => c.number === +court); if (!c) throw new HttpError(404, 'No such court');
const m = c.matchId ? it.t.match(c.matchId) : null;
if (!m || m.status !== 'live') throw new Error('No live match on this court');
if (matchId && m.id !== matchId) throw new Error('A different match is on this court now; the page has been refreshed');
return m;
};
r.get('/keeper/:slug/:court', (req, res, { slug, court }) => {
const it = item(slug);
keeperCheck(slug, court, q(req).get('k'));
if (!it.engine.courts.some(c => c.number === +court)) throw new HttpError(404, 'No such court');
send.html(res, adm.keeperPage(it.t, +court, q(req).get('k'), publicState(it)));
});
r.post('/keeper/:slug/:court/live', async (req, res, { slug, court }) => {
const body = await readBody(req);
keeperCheck(slug, court, body.k);
try {
registry.mutate(slug, (t, engine) => { const m = courtMatch({ t, engine }, court, body.match); m.live = [Math.max(0, +body.a | 0), Math.max(0, +body.b | 0)]; m.liveSets = Array.isArray(body.sets) ? body.sets.map(s => [+s[0] | 0, +s[1] | 0]).slice(0, 5) : []; });
send.json(res, { ok: true });
} catch (e) { send.json(res, { ok: false, error: e.message }, 400); }
});
r.post('/keeper/:slug/:court/final', async (req, res, { slug, court }) => {
const body = await readBody(req);
keeperCheck(slug, court, body.k);
try {
const msg = registry.mutate(slug, (t, engine) => {
const m = courtMatch({ t, engine }, court, body.match);
const sets = (Array.isArray(body.sets) ? body.sets : []).map(s => [+s[0] | 0, +s[1] | 0]);
engine.recordResult(m.id, sets, { actor: `keeper:court${court}` });
delete m.live; delete m.liveSets;
return `${t.teamName(m.winner)} wins ${sets.map(s => s.join('-')).join(', ')}`;
});
send.json(res, { ok: true, msg });
} catch (e) { send.json(res, { ok: false, error: e.message }, 400); }
});
// ---------- organizer ---------- // ---------- organizer ----------
const requireAdmin = (req, res) => { const requireAdmin = (req, res) => {
const who = auth.identify(req); const who = auth.identify(req);
@@ -71,7 +119,7 @@ export function buildRouter({ registry, auth, store }) {
r.get('/admin/t/:slug', (req, res, { slug }) => { r.get('/admin/t/:slug', (req, res, { slug }) => {
const who = requireAdmin(req, res); if (!who) return; const who = requireAdmin(req, res); if (!who) return;
send.html(res, adm.adminTournament(item(slug), who, origin(req), q(req).get('msg'), q(req).get('error'), store.events(slug, 40))); send.html(res, adm.adminTournament(item(slug), who, origin(req), q(req).get('msg'), q(req).get('error'), store.events(slug, 40), (t, c) => keeperUrl(req, t.slug, c.number)));
}); });
r.get('/admin/t/:slug/qr', async (req, res, { slug }) => { r.get('/admin/t/:slug/qr', async (req, res, { slug }) => {
const who = requireAdmin(req, res); if (!who) return; const who = requireAdmin(req, res); if (!who) return;
@@ -124,7 +172,7 @@ export function buildRouter({ registry, auth, store }) {
const wasLive = m.status === 'live'; const wasLive = m.status === 'live';
if (wasLive) engine.recordResult(m.id, sets, { actor: who }); if (wasLive) engine.recordResult(m.id, sets, { actor: who });
else { t.recordResult(m.id, sets, { actor: who }); m.court = null; } else { t.recordResult(m.id, sets, { actor: who }); m.court = null; }
delete m.live; delete m.live; delete m.liveSets;
return `Saved ${t.teamName(m.teamA)} vs ${t.teamName(m.teamB)}: ${sets.map(s => s.join('-')).join(', ')}`; return `Saved ${t.teamName(m.teamA)} vs ${t.teamName(m.teamB)}: ${sets.map(s => s.join('-')).join(', ')}`;
}); });
action('/live', ({ t, body }) => { const m = t.match(body.match); if (!m || m.status !== 'live') throw new Error('Match is not live'); m.live = [Math.max(0, +body.a | 0), Math.max(0, +body.b | 0)]; return 'ok'; }); action('/live', ({ t, body }) => { const m = t.match(body.match); if (!m || m.status !== 'live') throw new Error('Match is not live'); m.live = [Math.max(0, +body.a | 0), Math.max(0, +body.b | 0)]; return 'ok'; });
+23 -15
View File
@@ -34,7 +34,7 @@ export function adminHome(list, who, msg) {
</div>` }); </div>` });
} }
export function adminTournament({ t, engine }, who, origin, msg, err, events) { export function adminTournament({ t, engine }, who, origin, msg, err, events, keeperUrl = () => '#') {
const s = t.phase; const s = t.phase;
const teams = [...t.teams.values()].sort((a, b) => a.seed - b.seed); const teams = [...t.teams.values()].sort((a, b) => a.seed - b.seed);
const live = engine.courts.map(c => ({ c, m: c.matchId ? t.match(c.matchId) : null })); const live = engine.courts.map(c => ({ c, m: c.matchId ? t.match(c.matchId) : null }));
@@ -50,6 +50,22 @@ export function adminTournament({ t, engine }, who, origin, msg, err, events) {
<p class="muted">${t.rules.teamSize}s · to ${t.rules.pointsTo}, win by ${t.rules.winBy}${t.rules.cap ? `, cap ${t.rules.cap}` : ''}, best of ${t.rules.bestOf} · ${t.courtCount} court${t.courtCount > 1 ? 's' : ''} · <a href="/t/${esc(t.slug)}" target="_blank">public page</a> · <a href="/t/${esc(t.slug)}/display" target="_blank">TV display</a> · <a href="/admin/t/${esc(t.slug)}/qr">QR code</a></p> <p class="muted">${t.rules.teamSize}s · to ${t.rules.pointsTo}, win by ${t.rules.winBy}${t.rules.cap ? `, cap ${t.rules.cap}` : ''}, best of ${t.rules.bestOf} · ${t.courtCount} court${t.courtCount > 1 ? 's' : ''} · <a href="/t/${esc(t.slug)}" target="_blank">public page</a> · <a href="/t/${esc(t.slug)}/display" target="_blank">TV display</a> · <a href="/admin/t/${esc(t.slug)}/qr">QR code</a></p>
${flash(msg)}${flash(err, 'error')} ${flash(msg)}${flash(err, 'error')}
${s === 'live' ? `
<section class="card"><h2>Courts</h2>
<div class="grid2">${live.map(({ c, m }) => `
<div class="card" style="margin:0">
<div class="inline"><b>Court ${c.number}</b> <span class="pill">${esc(c.status)}${c.pauseReason ? ` · ${esc(c.pauseReason)}` : ''}</span>
${c.status === 'open' ? act('court', 'Pause', '', `<input type="hidden" name="court" value="${c.number}"><input type="hidden" name="op" value="pause"><input name="reason" placeholder="reason" style="width:110px">`) : act('court', 'Resume', 'primary', `<input type="hidden" name="court" value="${c.number}"><input type="hidden" name="op" value="resume">`)}
</div>
<p class="small"><a class="button small" href="${esc(keeperUrl(t, c))}" target="_blank">Score keeper</a> <button type="button" class="small" data-copy="${esc(keeperUrl(t, c))}">Copy link</button> <span class="muted">Hand this to whoever is scoring Court ${c.number}: it scores whatever match is on this court and finalizes it. Corrections stay here on the desk.</span></p>
${m ? scorePad(t, m) : '<p class="muted">No match assigned.</p>'}
</div>`).join('')}</div>
</section>
<section class="card"><h2>Queue</h2>
${queue.length ? `<table class="adm-table"><tr><th>#</th><th>Match</th><th>Predicted</th><th></th></tr>${queue.map((q, i) => `<tr><td>${i + 1}</td><td>${esc(t.teamName(q.match.teamA))} vs ${esc(t.teamName(q.match.teamB))} <span class="muted small">${q.match.stage === 'pool' ? `Pool ${esc(q.match.poolId)} R${q.match.round}` : esc(q.match.label ?? `Bracket R${q.match.round}`)}</span></td><td class="mono">C${q.court} ~${Math.round(q.eta / 60000)}m</td><td>${act('pushback', 'Push back', '', `<input type="hidden" name="match" value="${esc(q.match.id)}">`)}</td></tr>`).join('')}</table>` : '<p class="muted">Nothing waiting.</p>'}
</section>` : ''}
<section class="card"><h2>Phase</h2> <section class="card"><h2>Phase</h2>
<div class="phases"> <div class="phases">
${act('phase', 'Check-in (registration open)', s === 'checkin' ? 'cur' : '', '<input type="hidden" name="phase" value="checkin">')} ${act('phase', 'Check-in (registration open)', s === 'checkin' ? 'cur' : '', '<input type="hidden" name="phase" value="checkin">')}
@@ -62,20 +78,6 @@ export function adminTournament({ t, engine }, who, origin, msg, err, events) {
<form method="post" action="/admin/t/${esc(t.slug)}/broadcast" class="inline"><input name="text" placeholder="One-time announcement to every team page" style="flex:1;min-width:240px"><button class="small" type="submit">Broadcast</button></form> <form method="post" action="/admin/t/${esc(t.slug)}/broadcast" class="inline"><input name="text" placeholder="One-time announcement to every team page" style="flex:1;min-width:240px"><button class="small" type="submit">Broadcast</button></form>
</section> </section>
${s === 'live' ? `
<section class="card"><h2>Courts</h2>
<div class="grid2">${live.map(({ c, m }) => `
<div class="card" style="margin:0">
<div class="inline"><b>Court ${c.number}</b> <span class="pill">${esc(c.status)}${c.pauseReason ? ` · ${esc(c.pauseReason)}` : ''}</span>
${c.status === 'open' ? act('court', 'Pause', '', `<input type="hidden" name="court" value="${c.number}"><input type="hidden" name="op" value="pause"><input name="reason" placeholder="reason" style="width:110px">`) : act('court', 'Resume', 'primary', `<input type="hidden" name="court" value="${c.number}"><input type="hidden" name="op" value="resume">`)}
</div>
${m ? scorePad(t, m) : '<p class="muted">No match assigned.</p>'}
</div>`).join('')}</div>
</section>
<section class="card"><h2>Queue</h2>
${queue.length ? `<table class="adm-table"><tr><th>#</th><th>Match</th><th>Predicted</th><th></th></tr>${queue.map((q, i) => `<tr><td>${i + 1}</td><td>${esc(t.teamName(q.match.teamA))} vs ${esc(t.teamName(q.match.teamB))} <span class="muted small">${q.match.stage === 'pool' ? `Pool ${esc(q.match.poolId)} R${q.match.round}` : esc(q.match.label ?? `Bracket R${q.match.round}`)}</span></td><td class="mono">C${q.court} ~${Math.round(q.eta / 60000)}m</td><td>${act('pushback', 'Push back', '', `<input type="hidden" name="match" value="${esc(q.match.id)}">`)}</td></tr>`).join('')}</table>` : '<p class="muted">Nothing waiting.</p>'}
</section>` : ''}
<section class="card"><h2>Play</h2> <section class="card"><h2>Play</h2>
${!generated ? ` ${!generated ? `
<form method="post" action="/admin/t/${esc(t.slug)}/generate" class="inline"> <form method="post" action="/admin/t/${esc(t.slug)}/generate" class="inline">
@@ -159,3 +161,9 @@ export function qrPage(t, origin, dataUrl, who) {
<section class="card qr"><h1>${esc(t.name)}</h1><p class="lede">Scan to register, then to follow the tournament live.</p><img src="${dataUrl}" alt="QR code for ${esc(url)}"><p class="mono">${esc(url)}</p> <section class="card qr"><h1>${esc(t.name)}</h1><p class="lede">Scan to register, then to follow the tournament live.</p><img src="${dataUrl}" alt="QR code for ${esc(url)}"><p class="mono">${esc(url)}</p>
<p><button onclick="print()">Print</button> <a class="button" href="/admin/t/${esc(t.slug)}">Back to desk</a></p></section>` }); <p><button onclick="print()">Print</button> <a class="button" href="/admin/t/${esc(t.slug)}">Back to desk</a></p></section>` });
} }
/** Courtside Manager: the score keeper page for one court. Everything is rendered client-side from state. */
export function keeperPage(t, court, k, state) {
const body = `<div id="keeper" class="keeper" data-court="${court}" data-k="${esc(k)}"><div class="loading">Loading Court ${court}…</div></div>`;
return layout({ title: `Court ${court} score keeper`, body, state, script: '/static/keeper.js', bodyClass: 'keeper-body' });
}
+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>
+11
View File
@@ -31,6 +31,17 @@ c -o /dev/null -H 'content-type: application/json' -X POST "$BASE/admin/t/$SLUG/
curl -s "$BASE/t/$SLUG/state.json" | grep -q '"live":\[18,14\]' || fail "live score not on board" curl -s "$BASE/t/$SLUG/state.json" | grep -q '"live":\[18,14\]' || fail "live score not on board"
c -H 'content-type: application/json' -X POST "$BASE/admin/t/$SLUG/score" -d "{\"match\":\"$M\",\"sets\":\"21-14\"}" | grep -q '"ok":true' || fail "final score" c -H 'content-type: application/json' -X POST "$BASE/admin/t/$SLUG/score" -d "{\"match\":\"$M\",\"sets\":\"21-14\"}" | grep -q '"ok":true' || fail "final score"
c -H 'content-type: application/json' -X POST "$BASE/admin/t/$SLUG/score" -d "{\"match\":\"$M\",\"sets\":\"21-20\"}" | grep -q 'win by 2' || fail "bad score accepted" c -H 'content-type: application/json' -X POST "$BASE/admin/t/$SLUG/score" -d "{\"match\":\"$M\",\"sets\":\"21-20\"}" | grep -q 'win by 2' || fail "bad score accepted"
echo "· score keeper link scores and finalizes court 2"
KURL=$(c "$BASE/admin/t/$SLUG" | grep -oE "/keeper/$SLUG/2\?k=[A-Za-z0-9_-]+" | head -1); [ -n "$KURL" ] || fail "no keeper link on desk"
K=${KURL#*k=}
M2=$(curl -s "$BASE/t/$SLUG/state.json" | node -e 'const s=JSON.parse(require("fs").readFileSync(0));console.log(s.courts[1].match.id)')
[ "$(curl -s -o /dev/null -w '%{http_code}' "$BASE$KURL")" = 200 ] || fail "keeper page"
[ "$(curl -s -o /dev/null -w '%{http_code}' "$BASE/keeper/$SLUG/2?k=bad")" = 403 ] || fail "bad keeper token accepted"
curl -s -H 'content-type: application/json' -X POST "$BASE/keeper/$SLUG/2/live" -d "{\"k\":\"$K\",\"match\":\"$M2\",\"a\":20,\"b\":19,\"sets\":[]}" | grep -q '"ok":true' || fail "keeper live"
curl -s "$BASE/t/$SLUG/state.json" | grep -q '"live":\[20,19\]' || fail "keeper live score not on board"
curl -s -H 'content-type: application/json' -X POST "$BASE/keeper/$SLUG/2/final" -d "{\"k\":\"$K\",\"match\":\"$M2\",\"sets\":[[21,20]]}" | grep -q 'win by 2' || fail "keeper accepted invalid final"
curl -s -H 'content-type: application/json' -X POST "$BASE/keeper/$SLUG/2/final" -d "{\"k\":\"$K\",\"match\":\"$M2\",\"sets\":[[22,20]]}" | grep -q '"ok":true' || fail "keeper final"
curl -s -H 'content-type: application/json' -X POST "$BASE/keeper/$SLUG/2/final" -d "{\"k\":\"$K\",\"match\":\"$M2\",\"sets\":[[21,10]]}" | grep -q '"ok":false' || fail "keeper could re-finalize"
echo "· play out all pool matches" echo "· play out all pool matches"
for i in $(seq 1 20); do for i in $(seq 1 20); do
NEXT=$(curl -s "$BASE/t/$SLUG/state.json" | node -e 'const s=JSON.parse(require("fs").readFileSync(0));const c=s.courts.find(c=>c.match);console.log(c?c.match.id:"")') NEXT=$(curl -s "$BASE/t/$SLUG/state.json" | node -e 'const s=JSON.parse(require("fs").readFileSync(0));const c=s.courts.find(c=>c.match);console.log(c?c.match.id:"")')