Per-tournament time zone for all clock times (Central by default)

Release 0.6.1.

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 03:54:48 +00:00
1 parent 103c221a12
commit aaa3004612
10 files changed
+40 -11

No files matched your search

+8
View File
@@ -9,6 +9,14 @@ so you can always tell what a deployment is actually serving.
## [Unreleased]
## [0.6.1] - 2026-09-03
### Fixed
- Clock times (estimated finish, "ready ~", desk activity log) are shown in the tournament's
time zone rather than the viewer's device or the server's zone. Each tournament has a time
zone (Central by default, `DEFAULT_TIMEZONE` to change the default), chosen on creation and
editable from the desk's new Settings row along with the date and notes.
## [0.6.0] - 2026-09-03
### Added
+2 -1
View File
@@ -71,11 +71,12 @@ All organizer routes require the session cookie set by `/admin/login` (or Cloudf
| POST | `/admin/login` | `password`, `next` | Sets cookie, redirects to `next`. |
| GET | `/admin/logout` | | Clears cookie. |
| GET | `/admin` | | Desk home: tournament list and creation form. |
| POST | `/admin/new` | `name`, `date`, `courtCount`, `teamSize`, `pointsTo`, `winBy`, `cap` (0 = none), `bestOf`, `stages` (`pool,bracket` / `pool` / `bracket`), `notes` | Creates a tournament. |
| POST | `/admin/new` | `name`, `date`, `courtCount`, `teamSize`, `pointsTo`, `winBy`, `cap` (0 = none), `bestOf`, `stages` (`pool,bracket` / `pool` / `bracket`), `timezone`, `notes` | Creates a tournament. |
| GET | `/admin/t/<slug>` | | Tournament desk. |
| 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>/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
View File
@@ -70,6 +70,7 @@ Environment variables the app reads:
| `ADMIN_PASSWORD` | unset | Organizer password for `/admin/login`. |
| `SESSION_SECRET` | random per start | HMAC key for the organizer cookie. Set it so logins survive restarts. |
| `TRUST_CF_ACCESS` | `false` | Accept Cloudflare Access identity header as organizer login. |
| `DEFAULT_TIMEZONE` | `America/Chicago` | Time zone used for clock times when a tournament doesn't set its own. |
The app trusts `X-Forwarded-Proto` and `X-Forwarded-Host` (which cloudflared sets) to build the absolute URLs used in QR codes and team links. If you put a different reverse proxy in front, make sure it sets those headers.
+2 -2
View File
@@ -382,7 +382,7 @@ export class Tournament {
toJSON() {
return {
name: this.name, slug: this.slug ?? null, date: this.date ?? null, notes: this.notes ?? null,
rules: this.rules, courtCount: this.courtCount, stages: this.stages, phase: this.phase,
rules: this.rules, courtCount: this.courtCount, stages: this.stages, phase: this.phase, timezone: this.timezone ?? null,
teams: [...this.teams.values()], matches: this.matches, pools: this.pools, log: this.log.slice(-500), seq: this._seq,
banner: this.banner ?? null, createdAt: this.createdAt ?? null,
};
@@ -390,7 +390,7 @@ export class Tournament {
static fromJSON(j) {
const t = new Tournament({ name: j.name, rules: j.rules, courtCount: j.courtCount, stages: j.stages });
Object.assign(t, { slug: j.slug, date: j.date, notes: j.notes, phase: j.phase, matches: j.matches, pools: j.pools, log: j.log ?? [], _seq: j.seq ?? 1, banner: j.banner ?? null, createdAt: j.createdAt ?? null });
Object.assign(t, { slug: j.slug, date: j.date, notes: j.notes, timezone: j.timezone ?? null, phase: j.phase, matches: j.matches, pools: j.pools, log: j.log ?? [], _seq: j.seq ?? 1, banner: j.banner ?? null, createdAt: j.createdAt ?? null });
t.teams = new Map((j.teams ?? []).map(x => [x.id, x]));
return t;
}
+1 -1
View File
@@ -1 +1 @@
{"name":"courtside","type":"module","version":"0.6.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.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"}}
+5 -1
View File
@@ -1,4 +1,8 @@
// The one JSON document every public page renders from. No phone numbers, no team codes.
export const DEFAULT_TZ = process.env.DEFAULT_TIMEZONE || 'America/Chicago';
export function validTz(tz) { try { new Intl.DateTimeFormat('en-US', { timeZone: tz }); return tz; } catch { return null; } }
export const tzOf = t => validTz(t.timezone) ?? DEFAULT_TZ;
export const fmtTime = (ms, tz) => new Date(ms).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', timeZone: tz });
export function publicState({ t, engine }) {
const name = id => t.teamName(id);
const view = m => ({
@@ -17,7 +21,7 @@ export function publicState({ t, engine }) {
const teams = t.activeTeams().map(x => t.publicTeam(x.id));
const withdrawn = [...t.teams.values()].filter(x => x.status === 'withdrawn').map(x => t.publicTeam(x.id));
return {
slug: t.slug, name: t.name, date: t.date, notes: t.notes ?? '', phase: t.phase, banner: t.banner ?? null,
slug: t.slug, name: t.name, date: t.date, notes: t.notes ?? '', timezone: tzOf(t), phase: t.phase, banner: t.banner ?? null,
rules: t.rules, stages: t.stages, courtCount: t.courtCount,
teams, withdrawn, pools, bracket,
courts: board ? board.courts.map(c => ({ ...c, match: c.match ? view(t.match(c.match.id)) : null })) : [],
+2 -1
View File
@@ -14,7 +14,8 @@
const wdClass = t => t && withdrawnIds().has(t.id) ? 'wd' : '';
const forfeitTag = m => m.forfeitBy ? `<span class="btag ff">Forfeit · ${esc(state.withdrawn.concat(state.teams).find(t => t.id === m.forfeitBy)?.name ?? '')}</span>` : '';
const fromName = (t, from) => t ? name(t) : from ? `<span class="tbd">${from.loser ? 'loser' : 'winner'} on Court ${from.court}</span>` : '<span class="tbd">TBD</span>';
const clock = ms => new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
// clock times are shown in the tournament's time zone, not the viewer's device zone
const clock = ms => { try { return new Date(ms).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', timeZone: state.timezone || undefined }); } catch { return new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); } };
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 score = m => m.status === 'final' ? m.sets.map(s => `${s[0]}–${s[1]}`).join(', ')
+9 -2
View File
@@ -1,6 +1,6 @@
import QRCode from 'qrcode';
import { Router, HttpError, readBody, send, esc, sign, verify } from './http.js';
import { publicState } from './public-state.js';
import { publicState, validTz } from './public-state.js';
import { VERSION } from './version.js';
import * as pub from './views/public.js';
import * as adm from './views/admin.js';
@@ -113,7 +113,7 @@ export function buildRouter({ registry, auth, store }) {
const who = requireAdmin(req, res); if (!who) return;
const b = await readBody(req);
const rules = { teamSize: +b.teamSize || 2, pointsTo: +b.pointsTo || 21, winBy: +b.winBy || 2, cap: +b.cap || 0, bestOf: +b.bestOf || 1 };
const it = registry.create({ name: b.name, date: b.date, courtCount: +b.courtCount || 2, rules, stages: String(b.stages ?? 'pool,bracket').split(','), notes: b.notes ?? '' });
const it = registry.create({ name: b.name, date: b.date, courtCount: +b.courtCount || 2, rules, stages: String(b.stages ?? 'pool,bracket').split(','), notes: b.notes ?? '', timezone: validTz(b.timezone) });
send.redirect(res, `/admin/t/${it.t.slug}?msg=${encodeURIComponent('Created. Print the QR code and open registration when ready.')}`);
});
@@ -166,6 +166,13 @@ export function buildRouter({ registry, auth, store }) {
if (p === 'final') { t.phase = 'final'; t.emit({ type: 'phase', phase: 'final', champion: t.champion() }); return 'Marked final'; }
throw new Error('Unknown phase');
});
action('/settings', ({ t, body }) => {
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 });
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'; });
action('/broadcast', ({ t, engine, body }) => { const text = String(body.text ?? '').trim().slice(0, 300); if (!text) throw new Error('Nothing to send'); engine.broadcast(text); return 'Broadcast sent'; });
action('/generate', ({ t, body }) => {
+2 -2
View File
@@ -30,12 +30,12 @@ export class Registry {
list() { return [...this.items.entries()].map(([slug, { t }]) => ({ slug, name: t.name, date: t.date, phase: t.phase, teams: t.activeTeams().length, createdAt: t.createdAt })); }
get(slug) { return this.items.get(slug) ?? null; }
create({ name, date, courtCount = 2, rules = {}, stages = ['pool', 'bracket'], notes = '', slug: wanted = null }) {
create({ name, date, courtCount = 2, rules = {}, stages = ['pool', 'bracket'], notes = '', timezone = null, slug: wanted = null }) {
const base = slugify(wanted ?? name);
let slug = base, n = 2;
while (this.items.has(slug)) slug = `${base}-${n++}`;
const t = new Tournament({ name, courtCount: Number(courtCount), rules, stages });
t.slug = slug; t.date = date || null; t.notes = notes; t.createdAt = Date.now();
t.slug = slug; t.date = date || null; t.notes = notes; t.timezone = timezone || null; t.createdAt = Date.now();
const engine = new CourtEngine(t, { courtCount: t.courtCount });
this._attach(slug, t, engine);
this.persist(slug);
+8 -1
View File
@@ -1,6 +1,11 @@
import { esc } from '../http.js';
import { layout, flash } from './layout.js';
import { phaseLabel } from './public.js';
import { tzOf, fmtTime } from '../public-state.js';
const TZS = ['America/New_York', 'America/Chicago', 'America/Denver', 'America/Phoenix', 'America/Los_Angeles', 'America/Anchorage', 'Pacific/Honolulu', 'Europe/London', 'Europe/Riga', 'UTC'];
const tzLabel = z => ({ 'America/New_York': 'Eastern', 'America/Chicago': 'Central', 'America/Denver': 'Mountain', 'America/Phoenix': 'Arizona', 'America/Los_Angeles': 'Pacific', 'America/Anchorage': 'Alaska', 'Pacific/Honolulu': 'Hawaii', 'Europe/London': 'UK', 'Europe/Riga': 'Latvia', UTC: 'UTC' }[z] ?? z);
export const tzSelect = (cur) => `<select name="timezone">${TZS.map(z => `<option value="${z}" ${z === cur ? 'selected' : ''}>${tzLabel(z)} (${z})</option>`).join('')}</select>`;
export function loginPage(error = null, next = '/admin') {
return layout({ title: 'Organizer sign in', body: `
@@ -28,6 +33,7 @@ export function adminHome(list, who, msg) {
<label>Cap <input name="cap" type="number" value="25" min="0" max="60" title="0 = no cap"></label>
<label>Best of <select name="bestOf"><option>1</option><option>3</option></select></label></div>
<label>Stages <select name="stages"><option value="pool,bracket">Pools, then bracket</option><option value="pool">Pools only</option><option value="bracket">Bracket only</option></select></label>
<label>Time zone ${tzSelect('America/Chicago')}</label>
<label>Notes for players <textarea name="notes" rows="2" placeholder="Bring water. Rec division starts at 10."></textarea></label>
<button class="primary" type="submit">Create</button>
</form></section>
@@ -74,6 +80,7 @@ export function adminTournament({ t, engine }, who, origin, msg, err, events, ke
${act('phase', 'Final', s === 'final' ? 'cur' : '', '<input type="hidden" name="phase" value="final">')}
</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>
<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>
@@ -117,7 +124,7 @@ export function adminTournament({ t, engine }, who, origin, msg, err, events, ke
${!generated ? `<form method="post" action="/admin/t/${esc(t.slug)}/team" class="inline"><input type="hidden" name="op" value="add"><input name="name" placeholder="Walk-up team name" required maxlength="40"><input name="captain" placeholder="captain"><input name="players" type="number" value="${t.rules.teamSize}" min="1" style="width:64px"><input name="avgAge" type="number" placeholder="avg age" min="5" max="99" style="width:84px"><button class="small primary" type="submit">Add team</button></form>` : '<p class="muted small">Play is generated: use Withdraw for teams that leave. To add a team now, clear generated play first.</p>'}
</section>
<section class="card"><h2>Recent activity</h2><ul class="plain small">${events.slice(0, 40).map(e => `<li><span class="mono muted">${new Date(e.ts).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}</span> ${esc(describe(e, t))}</li>`).join('')}</ul></section>
<section class="card"><h2>Recent activity</h2><ul class="plain small">${events.slice(0, 40).map(e => `<li><span class="mono muted">${fmtTime(e.ts, tzOf(t))}</span> ${esc(describe(e, t))}</li>`).join('')}</ul></section>
<section class="card"><h2>Danger zone</h2>${act('delete', 'Delete this tournament', 'danger', `<label class="small"><input type="checkbox" name="confirm" value="yes" required> I understand this cannot be undone</label> `)}</section>`;
return layout({ title: `${t.name} desk`, admin: who, body, script: '/static/admin.js' });
}