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

@@ -23,6 +23,9 @@ const definitionsDb = require('../../../model/events/eventDefinitions.db')
const definitions = require('../../../model/events/eventDefinitions.model')
const versionsDb = require('../../../model/events/eventVersions.db')
const seriesDb = require('../../../model/events/eventSeries.db')
const series = require('../../../model/events/eventSeries.model')
const calendarModel = require('../../../model/events/eventCalendar.model')
const eventRunner = require('../../../utils/eventRunner')
const runsDb = require('../../../model/events/eventRuns.db')
const runs = require('../../../model/events/eventRuns.model')
const controls = require('../../../model/events/eventRunControls.model')
@@ -152,17 +155,83 @@ exports.catalog = (_req, res) => {
})
}
const shapeSeries = (s) => ({
id: s.id,
name: s.name,
slug: s.slug,
description: s.description,
ordering: s.ordering,
definitionCount: Number(s.definition_count || 0),
})
/** GET /api/v1/admin/events/series */
exports.listSeries = async (_req, res) => {
const rows = await seriesDb.list()
res.json({ series: rows.map(shapeSeries) })
}
/** POST /api/v1/admin/events/series */
exports.createSeries = async (req, res) => {
const result = await series.create(req.body, req.user?.id)
if (!result.ok) return res.status(result.status).json({ errors: result.errors })
await activity.log({
req,
action: 'event.series.created',
detail: { id: result.series.id, name: result.series.name },
})
res.status(201).json({ series: shapeSeries(result.series) })
}
/** PUT /api/v1/admin/events/series/:seriesId */
exports.updateSeries = async (req, res) => {
const id = asId(req.params.seriesId)
if (!id) return res.status(404).json({ error: 'no such series' })
const result = await series.update(id, req.body, req.user?.id)
if (!result.ok) return res.status(result.status).json({ errors: result.errors })
await activity.log({ req, action: 'event.series.updated', detail: { id, name: result.series.name } })
res.json({ series: shapeSeries(result.series) })
}
/**
* DELETE /api/v1/admin/events/series/:seriesId
*
* `detached` is in the response because the delete is not confined to the row:
* `series_id` is `ON DELETE SET NULL`, so definitions that belonged to the arc
* survive it without one. Saying how many is the difference between an operator
* knowing and an operator finding out.
*/
exports.deleteSeries = async (req, res) => {
const id = asId(req.params.seriesId)
if (!id) return res.status(404).json({ error: 'no such series' })
const result = await series.remove(id)
if (!result.ok) return res.status(result.status).json({ errors: result.errors })
await activity.log({ req, action: 'event.series.deleted', detail: { id, detached: result.detached } })
res.json({ ok: true, detached: result.detached })
}
/**
* GET /api/v1/admin/events/calendar
*
* `from` and `to` are UTC instants and the caller supplies both: a month grid
* knows its own boundaries in the viewer's zone, and having the server guess
* them would be the server guessing the viewer's zone.
*/
exports.calendar = async (req, res) => {
const result = await calendarModel.calendar({
from: req.query.from,
to: req.query.to,
status: req.query.status || null,
scope: req.query.scope || null,
seriesId: asId(req.query.seriesId),
horizonDays: eventRunner.HORIZON_DAYS,
})
if (!result.ok) return res.status(result.status).json({ errors: result.errors })
res.json({
series: rows.map((s) => ({
id: s.id,
name: s.name,
slug: s.slug,
description: s.description,
ordering: s.ordering,
})),
window: result.window,
horizon: result.horizon,
horizonDays: eventRunner.HORIZON_DAYS,
entries: result.entries,
truncated: result.truncated,
})
}
@@ -272,12 +341,16 @@ exports.publish = async (req, res) => {
await activity.log({
req,
action: 'event.definition.published',
detail: { id, version: result.version, versionId: result.versionId },
detail: { id, version: result.version, versionId: result.versionId, repinned: result.repinned },
})
return res.json({
event: shapeDefinition(result.definition),
version: result.version,
versionId: result.versionId,
// How many already-materialised occurrences moved to this version. The
// screen says so, because "my fix did not reach next Friday" is otherwise
// found out on Friday.
repinned: result.repinned,
})
}