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:
1 parent
103c221a12
commit
110077314d
10 files changed
+40
-11
No files matched your search
@@ -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 })) : [],
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user