// ── 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 }