feat(events): schedule, recurrence and the calendar (Phase 4)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 37s
PR Checks / client-build (pull_request) Successful in 43s
PR Checks / server-tests (pull_request) Successful in 13m26s

The four closed recurrence shapes computed in the definition's own IANA zone,
a fourteen-day materialisation horizon with projections beyond it, series as a
managed thing, and the admin calendar that replaces the plugin this feature
exists to replace. An event now happens on its own.

No schema change: Phase 1 built every column this needed.

- events/recurrence.js is the ONE place an occurrence is computed, so the
  runner's expansion and the calendar's forecast cannot disagree. No date
  library added — Node ships the tzdata one would vendor, behind Intl.
- The runner's materialise leg is now two halves: expand, then sweep. The
  window starts at `now - grace`, so an occurrence nobody could have seen is
  never invented retroactively; the horizon is what makes the missed sweep
  mean anything for a recurrence.
- Publishing is the schedule switch and archiving turns it off, and publishing
  re-pins every occurrence that has not started.
- A projection is never drawn over an instant a run occupies, so a cancelled
  occurrence does not reappear as a forecast.

54 new tests, incl. the DST fixture set the plan asked for and three new
statements proved against a real MariaDB. Suite 1768/1711/56 skipped/1 fail
(pre-existing CRLF). Walked end to end on the local review stack.

Docs: RunicGateway/docs#PENDING

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-09-02 16:10:16 -05:00
parent a481248bc0
commit 6e73660b52
30 changed files with 3722 additions and 77 deletions

View File

@@ -487,6 +487,24 @@ export const api = {
listEventVersions: (id) => req(`/admin/events/${id}/versions`),
eventCatalog: () => req('/admin/events/catalog'),
eventSeries: () => req('/admin/events/series'),
// Series writes are admin+editor rather than admin: naming an arc is
// authoring, and §N2's narrow gate is about committing the deployment to a
// run. The delete is a real delete and answers with how many definitions it
// detached — `series_id` is ON DELETE SET NULL, so nothing is destroyed.
createEventSeries: (body) => req('/admin/events/series', { method: 'POST', body }),
updateEventSeries: (id, body) => req(`/admin/events/series/${id}`, { method: 'PUT', body }),
deleteEventSeries: (id) => req(`/admin/events/series/${id}`, { method: 'DELETE' }),
// The calendar. `from`/`to` are UTC instants the caller computes from the
// month it is showing, in the READER's zone — the server never guesses it.
// A `status` or `scope` filter suppresses projections, which is why the
// month view sends neither.
eventCalendar: ({ from, to, status, scope, seriesId } = {}) => {
const qs = new URLSearchParams({ from, to })
if (status) qs.set('status', status)
if (scope) qs.set('scope', scope)
if (seriesId) qs.set('seriesId', String(seriesId))
return req(`/admin/events/calendar?${qs.toString()}`)
},
startEventRun: (id, body) => req(`/admin/events/${id}/runs`, { method: 'POST', body }),
listEventRuns: ({ definitionId, status, limit } = {}) => {
const qs = new URLSearchParams()