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

@@ -332,23 +332,29 @@ const SEEDS = [
// single absent variable renders nothing, in both parts). A standalone event
// has no `seriesName` and its line disappears rather than reading "Part of .".
//
// **No `{{actionUrl}}` and no button, deliberately.** There is no public event
// page until Phase 14, so the six public triggers declare no `url` variable at
// all (see `coreTriggers.js`), and a button here would render as an inert grey
// label in every mail — worse than none, because it advertises a link the
// reader cannot follow. Phase 14 adds the variable and the block together.
// **The button arrived with the page it points at** (Phase 14a). Until then
// there was no public event page, the six public triggers declared no url
// variable, and a button here would have rendered as an inert grey label in
// every mail — worse than no button, because it advertises a link the reader
// cannot follow. `eventUrl` is optional and `email.button` drops itself when
// its url interpolates to nothing, so an event that is not public still mails
// correctly: the block disappears rather than degrading.
{
key: 'notify.event-started',
name: 'Event starting',
channel: 'email',
protected: false,
seedVersion: 1,
// Bumped with the button. A deployment whose operator has not customized
// this template gets the new one; one that has is left alone and reported as
// stale, which is the whole mechanism.
seedVersion: 2,
subject: '{{title}} is starting',
variables: [
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion' },
{ name: 'summary', type: 'string', required: false, example: 'Orcish warbands are massing north of Yew.' },
{ name: 'seriesName', type: 'string', required: false, example: 'The Yew Campaign' },
{ name: 'startsAtLabel', type: 'string', required: false, example: 'Saturday 12 September at 8:00 pm (America/New_York)' },
{ name: 'eventUrl', type: 'string', required: false, example: '/site/events/the-yew-invasion?run=3692' },
{ name: 'unsubscribeUrl', type: 'string', required: false, example: 'https://example.com/unsubscribe/abc123' },
],
blocks: [
@@ -356,6 +362,7 @@ const SEEDS = [
text('summary', '{{summary}}'),
text('when', '{{startsAtLabel}}', { muted: true }),
text('series', '{{seriesName}}', { muted: true }),
button('open', 'Read more', '{{eventUrl}}', 'Read more about it here:'),
divider('rule'),
button('unsub', 'Unsubscribe', '{{unsubscribeUrl}}', 'To stop these emails, use this link:'),
],