diff --git a/CHANGELOG.md b/CHANGELOG.md index 81f1beb..9d1229f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/API.md b/docs/API.md index baee0c1..e734141 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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/` | | Tournament desk. | | GET | `/admin/t//qr` | | Printable QR page. `qr.png` returns the PNG. | | GET | `/admin/t//keeper-qr` | | Printable page with one score keeper QR code per court. | | POST | `/admin/t//phase` | `phase` = `checkin` / `closed` / `live` / `final` | Change phase. `live` requires generated play and starts court assignment. | +| POST | `/admin/t//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//banner` | `banner` | Set (or clear with empty) the persistent banner. | | POST | `/admin/t//broadcast` | `text` | One-time alert to all team pages. | | POST | `/admin/t//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. | diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index a0c355b..16e8cbc 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -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. diff --git a/engine/tournament.js b/engine/tournament.js index 67c5993..bb0b524 100644 --- a/engine/tournament.js +++ b/engine/tournament.js @@ -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; } diff --git a/package.json b/package.json index e404bec..2cbe4d2 100644 --- a/package.json +++ b/package.json @@ -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"}} \ No newline at end of file +{"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"}} \ No newline at end of file diff --git a/server/public-state.js b/server/public-state.js index acf2c9d..15cd864 100644 --- a/server/public-state.js +++ b/server/public-state.js @@ -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 })) : [], diff --git a/server/public/board.js b/server/public/board.js index a2fb38b..971b898 100644 --- a/server/public/board.js +++ b/server/public/board.js @@ -14,7 +14,8 @@ const wdClass = t => t && withdrawnIds().has(t.id) ? 'wd' : ''; const forfeitTag = m => m.forfeitBy ? `Forfeit · ${esc(state.withdrawn.concat(state.teams).find(t => t.id === m.forfeitBy)?.name ?? '')}` : ''; const fromName = (t, from) => t ? name(t) : from ? `${from.loser ? 'loser' : 'winner'} on Court ${from.court}` : 'TBD'; - 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(', ') diff --git a/server/routes.js b/server/routes.js index 74efd56..5eca36c 100644 --- a/server/routes.js +++ b/server/routes.js @@ -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 }) => { diff --git a/server/state.js b/server/state.js index 0066d37..c2b561b 100644 --- a/server/state.js +++ b/server/state.js @@ -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); diff --git a/server/views/admin.js b/server/views/admin.js index fb6efaa..a94a7c5 100644 --- a/server/views/admin.js +++ b/server/views/admin.js @@ -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) => ``; export function loginPage(error = null, next = '/admin') { return layout({ title: 'Organizer sign in', body: ` @@ -28,6 +33,7 @@ export function adminHome(list, who, msg) { + @@ -74,6 +80,7 @@ export function adminTournament({ t, engine }, who, origin, msg, err, events, ke ${act('phase', 'Final', s === 'final' ? 'cur' : '', '')}

The public QR page follows this. Going live starts assigning courts immediately.

+
@@ -117,7 +124,7 @@ export function adminTournament({ t, engine }, who, origin, msg, err, events, ke ${!generated ? `
` : '

Play is generated: use Withdraw for teams that leave. To add a team now, clear generated play first.

'} -

Recent activity

    ${events.slice(0, 40).map(e => `
  • ${new Date(e.ts).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })} ${esc(describe(e, t))}
  • `).join('')}
+

Recent activity

    ${events.slice(0, 40).map(e => `
  • ${fmtTime(e.ts, tzOf(t))} ${esc(describe(e, t))}
  • `).join('')}

Danger zone

${act('delete', 'Delete this tournament', 'danger', ` `)}
`; return layout({ title: `${t.name} desk`, admin: who, body, script: '/static/admin.js' }); }