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
This commit is contained in:
2026-09-08 06:18:38 -05:00
parent 6e6c24065c
commit 1667e636bd
39 changed files with 3964 additions and 45 deletions

View File

@@ -232,6 +232,14 @@ export function formFromDefinition(event) {
concurrencyKey: event?.concurrencyKey || '',
graceSeconds: event?.graceSeconds ?? 900,
timezone: event?.timezone || 'UTC',
// Whether the public calendar announces it (Phase 14a). `?? true` rather
// than `|| true`: a definition an operator has deliberately unlisted sends
// `false`, and `||` would quietly re-list it on the next save.
listed: event?.listed ?? true,
// Whether the public calendar announces it (Phase 14a). `?? true` rather
// than `|| true`: a definition an operator has deliberately unlisted sends
// `false`, and `||` would quietly re-list it on the next save.
listed: event?.listed ?? true,
...scheduleFormFrom(spec.schedule),
phases: (spec.phases || []).map((p) => ({
key: p.key || '',
@@ -351,6 +359,8 @@ export function payloadFromForm(form, { triggersById = new Map() } = {}) {
concurrencyKey: form.concurrencyKey || null,
graceSeconds: Number(form.graceSeconds),
timezone: form.timezone,
listed: Boolean(form.listed),
listed: Boolean(form.listed),
spec: { schedule: scheduleFromForm(form), phases },
},
}

View File

@@ -0,0 +1,99 @@
// Rendering an event's instant, shared by the public event screens.
//
// **The split these two functions make is EVENTS.md §I's, and it is the one
// thing about event times that is easy to get wrong.** The server returns UTC
// instants and never guesses the reader's zone. The client places them:
//
// • the DAY an entry is filed under is the reader's own — "what is on this
// month" is a question about the month the person reading is living in;
// • the TIME beside it is always the EVENT's zone, carried on the entry —
// because every listing this feature replaces is written in the shard's
// local zone, and "8pm" means the shard's evening to everyone reading it.
//
// Rendering the time in the reader's zone instead would be defensible and is
// wrong here: a player in Berlin told an American shard's event is at "02:00"
// has been told something true and useless, and told it in a way that makes the
// shard's own announcement look like a mistake.
/** The event's own wall clock, with the zone named so it misreads as nothing. */
export function eventTime(instant, timezone) {
try {
const time = new Intl.DateTimeFormat(undefined, {
timeZone: timezone,
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
}).format(new Date(instant))
return `${time} ${shortZone(timezone)}`
} catch {
// An unknown IANA name throws rather than falling back, and an event whose
// timezone column holds a typo must still render. UTC off the instant is the
// honest answer when the zone cannot be honoured.
return `${new Date(instant).toISOString().slice(11, 16)} UTC`
}
}
/** The zone as a reader recognises it: `America/New_York` → `New York`. */
function shortZone(timezone) {
if (!timezone) return 'UTC'
const tail = String(timezone).split('/').pop()
return tail.replace(/_/g, ' ')
}
/** The reader's own day, for the heading an entry is filed under. */
export function readerDayLabel(instant) {
const d = new Date(instant)
if (Number.isNaN(d.getTime())) return ''
return new Intl.DateTimeFormat(undefined, {
weekday: 'long',
day: 'numeric',
month: 'long',
year: d.getFullYear() === new Date().getFullYear() ? undefined : 'numeric',
}).format(d)
}
/** The event's own day and time together, for a page that shows one occurrence. */
export function eventDateTime(instant, timezone) {
const d = new Date(instant)
if (Number.isNaN(d.getTime())) return ''
try {
return `${new Intl.DateTimeFormat(undefined, {
timeZone: timezone,
weekday: 'long',
day: 'numeric',
month: 'long',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
}).format(d)} ${shortZone(timezone)}`
} catch {
return `${d.toISOString().slice(0, 16).replace('T', ' ')} UTC`
}
}
// The word beside an entry, for the four public statuses.
//
// **`cancelled` needs the instant, and that is the whole reason this is a
// function rather than a lookup table.** The server publishes `failed` and
// `missed` as `cancelled` too — to a visitor those three are one event, and the
// difference between them is about the deployment — but the three do not share
// one English sentence. "Did not happen" is right for a past occurrence and a
// plain falsehood for a future one, and a run four days out that an operator has
// called off is exactly the common case: the calendar was saying *did not
// happen* about next Friday.
//
// So the tense follows the clock, not the status. A future call-off reads
// **Cancelled**; a past one reads **Did not happen**, which is also the honest
// word for the failed and missed runs folded in with it.
const WORDS = {
live: 'Happening now',
scheduled: 'Scheduled',
completed: 'Finished',
}
export function statusWord(status, scheduledFor, now = Date.now()) {
if (WORDS[status]) return WORDS[status]
if (status !== 'cancelled') return status
const at = new Date(scheduledFor).getTime()
return Number.isNaN(at) || at <= now ? 'Did not happen' : 'Cancelled'
}