Files
website/server/src/model/events/eventCalendar.model.js
wtclaude 6e73660b52
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
feat(events): schedule, recurrence and the calendar (Phase 4)
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>
2026-09-02 16:10:16 -05:00

171 lines
6.9 KiB
JavaScript

// ── The calendar ───────────────────────────────────────────────────────────
//
// EVENTS.md §I: "month and list view, filtered by category, scope and series",
// and Phase 4's stated deliverable — *the thing this feature exists to replace*
// is a WordPress calendar plugin with no series field and no recurrence.
//
// **A calendar entry is one of two things, and the difference is not cosmetic.**
//
// - A **run**: a real `event_runs` row. It has an id, a status, a health, a
// pinned version and a console. Somebody can cancel it. It exists because the
// runner materialised it inside its fourteen-day horizon, or because an admin
// started it by hand.
// - A **projection**: arithmetic. There is no row, nothing to cancel, and
// nothing has been committed to. It exists so that a monthly event is visible
// three weeks out instead of the calendar simply ending at the horizon (org
// lead, 2026-09-02).
//
// The API says which each is and the UI renders them differently, because an
// operator acting on a projection as though it were a booking is the failure
// this distinction exists to prevent. A projection is a forecast of what the
// runner *will* materialise, computed by the same `occurrencesBetween` the
// runner itself calls — one arithmetic, so the forecast cannot disagree with
// what later appears.
//
// **A projection is never emitted for an instant a run already occupies**, which
// is what keeps the fortnight inside the horizon from showing everything twice.
// That rule also does the right thing for a CANCELLED occurrence: the row is
// still there, so nothing re-projects it, and an event an operator called off
// does not reappear on the calendar as though it were still coming.
const runsDb = require('./eventRuns.db')
const definitionsDb = require('./eventDefinitions.db')
const recurrence = require('../../events/recurrence')
// A calendar request is operator-supplied, and a year-wide window across forty
// weekly definitions is how a month view becomes an outage. Ninety-two days is
// a three-month view — more than the month grid and the list either need.
const MAX_WINDOW_DAYS = 92
const MAX_ENTRIES = 1000
const runEntry = (run) => ({
kind: 'run',
runId: run.id,
definitionId: run.definition_id,
title: run.definition_title,
slug: run.definition_slug,
seriesId: run.series_id || null,
seriesName: run.series_name || null,
seriesSlug: run.series_slug || null,
scheduledFor: run.scheduled_for,
timezone: run.timezone,
scope: run.scope,
status: run.status,
health: run.health,
version: run.version_number,
rehearsal: Boolean(run.rehearsal),
waitingSteps: Number(run.waiting_steps || 0),
})
const projectedEntry = (definition, occurrence) => ({
kind: 'projected',
runId: null,
definitionId: definition.id,
title: definition.title,
slug: definition.slug,
seriesId: definition.series_id || null,
seriesName: definition.series_name || null,
seriesSlug: definition.series_slug || null,
scheduledFor: occurrence.at,
timezone: definition.timezone,
scope: '',
status: null,
health: null,
// Why this instant is not the wall clock the schedule names. Carried on the
// projection as well as on the materialised run, so the calendar can explain
// a DST-shifted time before it happens rather than after.
adjusted: occurrence.adjusted,
shiftMinutes: occurrence.shiftMinutes,
})
/**
* The calendar for a window.
*
* `{ ok, window, horizon, entries }` — entries ascending by instant, runs and
* projections interleaved. `horizon` is the instant past which nothing is
* materialised yet, so the UI can draw the line rather than infer it.
*
* **The instants are UTC and the placement is the client's.** A month grid has
* one date axis and the viewer's own zone is what "this month" means to the
* person reading it; each entry carries its own `timezone` so the time beside it
* reads `20:00 Europe/Berlin` and nobody misreads a shard's local schedule as
* their own. That is the split §E's "the timezone belongs to the event" implies:
* the event owns the time, the reader owns the calendar.
*/
async function calendar({
from,
to,
status = null,
scope = null,
seriesId = null,
horizonDays = 14,
now = new Date(),
} = {}) {
const start = from instanceof Date ? from : new Date(from)
const end = to instanceof Date ? to : new Date(to)
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
return { ok: false, status: 400, errors: ['from and to must be dates'] }
}
if (end <= start) {
return { ok: false, status: 400, errors: ['to must be after from'] }
}
if (end - start > MAX_WINDOW_DAYS * recurrence.DAY_MS) {
return { ok: false, status: 400, errors: [`the window may span at most ${MAX_WINDOW_DAYS} days`] }
}
const runs = await runsDb.listInWindow({ from: start, to: end, status, scope, seriesId })
const entries = runs.map(runEntry)
// Every instant a run already occupies, keyed by definition. Projections are
// per definition at the empty scope, so the definition and the instant are the
// whole key -- the same triple the unique index uses, with the scope fixed.
const taken = new Set(
runs
.filter((r) => !r.scope)
.map((r) => `${r.definition_id}@${new Date(r.scheduled_for).getTime()}`),
)
// A status filter is a filter on RUNS. A projection has no status, so asking
// for "everything that failed" must not answer with a forecast — it would be a
// forecast that failed, which is not a thing.
// The scope filter behaves the same way, and for the same reason: automatic
// expansion is at the empty scope (org lead, 2026-09-02), so a request narrowed
// to a named scope has no forecast to give.
if (!status && !scope) {
const definitions = await definitionsDb.findSchedulable()
for (const definition of definitions) {
if (seriesId && Number(definition.series_id) !== Number(seriesId)) continue
const schedule = definition.version_spec?.schedule
if (!schedule || schedule.kind === 'manual') continue
let occurrences = []
try {
occurrences = recurrence.occurrencesBetween(
schedule,
definition.timezone || 'UTC',
start,
end,
)
} catch {
continue
}
for (const occurrence of occurrences) {
if (taken.has(`${definition.id}@${occurrence.at.getTime()}`)) continue
entries.push(projectedEntry(definition, occurrence))
}
}
}
entries.sort((a, b) => new Date(a.scheduledFor) - new Date(b.scheduledFor))
return {
ok: true,
status: 200,
window: { from: start, to: end },
horizon: new Date(now.getTime() + horizonDays * recurrence.DAY_MS),
entries: entries.slice(0, MAX_ENTRIES),
truncated: entries.length > MAX_ENTRIES,
}
}
module.exports = { calendar, MAX_WINDOW_DAYS, MAX_ENTRIES }