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

@@ -17,6 +17,9 @@ import FiveOnFriday from './routes/public/FiveOnFriday.jsx'
import Newsletter from './routes/public/Newsletter.jsx'
import NewsletterIssue from './routes/public/NewsletterIssue.jsx'
import About from './routes/public/About.jsx'
import Events from './routes/public/Events.jsx'
import EventPage from './routes/public/EventPage.jsx'
import EventSeries from './routes/public/EventSeries.jsx'
import Status from './routes/public/Status.jsx'
import Wiki from './routes/wiki/Wiki.jsx'
import WikiArticle from './routes/wiki/WikiArticle.jsx'
@@ -74,6 +77,7 @@ import PlayerNotifications from './routes/player/PlayerNotifications.jsx'
import PlayerInbox from './routes/player/PlayerInbox.jsx'
import Unsubscribe from './routes/player/Unsubscribe.jsx'
import PlayerAppeals from './routes/player/PlayerAppeals.jsx'
import PlayerEvents from './routes/player/PlayerEvents.jsx'
export default function App() {
return (
@@ -107,6 +111,17 @@ export default function App() {
<Route path="/site/newsletter" element={<Newsletter />} />
<Route path="/site/newsletter/:id" element={<NewsletterIssue />} />
<Route path="/site/about" element={<About />} />
{/* Events (Phase 14a). `series/:slug` is declared before `:slug`
although it could not be shadowed by it — two segments against
one. It stays above because the ranking surprise this feature
has already shipped once was exactly here: a static segment
outranks a dynamic one whatever the source order, which is what
made `/admin/events/new` unreachable from Phase 6 to Phase 13.
Nothing static shares a segment with `:slug`, so nothing here
repeats it. */}
<Route path="/site/events" element={<Events />} />
<Route path="/site/events/series/:slug" element={<EventSeries />} />
<Route path="/site/events/:slug" element={<EventPage />} />
<Route path="/site/status" element={<Status />} />
<Route path="/wiki" element={<Wiki />} />
<Route path="/wiki/:slug" element={<WikiArticle />} />
@@ -290,6 +305,11 @@ export default function App() {
<Route path="/player" element={<PlayerIndex />} />
<Route path="/account" element={<PlayerAccount />} />
<Route path="/account/appeals" element={<PlayerAppeals />} />
{/* Participation history (Phase 14a). Under /account rather than
/player because it is role-agnostic self-service: staff are a
superset of players and an admin reading their own attendance
is as ordinary as anyone else doing it. */}
<Route path="/account/events" element={<PlayerEvents />} />
{/* The inbox took `/account/notifications` in engagement Phase 7
and the preferences screen moved under it. Content and
settings are different kinds of thing, and the plain word