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

@@ -92,10 +92,32 @@ async function baseFor(run) {
summary: definition.summary || undefined,
seriesName: definition.series_name || undefined,
timezone: run.timezone || definition.timezone || undefined,
eventUrl: eventUrl(definition, run),
definition,
}
}
/**
* The public page for one occurrence (Phase 14a).
*
* **Site-relative, and it carries the run.** The page lives at the definition's
* slug — one stable address for a weekly event, which is what makes a link in
* Discord survive a retitle — so the occurrence has to be in the query string or
* a mail about last Friday's invasion would open next Friday's.
*
* **`undefined` when the event is not public**, rather than a path that answers
* 404. An unlisted or not-yet-`ready` definition has no page, and `eventUrl` is
* declared optional precisely so its block can disappear from a template instead
* of rendering a dead button. That is `news.post`'s lesson applied before it
* costs anything: a link nobody can follow is worse than no link, because it
* advertises one.
*/
function eventUrl(definition, run) {
if (!definition.slug) return undefined
if (definition.state !== 'ready' || !definition.listed) return undefined
return `/site/events/${encodeURIComponent(definition.slug)}?run=${run.id}`
}
/**
* Fire one lifecycle trigger.
*