From b5616f359cc518ad7e3edf220eb16aea648c889e Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 25 Sep 2026 07:38:59 -0500 Subject: [PATCH] feat(events): publish runId on a public calendar run entry (Rust D125) A run entry on GET /public/events now names its run, the same id the event page already publishes on each occurrence and `?run=` takes. A Rust map marker carries core's run id and nothing else about its event, so without this the app could only find the event by fetching every event page. A projected entry has no runId: nothing is committed to it. Rehearsals and unlisted events stay absent from the calendar, so their markers stay unlinked. The web calendar ignores the field. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- server/src/model/events/eventPublic.model.js | 11 ++++++++- server/swagger/swagger-output.json | 17 ++++++++++++++ server/swagger/swagger.js | 6 +++++ server/test/eventPublic.test.js | 24 +++++++++++++++++++- 4 files changed, 56 insertions(+), 2 deletions(-) diff --git a/server/src/model/events/eventPublic.model.js b/server/src/model/events/eventPublic.model.js index 73a77fd..1425b9e 100644 --- a/server/src/model/events/eventPublic.model.js +++ b/server/src/model/events/eventPublic.model.js @@ -103,9 +103,18 @@ function phaseLabel(spec, phaseId) { return (phase && (phase.label || phase.id)) || null } -/** One calendar entry, from a materialised run. */ +/** + * One calendar entry, from a materialised run. + * + * **`runId` is published because the event page already publishes it** on every + * occurrence, and `?run=` takes it. The calendar was the one public shape that + * named a run without saying which, so a client holding a run id from elsewhere + * (a module's map marker) had no way to find its event but to fetch every event + * page. A projection has none: nothing is committed to it. + */ const publicRunEntry = (run) => ({ kind: 'run', + runId: run.id, title: run.definition_title, slug: run.definition_slug, seriesName: run.series_name || null, diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json index 9a744c3..c4654c0 100644 --- a/server/swagger/swagger-output.json +++ b/server/swagger/swagger-output.json @@ -26295,6 +26295,23 @@ } } }, + "runId": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 3692 + }, + "description": { + "type": "string", + "example": "Runs only: the run this entry is, the same id `PublicEventOccurrence.runId` carries and `/events/{slug}?run=` takes. A projected entry has none, because nothing is committed to it." + } + } + }, "title": { "type": "object", "properties": { diff --git a/server/swagger/swagger.js b/server/swagger/swagger.js index 0551567..9601cb7 100644 --- a/server/swagger/swagger.js +++ b/server/swagger/swagger.js @@ -1247,6 +1247,12 @@ const doc = { 'One calendar entry. `kind` says which of two things it is: a `run` is a materialised occurrence, a `projected` entry is arithmetic past the materialisation horizon — a forecast with nothing committed to it, which a client should draw as such.', properties: { kind: { type: 'string', enum: ['run', 'projected'], example: 'run' }, + runId: { + type: 'integer', + example: 3692, + description: + 'Runs only: the run this entry is, the same id `PublicEventOccurrence.runId` carries and `/events/{slug}?run=` takes. A projected entry has none, because nothing is committed to it.', + }, title: { type: 'string', example: 'The Yew Invasion' }, slug: { type: 'string', example: 'the-yew-invasion' }, seriesName: { type: 'string', nullable: true, example: 'The Yew Campaign' }, diff --git a/server/test/eventPublic.test.js b/server/test/eventPublic.test.js index b5758e2..f94b2fc 100644 --- a/server/test/eventPublic.test.js +++ b/server/test/eventPublic.test.js @@ -164,10 +164,32 @@ test('a calendar entry carries no operational field at all', async () => { // The whole security property of this file, asserted positively: the entry has // exactly these keys and gaining one is a deliberate act. assert.deepEqual(Object.keys(entry).sort(), [ - 'kind', 'live', 'scheduledFor', 'seriesName', 'seriesSlug', 'slug', 'status', 'timezone', 'title', + 'kind', 'live', 'runId', 'scheduledFor', 'seriesName', 'seriesSlug', 'slug', 'status', 'timezone', + 'title', ]) }) +test('a run entry names its run, and a projection names none', async () => { + // Rust phase 15, D125: a map marker carries core's run id, and the app finds + // the event it belongs to from this calendar. The id is the one the event page + // already publishes on each occurrence. + store.definitions[0].spec = { + ...SPEC, + schedule: { kind: 'weekly', days: ['saturday'], time: '00:00' }, + } + const result = await publicModel.calendar({ from: '2026-08-28', to: '2026-09-15', now: NOW }) + const runs = result.entries.filter((e) => e.kind === 'run') + const projected = result.entries.filter((e) => e.kind === 'projected') + assert.equal(runs.length, 1) + assert.equal(runs[0].runId, store.runs[0].id) + assert.ok(projected.length > 0, 'the weekly schedule must forecast past the one run') + for (const entry of projected) assert.equal('runId' in entry, false) + + const page = await publicModel.event('the-yew-invasion') + const occurrences = [page.event.current, page.event.next, ...page.event.upcoming, ...page.event.past] + assert.ok(occurrences.some((o) => o && o.runId === runs[0].runId)) +}) + test('the default window reaches back as well as forward', async () => { // §I: this route is "upcoming, live and recent". The default used to start at // `now`, which left no room for the third word — an event that finished an hour -- 2.49.1