Scoring rules editable from the desk until the first score

Release 0.6.2.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01MB7nCCAscYsb3zzkT6LHZi
This commit is contained in:
bkvargyasandClaude Fable 5.1 committed 2026-09-06 15:56:30 +00:00
1 parent 110077314d
commit 1fb31c5eec
5 files changed
+25 -3

No files matched your search

+7
View File
@@ -9,6 +9,13 @@ so you can always tell what a deployment is actually serving.
## [Unreleased]
## [0.6.2] - 2026-09-03
### Added
- Scoring rules (points to, win by, cap, best of) can be changed from the desk's Settings
area until the first score is entered, e.g. switching a set-up tournament to 15, win by 2,
cap 17. The tournament name is editable the same way.
## [0.6.1] - 2026-09-03
### Fixed
+1 -1
View File
@@ -76,7 +76,7 @@ All organizer routes require the session cookie set by `/admin/login` (or Cloudf
| GET | `/admin/t/<slug>/qr` | | Printable QR page. `qr.png` returns the PNG. |
| GET | `/admin/t/<slug>/keeper-qr` | | Printable page with one score keeper QR code per court. |
| POST | `/admin/t/<slug>/phase` | `phase` = `checkin` / `closed` / `live` / `final` | Change phase. `live` requires generated play and starts court assignment. |
| POST | `/admin/t/<slug>/settings` | `date`, `timezone` (IANA name, e.g. `America/Chicago`), `notes` | Edit tournament settings. All clock times on the board and desk are shown in this zone. |
| POST | `/admin/t/<slug>/settings` | `date`, `timezone` (IANA name, e.g. `America/Chicago`), `notes`, `name`; and until the first score: `pointsTo`, `winBy`, `cap` (0 = none), `bestOf`, `teamSize` | Edit tournament settings and scoring rules. All clock times are shown in the zone. |
| POST | `/admin/t/<slug>/banner` | `banner` | Set (or clear with empty) the persistent banner. |
| POST | `/admin/t/<slug>/broadcast` | `text` | One-time alert to all team pages. |
| POST | `/admin/t/<slug>/generate` | `what=pools`, `poolSize` — or `what=bracket`, `perPool`, `wildcards`, `type` (`single`/`double`), `thirdPlace`, `byAge` (bracket-only: age-matched round 1, random where no age) — or `what=manual`, `type`, `thirdPlace`, `a1`,`b1`,`a2`,`b2`,… (team ids per round-1 match; empty = bye) | Generate pools or bracket, automatically or from hand-picked pairs. |
+1 -1
View File
@@ -1 +1 @@
{"name":"courtside","type":"module","version":"0.6.1","scripts":{"test":"node --test test/*.test.js","sim":"node simulate.js","start":"node --disable-warning=ExperimentalWarning server/index.js","dev":"node --disable-warning=ExperimentalWarning --watch server/index.js","demo":"node --disable-warning=ExperimentalWarning scripts/seed-demo.js","smoke":"bash test/smoke.sh"},"description":"Self-hosted volleyball tournament desk: one QR code from check-in to live scores","main":"server/index.js","directories":{"test":"test"},"keywords":[],"author":"","license":"MIT","dependencies":{"qrcode":"^1.5.4","ws":"^8.21.3"},"engines":{"node":">=22.13"},"repository":{"type":"git","url":"https://gitea.cloudfreeiot.com/bkvargyas/courtside.git"}}
{"name":"courtside","type":"module","version":"0.6.2","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"}}
+15 -1
View File
@@ -170,7 +170,21 @@ export function buildRouter({ registry, auth, store }) {
if (body.timezone !== undefined) { if (body.timezone && !validTz(body.timezone)) throw new Error('Unknown time zone'); t.timezone = body.timezone || null; }
if (body.date !== undefined) t.date = String(body.date).trim() || null;
if (body.notes !== undefined) t.notes = String(body.notes).slice(0, 500);
t.emit({ type: 'settings', timezone: t.timezone, date: t.date });
if (body.name !== undefined && String(body.name).trim()) t.name = String(body.name).trim().slice(0, 60);
// scoring rules can change until the first score is on the books
if (['pointsTo', 'winBy', 'cap', 'bestOf', 'teamSize'].some(k => body[k] !== undefined)) {
if (t.matches.some(m => ['final', 'forfeit'].includes(m.status))) throw new Error('Scores have been entered; rules are locked');
const r = { ...t.rules };
if (body.pointsTo !== undefined) r.pointsTo = +body.pointsTo;
if (body.winBy !== undefined) r.winBy = +body.winBy;
if (body.cap !== undefined) r.cap = +body.cap || 0;
if (body.bestOf !== undefined) r.bestOf = +body.bestOf;
if (body.teamSize !== undefined) r.teamSize = +body.teamSize;
if (!(r.pointsTo >= 5 && r.pointsTo <= 50) || !(r.winBy >= 1 && r.winBy <= 5) || ![1, 3, 5].includes(r.bestOf) || !(r.teamSize >= 1 && r.teamSize <= 6)) throw new Error('Rules out of range');
if (r.cap && r.cap < r.pointsTo) throw new Error('Cap must be at least points-to (or 0 for no cap)');
t.rules = r;
}
t.emit({ type: 'settings', timezone: t.timezone, date: t.date, rules: t.rules });
return 'Settings saved';
});
action('/banner', ({ t, body }) => { t.banner = String(body.banner ?? '').trim().slice(0, 200) || null; t.emit({ type: 'banner', banner: t.banner }); return t.banner ? 'Banner set' : 'Banner cleared'; });
+1
View File
@@ -81,6 +81,7 @@ export function adminTournament({ t, engine }, who, origin, msg, err, events, ke
</div>
<p class="muted small">The public QR page follows this. Going live starts assigning courts immediately.</p>
<form method="post" action="/admin/t/${esc(t.slug)}/settings" class="inline"><label>Date <input name="date" type="date" value="${esc(t.date ?? '')}"></label><label>Time zone ${tzSelect(tzOf(t))}</label><input name="notes" placeholder="Notes shown on the registration page" value="${esc(t.notes ?? '')}" style="flex:1;min-width:200px"><button class="small" type="submit">Save settings</button></form>
${!t.matches.some(m => ['final', 'forfeit'].includes(m.status)) ? `<form method="post" action="/admin/t/${esc(t.slug)}/settings" class="inline"><span class="muted small">Scoring</span><label>Points to <input name="pointsTo" type="number" value="${t.rules.pointsTo}" min="5" max="50" style="width:64px"></label><label>Win by <input name="winBy" type="number" value="${t.rules.winBy}" min="1" max="5" style="width:56px"></label><label>Cap <input name="cap" type="number" value="${t.rules.cap || 0}" min="0" max="60" style="width:64px" title="0 = no cap"></label><label>Best of <select name="bestOf">${[1, 3, 5].map(n => `<option ${t.rules.bestOf === n ? 'selected' : ''}>${n}</option>`).join('')}</select></label><button class="small" type="submit">Save scoring</button><span class="muted small">Locked once the first score is entered.</span></form>` : ''}
<form method="post" action="/admin/t/${esc(t.slug)}/banner" class="inline"><input name="banner" placeholder="Banner on every public page (leave empty to clear)" value="${esc(t.banner ?? '')}" style="flex:1;min-width:240px"><button class="small" type="submit">Set banner</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>