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

@@ -124,6 +124,15 @@ async function validate(input, { existing = null } = {}) {
const seriesOrder = Number(seriesOrderRaw)
if (!Number.isInteger(seriesOrder)) errors.push('seriesOrder must be an integer')
// Whether this event is announced on the public calendar (Phase 14a). It is
// NOT whether it may run: `state` answers that, and the two are separate
// precisely because publishing is what makes a definition runnable — an
// unlisted event still schedules, still runs and is still on the admin
// calendar. A missing key means "leave it as it was", and a NEW definition
// defaults to listed, which is the column's own default and the ordinary
// case; unlisting is the deliberate act.
const listed = body.listed === undefined ? (existing ? Boolean(existing.listed) : true) : Boolean(body.listed)
// ── the spec ──
const rawSpec = body.spec === undefined ? existing?.spec ?? spec.emptySpec() : body.spec
const known = existing?.spec ? spec.actionIdsIn(existing.spec) : []
@@ -159,6 +168,7 @@ async function validate(input, { existing = null } = {}) {
concurrency_key: concurrencyKey,
grace_seconds: graceSeconds,
timezone,
listed,
spec: checked.spec,
},
}