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

@@ -75,6 +75,52 @@ async function listForRun(runId, limit = 500) {
return rows.map(hydrate)
}
/**
* One account's participation history, most recent event first (Phase 14a).
*
* **Joined all the way out to the definition, and the join is the access
* control.** A rehearsal is excluded by §D's own rule, and an unlisted
* definition is excluded because unlisting is what an operator does to an event
* they are not announcing — a history that named it would announce it to
* everyone who attended, which is everyone who could tell anybody.
*
* `member_key` is NOT selected. It is the game's identifier for a character and
* the caller is a player reading their own page; the run, the date, the score
* and the rank are what a history is, and the key adds a module-opaque string
* nothing on the page can render.
*
* `rank_at` is null until results are published, and that is a real state the
* screen shows rather than an error — a run whose participants are collected
* and unranked is exactly what Phase 10 made visible on the admin side.
*/
async function listForUser(userId, { limit = 50, before = null } = {}) {
const n = Math.min(Math.max(Number(limit) || 50, 1), 200)
const args = [userId]
// A keyset cursor on the participation row rather than an offset: the list
// gains a row every time the reader attends something, and an offset page two
// would skip whatever arrived in between.
const cursor = before ? ' AND p.id < ?' : ''
if (before) args.push(before)
const rows = await query(
`SELECT p.id, p.run_id, p.score, p.rank_at, p.joined_at, p.meta,
r.scheduled_for, r.started_at, r.ended_at, r.status, r.scope,
r.timezone, r.results_published_at,
d.title AS definition_title, d.slug AS definition_slug,
s.name AS series_name, s.slug AS series_slug
FROM event_run_participants p
JOIN event_runs r ON r.id = p.run_id
JOIN event_definitions d ON d.id = r.definition_id
LEFT JOIN event_series s ON s.id = d.series_id
WHERE p.user_id = ?${cursor}
AND r.rehearsal = 0
AND d.listed = 1
ORDER BY p.id DESC
LIMIT ${n}`,
args,
)
return rows.map(hydrate)
}
/** How many the run has. Its own query because the trigger payload needs only this. */
async function countForRun(runId) {
const rows = await query('SELECT COUNT(*) AS n FROM event_run_participants WHERE run_id = ?', [runId])
@@ -116,4 +162,4 @@ async function rankRun(runId) {
return Number(result.affectedRows || 0)
}
module.exports = { record, listForRun, countForRun, rankRun }
module.exports = { record, listForRun, listForUser, countForRun, rankRun }