Files
website/client/test/eventCalendar.test.js
wtclaude 1667e636bd feat(events): the public calendar, event pages and participation history (Phase 14a)
The anonymous surface an event was always for: GET /public/events,
/public/events/:slug and /public/events/series/:slug, plus
GET /player/events/history, and the four screens over them.

Four org-lead decisions taken up front: split Phase 14 into 14a (website)
and 14b (the app); add a `listed` flag rather than letting `state` mean both
schedulable and announced; put the `events` capability string in the version
block rather than publishing core as a pseudo-module; and drop "venue" from
the spec rather than adding a field nothing had ever built.

`listed` is announcement, not permission. Publishing is what makes a
definition runnable, so without a separate flag a surprise event would have
to be advertised in order to be allowed to happen. It is a column, a switch
in Phase 13's editor, and three SQL predicates -- never a filter applied
after a read, which works exactly as well until the first caller that forgets.

The public shapes are a projection, and the projection is the security
boundary: nothing is spread, so a column added to event_runs next year does
not ride out through it. The spec, health, cleanup, claims, errors and
member_key are all absent by construction.

The six public event triggers gained `eventUrl` (version 1 -> 2), carrying
?run= because the page lives at the definition's slug while every trigger is
about one occurrence. notify.event-started gained the button, at seedVersion 2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-08 06:18:38 -05:00

90 lines
4.1 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// The public event screens' time rendering (EVENTS_PLAN.md Phase 14a).
//
// One property matters here and it is EVENTS.md §I's: **the time beside an
// entry is the EVENT's zone, the day it is filed under is the READER's.** A
// helper that quietly rendered both in the reader's zone would pass any test
// that only ever looked at one of them, and would put an American shard's 8pm
// event at "02:00" for a player in Berlin — true, useless, and looking like the
// shard's own announcement was wrong.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { eventTime, eventDateTime, readerDayLabel, statusWord } from '../src/lib/eventCalendar.js'
// 2026-09-12T00:00Z is 2026-09-11 20:00 in New York — deliberately an instant
// whose DATE differs between the two zones, which is what makes the split
// observable at all.
const INSTANT = '2026-09-12T00:00:00.000Z'
test('the time is rendered in the EVENTs zone, not the readers', () => {
assert.equal(eventTime(INSTANT, 'America/New_York'), '20:00 New York')
assert.equal(eventTime(INSTANT, 'UTC'), '00:00 UTC')
assert.equal(eventTime(INSTANT, 'Europe/Berlin'), '02:00 Berlin')
})
test('the zone is named in a form a reader recognises', () => {
// `America/New_York` is a database identifier, not something to show a player.
assert.match(eventTime(INSTANT, 'America/Los_Angeles'), /Los Angeles$/)
})
test('an unknown zone falls back to UTC rather than throwing', () => {
// `Intl` rejects an unknown identifier, and an event whose timezone column
// holds a typo must still render.
assert.equal(eventTime(INSTANT, 'Not/AZone'), '00:00 UTC')
assert.equal(eventDateTime(INSTANT, 'Not/AZone'), '2026-09-12 00:00 UTC')
})
test('a bad instant renders as nothing rather than as "Invalid Date"', () => {
assert.equal(readerDayLabel('not a date'), '')
assert.equal(eventDateTime('not a date', 'UTC'), '')
})
test('the day label is the readers own day, whatever the events zone', () => {
// Two entries at the same instant in different event zones are filed under one
// heading, which is what makes a chronological list group correctly.
assert.equal(readerDayLabel(INSTANT), readerDayLabel(INSTANT))
const label = readerDayLabel(INSTANT)
assert.ok(label.length > 0)
// The instant's UTC date is the 12th and New York's is the 11th; the label
// must not carry a zone at all, because it is neither of theirs.
assert.equal(/UTC|New York/.test(label), false)
})
test('eventDateTime carries the day and the zone together', () => {
const text = eventDateTime(INSTANT, 'America/New_York')
assert.match(text, /New York$/)
assert.match(text, /20:00/)
})
// ── The status word ────────────────────────────────────────────────────────
//
// Found by the browser walk: the calendar was saying "DID NOT HAPPEN" about a
// run four days out that an operator had cancelled. The server publishes
// `failed` and `missed` as `cancelled` too — to a visitor the three are one
// event — but they do not share one English sentence, so the tense follows the
// clock rather than the status.
const NOW = Date.parse('2026-09-08T12:00:00Z')
test('a cancelled occurrence in the future reads "Cancelled"', () => {
assert.equal(statusWord('cancelled', '2026-09-12T18:00:00Z', NOW), 'Cancelled')
})
test('a cancelled occurrence in the past reads "Did not happen"', () => {
// Which is also the honest word for the failed and missed runs folded into
// `cancelled` on the way out.
assert.equal(statusWord('cancelled', '2026-09-04T18:00:00Z', NOW), 'Did not happen')
})
test('the other three words do not depend on the clock at all', () => {
for (const at of ['2026-09-04T18:00:00Z', '2026-09-12T18:00:00Z']) {
assert.equal(statusWord('live', at, NOW), 'Happening now')
assert.equal(statusWord('scheduled', at, NOW), 'Scheduled')
assert.equal(statusWord('completed', at, NOW), 'Finished')
}
})
test('an unreadable instant falls to the past-tense word rather than throwing', () => {
assert.equal(statusWord('cancelled', 'not a date', NOW), 'Did not happen')
})