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:
@@ -252,3 +252,40 @@ test('a Team slug is URL-encoded on every forum path', async () => {
|
||||
await api.teamForumReport('a b/c', { targetType: 'team_forum_thread', targetId: 1, reason: 'spam' })
|
||||
assert.equal(calls[0].url, '/api/v1/player/teams/a%20b%2Fc/forum/report')
|
||||
})
|
||||
|
||||
|
||||
// ── Public events (Phase 14a) ───────────────────────────────────────────
|
||||
//
|
||||
// The one shape worth pinning is `?run=`: it is what an announcement's link
|
||||
// carries, and a client that dropped it would make a mail about last Friday's
|
||||
// occurrence open next Friday's.
|
||||
|
||||
test('the public calendar asks for no window at all by default', async () => {
|
||||
willReply({ body: { entries: [] } })
|
||||
await api.publicEvents()
|
||||
// The server defaults to now through a month out, so the first render need
|
||||
// not compute two ISO instants before it can ask for anything.
|
||||
assert.equal(calls[0].url, '/api/v1/public/events')
|
||||
})
|
||||
|
||||
test('an event page carries the run when one was named, and not when it was not', async () => {
|
||||
willReply({ body: { ok: true } })
|
||||
await api.publicEvent('the-yew-invasion')
|
||||
assert.equal(calls[0].url, '/api/v1/public/events/the-yew-invasion')
|
||||
|
||||
willReply({ body: { ok: true } })
|
||||
await api.publicEvent('the-yew-invasion', 3692)
|
||||
assert.equal(calls[1].url, '/api/v1/public/events/the-yew-invasion?run=3692')
|
||||
})
|
||||
|
||||
test('an event slug is URL-encoded on every public path', async () => {
|
||||
willReply({ body: { ok: true } })
|
||||
await api.publicEventSeries('a b/c')
|
||||
assert.equal(calls[0].url, '/api/v1/public/events/series/a%20b%2Fc')
|
||||
})
|
||||
|
||||
test('participation history takes a keyset cursor, never an offset', async () => {
|
||||
willReply({ body: { entries: [] } })
|
||||
await api.player.eventHistory({ limit: 25, before: 900 })
|
||||
assert.equal(calls[0].url, '/api/v1/player/events/history?limit=25&before=900')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user