diff --git a/client/src/App.jsx b/client/src/App.jsx
index aef41a0..311d326 100644
--- a/client/src/App.jsx
+++ b/client/src/App.jsx
@@ -50,6 +50,7 @@ import EngagementSendLog from './routes/admin/views/EngagementSendLog.jsx'
import EngagementSuppressions from './routes/admin/views/EngagementSuppressions.jsx'
import EngagementRetention from './routes/admin/views/EngagementRetention.jsx'
import EventsAdmin from './routes/admin/views/EventsAdmin.jsx'
+import EventsCalendar from './routes/admin/views/EventsCalendar.jsx'
import EventEditor from './routes/admin/views/EventEditor.jsx'
import EventRun from './routes/admin/views/EventRun.jsx'
import TeamsAdmin from './routes/admin/views/TeamsAdmin.jsx'
@@ -204,6 +205,7 @@ export default function App() {
route it calls. `runs/:runId` is declared before `:id` so the
literal segment is never read as a definition id. */}
} />
+ } />
} />
} />
} />
diff --git a/client/src/api/client.js b/client/src/api/client.js
index a6e0a15..8f979af 100644
--- a/client/src/api/client.js
+++ b/client/src/api/client.js
@@ -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()
diff --git a/client/src/lib/eventAuthoring.js b/client/src/lib/eventAuthoring.js
index 8ce3179..77b8073 100644
--- a/client/src/lib/eventAuthoring.js
+++ b/client/src/lib/eventAuthoring.js
@@ -141,7 +141,7 @@ export function formFromDefinition(event) {
concurrencyKey: event?.concurrencyKey || '',
graceSeconds: event?.graceSeconds ?? 900,
timezone: event?.timezone || 'UTC',
- scheduleKind: spec.schedule?.kind || 'manual',
+ ...scheduleFormFrom(spec.schedule),
phases: (spec.phases || []).map((p) => ({
key: p.key || '',
label: p.label || '',
@@ -204,11 +204,136 @@ export function payloadFromForm(form) {
concurrencyKey: form.concurrencyKey || null,
graceSeconds: Number(form.graceSeconds),
timezone: form.timezone,
- spec: { schedule: { kind: form.scheduleKind || 'manual' }, phases },
+ spec: { schedule: scheduleFromForm(form), phases },
},
}
}
+
+// ── The schedule (Phase 4) ─────────────────────────────────────────────────
+//
+// The four closed shapes of §E, mirrored so the form can render one and the
+// preview can describe it. `events/spec.js` and `events/recurrence.js` remain
+// the deciders — this is what makes the form a form rather than a text box, and
+// it is the whole reason the schedule is not a cron string: a closed set has a
+// dropdown, and an operator can proofread a dropdown.
+
+export const WEEKDAYS = [
+ 'sunday',
+ 'monday',
+ 'tuesday',
+ 'wednesday',
+ 'thursday',
+ 'friday',
+ 'saturday',
+]
+
+export const SCHEDULE_KINDS = [
+ { value: 'manual', label: 'Started by hand' },
+ { value: 'once', label: 'Once, at a set time' },
+ { value: 'weekly', label: 'Weekly, on chosen days' },
+ { value: 'monthly', label: 'Monthly, on the nth weekday' },
+]
+
+// 1..4 and "last". There is no fifth: every month has a first through fourth of
+// every weekday, and "last" is what a month with five Fridays makes different
+// from "fourth" (org lead, 2026-09-02).
+export const MONTHLY_NTHS = [
+ { value: 1, label: 'First' },
+ { value: 2, label: 'Second' },
+ { value: 3, label: 'Third' },
+ { value: 4, label: 'Fourth' },
+ { value: -1, label: 'Last' },
+]
+
+const capitalise = (s) => String(s || '').charAt(0).toUpperCase() + String(s || '').slice(1)
+
+/**
+ * A schedule in words, in the event's own zone.
+ *
+ * The server says the same thing in `events/recurrence.js#describe`, and the two
+ * are allowed to differ on wording but not on meaning — this one is what an
+ * author reads while they are still typing, before anything has been saved.
+ */
+export function describeSchedule(schedule, timezone = 'UTC') {
+ if (!schedule || typeof schedule !== 'object') return 'No schedule'
+ const nth = MONTHLY_NTHS.find((n) => n.value === Number(schedule.nth))
+ switch (schedule.kind) {
+ case 'manual':
+ return 'Started by hand — nothing happens until an admin presses Start'
+ case 'once': {
+ if (!schedule.at) return 'Once — no date chosen yet'
+ return `Once, on ${String(schedule.at).replace('T', ' at ')} (${timezone})`
+ }
+ case 'weekly': {
+ const days = (schedule.days || []).map(capitalise)
+ if (!days.length || !schedule.time) return 'Weekly — choose days and a time'
+ const list =
+ days.length === 1
+ ? days[0]
+ : `${days.slice(0, -1).join(', ')} and ${days[days.length - 1]}`
+ return `Every ${list} at ${schedule.time} (${timezone})`
+ }
+ case 'monthly': {
+ if (!nth || !schedule.weekday || !schedule.time) {
+ return 'Monthly — choose a week, a weekday and a time'
+ }
+ return `The ${nth.label.toLowerCase()} ${capitalise(schedule.weekday)} of every month at ${schedule.time} (${timezone})`
+ }
+ default:
+ return 'No schedule'
+ }
+}
+
+/**
+ * The schedule half of the editor's working state.
+ *
+ * Every shape's fields are kept side by side rather than cleared when the kind
+ * changes, so an author who clicks Weekly, then Monthly, then back has not lost
+ * the days they picked. `scheduleFromForm` reads only the fields the chosen kind
+ * uses, which is what keeps the request body a clean single shape.
+ */
+export function scheduleFormFrom(schedule) {
+ const s = schedule || {}
+ return {
+ scheduleKind: s.kind || 'manual',
+ scheduleAt: s.kind === 'once' ? s.at || '' : '',
+ scheduleDays: s.kind === 'weekly' ? s.days || [] : [],
+ scheduleNth: s.kind === 'monthly' ? String(s.nth) : '1',
+ scheduleWeekday: s.kind === 'monthly' ? s.weekday || 'friday' : 'friday',
+ scheduleTime: s.kind === 'weekly' || s.kind === 'monthly' ? s.time || '20:00' : '20:00',
+ }
+}
+
+/** The schedule the form describes, as the spec object the server expects. */
+export function scheduleFromForm(form) {
+ switch (form.scheduleKind) {
+ case 'once':
+ return { kind: 'once', at: form.scheduleAt }
+ case 'weekly':
+ return { kind: 'weekly', days: form.scheduleDays || [], time: form.scheduleTime }
+ case 'monthly':
+ return {
+ kind: 'monthly',
+ nth: Number(form.scheduleNth),
+ weekday: form.scheduleWeekday,
+ time: form.scheduleTime,
+ }
+ default:
+ return { kind: 'manual' }
+ }
+}
+
+/**
+ * What a calendar entry is, and therefore what may be done with it.
+ *
+ * A `run` is a row: it has a console and somebody can cancel it. A `projected`
+ * entry is arithmetic the runner has not reached yet — there is nothing to open
+ * and nothing to stop, and an operator who treats one as a booking has been
+ * misled by the UI rather than by the server.
+ */
+export const isProjected = (entry) => entry?.kind === 'projected'
+
/** An empty box is `{}`, not a parse error — a step may legitimately take none. */
export function parseParams(text) {
const raw = (text || '').trim()
diff --git a/client/src/routes/admin/AdminLayout.jsx b/client/src/routes/admin/AdminLayout.jsx
index d489c5e..db77552 100644
--- a/client/src/routes/admin/AdminLayout.jsx
+++ b/client/src/routes/admin/AdminLayout.jsx
@@ -133,6 +133,10 @@ export const NAV = [
title: 'Events',
items: [
{ to: '/admin/events', label: 'Events', icon: IconCalendar, roles: ['admin', 'editor', 'moderator'] },
+ // Phase 4. The same staff gate as the list beside it: a calendar is a read,
+ // and the arcs it manages are authoring gated on the buttons rather than
+ // on the row.
+ { to: '/admin/events/calendar', label: 'Calendar', icon: IconCalendar, roles: ['admin', 'editor', 'moderator'] },
],
},
{
@@ -218,6 +222,7 @@ const TITLES = {
'/admin/engagement/sends': 'Send Log',
'/admin/engagement/retention': 'Retention',
'/admin/events': 'Events',
+ '/admin/events/calendar': 'Event calendar',
'/admin/events/new': 'New event',
}
diff --git a/client/src/routes/admin/views/EventEditor.jsx b/client/src/routes/admin/views/EventEditor.jsx
index 3ab4d99..2e37ff6 100644
--- a/client/src/routes/admin/views/EventEditor.jsx
+++ b/client/src/routes/admin/views/EventEditor.jsx
@@ -8,6 +8,11 @@ import {
payloadFromForm,
blankPhase,
blankStep,
+ describeSchedule,
+ scheduleFromForm,
+ SCHEDULE_KINDS,
+ MONTHLY_NTHS,
+ WEEKDAYS,
} from '../../../lib/eventAuthoring.js'
// Admin → Events → the definition editor (EVENTS.md §I, Phase 3).
@@ -191,7 +196,13 @@ export default function EventEditor() {
const result = await api.admin.publishEvent(id)
setEvent(result.event)
setVersions(await api.admin.listEventVersions(id).then((r) => r.versions || []))
- setNotice(`Published as v${result.version}.`)
+ // The re-pin count is said out loud, because an editor who does not know
+ // their fix reached next Friday finds out on Friday.
+ setNotice(
+ result.repinned
+ ? `Published as v${result.version}. ${result.repinned} scheduled occurrence${result.repinned === 1 ? '' : 's'} moved to it.`
+ : `Published as v${result.version}.`,
+ )
} catch (err) {
setProblems(err.body?.errors || [err.message])
} finally {
@@ -329,13 +340,94 @@ export default function EventEditor() {
{/* ── Schedule ── */}
+ {/*
+ Four closed shapes rendered as a form, never a cron string. A cron
+ expression is the one field an operator cannot proofread, and the whole
+ point of the closed set is that this panel can be read back in English —
+ which is what the preview line under it does.
+ */}
-
Schedule
-
- Started by hand. Recurrence — once, weekly, monthly on the nth weekday —
- is computed in the event’s own timezone and arrives in the next phase, with the calendar.
- Until then an occurrence exists because somebody pressed Start now, and the
- schedule shape a definition may carry is deliberately the single one the runner honours.
+
+ Times are the event’s own, in {form.timezone} — not the reader’s. A
+ recurring schedule goes live when the definition is published and stops when it is
+ archived; occurrences become real runs a fortnight before they happen, and the
+ calendar forecasts the rest. A time that daylight saving skips moves forward to the next
+ one that exists, and an hour that happens twice takes the first.
diff --git a/client/src/routes/admin/views/EventsAdmin.jsx b/client/src/routes/admin/views/EventsAdmin.jsx
index 1153211..8fcc472 100644
--- a/client/src/routes/admin/views/EventsAdmin.jsx
+++ b/client/src/routes/admin/views/EventsAdmin.jsx
@@ -20,9 +20,10 @@ import { runStatusWord, isTerminalRun } from '../../../lib/eventAuthoring.js'
// comes from the run row itself (`waitingSteps`), so a run needs nobody to open
// it before it can say so.
//
-// What is NOT here: a calendar. Recurrence and the month view are Phase 4, and a
-// definition today can only carry `schedule: { kind: 'manual' }` — so the honest
-// list is a list, and the screen says as much rather than showing an empty grid.
+// **The calendar is a separate screen, not a third table here.** It answers
+// "when", this one answers "what" — and Phase 4, which built it, also made a
+// definition able to carry a recurrence, so the two questions stopped having the
+// same answer the moment an occurrence could exist before anybody pressed Start.
const STATE_WORD = { draft: 'Draft', ready: 'Ready', archived: 'Archived' }
@@ -108,8 +109,9 @@ export default function EventsAdmin() {
Scheduled, bounded, audited changes to the live world. A definition is authored as a draft,
published as an immutable version, and every occurrence of it runs against the version it
- pinned. Recurrence and the calendar arrive with the next phase — for now an occurrence is
- started by hand.
+ pinned. A definition can repeat — once, weekly, or on the nth weekday of the month, in its
+ own timezone — and the calendar is where those
+ occurrences are read.
+
+ Calendar
+
{mayAuthor && (
New event
diff --git a/client/src/routes/admin/views/EventsCalendar.jsx b/client/src/routes/admin/views/EventsCalendar.jsx
new file mode 100644
index 0000000..a96ec3b
--- /dev/null
+++ b/client/src/routes/admin/views/EventsCalendar.jsx
@@ -0,0 +1,457 @@
+import { useCallback, useEffect, useMemo, useState } from 'react'
+import { Link } from 'react-router-dom'
+import { Loading, ErrorState } from '../../../components/PageState.jsx'
+import { useAuth } from '../../../contexts/AuthContext.jsx'
+import { api } from '../../../api/client.js'
+import { runStatusWord, isProjected } from '../../../lib/eventAuthoring.js'
+
+// Admin → Events → Calendar (EVENTS.md §I, Phase 4).
+//
+// **This screen is the deliverable.** What this feature replaces is a WordPress
+// calendar plugin with no series field, no recurrence and no results — so a
+// month grid that knows about arcs, repeats and local time is not decoration
+// here, it is the point.
+//
+// **Two kinds of entry, drawn differently on purpose.** A solid one is a *run*:
+// a real row with a status, a pinned version and a console, and somebody can
+// cancel it. A dashed one is a *projection*: arithmetic past the runner's
+// fourteen-day horizon, with no row behind it, nothing committed and nothing to
+// open. An operator who treats a forecast as a booking has been misled by the
+// UI, not by the server, so the difference is drawn rather than merely stated —
+// and the legend says which is which.
+//
+// **The grid's date axis is the READER's zone; each entry's time is the
+// EVENT's.** §E gives the timezone to the event because every listing this
+// replaces is written in the shard's local zone, but "what is happening this
+// month" is a question about the month the person reading is living in. So the
+// cell an event lands in is the reader's date, and the time beside it always
+// carries the event's own zone — `20:00 Europe/Berlin` misreads as nothing.
+
+const DAY_MS = 86_400_000
+
+const STATUS_COLOR = {
+ failed: '#d98b84',
+ missed: '#d98b84',
+ paused: '#d9c184',
+ cancelled: 'var(--muted)',
+ running: '#8fc79a',
+}
+
+/** The event's own wall clock, which is the only time worth showing beside it. */
+function localTime(instant, timezone) {
+ try {
+ return new Intl.DateTimeFormat(undefined, {
+ timeZone: timezone,
+ hour: '2-digit',
+ minute: '2-digit',
+ hourCycle: 'h23',
+ }).format(new Date(instant))
+ } catch {
+ return new Date(instant).toISOString().slice(11, 16)
+ }
+}
+
+/** The reader's own date key, which is what places an entry in a cell. */
+const readerDayKey = (instant) => {
+ const d = new Date(instant)
+ return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`
+}
+
+/**
+ * The six-week grid a month view draws, Monday first.
+ *
+ * Always six weeks rather than however many the month needs: a grid that
+ * changes height as you page through it is a grid whose rows move under the
+ * cursor.
+ */
+function monthGrid(year, month) {
+ const first = new Date(year, month, 1)
+ const offset = (first.getDay() + 6) % 7
+ const start = new Date(year, month, 1 - offset)
+ return Array.from({ length: 42 }, (_, i) => new Date(start.getTime() + i * DAY_MS))
+}
+
+const MONTH_NAMES = [
+ 'January', 'February', 'March', 'April', 'May', 'June',
+ 'July', 'August', 'September', 'October', 'November', 'December',
+]
+
+export default function EventsCalendar() {
+ const { user } = useAuth()
+ const today = useMemo(() => new Date(), [])
+ const [year, setYear] = useState(today.getFullYear())
+ const [month, setMonth] = useState(today.getMonth())
+ const [view, setView] = useState('month')
+ const [seriesId, setSeriesId] = useState('')
+ const [series, setSeries] = useState([])
+ const [data, setData] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+ const [managingSeries, setManagingSeries] = useState(false)
+
+ const mayAuthor = user?.role === 'admin' || user?.role === 'editor'
+
+ // The window is the grid's own span, not the month's: an entry in the leading
+ // or trailing week of the grid belongs to a neighbouring month and still has
+ // to be fetched, or the first row of every month renders empty.
+ const grid = useMemo(() => monthGrid(year, month), [year, month])
+ const window = useMemo(() => {
+ if (view === 'month') {
+ return { from: grid[0], to: new Date(grid[41].getTime() + DAY_MS) }
+ }
+ // The list view answers a different question — "what is coming" — so it runs
+ // forward from now rather than over a calendar month.
+ const from = new Date()
+ return { from, to: new Date(from.getTime() + 60 * DAY_MS) }
+ }, [view, grid])
+
+ const load = useCallback(async () => {
+ setLoading(true)
+ setError(null)
+ try {
+ const [calendar, seriesList] = await Promise.all([
+ api.admin.eventCalendar({
+ from: window.from.toISOString(),
+ to: window.to.toISOString(),
+ seriesId: seriesId || undefined,
+ }),
+ api.admin.eventSeries(),
+ ])
+ setData(calendar)
+ setSeries(seriesList.series || [])
+ } catch (err) {
+ setError(err)
+ } finally {
+ setLoading(false)
+ }
+ }, [window.from, window.to, seriesId])
+
+ useEffect(() => {
+ load()
+ }, [load])
+
+ const byDay = useMemo(() => {
+ const map = new Map()
+ for (const entry of data?.entries || []) {
+ const key = readerDayKey(entry.scheduledFor)
+ if (!map.has(key)) map.set(key, [])
+ map.get(key).push(entry)
+ }
+ return map
+ }, [data])
+
+ const step = (delta) => {
+ const next = new Date(year, month + delta, 1)
+ setYear(next.getFullYear())
+ setMonth(next.getMonth())
+ }
+
+ if (loading && !data) return
+ if (error && !data) return
+
+ const horizon = data?.horizon ? new Date(data.horizon) : null
+
+ return (
+ <>
+
+
+ {/*
+ The stepper belongs to the MONTH view only. The list answers "what is
+ coming" and runs sixty days forward from now whatever month is
+ selected -- so paging it would be three controls that visibly do
+ nothing, which is the one thing this feature has refused since Phase
+ 1. The heading says which question is being asked instead.
+ */}
+ {view === 'month' && (
+ <>
+
+
+ {`${MONTH_NAMES[month]} ${year}`}
+
+
+
+ >
+ )}
+ {view === 'list' && (
+ The next 60 days
+ )}
+
+
+
+
+
+
+ {mayAuthor && (
+
+ )}
+ Events
+
+
+
+ {/* The legend is not optional. The whole screen rests on the reader
+ knowing that a dashed entry is not a booking. */}
+
+ Scheduled run is a real occurrence with
+ a console — it can be opened, paused and cancelled.{' '}
+ Forecast is what the
+ recurrence works out to beyond the {data?.horizonDays ?? 14}-day horizon: nothing is
+ committed yet and there is nothing to open.
+ {horizon && ` Everything up to ${horizon.toLocaleDateString()} is real.`}
+
+ {isProjected(entry) ? Forecast : runStatusWord(entry.status)}
+ {entry.waitingSteps > 0 && (
+ · waiting on a person
+ )}
+
+
+ ))}
+
+
+ )}
+
+ )}
+ >
+ )
+}
+
+// A projection has no run id, so the definition and the instant are its
+// identity — the same pair the server dedupes projections against.
+const entryKey = (entry) =>
+ entry.runId ? `run-${entry.runId}` : `proj-${entry.definitionId}-${entry.scheduledFor}`
+
+const chip = {
+ display: 'inline-block',
+ padding: '0 5px',
+ borderRadius: 3,
+ borderWidth: 1,
+ border: '1px solid var(--muted)',
+ fontSize: '0.72rem',
+}
+
+/**
+ * The arcs, managed where they are used.
+ *
+ * A series is a label, not authored content — nothing pins one and no run
+ * references one — so this is a small inline panel rather than a screen of its
+ * own, and it lives on the calendar because the calendar is what makes an arc
+ * visible in the first place. §I: *"Royal Spy Mission → Risky Partner → Message
+ * From the Void" is continuity that exists nowhere in the tooling this replaces.*
+ *
+ * The delete is a real delete, and it says what it will detach before it
+ * happens: `series_id` is ON DELETE SET NULL, so the definitions survive without
+ * an arc and re-attaching one is a dropdown in the editor. Nothing is destroyed,
+ * which is why this is the one delete in this feature that is not an archive.
+ */
+function SeriesManager({ series, onChanged }) {
+ const [name, setName] = useState('')
+ const [busy, setBusy] = useState(false)
+ const [problem, setProblem] = useState(null)
+
+ const run = async (fn) => {
+ setBusy(true)
+ setProblem(null)
+ try {
+ await fn()
+ await onChanged()
+ } catch (err) {
+ setProblem(err?.body?.errors?.join('; ') || err?.message || 'That did not work')
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ return (
+
+
Series
+
+ An arc several events form together. The order here is where a series sits among the
+ others; where an event sits within its arc is that event’s own order, in the
+ editor.
+
+ )
+}
+
+function EntryChip({ entry }) {
+ const projected = isProjected(entry)
+ const to = entry.runId ? `/admin/events/runs/${entry.runId}` : `/admin/events/${entry.definitionId}`
+ return (
+
+ {localTime(entry.scheduledFor, entry.timezone)} {entry.title}
+ {entry.waitingSteps > 0 && ●}
+
+ )
+}
diff --git a/client/test/eventAuthoring.test.js b/client/test/eventAuthoring.test.js
index a1e11c4..1fe8a9b 100644
--- a/client/test/eventAuthoring.test.js
+++ b/client/test/eventAuthoring.test.js
@@ -12,6 +12,12 @@ import {
blankPhase,
describeLogLine,
runStatusWord,
+ describeSchedule,
+ scheduleFormFrom,
+ scheduleFromForm,
+ isProjected,
+ WEEKDAYS,
+ MONTHLY_NTHS,
} from '../src/lib/eventAuthoring.js'
// lib/eventAuthoring.js — what the three Events screens say and what they let
@@ -308,3 +314,105 @@ test('every run status has a word, and an unknown one falls through rather than
}
assert.equal(runStatusWord('something-new'), 'something-new')
})
+
+
+// ── The schedule form (Phase 4) ─────────────────────────────────────
+//
+// The form is the whole argument against cron: a closed set of four shapes has a
+// dropdown, and a dropdown can be proofread. What is checked here is that the
+// round trip through the form does not quietly change what the author wrote —
+// the server would refuse a malformed schedule, but it cannot refuse a
+// well-formed one that says something the author did not mean.
+
+test('a schedule survives the round trip through the form unchanged', () => {
+ for (const schedule of [
+ { kind: 'manual' },
+ { kind: 'once', at: '2026-10-31T20:00' },
+ { kind: 'weekly', days: ['monday', 'friday'], time: '20:00' },
+ { kind: 'monthly', nth: -1, weekday: 'friday', time: '19:30' },
+ ]) {
+ const form = scheduleFormFrom(schedule)
+ assert.deepEqual(scheduleFromForm(form), schedule, JSON.stringify(schedule))
+ }
+})
+
+test('switching kind keeps the other shapes fields, and sends only the chosen one', () => {
+ // An author who clicks Weekly, then Monthly, then back must not find the days
+ // they picked gone — but the request body must still be a single clean shape,
+ // not a union of everything they touched.
+ const form = { ...scheduleFormFrom({ kind: 'weekly', days: ['friday'], time: '20:00' }), scheduleKind: 'monthly' }
+ const sent = scheduleFromForm(form)
+ assert.deepEqual(Object.keys(sent).sort(), ['kind', 'nth', 'time', 'weekday'])
+ assert.equal(form.scheduleDays.includes('friday'), true)
+})
+
+test('formFromDefinition carries the whole schedule, not only its kind', () => {
+ const form = formFromDefinition({
+ title: 'Fishing contest',
+ timezone: 'Europe/Berlin',
+ spec: {
+ schedule: { kind: 'monthly', nth: -1, weekday: 'friday', time: '19:30' },
+ phases: [{ key: 'main', label: 'Main', steps: [] }],
+ },
+ })
+ assert.equal(form.scheduleKind, 'monthly')
+ assert.equal(form.scheduleNth, '-1')
+ assert.equal(form.scheduleWeekday, 'friday')
+ assert.equal(form.scheduleTime, '19:30')
+
+ const built = payloadFromForm(form)
+ assert.equal(built.ok, true)
+ assert.deepEqual(built.payload.spec.schedule, {
+ kind: 'monthly',
+ nth: -1,
+ weekday: 'friday',
+ time: '19:30',
+ })
+})
+
+test('a definition with no schedule at all reads as manual rather than as broken', () => {
+ const form = formFromDefinition({ title: 'x', spec: { phases: [] } })
+ assert.equal(form.scheduleKind, 'manual')
+ assert.deepEqual(scheduleFromForm(form), { kind: 'manual' })
+})
+
+test('every schedule describes as a sentence, and a half-built one says what is missing', () => {
+ assert.match(describeSchedule({ kind: 'manual' }), /by hand/)
+ assert.equal(
+ describeSchedule({ kind: 'weekly', days: ['friday', 'saturday'], time: '20:00' }, 'Europe/Berlin'),
+ 'Every Friday and Saturday at 20:00 (Europe/Berlin)',
+ )
+ assert.equal(
+ describeSchedule({ kind: 'monthly', nth: -1, weekday: 'friday', time: '19:30' }, 'Asia/Kolkata'),
+ 'The last Friday of every month at 19:30 (Asia/Kolkata)',
+ )
+ // Half-built is the state the preview spends most of its life in — an author
+ // is typing. It must prompt, never render "undefined".
+ for (const partial of [
+ { kind: 'weekly', days: [], time: '20:00' },
+ { kind: 'weekly', days: ['friday'], time: '' },
+ { kind: 'monthly', nth: 1, weekday: '', time: '19:00' },
+ { kind: 'once', at: '' },
+ ]) {
+ const text = describeSchedule(partial, 'UTC')
+ assert.ok(text.length > 0)
+ assert.ok(!text.includes('undefined'), `${JSON.stringify(partial)} rendered: ${text}`)
+ assert.match(text, /choose|no date/i)
+ }
+})
+
+test('the weekday and nth vocabularies match the server', () => {
+ // Verbatim `events/recurrence.js`. A client list that drifted would offer a
+ // value the server refuses, which is exactly the class of failure this file
+ // exists to catch.
+ assert.deepEqual(WEEKDAYS, [
+ 'sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday',
+ ])
+ assert.deepEqual(MONTHLY_NTHS.map((n) => n.value), [1, 2, 3, 4, -1])
+})
+
+test('a projection is told apart from a run, because only one of them can be acted on', () => {
+ assert.equal(isProjected({ kind: 'projected', runId: null }), true)
+ assert.equal(isProjected({ kind: 'run', runId: 12 }), false)
+ assert.equal(isProjected(null), false)
+})
diff --git a/server/routes.guards.json b/server/routes.guards.json
index f7e7657..8157a41 100644
--- a/server/routes.guards.json
+++ b/server/routes.guards.json
@@ -491,6 +491,15 @@
"requireAuth"
]
},
+ {
+ "method": "GET",
+ "path": "/api/v1/admin/events/calendar",
+ "handlers": 1,
+ "gates": [
+ "noindex",
+ "requireAuth"
+ ]
+ },
{
"method": "GET",
"path": "/api/v1/admin/events/catalog",
@@ -590,6 +599,33 @@
"requireAuth"
]
},
+ {
+ "method": "POST",
+ "path": "/api/v1/admin/events/series",
+ "handlers": 2,
+ "gates": [
+ "noindex",
+ "requireAuth"
+ ]
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/admin/events/series/:seriesId",
+ "handlers": 2,
+ "gates": [
+ "noindex",
+ "requireAuth"
+ ]
+ },
+ {
+ "method": "PUT",
+ "path": "/api/v1/admin/events/series/:seriesId",
+ "handlers": 2,
+ "gates": [
+ "noindex",
+ "requireAuth"
+ ]
+ },
{
"method": "GET",
"path": "/api/v1/admin/invites",
diff --git a/server/routes.manifest.json b/server/routes.manifest.json
index 388ae08..63c1d8e 100644
--- a/server/routes.manifest.json
+++ b/server/routes.manifest.json
@@ -217,6 +217,10 @@
"method": "GET",
"path": "/api/v1/admin/events/:id/versions"
},
+ {
+ "method": "GET",
+ "path": "/api/v1/admin/events/calendar"
+ },
{
"method": "GET",
"path": "/api/v1/admin/events/catalog"
@@ -261,6 +265,18 @@
"method": "GET",
"path": "/api/v1/admin/events/series"
},
+ {
+ "method": "POST",
+ "path": "/api/v1/admin/events/series"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/admin/events/series/:seriesId"
+ },
+ {
+ "method": "PUT",
+ "path": "/api/v1/admin/events/series/:seriesId"
+ },
{
"method": "GET",
"path": "/api/v1/admin/invites"
diff --git a/server/src/events/recurrence.js b/server/src/events/recurrence.js
new file mode 100644
index 0000000..f28439a
--- /dev/null
+++ b/server/src/events/recurrence.js
@@ -0,0 +1,335 @@
+// ── Occurrence arithmetic, in the event's own timezone ─────────────────────
+//
+// EVENTS.md §E, "Two scheduling decisions the calendar forces", and Phase 4 of
+// EVENTS_PLAN.md. Given a closed recurrence shape, an IANA zone and a window,
+// this file answers *which UTC instants* an event happens at. Nothing else
+// computes an occurrence; the runner materialises what this returns and the
+// calendar projects what this returns, so there is exactly one arithmetic to be
+// wrong.
+//
+// **Why there is no library here.** The server's dependency tree has no date
+// library at all — no luxon, no date-fns, no tz package (check `package.json`
+// before adding one). What it does have is Node's own full tzdata behind
+// `Intl.DateTimeFormat`, which is the same database a library would ship a copy
+// of and is already what `eventDefinitions.model.js` validates a zone name
+// against. So the arithmetic is: *format an instant into the zone's wall clock*
+// (which `Intl` does exactly) and invert that mapping by search. Everything
+// below is that one idea.
+//
+// **Why not cron.** Decided in §E and restated in the plan: there is no parser
+// in the tree, the only precedent is in the bot (another process), and a cron
+// string is the one field an operator cannot proofread. Four closed shapes
+// render as a form, and a form is checkable.
+//
+// **The two DST rules** (org lead, 2026-09-02), which exist because a weekly
+// 02:30 event in `Europe/Berlin` is a real thing an operator will author:
+//
+// - A **nonexistent** local time — the spring-forward gap — steps forward to the
+// first wall clock that does exist. 02:30 becomes 03:00, not 03:30: the event
+// happens as close to the authored time as the calendar allows.
+// - An **ambiguous** local time — the fall-back hour, which happens twice —
+// takes the FIRST, the pre-transition offset.
+//
+// Both are reported back as `adjusted`, so a run can record why its clock reads
+// oddly rather than leaving an operator to discover DST for themselves at 3am.
+// Neither rule ever drops an occurrence: a weekly event happens every week.
+
+// Indexed to match `Date#getUTCDay`, which is what the civil-calendar helpers
+// below return. Names rather than numbers everywhere an operator can see them —
+// `days: ['friday']` is proofreadable and `days: [5]` is not, which is the same
+// argument that rejected cron.
+const WEEKDAYS = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday']
+
+// `nth: -1` is "the last one in the month", and it is not a synonym for 4: a
+// month with five Fridays has a last Friday that is not the fourth. 1..4 always
+// exist in every month (1 + 6 + 21 = 28), so there is deliberately no fifth and
+// therefore no absent-occurrence case to define (org lead, 2026-09-02).
+const MONTHLY_NTH = [1, 2, 3, 4, -1]
+
+const TIME_RE = /^([01][0-9]|2[0-3]):([0-5][0-9])$/
+const AT_RE = /^([0-9]{4})-([0-9]{2})-([0-9]{2})[T ]([01][0-9]|2[0-3]):([0-5][0-9])$/
+
+const MINUTE_MS = 60_000
+const DAY_MS = 86_400_000
+
+// No real DST gap exceeds two hours (Lord Howe's is 30 minutes; the largest
+// historical jumps are a day, and those are line-of-date changes rather than
+// gaps in the local clock). Four hours is a bound, not an expectation: it stops
+// a malformed zone turning the search into a hang.
+const MAX_GAP_MINUTES = 240
+
+// Bounds on what one call may return. A projection window is operator-supplied
+// (the calendar's month, the horizon), and an unbounded expansion of a daily
+// schedule across a decade is how a calendar request becomes an outage.
+const MAX_OCCURRENCES = 500
+
+const formatters = new Map()
+
+function formatterFor(zone) {
+ let f = formatters.get(zone)
+ if (!f) {
+ // `hourCycle: 'h23'` rather than `hour12: false`, which renders midnight as
+ // hour 24 in some ICU versions and would put every midnight event on the
+ // previous day.
+ f = new Intl.DateTimeFormat('en-US', {
+ timeZone: zone,
+ hourCycle: 'h23',
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ })
+ formatters.set(zone, f)
+ }
+ return f
+}
+
+/** The wall clock an instant reads as, in this zone. */
+function wallPartsAt(zone, ms) {
+ const parts = formatterFor(zone).formatToParts(new Date(ms))
+ const get = (type) => Number(parts.find((p) => p.type === type)?.value)
+ return {
+ y: get('year'),
+ m: get('month'),
+ d: get('day'),
+ h: get('hour'),
+ mi: get('minute'),
+ s: get('second'),
+ }
+}
+
+/**
+ * That same wall clock as a number, by reading it as though it were UTC.
+ *
+ * This is the trick the whole file rests on: two wall clocks are equal exactly
+ * when these numbers are, and `wallMs - instant` is the zone's offset at that
+ * instant. It is never a real instant and must not be used as one.
+ */
+function wallMs(zone, ms) {
+ const p = wallPartsAt(zone, ms)
+ return Date.UTC(p.y, p.m - 1, p.d, p.h, p.mi, p.s)
+}
+
+const offsetMs = (zone, ms) => wallMs(zone, ms) - ms
+
+/**
+ * Every instant that reads as this wall clock in this zone, earliest first.
+ *
+ * Ordinarily one. Two in the fall-back hour, none in the spring-forward gap —
+ * and the length of this array is how the caller tells those three apart.
+ *
+ * Sampling the offset a day either side is what makes it correct across a
+ * transition: subtracting each candidate offset gives the two instants worth
+ * testing, and the test is whether the instant formats back to what was asked.
+ */
+function instantsForWall(zone, target) {
+ const candidates = new Set([
+ target - offsetMs(zone, target - DAY_MS),
+ target - offsetMs(zone, target + DAY_MS),
+ ])
+ const valid = []
+ for (const ms of candidates) {
+ if (wallMs(zone, ms) === target) valid.push(ms)
+ }
+ return valid.sort((a, b) => a - b)
+}
+
+/**
+ * Resolve a local wall clock to a UTC instant, applying the two DST rules.
+ *
+ * `{ at, adjusted, shiftMinutes }` — `adjusted` is `null` on an ordinary day,
+ * `'gap'` when the authored time did not exist and was stepped forward, and
+ * `'ambiguous'` when it happened twice and the first was taken.
+ */
+function resolveWall(zone, y, m, d, h, mi) {
+ const target = Date.UTC(y, m - 1, d, h, mi, 0)
+ const valid = instantsForWall(zone, target)
+ if (valid.length === 1) return { at: new Date(valid[0]), adjusted: null, shiftMinutes: 0 }
+ if (valid.length > 1) return { at: new Date(valid[0]), adjusted: 'ambiguous', shiftMinutes: 0 }
+
+ // The gap. Step the WALL CLOCK forward — not the instant — until it lands on
+ // a time that exists, which is the first instant after the transition.
+ for (let step = 1; step <= MAX_GAP_MINUTES; step += 1) {
+ const shifted = instantsForWall(zone, target + step * MINUTE_MS)
+ if (shifted.length) return { at: new Date(shifted[0]), adjusted: 'gap', shiftMinutes: step }
+ }
+ return null
+}
+
+// ── The civil calendar ─────────────────────────────────────────────────────
+//
+// Dates with no zone attached: "the 14th of September" as a thing to iterate,
+// before any question of what instant it starts at. `Date.UTC` is used purely
+// as calendar arithmetic here and none of these numbers is an instant.
+
+const dayIndex = (y, m, d) => Date.UTC(y, m - 1, d) / DAY_MS
+
+function civilFromIndex(n) {
+ const dt = new Date(n * DAY_MS)
+ return { y: dt.getUTCFullYear(), m: dt.getUTCMonth() + 1, d: dt.getUTCDate() }
+}
+
+const weekdayOf = (y, m, d) => new Date(Date.UTC(y, m - 1, d)).getUTCDay()
+
+const daysInMonth = (y, m) => new Date(Date.UTC(y, m, 0)).getUTCDate()
+
+/** Is this a real date? `2026-02-30` parses as a string and is not a day. */
+const isRealDate = (y, m, d) => m >= 1 && m <= 12 && d >= 1 && d <= daysInMonth(y, m)
+
+/**
+ * The day of the month that is the nth (or last) given weekday.
+ *
+ * `nth` is 1..4 or -1. Answers `null` only for an nth that cannot exist, which
+ * the validated shapes never produce — the guard is here so that a spec written
+ * by hand into the database cannot make the runner throw.
+ */
+function nthWeekdayDay(y, m, weekday, nth) {
+ const last = daysInMonth(y, m)
+ if (nth === -1) {
+ const back = (weekdayOf(y, m, last) - weekday + 7) % 7
+ return last - back
+ }
+ const forward = (weekday - weekdayOf(y, m, 1) + 7) % 7
+ const day = 1 + forward + (nth - 1) * 7
+ return day <= last ? day : null
+}
+
+// ── Expansion ──────────────────────────────────────────────────────────────
+
+/**
+ * Every occurrence of `schedule` in `[from, to)`, earliest first.
+ *
+ * `[{ at: Date, adjusted, shiftMinutes }]`. `manual` answers `[]` — it is the
+ * shape that means "there is no recurrence", and an admin's own
+ * `POST /:id/runs` is the only thing that creates one of its occurrences.
+ *
+ * The window is in INSTANTS and the walk is in LOCAL DAYS, which is why each
+ * walk starts a day early and ends a day late: a local day can begin up to
+ * fourteen hours either side of the same UTC day.
+ */
+function occurrencesBetween(schedule, zone, from, to, { limit = MAX_OCCURRENCES } = {}) {
+ const fromMs = from instanceof Date ? from.getTime() : Number(from)
+ const toMs = to instanceof Date ? to.getTime() : Number(to)
+ if (!Number.isFinite(fromMs) || !Number.isFinite(toMs) || toMs <= fromMs) return []
+ if (!schedule || typeof schedule !== 'object') return []
+
+ const cap = Math.min(Math.max(Number(limit) || MAX_OCCURRENCES, 1), MAX_OCCURRENCES)
+ const out = []
+ const keep = (resolved) => {
+ if (!resolved) return
+ const t = resolved.at.getTime()
+ if (t >= fromMs && t < toMs && out.length < cap) out.push(resolved)
+ }
+
+ if (schedule.kind === 'manual') return []
+
+ if (schedule.kind === 'once') {
+ const m = AT_RE.exec(String(schedule.at || ''))
+ if (!m) return []
+ keep(resolveWall(zone, Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4]), Number(m[5])))
+ return out
+ }
+
+ const time = TIME_RE.exec(String(schedule.time || ''))
+ if (!time) return []
+ const hour = Number(time[1])
+ const minute = Number(time[2])
+
+ if (schedule.kind === 'weekly') {
+ const wanted = new Set(
+ (schedule.days || []).map((d) => WEEKDAYS.indexOf(String(d))).filter((i) => i >= 0),
+ )
+ if (!wanted.size) return []
+ const first = wallPartsAt(zone, fromMs)
+ const last = wallPartsAt(zone, toMs)
+ const startDay = dayIndex(first.y, first.m, first.d) - 1
+ const endDay = dayIndex(last.y, last.m, last.d) + 1
+ for (let n = startDay; n <= endDay && out.length < cap; n += 1) {
+ const { y, m, d } = civilFromIndex(n)
+ if (wanted.has(weekdayOf(y, m, d))) keep(resolveWall(zone, y, m, d, hour, minute))
+ }
+ return out
+ }
+
+ if (schedule.kind === 'monthly') {
+ const weekday = WEEKDAYS.indexOf(String(schedule.weekday))
+ const nth = Number(schedule.nth)
+ if (weekday < 0 || !MONTHLY_NTH.includes(nth)) return []
+ const first = wallPartsAt(zone, fromMs)
+ const last = wallPartsAt(zone, toMs)
+ // Months as a single running count, so a window crossing a new year is not
+ // a special case.
+ const startMonth = first.y * 12 + (first.m - 1) - 1
+ const endMonth = last.y * 12 + (last.m - 1) + 1
+ for (let n = startMonth; n <= endMonth && out.length < cap; n += 1) {
+ const y = Math.floor(n / 12)
+ const m = (n % 12) + 1
+ const day = nthWeekdayDay(y, m, weekday, nth)
+ if (day) keep(resolveWall(zone, y, m, day, hour, minute))
+ }
+ return out
+ }
+
+ return []
+}
+
+/** The next occurrence at or after `from`, or null. A bounded look-ahead. */
+function nextOccurrence(schedule, zone, from, { withinDays = 400 } = {}) {
+ const fromMs = from instanceof Date ? from.getTime() : Number(from)
+ const [first] = occurrencesBetween(schedule, zone, fromMs, fromMs + withinDays * DAY_MS, {
+ limit: 1,
+ })
+ return first || null
+}
+
+/**
+ * How a schedule reads to a person, in the event's own zone.
+ *
+ * Server-side because two surfaces need the same sentence — the calendar's list
+ * and the run's own record of why it exists — and because the client's copy in
+ * `eventAuthoring.js` is a mirror that is allowed to drift on wording but not on
+ * meaning.
+ */
+function describe(schedule, zone = 'UTC') {
+ if (!schedule || typeof schedule !== 'object') return 'No schedule'
+ const cap = (s) => String(s).charAt(0).toUpperCase() + String(s).slice(1)
+ const nthLabel = { 1: 'first', 2: 'second', 3: 'third', 4: 'fourth', '-1': 'last' }
+ switch (schedule.kind) {
+ case 'manual':
+ return 'Started by hand'
+ case 'once':
+ return `Once, on ${String(schedule.at).replace('T', ' ')} (${zone})`
+ case 'weekly': {
+ const days = (schedule.days || []).map(cap)
+ const list =
+ days.length <= 1
+ ? days.join('')
+ : `${days.slice(0, -1).join(', ')} and ${days[days.length - 1]}`
+ return `Every ${list} at ${schedule.time} (${zone})`
+ }
+ case 'monthly':
+ return `The ${nthLabel[String(schedule.nth)]} ${cap(schedule.weekday)} of every month at ${schedule.time} (${zone})`
+ default:
+ return 'No schedule'
+ }
+}
+
+module.exports = {
+ WEEKDAYS,
+ MONTHLY_NTH,
+ TIME_RE,
+ AT_RE,
+ MAX_OCCURRENCES,
+ DAY_MS,
+ wallPartsAt,
+ offsetMs,
+ instantsForWall,
+ resolveWall,
+ isRealDate,
+ nthWeekdayDay,
+ occurrencesBetween,
+ nextOccurrence,
+ describe,
+}
diff --git a/server/src/events/spec.js b/server/src/events/spec.js
index 7551d96..da888f8 100644
--- a/server/src/events/spec.js
+++ b/server/src/events/spec.js
@@ -12,18 +12,24 @@
// one that decides. A spec arriving by any other route (a restore, a fixture, a
// module shipping a definition as content) gets the same answer.
//
-// **What Phase 1 knows, and what it deliberately refuses.** Two top-level keys
-// exist today: `schedule` and `phases`. `schedule` accepts only `{ kind:
-// 'manual' }`, because Phase 4 is what computes an occurrence from a recurrence
-// in an IANA zone and a spec that could name `weekly` before then would be a
-// schedule nothing honours. Unknown top-level keys are REFUSED rather than
-// preserved: a spec that silently carries `announcements` today is a spec whose
-// author believes announcements work, and the later phase that gives the key
-// meaning would inherit a corpus of unvalidated ones. The refusal list is the
-// changelog — Phase 4 adds the recurrence shapes, Phase 5 adds a phase's
+// **What this file knows, and what it deliberately refuses.** Two top-level keys
+// exist today: `schedule` and `phases`. Unknown top-level keys are REFUSED rather
+// than preserved: a spec that silently carries `announcements` today is a spec
+// whose author believes announcements work, and the later phase that gives the
+// key meaning would inherit a corpus of unvalidated ones. The refusal list is the
+// changelog — Phase 4 added the recurrence shapes, Phase 5 adds a phase's
// `advance`, Phase 10 adds `announcements`.
+//
+// **Phase 4 widened `schedule` from one shape to four** — `manual`, `once`,
+// `weekly`, `monthly` — and every check on them is a check on SHAPE. The
+// arithmetic they describe lives in `events/recurrence.js`, and the zone they are
+// computed in is `event_definitions.timezone`, a sibling column this file cannot
+// see and does not need to: a well-formed wall clock resolves in every zone (a
+// DST gap shifts it, it is never rejected), so a schedule that validates here
+// computes there.
const registries = require('../modules/registries')
+const recurrence = require('./recurrence')
const { checkLiteral } = require('../engagement/conditions')
// A phase key is a slug: it is stored in `event_run_steps.phase`, it is what the
@@ -39,11 +45,20 @@ const MAX_PHASES = 40
const MAX_STEPS_PER_PHASE = 100
const MAX_STEPS = 500
-// The schedule shapes this phase understands. Phase 4 replaces this list with
-// the four closed shapes of §E — `once`, `weekly`, `monthly`, `manual` — and
-// their timezone arithmetic. It is a list of one rather than an implicit default
-// so that the widening is a diff on this line.
-const SCHEDULE_KINDS = ['manual']
+// The four closed shapes of §E. `manual` is first because it is the default and
+// what an unscheduled draft carries; the other three are recurrences the runner
+// expands into occurrences ahead of time.
+const SCHEDULE_KINDS = ['manual', 'once', 'weekly', 'monthly']
+
+// The keys each shape may carry, and the ONLY ones. A `weekly` that also names
+// an `at` is an author who believes something about it that is not true — the
+// same argument the top-level refusal makes, one level down.
+const SCHEDULE_KEYS = {
+ manual: [],
+ once: ['at'],
+ weekly: ['days', 'time'],
+ monthly: ['nth', 'weekday', 'time'],
+}
// What a step does when its attempts are exhausted (§L). The disposition only —
// retry is not one of the values, it is what happens BEFORE one of them. Each
@@ -63,6 +78,86 @@ const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArr
/** The default disposition for an action whose risk class core knows. */
const defaultOnFailure = (risk) => ON_FAILURE_BY_RISK[risk] || 'pause'
+/**
+ * Check one schedule shape and answer the normalised form of it.
+ *
+ * Always answers a valid schedule — `{ kind: 'manual' }` when the input was not
+ * one — because `validate` collects every error and carries on, and a caller
+ * reading `spec.schedule.days` of a refused spec should find an empty recurrence
+ * rather than a half-built one.
+ *
+ * **`days` is normalised into week order**, not into the order they were typed.
+ * The spec is compared, described and diffed, and `['friday','monday']` and
+ * `['monday','friday']` naming the same schedule while differing as JSON is a
+ * version history that reports edits nobody made.
+ */
+function validateSchedule(kind, raw, errors) {
+ const at = (key) => `spec.schedule.${key}`
+
+ if (kind === 'once') {
+ const m = recurrence.AT_RE.exec(String(raw.at ?? ''))
+ if (!m) {
+ errors.push(`${at('at')}: expected a local date and time as YYYY-MM-DDTHH:MM`)
+ return { kind: 'manual' }
+ }
+ const [, y, mo, d, h, mi] = m.map(Number)
+ // The regex admits `2026-02-30`, which is a string and not a day.
+ if (!recurrence.isRealDate(y, mo, d)) {
+ errors.push(`${at('at')}: "${raw.at}" is not a real date`)
+ return { kind: 'manual' }
+ }
+ // Stored as the operator wrote it — a wall clock in the definition's own
+ // zone, never a UTC instant. §E: the schedule belongs to the event, and the
+ // instant is derived at materialisation.
+ const pad = (n) => String(n).padStart(2, '0')
+ return { kind: 'once', at: `${y}-${pad(mo)}-${pad(d)}T${pad(h)}:${pad(mi)}` }
+ }
+
+ if (kind === 'weekly' || kind === 'monthly') {
+ const time = recurrence.TIME_RE.test(String(raw.time ?? '')) ? String(raw.time) : null
+ if (!time) errors.push(`${at('time')}: expected a 24-hour time as HH:MM`)
+
+ if (kind === 'weekly') {
+ const rawDays = Array.isArray(raw.days) ? raw.days : null
+ if (!rawDays || rawDays.length === 0) {
+ errors.push(`${at('days')}: expected a non-empty array of weekday names`)
+ return { kind: 'manual' }
+ }
+ const unknown = rawDays.filter((d) => !recurrence.WEEKDAYS.includes(String(d).toLowerCase()))
+ if (unknown.length) {
+ errors.push(
+ `${at('days')}: unknown weekday(s) ${unknown.join(', ')} — expected ${recurrence.WEEKDAYS.join(', ')}`,
+ )
+ }
+ const days = recurrence.WEEKDAYS.filter((name) =>
+ rawDays.some((d) => String(d).toLowerCase() === name),
+ )
+ if (!time || !days.length) return { kind: 'manual' }
+ return { kind: 'weekly', days, time }
+ }
+
+ const weekday = String(raw.weekday ?? '').toLowerCase()
+ if (!recurrence.WEEKDAYS.includes(weekday)) {
+ errors.push(
+ `${at('weekday')}: expected one of ${recurrence.WEEKDAYS.join(', ')}`,
+ )
+ }
+ const nth = Number(raw.nth)
+ if (!recurrence.MONTHLY_NTH.includes(nth)) {
+ // -1 is "last", which a month with five Fridays makes different from 4.
+ // There is no 5: every month has a first through fourth of every weekday,
+ // so the closed set has no absent case (org lead, 2026-09-02).
+ errors.push(`${at('nth')}: expected 1, 2, 3, 4 or -1 (last)`)
+ }
+ if (!time || !recurrence.WEEKDAYS.includes(weekday) || !recurrence.MONTHLY_NTH.includes(nth)) {
+ return { kind: 'manual' }
+ }
+ return { kind: 'monthly', nth, weekday, time }
+ }
+
+ return { kind: 'manual' }
+}
+
/**
* Check one authored param object against an action's declared params.
*
@@ -136,18 +231,21 @@ function validate(raw, { knownActionIds = [] } = {}) {
}
// ── schedule ──
- const rawSchedule = raw.schedule === undefined || raw.schedule === null ? { kind: 'manual' } : raw.schedule
+ const rawSchedule =
+ raw.schedule === undefined || raw.schedule === null ? { kind: 'manual' } : raw.schedule
let schedule = { kind: 'manual' }
if (!isPlainObject(rawSchedule)) {
errors.push('spec.schedule: expected an object')
} else if (!SCHEDULE_KINDS.includes(rawSchedule.kind)) {
- errors.push(
- `spec.schedule: kind must be one of ${SCHEDULE_KINDS.join(', ')} (recurrence arrives in Phase 4)`,
- )
+ errors.push(`spec.schedule: kind must be one of ${SCHEDULE_KINDS.join(', ')}`)
} else {
- const extra = Object.keys(rawSchedule).filter((k) => k !== 'kind')
- if (extra.length) errors.push(`spec.schedule: unknown key(s) ${extra.join(', ')}`)
- schedule = { kind: rawSchedule.kind }
+ const kind = rawSchedule.kind
+ const allowedKeys = new Set(['kind', ...SCHEDULE_KEYS[kind]])
+ const extra = Object.keys(rawSchedule).filter((k) => !allowedKeys.has(k))
+ if (extra.length) {
+ errors.push(`spec.schedule: unknown key(s) ${extra.join(', ')} for kind "${kind}"`)
+ }
+ schedule = validateSchedule(kind, rawSchedule, errors)
}
// ── phases ──
diff --git a/server/src/model/events/eventCalendar.model.js b/server/src/model/events/eventCalendar.model.js
new file mode 100644
index 0000000..3dedc85
--- /dev/null
+++ b/server/src/model/events/eventCalendar.model.js
@@ -0,0 +1,170 @@
+// ── 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 }
diff --git a/server/src/model/events/eventDefinitions.db.js b/server/src/model/events/eventDefinitions.db.js
index 0794cfe..c237e79 100644
--- a/server/src/model/events/eventDefinitions.db.js
+++ b/server/src/model/events/eventDefinitions.db.js
@@ -114,6 +114,39 @@ const markReady = (id, versionId, userId) =>
[versionId, userId, id],
)
+/**
+ * Every definition the runner should expand a recurrence for (Phase 4).
+ *
+ * `ready` is the whole gate, and it is deliberately the only one: EVENTS.md §E
+ * defines `ready` as "a version has been published and the schedule is live", so
+ * publishing IS the switch and archiving is how an operator turns a recurrence
+ * off. A separate schedule-enabled flag would be a second answer to a question
+ * `state` already answers, and the two would eventually disagree.
+ *
+ * The VERSION's spec is joined rather than the definition's working copy: the
+ * draft is what an author is midway through editing, and a half-typed `weekly`
+ * must never materialise anything. The pinned spec comes back with it, so the
+ * whole expansion is one round trip.
+ *
+ * The series columns are here for the CALENDAR rather than the runner, which
+ * ignores them: a projected occurrence has to be filterable and labellable by
+ * its arc exactly as a materialised run is, and a second query to learn the name
+ * of a row this one already reached would be two round trips for a join.
+ */
+const findSchedulable = async () => {
+ const rows = await query(
+ `SELECT d.id, d.title, d.slug, d.timezone, d.grace_seconds, d.concurrency_key,
+ d.current_version_id, d.series_id, v.spec AS version_spec,
+ s.name AS series_name, s.slug AS series_slug
+ FROM event_definitions d
+ JOIN event_versions v ON v.id = d.current_version_id
+ LEFT JOIN event_series s ON s.id = d.series_id
+ WHERE d.state = 'ready'
+ ORDER BY d.id`,
+ )
+ return rows.map((row) => ({ ...row, version_spec: parseJson(row.version_spec, null) }))
+}
+
/**
* Archive. Never a hard delete while runs reference it (§ API surface) — and the
* schema would refuse one anyway, because `event_runs.version_id` RESTRICTs.
@@ -127,6 +160,7 @@ module.exports = {
getById,
getBySlug,
slugTaken,
+ findSchedulable,
insert,
update,
markReady,
diff --git a/server/src/model/events/eventDefinitions.model.js b/server/src/model/events/eventDefinitions.model.js
index be5008f..813c2ce 100644
--- a/server/src/model/events/eventDefinitions.model.js
+++ b/server/src/model/events/eventDefinitions.model.js
@@ -23,6 +23,7 @@
const db = require('./eventDefinitions.db')
const versionsDb = require('./eventVersions.db')
const runsDb = require('./eventRuns.db')
+const logDb = require('./eventRunLog.db')
const seriesDb = require('./eventSeries.db')
const spec = require('../../events/spec')
const { slugify, uniqueSlug } = require('../teams/teamSlug')
@@ -229,7 +230,31 @@ async function publish(id, userId) {
const version = await versionsDb.nextVersion(id)
const versionId = await versionsDb.insert(id, version, checked.spec, userId)
await db.markReady(id, versionId, userId)
- return { ok: true, versionId, version, definition: await db.getById(id) }
+
+ // Occurrences already materialised ahead of their instant move to the new
+ // version; ones that have begun do not (org lead, 2026-09-02). Logged per run
+ // rather than only counted, because "which version did this run actually use"
+ // is the first question an audit asks and the pin is no longer immutable while
+ // a run is still `scheduled`.
+ const pending = await runsDb.listScheduledFor(id)
+ const stale = pending.filter((r) => Number(r.version_id) !== Number(versionId))
+ const repinned = stale.length ? await runsDb.repinScheduled(id, versionId) : 0
+ for (const run of stale) {
+ await logDb.write({
+ runId: run.id,
+ kind: 'run.status',
+ detail: {
+ to: 'scheduled',
+ repinned: true,
+ fromVersionId: run.version_id,
+ toVersionId: versionId,
+ toVersion: version,
+ by: userId,
+ },
+ })
+ }
+
+ return { ok: true, versionId, version, repinned, definition: await db.getById(id) }
}
/**
diff --git a/server/src/model/events/eventRuns.db.js b/server/src/model/events/eventRuns.db.js
index 3c976f3..e537435 100644
--- a/server/src/model/events/eventRuns.db.js
+++ b/server/src/model/events/eventRuns.db.js
@@ -97,6 +97,92 @@ const materialise = async (run) => {
return Number(result?.affectedRows || 0) === 1 ? result.insertId : null
}
+/**
+ * Every run whose instant falls inside a window — the calendar's real half.
+ *
+ * Ascending, unlike the admin run list: a calendar is read forwards. The join
+ * reaches the series so a month can be filtered to one arc without a second
+ * round trip, and `d.timezone` is NOT what comes back — `r.timezone` is, because
+ * a run records the zone it was COMPUTED in and a definition's zone can be
+ * edited afterwards.
+ */
+const listInWindow = async ({ from, to, status = null, scope = null, seriesId = null, limit = 500 } = {}) => {
+ const where = ['r.scheduled_for >= ?', 'r.scheduled_for < ?']
+ const args = [from, to]
+ if (status) {
+ where.push('r.status = ?')
+ args.push(status)
+ }
+ if (scope !== null && scope !== undefined) {
+ where.push('r.scope = ?')
+ args.push(scope)
+ }
+ if (seriesId) {
+ where.push('d.series_id = ?')
+ args.push(seriesId)
+ }
+ const n = Math.min(Math.max(Number(limit) || 500, 1), 1000)
+ const rows = await query(
+ `SELECT r.*, d.title AS definition_title, d.slug AS definition_slug,
+ d.series_id AS series_id, se.name AS series_name, se.slug AS series_slug,
+ v.version AS version_number,
+ (SELECT COUNT(*) FROM event_run_steps s
+ WHERE s.run_id = r.id AND s.status = 'running' AND s.claim_expires_at IS NULL) AS waiting_steps
+ FROM event_runs r
+ JOIN event_definitions d ON d.id = r.definition_id
+ JOIN event_versions v ON v.id = r.version_id
+ LEFT JOIN event_series se ON se.id = d.series_id
+ WHERE ${where.join(' AND ')}
+ ORDER BY r.scheduled_for, r.id
+ LIMIT ${n}`,
+ args,
+ )
+ return rows.map(hydrate)
+}
+
+/**
+ * Point every not-yet-started occurrence of a definition at a new version.
+ *
+ * Publishing calls this, and the guard is the whole statement: `status =
+ * 'scheduled'` and `started_at IS NULL`. A run that has begun keeps the version
+ * it pinned, for ever, because that pin is what makes it explicable afterwards
+ * -- and a run that has NOT begun has nothing to explain yet.
+ *
+ * **Why re-pinning is the right answer and doing nothing is not** (org lead,
+ * 2026-09-02): occurrences are materialised a fortnight ahead, so on the day an
+ * editor fixes a typo there are already fourteen days of rows carrying the old
+ * spec. Left alone, the fix reaches none of them, and the operator's only
+ * recourse -- cancelling each one -- is worse: a cancelled row still holds its
+ * slot in `uq_evrun_occurrence`, so the occurrence does not come back on the new
+ * version, it disappears.
+ *
+ * Answers how many were moved, so publish can say so rather than leaving it to
+ * be noticed.
+ */
+const repinScheduled = async (definitionId, versionId) => {
+ const result = await query(
+ `UPDATE event_runs
+ SET version_id = ?
+ WHERE definition_id = ?
+ AND status = 'scheduled'
+ AND started_at IS NULL
+ AND version_id <> ?`,
+ [versionId, definitionId, versionId],
+ )
+ return Number(result?.affectedRows || 0)
+}
+
+/** The scheduled, not-yet-started occurrences a re-pin would move. */
+const listScheduledFor = async (definitionId) =>
+ (
+ await query(
+ `SELECT id, version_id, scheduled_for FROM event_runs
+ WHERE definition_id = ? AND status = 'scheduled' AND started_at IS NULL
+ ORDER BY scheduled_for`,
+ [definitionId],
+ )
+ ).map(hydrate)
+
/** The occurrence the unique key names, whether or not this call created it. */
const findOccurrence = async (definitionId, scope, scheduledFor) => {
const [row] = await query(
@@ -370,6 +456,9 @@ module.exports = {
list,
getById,
materialise,
+ listInWindow,
+ repinScheduled,
+ listScheduledFor,
findOccurrence,
countActiveForDefinition,
findDue,
diff --git a/server/src/model/events/eventRuns.model.js b/server/src/model/events/eventRuns.model.js
index 34294c5..f1dbd20 100644
--- a/server/src/model/events/eventRuns.model.js
+++ b/server/src/model/events/eventRuns.model.js
@@ -50,9 +50,21 @@ function renderConcurrencyKey(template, params) {
*
* `scheduledFor` defaults to now — "start now" is an occurrence whose instant is
* the present, not a separate concept, which is what keeps the runner's one
- * materialise/advance path honest when Phase 4 adds recurrence on top.
+ * materialise/advance path honest now that Phase 4 has put recurrence on top.
+ *
+ * **Phase 4's expansion calls this, rather than a second insert path beside it.**
+ * That is deliberate: every check here — the definition is still `ready`, the
+ * version still has phases, the concurrency key renders, the first phase's steps
+ * are materialised with their idempotency keys — is one a scheduled occurrence
+ * needs at least as much as a hand-started one, because there is nobody watching
+ * when it happens. The `INSERT IGNORE` answering `created: false` is what makes
+ * it safe to call on every tick for every occurrence inside the horizon.
*/
-async function create(definitionId, { scope = '', scheduledFor = null, rehearsal = false, params = null } = {}, userId) {
+async function create(
+ definitionId,
+ { scope = '', scheduledFor = null, rehearsal = false, params = null, source = 'manual' } = {},
+ userId,
+) {
const definition = await definitionsDb.getById(definitionId)
if (!definition) return { ok: false, status: 404, errors: ['no such event definition'] }
if (definition.state !== 'ready') {
@@ -107,6 +119,11 @@ async function create(definitionId, { scope = '', scheduledFor = null, rehearsal
version: version.version,
scope: scopeValue,
rehearsal: Boolean(rehearsal),
+ // 'manual' is an admin pressing start; 'schedule' is the runner expanding
+ // a recurrence (Phase 4). Both produce the same row, and the log is the
+ // only place the difference is recorded — `started_by` is NULL for both a
+ // scheduled occurrence and one started by a since-deleted account.
+ source,
by: userId,
},
})
diff --git a/server/src/model/events/eventSeries.db.js b/server/src/model/events/eventSeries.db.js
index 2ed3729..17a6658 100644
--- a/server/src/model/events/eventSeries.db.js
+++ b/server/src/model/events/eventSeries.db.js
@@ -1,17 +1,31 @@
// ── event_series — SQL only ────────────────────────────────────────────────
//
-// EVENTS.md §D. The arc a definition may belong to. Phase 1 needs the reads —
-// `event_definitions.series_id` is a foreign key and the definition save path
-// has to check it resolves — and creating one is Phase 4's, where the calendar
-// is what makes an arc visible.
+// EVENTS.md §D. The arc a definition may belong to. Phase 1 needed only the
+// reads — `event_definitions.series_id` is a foreign key and the definition save
+// path has to check it resolves — and Phase 4 adds the writes, because the
+// calendar is what makes an arc visible and a form cannot offer a value nobody
+// can create.
+//
+// `ordering` here places a SERIES among the others on the calendar. A
+// definition's place WITHIN its arc is `event_definitions.series_order`, which
+// is the column an editor drags; the two are deliberately different columns on
+// different tables and the schema comment says so.
const { query } = require('../../utils/db')
-const list = async () =>
- query('SELECT * FROM event_series ORDER BY ordering, name, id')
+// `definition_count` is a correlated subquery rather than a join with a GROUP BY:
+// the list is a handful of rows, and the delete path needs the same number to
+// tell an operator what they are about to detach.
+const SELECT_LIST = `
+ SELECT s.*,
+ (SELECT COUNT(*) FROM event_definitions d WHERE d.series_id = s.id) AS definition_count
+ FROM event_series s
+`
+
+const list = async () => query(`${SELECT_LIST} ORDER BY s.ordering, s.name, s.id`)
const getById = async (id) => {
- const [row] = await query('SELECT * FROM event_series WHERE id = ?', [id])
+ const [row] = await query(`${SELECT_LIST} WHERE s.id = ?`, [id])
return row || null
}
@@ -20,4 +34,40 @@ const exists = async (id) => {
return Boolean(row)
}
-module.exports = { list, getById, exists }
+/** Does any OTHER series hold this slug? The uniqueness pre-check. */
+const slugTaken = async (slug, exceptId = null) => {
+ const rows = exceptId
+ ? await query('SELECT id FROM event_series WHERE slug = ? AND id <> ?', [slug, exceptId])
+ : await query('SELECT id FROM event_series WHERE slug = ?', [slug])
+ return rows.length > 0
+}
+
+const insert = async (s) => {
+ const result = await query(
+ `INSERT INTO event_series (name, slug, description, ordering, created_by)
+ VALUES (?, ?, ?, ?, ?)`,
+ [s.name, s.slug, s.description, s.ordering, s.created_by],
+ )
+ return Number(result.insertId)
+}
+
+const update = (id, s) =>
+ query(
+ `UPDATE event_series SET name = ?, slug = ?, description = ?, ordering = ? WHERE id = ?`,
+ [s.name, s.slug, s.description, s.ordering, id],
+ )
+
+/**
+ * A hard delete, and the one place in this feature that is one.
+ *
+ * A series is a label rather than authored content: nothing pins one, no run
+ * references one, and `event_definitions.series_id` is `ON DELETE SET NULL`, so
+ * removing a series detaches its definitions and destroys nothing. That is why
+ * it is not archived the way a definition is — an archived label would be a
+ * state every calendar query has to remember for no benefit. The model answers
+ * with how many definitions were detached, so the operator learns what happened
+ * rather than discovering it on the calendar.
+ */
+const remove = (id) => query('DELETE FROM event_series WHERE id = ?', [id])
+
+module.exports = { list, getById, exists, slugTaken, insert, update, remove }
diff --git a/server/src/model/events/eventSeries.model.js b/server/src/model/events/eventSeries.model.js
new file mode 100644
index 0000000..04eb1e1
--- /dev/null
+++ b/server/src/model/events/eventSeries.model.js
@@ -0,0 +1,93 @@
+// ── Event series — the arc ─────────────────────────────────────────────────
+//
+// EVENTS.md §D and §I. "Royal Spy Mission → Risky Partner → Message From the
+// Void" is continuity that exists nowhere in the tooling this feature replaces
+// (§ "What the real calendar shows, and what it is missing": *no series or
+// recurrence field*). One small table buys it, and this is the policy half.
+//
+// **Why the writes are `admin, editor` and not `admin`.** A series is authoring,
+// and it is the same act as writing the definition that goes in it — §N2's
+// narrow gate is about *committing the deployment to a run* (publish, start),
+// which naming an arc does not do. An editor who can write the events but not
+// the arc they belong to would have to ask an admin to type a title.
+//
+// **A slug is derived once and then frozen**, exactly as a definition's is: the
+// public arc page lives at `/events/series/:slug` (Phase 14), and a slug that
+// moved would break every link to it. Renaming the series is free.
+
+const db = require('./eventSeries.db')
+const { slugify, uniqueSlug } = require('../teams/teamSlug')
+
+const MAX_NAME = 160
+const MAX_DESCRIPTION = 2000
+
+const trimOrNull = (v, max) => {
+ if (v === undefined || v === null) return null
+ const s = String(v).trim()
+ return s === '' ? null : s.slice(0, max)
+}
+
+const list = () => db.list()
+
+const getById = (id) => db.getById(id)
+
+async function validate(input, { existing = null } = {}) {
+ const errors = []
+ const body = input && typeof input === 'object' ? input : {}
+
+ const name = trimOrNull(body.name, MAX_NAME)
+ if (!name) errors.push('name is required')
+
+ const description = trimOrNull(body.description, MAX_DESCRIPTION)
+
+ const orderingRaw = body.ordering === undefined ? (existing?.ordering ?? 0) : body.ordering
+ const ordering = Number(orderingRaw)
+ if (!Number.isInteger(ordering) || ordering < 0 || ordering > 9999) {
+ errors.push('ordering must be an integer 0..9999')
+ }
+
+ if (errors.length) return { ok: false, errors }
+ return { ok: true, series: { name, description, ordering } }
+}
+
+async function create(input, userId) {
+ const checked = await validate(input)
+ if (!checked.ok) return { ok: false, status: 400, errors: checked.errors }
+
+ // The taken set is read here rather than inside `uniqueSlug` because that
+ // helper is pure — the same shape the team and definition paths use.
+ const taken = (await db.list()).map((s) => s.slug)
+ const slug = uniqueSlug(checked.series.name, taken, { fallback: 'series' })
+
+ const id = await db.insert({ ...checked.series, slug, created_by: userId || null })
+ return { ok: true, status: 201, series: await db.getById(id) }
+}
+
+async function update(id, input, userId) {
+ const existing = await db.getById(id)
+ if (!existing) return { ok: false, status: 404, errors: ['no such series'] }
+
+ const checked = await validate(input, { existing })
+ if (!checked.ok) return { ok: false, status: 400, errors: checked.errors }
+
+ // The slug is the existing one, deliberately: renaming a series must not move
+ // the address its arc page lives at.
+ await db.update(id, { ...checked.series, slug: existing.slug })
+ return { ok: true, status: 200, series: await db.getById(id) }
+}
+
+/**
+ * Delete a series, detaching whatever belonged to it.
+ *
+ * The count comes back so the caller can say *"3 events were detached"* rather
+ * than leaving an operator to notice on the calendar. `series_id` is
+ * `ON DELETE SET NULL`, so nothing is destroyed and re-attaching is a dropdown.
+ */
+async function remove(id) {
+ const existing = await db.getById(id)
+ if (!existing) return { ok: false, status: 404, errors: ['no such series'] }
+ await db.remove(id)
+ return { ok: true, status: 200, detached: Number(existing.definition_count || 0) }
+}
+
+module.exports = { list, getById, validate, create, update, remove, slugify, MAX_NAME }
diff --git a/server/src/router/v1/admin/events.controller.js b/server/src/router/v1/admin/events.controller.js
index a9b0900..a070cbe 100644
--- a/server/src/router/v1/admin/events.controller.js
+++ b/server/src/router/v1/admin/events.controller.js
@@ -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,
})
}
diff --git a/server/src/router/v1/admin/events.router.js b/server/src/router/v1/admin/events.router.js
index 9c1360b..980cf62 100644
--- a/server/src/router/v1/admin/events.router.js
+++ b/server/src/router/v1/admin/events.router.js
@@ -18,8 +18,13 @@
// stubbed — there is no advance condition until Phase 5, no resource ledger
// until Phase 8 and no caps to price against until Phase 6.
//
-// **Literal paths are declared before `/:id`**, so `/catalog`, `/series` and
-// `/runs` are never read as an event id.
+// **Literal paths are declared before `/:id`**, so `/catalog`, `/series`,
+// `/calendar` and `/runs` are never read as an event id.
+//
+// **Phase 4 added the series writes and the calendar.** The 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 calendar is a
+// staff read like every other read here.
const express = require('express')
@@ -51,13 +56,74 @@ eventsRouter.get(
'/series',
// #swagger.tags = ['Admin · Events']
// #swagger.summary = 'List the event series a definition may belong to'
- // #swagger.description = 'A series is the arc several definitions form together. Read-only in this phase: creating and ordering one arrives with the calendar.'
+ // #swagger.description = 'A series is the arc several definitions form together - Royal Spy Mission then Risky Partner then Message From the Void - which is continuity the tooling this feature replaces has no field for at all. definitionCount is how many definitions currently belong to each.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The series', content: { "application/json": { schema: { type: "object", properties: { series: { type: "array", items: { type: "object", properties: { id: { type: "integer" }, name: { type: "string" }, slug: { type: "string" }, description: { type: "string", nullable: true }, ordering: { type: "integer" } } } } } } } } } */
/* #swagger.responses[403] = { description: 'Not staff', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
controller.listSeries,
)
+eventsRouter.post(
+ '/series',
+ // #swagger.tags = ['Admin · Events']
+ // #swagger.summary = 'Create an event series'
+ // #swagger.description = 'Admin or editor, not admin alone: naming an arc is authoring, and the narrow gate of section N2 is about committing the deployment to a run (publish, start), which this does not. The slug is derived from the name once and then frozen, because the public arc page lives at it; renaming the series afterwards is free. ordering places this series among the others on the calendar, and is not a position within it - a definition place in its arc is its own seriesOrder.'
+ // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
+ /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { name: { type: "string" }, description: { type: "string", nullable: true }, ordering: { type: "integer" } }, required: ["name"] } } } } */
+ /* #swagger.responses[201] = { description: 'The created series', content: { "application/json": { schema: { type: "object", properties: { series: { type: "object", additionalProperties: true } } } } } } */
+ /* #swagger.responses[400] = { description: 'Validation failed', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
+ /* #swagger.responses[403] = { description: 'Not an admin or editor', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ adminOrEditor,
+ controller.createSeries,
+)
+
+eventsRouter.put(
+ '/series/:seriesId',
+ // #swagger.tags = ['Admin · Events']
+ // #swagger.summary = 'Rename or reorder an event series'
+ // #swagger.description = 'The slug is deliberately not editable: it is the address the arc page lives at, and a slug that moved would break every link to it.'
+ // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
+ /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { name: { type: "string" }, description: { type: "string", nullable: true }, ordering: { type: "integer" } }, required: ["name"] } } } } */
+ /* #swagger.responses[200] = { description: 'The updated series', content: { "application/json": { schema: { type: "object", properties: { series: { type: "object", additionalProperties: true } } } } } } */
+ /* #swagger.responses[400] = { description: 'Validation failed', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
+ /* #swagger.responses[404] = { description: 'No such series', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ /* #swagger.responses[403] = { description: 'Not an admin or editor', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ adminOrEditor,
+ controller.updateSeries,
+)
+
+eventsRouter.delete(
+ '/series/:seriesId',
+ // #swagger.tags = ['Admin · Events']
+ // #swagger.summary = 'Delete an event series, detaching whatever belonged to it'
+ // #swagger.description = 'A hard delete, and the only one in this feature - a definition is archived instead. A series is a label rather than authored content: nothing pins one, no run references one, and event_definitions.series_id is ON DELETE SET NULL, so its definitions survive without an arc and re-attaching is a dropdown. The response says how many were detached.'
+ // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
+ /* #swagger.responses[200] = { description: 'Deleted; detached is how many definitions lost their series', content: { "application/json": { schema: { type: "object", properties: { ok: { type: "boolean" }, detached: { type: "integer" } } } } } } */
+ /* #swagger.responses[404] = { description: 'No such series', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ /* #swagger.responses[403] = { description: 'Not an admin or editor', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ adminOrEditor,
+ controller.deleteSeries,
+)
+
+// ── The calendar ────────────────────────────────────────────────────
+
+eventsRouter.get(
+ '/calendar',
+ // #swagger.tags = ['Admin · Events']
+ // #swagger.summary = 'The calendar for a window: materialised runs and projected occurrences'
+ // #swagger.description = 'Staff, like every other read here. Each entry is one of two kinds and the difference matters: a run entry is a real row with a status, a pinned version and a console, and somebody can cancel it; a projected entry is arithmetic - no row, nothing committed, nothing to cancel. Runs exist inside the runner materialisation horizon (14 days by default, horizonDays in the response); beyond it the same recurrence arithmetic forecasts what will be materialised, so a monthly event is still visible three weeks out. A projection is never emitted for an instant a run already occupies, which is also why a cancelled occurrence does not reappear as a forecast. Instants are UTC and each entry carries the event own IANA zone: the event owns the time, the reader owns the calendar. Filtering by status or by a named scope suppresses projections, because a forecast has no status and automatic expansion happens at the empty scope.'
+ // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
+ // #swagger.parameters['from'] = { in: 'query', description: 'Window start, a UTC instant', required: true, schema: { type: 'string' } }
+ // #swagger.parameters['to'] = { in: 'query', description: 'Window end, a UTC instant. At most 92 days after from', required: true, schema: { type: 'string' } }
+ // #swagger.parameters['status'] = { in: 'query', description: 'Only runs in this status; suppresses projections', required: false, schema: { type: 'string' } }
+ // #swagger.parameters['scope'] = { in: 'query', description: 'Only runs at this scope; suppresses projections', required: false, schema: { type: 'string' } }
+ // #swagger.parameters['seriesId'] = { in: 'query', description: 'Only events belonging to this series', required: false, schema: { type: 'integer' } }
+ /* #swagger.responses[200] = { description: 'The window', content: { "application/json": { schema: { type: "object", properties: { window: { type: "object", additionalProperties: true }, horizon: { type: "string" }, horizonDays: { type: "integer" }, truncated: { type: "boolean" }, entries: { type: "array", items: { type: "object", properties: { kind: { type: "string" }, runId: { type: "integer", nullable: true }, definitionId: { type: "integer" }, title: { type: "string" }, slug: { type: "string" }, seriesName: { type: "string", nullable: true }, scheduledFor: { type: "string" }, timezone: { type: "string" }, scope: { type: "string" }, status: { type: "string", nullable: true }, health: { type: "string", nullable: true }, adjusted: { type: "string", nullable: true } } } } } } } } } */
+ /* #swagger.responses[400] = { description: 'The window is missing, inverted or wider than 92 days', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
+ /* #swagger.responses[403] = { description: 'Not staff', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ controller.calendar,
+)
+
// ── Runs ──────────────────────────────────────────────────────────────────
//
// Declared ahead of /:id so the literal path is never read as a definition id.
@@ -265,9 +331,9 @@ eventsRouter.post(
'/:id/publish',
// #swagger.tags = ['Admin · Events']
// #swagger.summary = 'Snapshot the working spec into an immutable version and mark the definition ready'
- // #swagger.description = 'Admin only, deliberately, and not the same gate as the live run controls: publishing commits a definition that a schedule will later start unattended. The spec is re-validated against the registries as they stand right now rather than trusted from the save that wrote it, so a module uninstalled in between blocks the publish instead of producing a run that fails at dispatch.'
+ // #swagger.description = 'Admin only, deliberately, and not the same gate as the live run controls: publishing commits a definition that a schedule will later start unattended. The spec is re-validated against the registries as they stand right now rather than trusted from the save that wrote it, so a module uninstalled in between blocks the publish instead of producing a run that fails at dispatch. Publishing also RE-PINS every occurrence of this definition that is still scheduled and has not started, and `repinned` says how many moved: occurrences are materialised a fortnight ahead, so without this an edit would reach none of the runs already on the calendar. A run that has begun keeps the version it pinned.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
- /* #swagger.responses[200] = { description: 'The definition, now ready, and the version that was cut', content: { "application/json": { schema: { type: "object", properties: { event: { type: "object", additionalProperties: true }, version: { type: "integer" }, versionId: { type: "integer" } } } } } } */
+ /* #swagger.responses[200] = { description: 'The definition, now ready, the version that was cut, and how many scheduled occurrences moved to it', content: { "application/json": { schema: { type: "object", properties: { event: { type: "object", additionalProperties: true }, version: { type: "integer" }, versionId: { type: "integer" }, repinned: { type: "integer" } } } } } } */
/* #swagger.responses[400] = { description: 'The spec is invalid, or no phase has any steps', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
/* #swagger.responses[409] = { description: 'A step names an action no module registers, or the definition is archived', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
/* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
diff --git a/server/src/utils/eventRunner.js b/server/src/utils/eventRunner.js
index 3c7bee7..9f6b4c1 100644
--- a/server/src/utils/eventRunner.js
+++ b/server/src/utils/eventRunner.js
@@ -12,14 +12,22 @@
// 3. **advance** — claim each due run and move it through its phases
// 4. **prune** — the `event_run_log` retention sweep, on its own long clock
//
-// **What "materialise" means in this phase.** §E's tick materialises due
-// occurrences from a recurrence; the spec validator accepts `kind: 'manual'`
-// alone until Phase 4, so there is no recurrence to expand and the only
-// occurrences that exist are the ones an admin created. What that leaves for this
-// leg is the half that is already real and already needed: the grace window. A
-// run whose instant passed while the process was down does not start late and
-// silently — it becomes `missed`, which is terminal and which a human can see
-// (§L). Phase 4 adds the expansion above it.
+// **What "materialise" means, and why it is two halves.** Phase 4 completed it.
+// The first half EXPANDS: every `ready` definition's recurrence is computed in
+// its own IANA zone and every occurrence inside a fourteen-day horizon becomes a
+// real `scheduled` row (`INSERT IGNORE` against the occurrence key, so the tick
+// that already made one makes nothing). The second half SWEEPS: a run whose
+// instant passed while the process was down does not start late and silently —
+// it becomes `missed`, which is terminal and which a human can see (§L).
+//
+// **The two halves need each other, and the horizon is why.** Expansion looks
+// forward from `now - grace` only, so an occurrence nobody ever materialised is
+// never invented retroactively — waking up after three days down must not
+// manufacture three days of history that no operator could have seen or
+// cancelled. It does not have to: because rows exist a fortnight ahead of their
+// instant, an outage that spans an occurrence finds the row already there, and
+// the sweep marks it `missed` honestly. The horizon is what makes the missed
+// sweep mean anything for a recurring event.
//
// **Two properties this file must not lose**, both already paid for once on this
// codebase:
@@ -45,6 +53,9 @@ const runsDb = require('../model/events/eventRuns.db')
const stepsDb = require('../model/events/eventRunSteps.db')
const logDb = require('../model/events/eventRunLog.db')
const versionsDb = require('../model/events/eventVersions.db')
+const definitionsDb = require('../model/events/eventDefinitions.db')
+const runsModel = require('../model/events/eventRuns.model')
+const recurrence = require('../events/recurrence')
const registries = require('../modules/registries')
const { dispatchStep } = require('../events/dispatch')
const log = require('./logger')('event-runner')
@@ -59,6 +70,19 @@ const POLL_MS = Number(process.env.EVENT_POLL_MS) || 15_000
const RUN_BATCH = Number(process.env.EVENT_RUN_BATCH) || 50
const STEPS_PER_TICK = Number(process.env.EVENT_STEPS_PER_TICK) || 25
+// How far ahead a recurrence is turned into real rows (org lead, 2026-09-02).
+// Fourteen days is a fortnight of occurrences an operator can see, cancel and
+// reschedule ONE AT A TIME, which a projection is not — and it is short enough
+// that a definition edited today affects almost everything still ahead of it.
+// Beyond it the calendar projects rather than materialises, so a monthly event
+// is still visible three weeks out without a row nobody will honour.
+const HORIZON_DAYS = Number(process.env.EVENT_MATERIALISE_AHEAD_DAYS) || 14
+
+// A bound on one definition's expansion in one tick, not a target. A daily
+// schedule over a fortnight is fourteen; this is what stops a hand-written spec
+// turning one tick into a thousand inserts.
+const MAX_OCCURRENCES_PER_DEFINITION = 100
+
// A step's retries (org lead, 2026-09-02). §L specifies `retry(n) -> skip |
// pause | abort_run` and names no `n`; it lives here, as one number an operator
// can change, rather than in a column no authoring surface would ever show.
@@ -397,6 +421,98 @@ async function processRun(run, now = new Date()) {
}
/** Occurrences that passed their own grace window while nothing was running (§L). */
+/**
+ * Turn every `ready` definition's recurrence into rows inside the horizon.
+ *
+ * The first half of the materialise leg (§E). Answers how many occurrences were
+ * newly created, which is zero on almost every tick — the horizon moves fifteen
+ * seconds at a time, so a weekly event creates one row a week and answers
+ * `created: false` for the same fourteen occurrences in between.
+ *
+ * **The window starts at `now - grace`, not at `now`.** An occurrence whose
+ * instant just passed is still startable inside the definition's own grace
+ * window, and that is exactly the case of a definition published four minutes
+ * before its first occurrence. An occurrence older than that is not materialised
+ * at all rather than materialised-then-swept: a row nobody could ever have seen
+ * is not history, and writing one would put a `missed` event on the calendar for
+ * a date on which this deployment had no such event.
+ *
+ * **Expansion is per definition and one failure does not stop the sweep.** A
+ * spec written directly into the database with a shape the validator would have
+ * refused is a bad row, not a bad tick.
+ */
+async function expandSchedules(now) {
+ const definitions = await definitionsDb.findSchedulable()
+ let created = 0
+
+ for (const definition of definitions) {
+ const schedule = definition.version_spec?.schedule
+ if (!schedule || schedule.kind === 'manual') continue
+
+ const from = now.getTime() - Number(definition.grace_seconds || 0) * 1000
+ const to = now.getTime() + HORIZON_DAYS * recurrence.DAY_MS
+
+ let occurrences
+ try {
+ occurrences = recurrence.occurrencesBetween(schedule, definition.timezone || 'UTC', from, to, {
+ limit: MAX_OCCURRENCES_PER_DEFINITION,
+ })
+ } catch (err) {
+ log.error('could not expand a schedule', {
+ definition: definition.id,
+ timezone: definition.timezone,
+ message: err.message,
+ })
+ continue
+ }
+
+ for (const occurrence of occurrences) {
+ try {
+ // `runsModel.create` rather than a second insert path: it re-checks that
+ // the definition is still `ready` and the version still has phases, and
+ // it materialises the first phase's steps with their idempotency keys.
+ // Nobody is watching a scheduled occurrence, so it needs those checks
+ // more than a hand-started one does.
+ const result = await runsModel.create(
+ definition.id,
+ // Scope is empty, deliberately (org lead, 2026-09-02). A fan-out across
+ // named scopes needs a registry of what a scope IS, which no phase owns
+ // yet; inventing one here would be a contract the modules were never
+ // asked about. An admin's own start route still takes any scope.
+ { scope: '', scheduledFor: occurrence.at, source: 'schedule' },
+ null,
+ )
+ if (!result.ok || !result.created) continue
+ created += 1
+ if (occurrence.adjusted) {
+ // Why the clock reads oddly, recorded where an operator will look for
+ // it rather than left to be rediscovered at 3am on the last Sunday in
+ // October.
+ await logDb.write({
+ runId: result.run.id,
+ kind: 'run.created',
+ detail: {
+ dstAdjusted: occurrence.adjusted,
+ shiftMinutes: occurrence.shiftMinutes,
+ timezone: definition.timezone,
+ scheduledFor: occurrence.at,
+ },
+ })
+ }
+ } catch (err) {
+ log.error('could not materialise an occurrence', {
+ definition: definition.id,
+ at: occurrence.at,
+ message: err.message,
+ })
+ }
+ }
+ }
+
+ if (created) log.info('occurrences materialised', { created, horizonDays: HORIZON_DAYS })
+ return created
+}
+
async function sweepMissed(now) {
const missed = await runsDb.findMissed(now)
let n = 0
@@ -433,6 +549,12 @@ async function tick(now = new Date()) {
log.error('failed to reclaim stale claims', { message: err.message })
}
+ try {
+ await expandSchedules(now)
+ } catch (err) {
+ log.error('schedule expansion failed', { message: err.message })
+ }
+
try {
await sweepMissed(now)
} catch (err) {
@@ -507,11 +629,13 @@ module.exports = {
advanceRun,
drainStep,
sweepMissed,
+ expandSchedules,
prune,
OWNER,
POLL_MS,
MAX_ATTEMPTS,
RETRY_MS,
+ HORIZON_DAYS,
RUN_LEASE_MS,
LOG_RETENTION_DAYS,
RUN_TERMINAL,
diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json
index 8d7a039..10ba6ba 100644
--- a/server/swagger/swagger-output.json
+++ b/server/swagger/swagger-output.json
@@ -3534,6 +3534,174 @@
}
}
},
+ "/api/v1/admin/events/calendar": {
+ "get": {
+ "tags": [
+ "Admin · Events"
+ ],
+ "summary": "The calendar for a window: materialised runs and projected occurrences",
+ "description": "Staff, like every other read here. Each entry is one of two kinds and the difference matters: a run entry is a real row with a status, a pinned version and a console, and somebody can cancel it; a projected entry is arithmetic - no row, nothing committed, nothing to cancel. Runs exist inside the runner materialisation horizon (14 days by default, horizonDays in the response); beyond it the same recurrence arithmetic forecasts what will be materialised, so a monthly event is still visible three weeks out. A projection is never emitted for an instant a run already occupies, which is also why a cancelled occurrence does not reappear as a forecast. Instants are UTC and each entry carries the event own IANA zone: the event owns the time, the reader owns the calendar. Filtering by status or by a named scope suppresses projections, because a forecast has no status and automatic expansion happens at the empty scope.",
+ "parameters": [
+ {
+ "name": "from",
+ "in": "query",
+ "description": "Window start, a UTC instant",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "to",
+ "in": "query",
+ "description": "Window end, a UTC instant. At most 92 days after from",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "status",
+ "in": "query",
+ "description": "Only runs in this status; suppresses projections",
+ "required": false,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "scope",
+ "in": "query",
+ "description": "Only runs at this scope; suppresses projections",
+ "required": false,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "seriesId",
+ "in": "query",
+ "description": "Only events belonging to this series",
+ "required": false,
+ "schema": {
+ "type": "integer"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The window",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "window": {
+ "type": "object",
+ "additionalProperties": true
+ },
+ "horizon": {
+ "type": "string"
+ },
+ "horizonDays": {
+ "type": "integer"
+ },
+ "truncated": {
+ "type": "boolean"
+ },
+ "entries": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "kind": {
+ "type": "string"
+ },
+ "runId": {
+ "type": "integer",
+ "nullable": true
+ },
+ "definitionId": {
+ "type": "integer"
+ },
+ "title": {
+ "type": "string"
+ },
+ "slug": {
+ "type": "string"
+ },
+ "seriesName": {
+ "type": "string",
+ "nullable": true
+ },
+ "scheduledFor": {
+ "type": "string"
+ },
+ "timezone": {
+ "type": "string"
+ },
+ "scope": {
+ "type": "string"
+ },
+ "status": {
+ "type": "string",
+ "nullable": true
+ },
+ "health": {
+ "type": "string",
+ "nullable": true
+ },
+ "adjusted": {
+ "type": "string",
+ "nullable": true
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "The window is missing, inverted or wider than 92 days",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "errors": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Not staff",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
"/api/v1/admin/events/catalog": {
"get": {
"tags": [
@@ -4431,7 +4599,7 @@
"Admin · Events"
],
"summary": "List the event series a definition may belong to",
- "description": "A series is the arc several definitions form together. Read-only in this phase: creating and ordering one arrives with the calendar.",
+ "description": "A series is the arc several definitions form together - Royal Spy Mission then Risky Partner then Message From the Void - which is continuity the tooling this feature replaces has no field for at all. definitionCount is how many definitions currently belong to each.",
"responses": {
"200": {
"description": "The series",
@@ -4488,6 +4656,265 @@
"bearerAuth": []
}
]
+ },
+ "post": {
+ "tags": [
+ "Admin · Events"
+ ],
+ "summary": "Create an event series",
+ "description": "Admin or editor, not admin alone: naming an arc is authoring, and the narrow gate of section N2 is about committing the deployment to a run (publish, start), which this does not. The slug is derived from the name once and then frozen, because the public arc page lives at it; renaming the series afterwards is free. ordering places this series among the others on the calendar, and is not a position within it - a definition place in its arc is its own seriesOrder.",
+ "responses": {
+ "201": {
+ "description": "The created series",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "series": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Validation failed",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "errors": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Not an admin or editor",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string",
+ "nullable": true
+ },
+ "ordering": {
+ "type": "integer"
+ }
+ },
+ "required": [
+ "name"
+ ]
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/admin/events/series/{seriesId}": {
+ "put": {
+ "tags": [
+ "Admin · Events"
+ ],
+ "summary": "Rename or reorder an event series",
+ "description": "The slug is deliberately not editable: it is the address the arc page lives at, and a slug that moved would break every link to it.",
+ "parameters": [
+ {
+ "name": "seriesId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The updated series",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "series": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Validation failed",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "errors": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Not an admin or editor",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "No such series",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string",
+ "nullable": true
+ },
+ "ordering": {
+ "type": "integer"
+ }
+ },
+ "required": [
+ "name"
+ ]
+ }
+ }
+ }
+ }
+ },
+ "delete": {
+ "tags": [
+ "Admin · Events"
+ ],
+ "summary": "Delete an event series, detaching whatever belonged to it",
+ "description": "A hard delete, and the only one in this feature - a definition is archived instead. A series is a label rather than authored content: nothing pins one, no run references one, and event_definitions.series_id is ON DELETE SET NULL, so its definitions survive without an arc and re-attaching is a dropdown. The response says how many were detached.",
+ "parameters": [
+ {
+ "name": "seriesId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Deleted; detached is how many definitions lost their series",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "ok": {
+ "type": "boolean"
+ },
+ "detached": {
+ "type": "integer"
+ }
+ }
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Not an admin or editor",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "No such series",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ]
}
},
"/api/v1/admin/events/{id}": {
@@ -4755,7 +5182,7 @@
"Admin · Events"
],
"summary": "Snapshot the working spec into an immutable version and mark the definition ready",
- "description": "Admin only, deliberately, and not the same gate as the live run controls: publishing commits a definition that a schedule will later start unattended. The spec is re-validated against the registries as they stand right now rather than trusted from the save that wrote it, so a module uninstalled in between blocks the publish instead of producing a run that fails at dispatch.",
+ "description": "Admin only, deliberately, and not the same gate as the live run controls: publishing commits a definition that a schedule will later start unattended. The spec is re-validated against the registries as they stand right now rather than trusted from the save that wrote it, so a module uninstalled in between blocks the publish instead of producing a run that fails at dispatch. Publishing also RE-PINS every occurrence of this definition that is still scheduled and has not started, and `repinned` says how many moved: occurrences are materialised a fortnight ahead, so without this an edit would reach none of the runs already on the calendar. A run that has begun keeps the version it pinned.",
"parameters": [
{
"name": "id",
@@ -4768,7 +5195,7 @@
],
"responses": {
"200": {
- "description": "The definition, now ready, and the version that was cut",
+ "description": "The definition, now ready, the version that was cut, and how many scheduled occurrences moved to it",
"content": {
"application/json": {
"schema": {
@@ -4783,6 +5210,9 @@
},
"versionId": {
"type": "integer"
+ },
+ "repinned": {
+ "type": "integer"
}
}
}
diff --git a/server/test/eventRecurrence.test.js b/server/test/eventRecurrence.test.js
new file mode 100644
index 0000000..9c88b13
--- /dev/null
+++ b/server/test/eventRecurrence.test.js
@@ -0,0 +1,273 @@
+// ── Occurrence arithmetic (EVENTS.md §E, Phase 4) ──────────────────────────
+//
+// The plan asks for DST-crossing cases as EXPLICIT FIXTURES, and this file is
+// them. The reason it is worth a test file of its own is that every failure here
+// is silent in production: an event computed an hour off, or dropped for one
+// week a year, looks exactly like an event that happened correctly until an
+// operator is standing in the wrong place at the wrong time.
+//
+// The zone data is Node's own tzdata behind `Intl`, so these fixtures assert
+// against the real transitions rather than against a hand-written offset table:
+//
+// • Europe/Berlin, 2026-03-29 — CET (+1) to CEST (+2). 02:00 to 03:00 does not
+// exist. A weekly 02:30 event is the case the org lead decided.
+// • Europe/Berlin, 2026-10-25 — CEST (+2) back to CET (+1). 02:00 to 03:00
+// happens twice.
+// • Asia/Kolkata — +05:30, no DST at all, and a half-hour offset, which is
+// what catches an implementation that assumed whole hours.
+// • Australia/Lord_Howe — a THIRTY-MINUTE DST shift, which is what catches one
+// that assumed the gap is always an hour.
+
+process.env.DB_HOST = '127.0.0.1'
+process.env.DB_PORT = '59999'
+
+const { test, after } = require('node:test')
+const assert = require('node:assert/strict')
+
+const r = require('../src/events/recurrence')
+const db = require('../src/utils/db')
+
+after(() => db.close())
+
+/** What an instant reads as on the wall in a zone — the assertion that matters. */
+const wall = (zone, at) => {
+ const p = r.wallPartsAt(zone, at instanceof Date ? at.getTime() : at)
+ const pad = (n) => String(n).padStart(2, '0')
+ return `${p.y}-${pad(p.m)}-${pad(p.d)} ${pad(p.h)}:${pad(p.mi)}`
+}
+
+const walls = (occurrences, zone) => occurrences.map((o) => wall(zone, o.at))
+
+// ── The rule the whole feature rests on ────────────────────────────────────
+
+test('a weekly event keeps its LOCAL time across a DST boundary', () => {
+ // The single most important assertion in this file. Friday 20:00 in Berlin is
+ // 19:00 UTC in winter and 18:00 UTC in summer, and it is 20:00 on the wall on
+ // every one of those Fridays. A recurrence computed in UTC would put half the
+ // year an hour out, which is exactly what §E forbids.
+ const schedule = { kind: 'weekly', days: ['friday'], time: '20:00' }
+ const found = r.occurrencesBetween(schedule, 'Europe/Berlin', Date.UTC(2026, 2, 20), Date.UTC(2026, 3, 11))
+
+ assert.deepEqual(walls(found, 'Europe/Berlin'), [
+ '2026-03-20 20:00',
+ '2026-03-27 20:00',
+ '2026-04-03 20:00',
+ '2026-04-10 20:00',
+ ])
+ // And the UTC instants really did move, which is what proves the zone was
+ // consulted rather than the arithmetic accidentally agreeing.
+ assert.equal(found[1].at.toISOString(), '2026-03-27T19:00:00.000Z')
+ assert.equal(found[2].at.toISOString(), '2026-04-03T18:00:00.000Z')
+})
+
+test('a weekly event keeps its local time across the October transition too', () => {
+ const schedule = { kind: 'weekly', days: ['friday'], time: '20:00' }
+ const found = r.occurrencesBetween(schedule, 'Europe/Berlin', Date.UTC(2026, 9, 20), Date.UTC(2026, 10, 7))
+ assert.deepEqual(walls(found, 'Europe/Berlin'), [
+ '2026-10-23 20:00',
+ '2026-10-30 20:00',
+ '2026-11-06 20:00',
+ ])
+ assert.equal(found[0].at.toISOString(), '2026-10-23T18:00:00.000Z')
+ assert.equal(found[1].at.toISOString(), '2026-10-30T19:00:00.000Z')
+})
+
+// ── The two DST rules, as decided ──────────────────────────────────────────
+
+test('a local time the spring gap swallows moves FORWARD to the first one that exists', () => {
+ // 2026-03-29 in Berlin: 02:00 becomes 03:00 and 02:30 never happens. The
+ // decision is the first valid instant — 03:00 — rather than "shift by the gap"
+ // (03:30): the event happens as close to the authored time as the calendar
+ // allows.
+ const resolved = r.resolveWall('Europe/Berlin', 2026, 3, 29, 2, 30)
+ assert.equal(resolved.adjusted, 'gap')
+ assert.equal(wall('Europe/Berlin', resolved.at), '2026-03-29 03:00')
+ assert.equal(resolved.at.toISOString(), '2026-03-29T01:00:00.000Z')
+ assert.equal(resolved.shiftMinutes, 30)
+})
+
+test('a local time that happens twice takes the FIRST of them', () => {
+ // 2026-10-25 in Berlin: 02:30 comes round at 00:30Z (+2, still CEST) and again
+ // at 01:30Z (+1, now CET). The first is the answer, and the second must not be
+ // — an event that fired at the later one would be an hour late by the clock
+ // the author wrote it against.
+ const resolved = r.resolveWall('Europe/Berlin', 2026, 10, 25, 2, 30)
+ assert.equal(resolved.adjusted, 'ambiguous')
+ assert.equal(resolved.at.toISOString(), '2026-10-25T00:30:00.000Z')
+ assert.equal(wall('Europe/Berlin', resolved.at), '2026-10-25 02:30')
+
+ // Both instants really do read 02:30 — the fixture is only meaningful if the
+ // ambiguity is real.
+ const both = r.instantsForWall('Europe/Berlin', Date.UTC(2026, 9, 25, 2, 30))
+ assert.equal(both.length, 2)
+ assert.equal(both[0], Date.UTC(2026, 9, 25, 0, 30))
+ assert.equal(both[1], Date.UTC(2026, 9, 25, 1, 30))
+})
+
+test('a weekly event in the gap still happens that week — it is never dropped', () => {
+ // The rule that makes the gap decision worth having. A Sunday 02:30 event in
+ // Berlin happens on 29 March like every other Sunday; it simply happens at
+ // 03:00.
+ const schedule = { kind: 'weekly', days: ['sunday'], time: '02:30' }
+ const found = r.occurrencesBetween(schedule, 'Europe/Berlin', Date.UTC(2026, 2, 20), Date.UTC(2026, 3, 6))
+ assert.deepEqual(walls(found, 'Europe/Berlin'), [
+ '2026-03-22 02:30',
+ '2026-03-29 03:00',
+ '2026-04-05 02:30',
+ ])
+ assert.equal(found[1].adjusted, 'gap')
+ assert.equal(found[0].adjusted, null)
+})
+
+test('a thirty-minute DST shift resolves too — the gap is not always an hour', () => {
+ // Lord Howe Island shifts by 30 minutes (+10:30 to +11:00). 2026-10-04 has no
+ // 02:15 local. An implementation that assumed a whole-hour gap gets this wrong.
+ const resolved = r.resolveWall('Australia/Lord_Howe', 2026, 10, 4, 2, 15)
+ assert.equal(resolved.adjusted, 'gap')
+ assert.equal(wall('Australia/Lord_Howe', resolved.at), '2026-10-04 02:30')
+})
+
+test('a zone with no DST at all is left completely alone', () => {
+ // Asia/Kolkata is +05:30 all year, and the half hour is the point: an
+ // implementation carrying whole-hour offsets around would be 30 minutes out
+ // here, every day, without any transition to blame.
+ const schedule = { kind: 'weekly', days: ['friday'], time: '19:30' }
+ const found = r.occurrencesBetween(schedule, 'Asia/Kolkata', Date.UTC(2026, 2, 20), Date.UTC(2026, 3, 11))
+ assert.equal(found.length, 4)
+ for (const o of found) {
+ assert.equal(o.adjusted, null)
+ assert.equal(wall('Asia/Kolkata', o.at).slice(11), '19:30')
+ assert.equal(o.at.toISOString().slice(11, 16), '14:00')
+ }
+})
+
+// ── The shapes ─────────────────────────────────────────────────────────────
+
+test('`once` produces its single occurrence, and only inside the window', () => {
+ const schedule = { kind: 'once', at: '2026-10-31T20:00' }
+ const inside = r.occurrencesBetween(schedule, 'Europe/Berlin', Date.UTC(2026, 9, 1), Date.UTC(2026, 10, 1))
+ assert.equal(inside.length, 1)
+ assert.equal(wall('Europe/Berlin', inside[0].at), '2026-10-31 20:00')
+
+ const outside = r.occurrencesBetween(schedule, 'Europe/Berlin', Date.UTC(2026, 10, 1), Date.UTC(2026, 11, 1))
+ assert.deepEqual(outside, [])
+})
+
+test('`weekly` honours every named day, in week order', () => {
+ const schedule = { kind: 'weekly', days: ['saturday', 'wednesday'], time: '18:00' }
+ const found = r.occurrencesBetween(schedule, 'UTC', Date.UTC(2026, 5, 1), Date.UTC(2026, 5, 15))
+ assert.deepEqual(walls(found, 'UTC'), [
+ '2026-06-03 18:00',
+ '2026-06-06 18:00',
+ '2026-06-10 18:00',
+ '2026-06-13 18:00',
+ ])
+})
+
+test('`monthly` with nth: -1 is the LAST weekday, which is not always the fourth', () => {
+ // The whole reason -1 exists. May 2026 has five Fridays and July 2026 has five;
+ // in those months "last" and "fourth" are different days, and a fishing contest
+ // on the last Friday is exactly that shape.
+ const last = r.occurrencesBetween(
+ { kind: 'monthly', nth: -1, weekday: 'friday', time: '19:00' },
+ 'UTC',
+ Date.UTC(2026, 4, 1),
+ Date.UTC(2026, 8, 1),
+ )
+ const fourth = r.occurrencesBetween(
+ { kind: 'monthly', nth: 4, weekday: 'friday', time: '19:00' },
+ 'UTC',
+ Date.UTC(2026, 4, 1),
+ Date.UTC(2026, 8, 1),
+ )
+ // August 2026 has four Fridays, so "last" and "fourth" agree there and
+ // disagree in May and July. That the two lists share a member is the point:
+ // -1 is not a synonym for 4, and it is not a synonym for "different" either.
+ assert.deepEqual(walls(last, 'UTC'), [
+ '2026-05-29 19:00',
+ '2026-06-26 19:00',
+ '2026-07-31 19:00',
+ '2026-08-28 19:00',
+ ])
+ assert.deepEqual(walls(fourth, 'UTC'), [
+ '2026-05-22 19:00',
+ '2026-06-26 19:00',
+ '2026-07-24 19:00',
+ '2026-08-28 19:00',
+ ])
+ assert.notDeepEqual(walls(last, 'UTC'), walls(fourth, 'UTC'))
+})
+
+test('every month has a first through fourth of every weekday', () => {
+ // The claim the closed set rests on: because there is no `nth: 5`, there is no
+ // absent-occurrence case to define. Checked across three years rather than
+ // asserted in a comment.
+ for (let year = 2026; year <= 2028; year += 1) {
+ for (let month = 1; month <= 12; month += 1) {
+ for (let weekday = 0; weekday <= 6; weekday += 1) {
+ for (const nth of [1, 2, 3, 4, -1]) {
+ const day = r.nthWeekdayDay(year, month, weekday, nth)
+ assert.ok(day, `${year}-${month} weekday ${weekday} nth ${nth} should exist`)
+ }
+ }
+ }
+ }
+})
+
+test('`manual` is not a recurrence and expands to nothing', () => {
+ assert.deepEqual(r.occurrencesBetween({ kind: 'manual' }, 'UTC', Date.UTC(2026, 0, 1), Date.UTC(2027, 0, 1)), [])
+})
+
+// ── Bounds and refusals ────────────────────────────────────────────────────
+
+test('an inverted or empty window answers with nothing rather than throwing', () => {
+ const schedule = { kind: 'weekly', days: ['friday'], time: '20:00' }
+ assert.deepEqual(r.occurrencesBetween(schedule, 'UTC', Date.UTC(2026, 5, 1), Date.UTC(2026, 4, 1)), [])
+ assert.deepEqual(r.occurrencesBetween(schedule, 'UTC', Date.UTC(2026, 5, 1), Date.UTC(2026, 5, 1)), [])
+})
+
+test('a malformed schedule expands to nothing rather than to a wrong instant', () => {
+ // These shapes cannot come through `spec.js`, but they can come from a row
+ // written directly into the database — and the runner must not turn one into a
+ // world change at an invented time.
+ assert.deepEqual(r.occurrencesBetween({ kind: 'weekly', days: [], time: '20:00' }, 'UTC', 0, 1e12), [])
+ assert.deepEqual(r.occurrencesBetween({ kind: 'weekly', days: ['friday'], time: '25:00' }, 'UTC', 0, 1e12), [])
+ assert.deepEqual(r.occurrencesBetween({ kind: 'monthly', nth: 9, weekday: 'friday', time: '19:00' }, 'UTC', 0, 1e12), [])
+ assert.deepEqual(r.occurrencesBetween({ kind: 'once', at: 'tomorrow' }, 'UTC', 0, 1e12), [])
+ assert.deepEqual(r.occurrencesBetween(null, 'UTC', 0, 1e12), [])
+})
+
+test('the expansion is bounded, so a wide window cannot become an outage', () => {
+ const schedule = {
+ kind: 'weekly',
+ days: ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'],
+ time: '12:00',
+ }
+ const found = r.occurrencesBetween(schedule, 'UTC', Date.UTC(2020, 0, 1), Date.UTC(2030, 0, 1))
+ assert.equal(found.length, r.MAX_OCCURRENCES)
+
+ const smaller = r.occurrencesBetween(schedule, 'UTC', Date.UTC(2026, 0, 1), Date.UTC(2027, 0, 1), { limit: 10 })
+ assert.equal(smaller.length, 10)
+})
+
+test('nextOccurrence looks forward and finds nothing when there is nothing', () => {
+ const weekly = r.nextOccurrence({ kind: 'weekly', days: ['friday'], time: '20:00' }, 'UTC', Date.UTC(2026, 5, 1))
+ assert.equal(wall('UTC', weekly.at), '2026-06-05 20:00')
+
+ // A `once` already in the past has no next occurrence, which is what stops a
+ // one-off event being re-materialised for ever.
+ assert.equal(r.nextOccurrence({ kind: 'once', at: '2020-01-01T12:00' }, 'UTC', Date.UTC(2026, 5, 1)), null)
+ assert.equal(r.nextOccurrence({ kind: 'manual' }, 'UTC', Date.UTC(2026, 5, 1)), null)
+})
+
+test('describe says the schedule back in words, in the event own zone', () => {
+ assert.equal(
+ r.describe({ kind: 'weekly', days: ['friday', 'saturday'], time: '20:00' }, 'Europe/Berlin'),
+ 'Every Friday and Saturday at 20:00 (Europe/Berlin)',
+ )
+ assert.equal(
+ r.describe({ kind: 'monthly', nth: -1, weekday: 'friday', time: '19:30' }, 'Asia/Kolkata'),
+ 'The last Friday of every month at 19:30 (Asia/Kolkata)',
+ )
+ assert.equal(r.describe({ kind: 'manual' }), 'Started by hand')
+})
diff --git a/server/test/eventRunner.test.js b/server/test/eventRunner.test.js
index 281cdd7..e9d5dbb 100644
--- a/server/test/eventRunner.test.js
+++ b/server/test/eventRunner.test.js
@@ -35,6 +35,7 @@ const runsDb = require('../src/model/events/eventRuns.db')
const stepsDb = require('../src/model/events/eventRunSteps.db')
const logDb = require('../src/model/events/eventRunLog.db')
const versionsDb = require('../src/model/events/eventVersions.db')
+const definitionsDb = require('../src/model/events/eventDefinitions.db')
const db = require('../src/utils/db')
after(() => db.close())
@@ -47,7 +48,7 @@ const later = (ms) => new Date(T0.getTime() + ms)
let store
const originals = {}
-for (const [name, mod] of [['runsDb', runsDb], ['stepsDb', stepsDb], ['logDb', logDb], ['versionsDb', versionsDb]]) {
+for (const [name, mod] of [['runsDb', runsDb], ['stepsDb', stepsDb], ['logDb', logDb], ['versionsDb', versionsDb], ['definitionsDb', definitionsDb]]) {
originals[name] = { mod, fns: { ...mod } }
}
@@ -68,6 +69,14 @@ function installStubs() {
nextStepId: 1,
}
+ // Phase 4 put a schedule-expansion leg in front of the tick. This file is
+ // about what the runner does with runs that ALREADY exist, so it has nothing
+ // to expand — but the leg is a real query, and left unstubbed every `tick()`
+ // here would reach for the dead-port pool and wait on it. Answering with an
+ // empty list is what keeps this file measuring the runner rather than a
+ // connection timeout.
+ Object.assign(definitionsDb, { findSchedulable: async () => [] })
+
// Snapshots, not live references. A SQL SELECT hands back a copy, and the
// runner reads `step.attempts` as the value BEFORE its own claim incremented
// it — returning references here would make the retry budget off by one in the
diff --git a/server/test/eventRunnerSql.test.js b/server/test/eventRunnerSql.test.js
index 51bdda3..01d4760 100644
--- a/server/test/eventRunnerSql.test.js
+++ b/server/test/eventRunnerSql.test.js
@@ -38,6 +38,22 @@
// unsettled seq instead, which is a different step whenever a phase carried
// on past an `on_failure: skip` failure.
//
+// **Phase 4 added two reads**, and a read earns a place here when a stub cannot
+// tell it is wrong:
+//
+// * **`findSchedulable`** - the query the runner runs on EVERY tick to decide
+// what has a recurrence to expand. It joins a definition to its published
+// version and left-joins the series, and every stub of it in
+// `eventSchedule.test.js` is a hand-written object rather than that join. A
+// syntax error or a wrong join direction here is a runner that materialises
+// nothing, silently, for ever.
+// * **`repinScheduled`** - the UPDATE publish runs over already-materialised
+// occurrences. Its guard is the whole statement, and the two rows it must
+// NOT touch are a run that has started and a run that is already terminal.
+// * **`listInWindow`** - the calendar's real half, with a correlated subquery
+// for `waiting_steps` and a LEFT JOIN that must not drop a definition with no
+// series.
+//
// Plus the two unique indexes that are load-bearing rather than tidy:
// `uq_evrun_occurrence` (which, not the claim, is what stops two runs of one
// occurrence existing) and `uq_evstep_slot` (which is what makes re-materialising
@@ -61,10 +77,29 @@ const mariadb = require('mariadb')
// Trimmed to the columns these statements read or write. The ENUMs are verbatim,
// because "is `missed` a legal value" is one of the things being proved.
const SCHEMA = `
+CREATE TABLE event_series (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ name VARCHAR(160) NOT NULL,
+ slug VARCHAR(160) NOT NULL,
+ ordering INT NOT NULL DEFAULT 0
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE event_definitions (
id INT AUTO_INCREMENT PRIMARY KEY,
+ title VARCHAR(200) NOT NULL DEFAULT 'x',
+ slug VARCHAR(200) NOT NULL DEFAULT 'x',
+ state ENUM('draft','ready','archived') NOT NULL DEFAULT 'draft',
+ current_version_id INT NULL,
+ series_id INT NULL,
+ concurrency_key VARCHAR(190) NULL,
+ timezone VARCHAR(64) NOT NULL DEFAULT 'UTC',
grace_seconds INT NOT NULL DEFAULT 900
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+CREATE TABLE event_versions (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ definition_id INT NOT NULL,
+ version INT NOT NULL DEFAULT 1,
+ spec JSON NOT NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE event_runs (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
definition_id INT NOT NULL,
@@ -207,6 +242,41 @@ SELECT r.id FROM event_runs r
WHERE r.status = 'scheduled'
AND r.scheduled_for + INTERVAL d.grace_seconds SECOND < ?`
+// Verbatim `eventDefinitions.db#findSchedulable`.
+const FIND_SCHEDULABLE = `
+SELECT d.id, d.title, d.slug, d.timezone, d.grace_seconds, d.concurrency_key,
+ d.current_version_id, d.series_id, v.spec AS version_spec,
+ s.name AS series_name, s.slug AS series_slug
+ FROM event_definitions d
+ JOIN event_versions v ON v.id = d.current_version_id
+ LEFT JOIN event_series s ON s.id = d.series_id
+ WHERE d.state = 'ready'
+ ORDER BY d.id`
+
+// Verbatim `eventRuns.db#listInWindow`, with no optional filter applied.
+const LIST_IN_WINDOW = `
+SELECT r.*, d.title AS definition_title, d.slug AS definition_slug,
+ d.series_id AS series_id, se.name AS series_name, se.slug AS series_slug,
+ v.version AS version_number,
+ (SELECT COUNT(*) FROM event_run_steps s
+ WHERE s.run_id = r.id AND s.status = 'running' AND s.claim_expires_at IS NULL) AS waiting_steps
+ FROM event_runs r
+ JOIN event_definitions d ON d.id = r.definition_id
+ JOIN event_versions v ON v.id = r.version_id
+ LEFT JOIN event_series se ON se.id = d.series_id
+ WHERE r.scheduled_for >= ? AND r.scheduled_for < ?
+ ORDER BY r.scheduled_for, r.id
+ LIMIT 500`
+
+// Verbatim `eventRuns.db#repinScheduled`.
+const REPIN_SCHEDULED = `
+UPDATE event_runs
+ SET version_id = ?
+ WHERE definition_id = ?
+ AND status = 'scheduled'
+ AND started_at IS NULL
+ AND version_id <> ?`
+
const DB = `rg_events_test_${process.pid}`
let pool = null
let available = false
@@ -274,6 +344,8 @@ beforeEach(async () => {
await pool.query('DELETE FROM event_run_steps')
await pool.query('DELETE FROM event_runs')
await pool.query('DELETE FROM event_definitions')
+ await pool.query('DELETE FROM event_versions')
+ await pool.query('DELETE FROM event_series')
})
async function seedRun(over = {}) {
@@ -659,3 +731,163 @@ test('a guarded transition refuses a run that was cancelled underneath it', asyn
assert.equal(rows(await pool.query(TRANSITION, ['running', 'two', runId, 'running'])), 0)
assert.equal((await runById(runId)).status, 'cancelled')
})
+
+
+// ── Phase 4: the two reads ────────────────────────────────────────
+
+/** A definition with a published version, and optionally a series. */
+async function seedDefinition({ state = 'ready', spec = { schedule: { kind: 'manual' } }, series = null } = {}) {
+ let seriesId = null
+ if (series) {
+ const s = await pool.query('INSERT INTO event_series (name, slug) VALUES (?, ?)', [series, series])
+ seriesId = s.insertId
+ }
+ const d = await pool.query(
+ 'INSERT INTO event_definitions (state, series_id, timezone) VALUES (?, ?, ?)',
+ [state, seriesId, 'Europe/Berlin'],
+ )
+ const v = await pool.query(
+ 'INSERT INTO event_versions (definition_id, version, spec) VALUES (?, 1, ?)',
+ [d.insertId, JSON.stringify(spec)],
+ )
+ await pool.query('UPDATE event_definitions SET current_version_id = ? WHERE id = ?', [
+ v.insertId,
+ d.insertId,
+ ])
+ return { definitionId: d.insertId, versionId: v.insertId, seriesId }
+}
+
+test('findSchedulable returns ready definitions with their PUBLISHED spec, series or not', async (t) => {
+ if (needDb(t)) return
+ const withSeries = await seedDefinition({
+ spec: { schedule: { kind: 'weekly', days: ['friday'], time: '20:00' } },
+ series: 'royal-spy',
+ })
+ const withoutSeries = await seedDefinition({ spec: { schedule: { kind: 'manual' } } })
+
+ const found = await pool.query(FIND_SCHEDULABLE)
+ const ids = found.map((r) => r.id).sort((a, b) => a - b)
+ assert.deepEqual(ids, [withSeries.definitionId, withoutSeries.definitionId].sort((a, b) => a - b))
+
+ // The LEFT JOIN must not drop the definition that belongs to no arc — an
+ // inner join here would make every event outside a series unschedulable, and
+ // most events are outside one.
+ const plain = found.find((r) => r.id === withoutSeries.definitionId)
+ assert.equal(plain.series_name, null)
+
+ const arced = found.find((r) => r.id === withSeries.definitionId)
+ assert.equal(arced.series_name, 'royal-spy')
+ assert.equal(arced.timezone, 'Europe/Berlin')
+
+ // The spec really came back, and really came back parseable.
+ const spec = typeof arced.version_spec === 'string' ? JSON.parse(arced.version_spec) : arced.version_spec
+ assert.equal(spec.schedule.kind, 'weekly')
+})
+
+test('findSchedulable skips a draft, an archived one, and one with no published version', async (t) => {
+ if (needDb(t)) return
+ await seedDefinition({ state: 'draft' })
+ await seedDefinition({ state: 'archived' })
+ // `ready` with a dangling version pointer: the JOIN is what must drop it, and
+ // a definition whose version row went missing must not become a runner crash.
+ const orphan = await seedDefinition({ state: 'ready' })
+ await pool.query('DELETE FROM event_versions WHERE id = ?', [orphan.versionId])
+
+ assert.equal((await pool.query(FIND_SCHEDULABLE)).length, 0)
+})
+
+test('listInWindow is half-open on the window, and counts only PARKED steps as waiting', async (t) => {
+ if (needDb(t)) return
+ const def = await seedDefinition()
+ const at = async (when) => {
+ const r = await pool.query(
+ 'INSERT INTO event_runs (definition_id, version_id, scope, scheduled_for) VALUES (?, ?, ?, ?)',
+ [def.definitionId, def.versionId, '', when],
+ )
+ return r.insertId
+ }
+ const before = await at(new Date('2026-09-01T00:00:00Z'))
+ const onFrom = await at(new Date('2026-09-02T00:00:00Z'))
+ const inside = await at(new Date('2026-09-05T00:00:00Z'))
+ const onTo = await at(new Date('2026-09-09T00:00:00Z'))
+
+ // `>= from AND < to` — the instant ON the upper bound belongs to the NEXT
+ // window. A closed interval would draw the last day of one month and the first
+ // of the next as the same occurrence twice.
+ const found = await pool.query(LIST_IN_WINDOW, [
+ new Date('2026-09-02T00:00:00Z'),
+ new Date('2026-09-09T00:00:00Z'),
+ ])
+ assert.deepEqual(found.map((r) => r.id), [onFrom, inside])
+ assert.ok(!found.some((r) => r.id === before || r.id === onTo))
+
+ // A parked step is `running` with a NULL lease; a leased one is the runner
+ // mid-dispatch and is not waiting on anybody.
+ await seedStep(inside, { status: 'running', claimExpiresAt: null, key: 'a'.repeat(40) })
+ await seedStep(inside, {
+ seq: 1,
+ status: 'running',
+ claimedBy: 'host',
+ claimExpiresAt: later(60_000),
+ key: 'b'.repeat(40),
+ })
+ const again = await pool.query(LIST_IN_WINDOW, [
+ new Date('2026-09-02T00:00:00Z'),
+ new Date('2026-09-09T00:00:00Z'),
+ ])
+ assert.equal(Number(again.find((r) => r.id === inside).waiting_steps), 1)
+})
+
+
+test('repinScheduled moves the occurrences that have not begun, and only those', async (t) => {
+ if (needDb(t)) return
+ // The case: an editor fixes a typo on a weekly event on Wednesday. Two Fridays
+ // are already materialised on v3, last Friday's run is finished, and one is in
+ // flight right now.
+ const def = await pool.query('INSERT INTO event_definitions (grace_seconds) VALUES (900)')
+ const mk = async (status, versionId, startedAt, when) =>
+ (
+ await pool.query(
+ `INSERT INTO event_runs (definition_id, version_id, scope, status, scheduled_for, started_at)
+ VALUES (?, ?, ?, ?, ?, ?)`,
+ [def.insertId, versionId, `s${when}`, status, T0, startedAt],
+ )
+ ).insertId
+
+ const ahead1 = await mk('scheduled', 3, null, 1)
+ const ahead2 = await mk('scheduled', 3, null, 2)
+ const running = await mk('running', 3, T0, 3)
+ const done = await mk('completed', 3, T0, 4)
+ // Already on the new version: excluded by `version_id <> ?`, so a second
+ // publish of an unchanged definition is not a fleet of pointless writes.
+ const already = await mk('scheduled', 4, null, 5)
+
+ const moved = rows(await pool.query(REPIN_SCHEDULED, [4, def.insertId, 4]))
+ assert.equal(moved, 2)
+
+ const versionOf = async (id) =>
+ Number((await pool.query('SELECT version_id FROM event_runs WHERE id = ?', [id]))[0].version_id)
+ assert.equal(await versionOf(ahead1), 4)
+ assert.equal(await versionOf(ahead2), 4)
+ // A run that has begun keeps the version it pinned, for ever: that pin is what
+ // makes it explicable afterwards.
+ assert.equal(await versionOf(running), 3)
+ assert.equal(await versionOf(done), 3)
+ assert.equal(await versionOf(already), 4)
+})
+
+test('a scheduled run whose started_at is somehow set is left alone', async (t) => {
+ if (needDb(t)) return
+ // Belt and braces on the guard: `status = 'scheduled'` and `started_at IS NULL`
+ // are two conditions rather than one because a row that has both is the only
+ // row that is provably untouched.
+ const def = await pool.query('INSERT INTO event_definitions (grace_seconds) VALUES (900)')
+ const r = await pool.query(
+ `INSERT INTO event_runs (definition_id, version_id, scope, status, scheduled_for, started_at)
+ VALUES (?, 3, '', 'scheduled', ?, ?)`,
+ [def.insertId, T0, T0],
+ )
+ assert.equal(rows(await pool.query(REPIN_SCHEDULED, [4, def.insertId, 4])), 0)
+ const after = (await pool.query('SELECT version_id FROM event_runs WHERE id = ?', [r.insertId]))[0]
+ assert.equal(Number(after.version_id), 3)
+})
diff --git a/server/test/eventSchedule.test.js b/server/test/eventSchedule.test.js
new file mode 100644
index 0000000..0b8652e
--- /dev/null
+++ b/server/test/eventSchedule.test.js
@@ -0,0 +1,391 @@
+// ── Expansion and the calendar (EVENTS_PLAN.md Phase 4) ────────────────────
+//
+// The phase's shipped claim: **a published definition with a recurrence produces
+// occurrences on its own, and the calendar shows the ones that exist beside the
+// ones that will.** The arithmetic underneath is proved separately in
+// `eventRecurrence.test.js`; this file is about the two decisions the org lead
+// took on 2026-09-02 and the properties they imply:
+//
+// • occurrences become REAL ROWS inside a fourteen-day horizon, and beyond it
+// the calendar projects rather than materialising
+// • a projection is never emitted for an instant a run already occupies — so
+// the fortnight inside the horizon is not drawn twice, and a CANCELLED
+// occurrence does not come back as a forecast
+// • expansion looks forward from `now - grace` only, so an occurrence nobody
+// could ever have seen is not invented retroactively
+// • only `ready` definitions expand: publishing IS the schedule switch (§E)
+// and archiving is how an operator turns one off
+// • expansion is idempotent, because it runs every fifteen seconds for ever
+//
+// Stubbed at the `.db` layer, the shape `eventRunner.test.js` uses.
+
+process.env.DB_HOST = '127.0.0.1'
+process.env.DB_PORT = '59999'
+
+const { test, beforeEach, afterEach, after } = require('node:test')
+const assert = require('node:assert/strict')
+
+const registries = require('../src/modules/registries')
+const runner = require('../src/utils/eventRunner')
+const calendarModel = require('../src/model/events/eventCalendar.model')
+const definitionsDb = require('../src/model/events/eventDefinitions.db')
+const runsDb = require('../src/model/events/eventRuns.db')
+const stepsDb = require('../src/model/events/eventRunSteps.db')
+const logDb = require('../src/model/events/eventRunLog.db')
+const versionsDb = require('../src/model/events/eventVersions.db')
+const db = require('../src/utils/db')
+
+after(() => db.close())
+
+// A Tuesday. Chosen so a "friday" schedule has its first occurrence three days
+// out — inside the horizon, but not today, which is what keeps "materialised"
+// and "due" from being confusable in these fixtures.
+const NOW = new Date('2026-09-01T12:00:00Z')
+
+const SPEC = {
+ schedule: { kind: 'weekly', days: ['friday'], time: '20:00' },
+ phases: [{ key: 'main', label: 'Main', steps: [] }],
+}
+
+let store
+const originals = {}
+for (const [name, mod] of [
+ ['definitionsDb', definitionsDb],
+ ['runsDb', runsDb],
+ ['stepsDb', stepsDb],
+ ['logDb', logDb],
+ ['versionsDb', versionsDb],
+]) {
+ originals[name] = { mod, fns: { ...mod } }
+}
+
+const restoreOriginals = () => {
+ for (const { mod, fns } of Object.values(originals)) Object.assign(mod, fns)
+}
+
+const clone = (o) => JSON.parse(JSON.stringify(o))
+
+/** One `ready` definition with a published version carrying `spec`. */
+function addDefinition(id, overrides = {}) {
+ const definition = {
+ id,
+ title: `Event ${id}`,
+ slug: `event-${id}`,
+ state: 'ready',
+ timezone: 'UTC',
+ grace_seconds: 900,
+ concurrency_key: null,
+ current_version_id: id * 100,
+ series_id: null,
+ series_name: null,
+ series_slug: null,
+ spec: clone(SPEC),
+ ...overrides,
+ }
+ store.definitions.set(id, definition)
+ store.versions.set(definition.current_version_id, {
+ id: definition.current_version_id,
+ definition_id: id,
+ version: 1,
+ spec: definition.spec,
+ })
+ return definition
+}
+
+function installStubs() {
+ store = { definitions: new Map(), versions: new Map(), runs: [], steps: [], log: [], nextRunId: 1 }
+
+ Object.assign(definitionsDb, {
+ findSchedulable: async () =>
+ [...store.definitions.values()]
+ .filter((d) => d.state === 'ready' && d.current_version_id)
+ .map((d) => ({ ...d, version_spec: store.versions.get(d.current_version_id)?.spec || null })),
+ getById: async (id) => store.definitions.get(id) || null,
+ list: async () => [...store.definitions.values()],
+ })
+
+ Object.assign(versionsDb, { getById: async (id) => store.versions.get(id) || null })
+
+ Object.assign(runsDb, {
+ materialise: async (run) => {
+ const at = new Date(run.scheduled_for).getTime()
+ // The unique index, in memory: one row per (definition, scope, instant).
+ const clash = store.runs.find(
+ (r) => r.definition_id === run.definition_id && r.scope === (run.scope || '') && new Date(r.scheduled_for).getTime() === at,
+ )
+ if (clash) return null
+ const id = store.nextRunId++
+ const definition = store.definitions.get(run.definition_id)
+ store.runs.push({
+ ...run,
+ id,
+ scope: run.scope || '',
+ status: 'scheduled',
+ health: 'ok',
+ waiting_steps: 0,
+ definition_title: definition?.title,
+ definition_slug: definition?.slug,
+ series_id: definition?.series_id ?? null,
+ series_name: definition?.series_name ?? null,
+ series_slug: definition?.series_slug ?? null,
+ version_number: 1,
+ })
+ return id
+ },
+ getById: async (id) => store.runs.find((r) => r.id === id) || null,
+ findOccurrence: async (definitionId, scope, at) =>
+ store.runs.find(
+ (r) => r.definition_id === definitionId && r.scope === (scope || '') && new Date(r.scheduled_for).getTime() === new Date(at).getTime(),
+ ) || null,
+ listInWindow: async ({ from, to, status = null, scope = null, seriesId = null }) =>
+ store.runs
+ .filter((r) => {
+ const at = new Date(r.scheduled_for).getTime()
+ if (at < new Date(from).getTime() || at >= new Date(to).getTime()) return false
+ if (status && r.status !== status) return false
+ if (scope !== null && scope !== undefined && r.scope !== scope) return false
+ if (seriesId && Number(r.series_id) !== Number(seriesId)) return false
+ return true
+ })
+ .sort((a, b) => new Date(a.scheduled_for) - new Date(b.scheduled_for)),
+ })
+
+ Object.assign(stepsDb, { materialisePhase: async () => [] })
+ Object.assign(logDb, { write: async (line) => { store.log.push(line); return 1 } })
+}
+
+beforeEach(() => {
+ registries._reset()
+ registries.registerCore()
+ installStubs()
+})
+
+afterEach(restoreOriginals)
+
+const instants = () => store.runs.map((r) => new Date(r.scheduled_for).toISOString()).sort()
+
+// ── Expansion ──────────────────────────────────────────────────────────────
+
+test('a weekly definition materialises exactly the occurrences inside the horizon', async () => {
+ addDefinition(1)
+ const created = await runner.expandSchedules(NOW)
+
+ // 1 September 2026 is a Tuesday. Fridays inside 14 days: the 4th and the 11th.
+ assert.equal(created, 2)
+ assert.deepEqual(instants(), ['2026-09-04T20:00:00.000Z', '2026-09-11T20:00:00.000Z'])
+})
+
+test('expansion is idempotent — running it again creates nothing', async () => {
+ // The property the whole design leans on: this runs every fifteen seconds for
+ // ever. `INSERT IGNORE` against the occurrence key is what makes that free,
+ // and a second call that created rows would be a duplicate event, not a
+ // duplicate row.
+ addDefinition(1)
+ assert.equal(await runner.expandSchedules(NOW), 2)
+ assert.equal(await runner.expandSchedules(NOW), 0)
+ assert.equal(await runner.expandSchedules(new Date(NOW.getTime() + 60_000)), 0)
+ assert.equal(store.runs.length, 2)
+})
+
+test('only `ready` definitions expand — publishing is the switch, archiving turns it off', async () => {
+ addDefinition(1, { state: 'draft' })
+ addDefinition(2, { state: 'archived' })
+ addDefinition(3, { state: 'ready' })
+ await runner.expandSchedules(NOW)
+ assert.deepEqual([...new Set(store.runs.map((r) => r.definition_id))], [3])
+})
+
+test('a draft edit cannot materialise anything — the VERSION spec is what expands', async () => {
+ // The definition's working copy says daily; the published version says weekly.
+ // A half-typed recurrence an author is midway through must never produce a run.
+ const definition = addDefinition(1)
+ definition.spec = {
+ schedule: { kind: 'weekly', days: ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'], time: '20:00' },
+ phases: SPEC.phases,
+ }
+ await runner.expandSchedules(NOW)
+ assert.equal(store.runs.length, 2)
+})
+
+test('a manual definition expands to nothing at all', async () => {
+ addDefinition(1, { spec: { schedule: { kind: 'manual' }, phases: SPEC.phases } })
+ store.versions.get(100).spec = store.definitions.get(1).spec
+ assert.equal(await runner.expandSchedules(NOW), 0)
+ assert.equal(store.runs.length, 0)
+})
+
+test('an occurrence older than the grace window is never materialised at all', async () => {
+ // Not materialised-then-swept. A row nobody could ever have seen or cancelled
+ // is not history, and writing one would put a `missed` event on the calendar
+ // for a date on which this deployment had no such event. The horizon is what
+ // makes the missed sweep meaningful instead: a real outage finds rows already
+ // there, because they were written a fortnight early.
+ addDefinition(1, { grace_seconds: 900 })
+ // A Monday, three days after the Friday occurrence — far outside the grace.
+ await runner.expandSchedules(new Date('2026-09-07T12:00:00Z'))
+ assert.ok(!instants().includes('2026-09-04T20:00:00.000Z'))
+})
+
+test('an occurrence still inside the grace window IS materialised', async () => {
+ // The case this rule exists for: a definition published four minutes before
+ // its own first occurrence. `now - grace` is the window start, so the
+ // occurrence that has only just passed is still created and still startable.
+ addDefinition(1, { grace_seconds: 3600 })
+ await runner.expandSchedules(new Date('2026-09-04T20:10:00Z'))
+ assert.ok(instants().includes('2026-09-04T20:00:00.000Z'))
+})
+
+test('a DST-adjusted occurrence records WHY its clock reads oddly', async () => {
+ // Discovering daylight saving at 3am on the last Sunday in October is the
+ // failure this line exists to prevent.
+ addDefinition(1, {
+ timezone: 'Europe/Berlin',
+ spec: { schedule: { kind: 'weekly', days: ['sunday'], time: '02:30' }, phases: SPEC.phases },
+ })
+ store.versions.get(100).spec = store.definitions.get(1).spec
+
+ await runner.expandSchedules(new Date('2026-03-22T12:00:00Z'))
+ const adjusted = store.log.find((l) => l.detail?.dstAdjusted)
+ assert.equal(adjusted.detail.dstAdjusted, 'gap')
+ assert.equal(adjusted.detail.timezone, 'Europe/Berlin')
+ assert.ok(instants().includes('2026-03-29T01:00:00.000Z'))
+})
+
+test('a definition whose spec is nonsense is skipped, and the sweep carries on', async () => {
+ // A spec written straight into the database with a shape the validator would
+ // have refused is a bad row, not a bad tick.
+ addDefinition(1, { spec: { schedule: { kind: 'weekly', days: ['froday'], time: '20:00' }, phases: SPEC.phases } })
+ store.versions.get(100).spec = store.definitions.get(1).spec
+ addDefinition(2)
+
+ const created = await runner.expandSchedules(NOW)
+ assert.equal(created, 2)
+ assert.deepEqual([...new Set(store.runs.map((r) => r.definition_id))], [2])
+})
+
+test('every materialised occurrence is marked as coming from the schedule', async () => {
+ // `started_by` is NULL for a scheduled occurrence and for one an admin started
+ // whose account has since gone, so the log is the only place the two are told
+ // apart.
+ addDefinition(1)
+ await runner.expandSchedules(NOW)
+ const created = store.log.filter((l) => l.kind === 'run.created' && l.detail?.source)
+ assert.equal(created.length, 2)
+ for (const line of created) {
+ assert.equal(line.detail.source, 'schedule')
+ assert.equal(line.detail.by, null)
+ }
+})
+
+// ── The calendar ───────────────────────────────────────────────────────────
+
+test('inside the horizon the calendar shows runs; beyond it, projections', async () => {
+ addDefinition(1)
+ await runner.expandSchedules(NOW)
+
+ const result = await calendarModel.calendar({
+ from: new Date('2026-09-01T00:00:00Z'),
+ to: new Date('2026-10-01T00:00:00Z'),
+ now: NOW,
+ })
+
+ const kinds = result.entries.map((e) => `${e.kind} ${new Date(e.scheduledFor).toISOString().slice(0, 10)}`)
+ assert.deepEqual(kinds, [
+ 'run 2026-09-04',
+ 'run 2026-09-11',
+ 'projected 2026-09-18',
+ 'projected 2026-09-25',
+ ])
+ // The forecast is arithmetic and says so: no row, nothing to open.
+ for (const entry of result.entries.filter((e) => e.kind === 'projected')) {
+ assert.equal(entry.runId, null)
+ assert.equal(entry.status, null)
+ }
+})
+
+test('a projection is never drawn over an instant a run already occupies', async () => {
+ addDefinition(1)
+ await runner.expandSchedules(NOW)
+ const result = await calendarModel.calendar({
+ from: new Date('2026-09-01T00:00:00Z'),
+ to: new Date('2026-09-15T00:00:00Z'),
+ now: NOW,
+ })
+ assert.equal(result.entries.length, 2)
+ assert.ok(result.entries.every((e) => e.kind === 'run'))
+})
+
+test('a CANCELLED occurrence does not come back as a forecast', async () => {
+ // The same rule, and the case it earns its keep on. An operator who called an
+ // event off must not find it on the calendar again ten seconds later looking
+ // like it is still coming.
+ addDefinition(1)
+ await runner.expandSchedules(NOW)
+ store.runs[0].status = 'cancelled'
+
+ const result = await calendarModel.calendar({
+ from: new Date('2026-09-01T00:00:00Z'),
+ to: new Date('2026-09-15T00:00:00Z'),
+ now: NOW,
+ })
+ const onTheDay = result.entries.filter((e) => new Date(e.scheduledFor).toISOString().startsWith('2026-09-04'))
+ assert.equal(onTheDay.length, 1)
+ assert.equal(onTheDay[0].kind, 'run')
+ assert.equal(onTheDay[0].status, 'cancelled')
+})
+
+test('a status filter suppresses projections, because a forecast has no status', async () => {
+ addDefinition(1)
+ await runner.expandSchedules(NOW)
+ const result = await calendarModel.calendar({
+ from: new Date('2026-09-01T00:00:00Z'),
+ to: new Date('2026-10-01T00:00:00Z'),
+ status: 'scheduled',
+ now: NOW,
+ })
+ assert.ok(result.entries.every((e) => e.kind === 'run'))
+ assert.equal(result.entries.length, 2)
+})
+
+test('a series filter narrows runs and projections alike', async () => {
+ addDefinition(1, { series_id: 7, series_name: 'Royal Spy Mission' })
+ addDefinition(2, { series_id: 9, series_name: 'Something Else' })
+ await runner.expandSchedules(NOW)
+
+ const result = await calendarModel.calendar({
+ from: new Date('2026-09-01T00:00:00Z'),
+ to: new Date('2026-10-01T00:00:00Z'),
+ seriesId: 7,
+ now: NOW,
+ })
+ assert.ok(result.entries.length > 2)
+ assert.ok(result.entries.every((e) => e.seriesName === 'Royal Spy Mission'))
+ assert.ok(result.entries.some((e) => e.kind === 'projected'))
+})
+
+test('the window is bounded, inverted windows are refused, and the horizon is reported', async () => {
+ const wide = await calendarModel.calendar({
+ from: new Date('2026-01-01T00:00:00Z'),
+ to: new Date('2027-01-01T00:00:00Z'),
+ now: NOW,
+ })
+ assert.equal(wide.ok, false)
+ assert.equal(wide.status, 400)
+ assert.match(wide.errors.join(' '), /at most 92 days/)
+
+ const inverted = await calendarModel.calendar({
+ from: new Date('2026-09-10T00:00:00Z'),
+ to: new Date('2026-09-01T00:00:00Z'),
+ now: NOW,
+ })
+ assert.equal(inverted.ok, false)
+
+ const fine = await calendarModel.calendar({
+ from: new Date('2026-09-01T00:00:00Z'),
+ to: new Date('2026-09-15T00:00:00Z'),
+ horizonDays: 14,
+ now: NOW,
+ })
+ assert.equal(fine.ok, true)
+ assert.equal(fine.horizon.toISOString(), '2026-09-15T12:00:00.000Z')
+})
diff --git a/server/test/eventSeries.test.js b/server/test/eventSeries.test.js
new file mode 100644
index 0000000..b733639
--- /dev/null
+++ b/server/test/eventSeries.test.js
@@ -0,0 +1,113 @@
+// ── Event series, the arc (EVENTS.md §D/§I, Phase 4) ───────────────────────
+//
+// One small table, and the reason it is worth testing at all is the two rules
+// that are not obvious from its four columns:
+//
+// • the slug is derived once and FROZEN. The public arc page lives at it, so
+// a rename that moved it would break every link — including the ones inside
+// the Discord posts this feature will eventually write.
+// • the delete is a real delete, and it is the only one in this feature. A
+// definition is archived instead, because a run pins its version and history
+// that cannot be explained defeats the audit. A series pins nothing: it is a
+// label, `series_id` is ON DELETE SET NULL, and the count of what it detached
+// is what an operator needs to be told.
+
+process.env.DB_HOST = '127.0.0.1'
+process.env.DB_PORT = '59999'
+
+const { test, beforeEach, afterEach, after } = require('node:test')
+const assert = require('node:assert/strict')
+
+const seriesDb = require('../src/model/events/eventSeries.db')
+const series = require('../src/model/events/eventSeries.model')
+const db = require('../src/utils/db')
+
+after(() => db.close())
+
+let store
+const original = { ...seriesDb }
+
+beforeEach(() => {
+ store = { rows: new Map(), nextId: 1 }
+ Object.assign(seriesDb, {
+ list: async () => [...store.rows.values()].sort((a, b) => a.ordering - b.ordering || a.id - b.id),
+ getById: async (id) => store.rows.get(id) || null,
+ exists: async (id) => store.rows.has(id),
+ insert: async (s) => {
+ const id = store.nextId++
+ store.rows.set(id, { ...s, id, definition_count: 0 })
+ return id
+ },
+ update: async (id, s) => {
+ const existing = store.rows.get(id)
+ store.rows.set(id, { ...existing, ...s })
+ return 1
+ },
+ remove: async (id) => {
+ store.rows.delete(id)
+ return 1
+ },
+ })
+})
+
+afterEach(() => Object.assign(seriesDb, original))
+
+test('a series is created with a slug derived from its name', async () => {
+ const result = await series.create({ name: 'Royal Spy Mission', ordering: 2 }, 7)
+ assert.equal(result.ok, true)
+ assert.equal(result.status, 201)
+ assert.equal(result.series.slug, 'royal-spy-mission')
+ assert.equal(result.series.ordering, 2)
+ assert.equal(result.series.created_by, 7)
+})
+
+test('two series with the same name get distinct slugs', async () => {
+ // `slug` is UNIQUE in the schema, so without this the second create is a 1452
+ // reaching a controller as a 500.
+ const first = await series.create({ name: 'Winter Arc' })
+ const second = await series.create({ name: 'Winter Arc' })
+ assert.equal(first.series.slug, 'winter-arc')
+ assert.equal(second.series.slug, 'winter-arc-2')
+})
+
+test('renaming a series does NOT move its slug', async () => {
+ // The rule with teeth. The arc page lives at the slug, and a rename is the
+ // ordinary act of an editor tidying up wording months later.
+ const created = await series.create({ name: 'Royal Spy Mission' })
+ const updated = await series.update(created.series.id, { name: 'The Royal Spy Missions' })
+ assert.equal(updated.ok, true)
+ assert.equal(updated.series.name, 'The Royal Spy Missions')
+ assert.equal(updated.series.slug, 'royal-spy-mission')
+})
+
+test('a nameless series is refused, and so is a nonsense ordering', async () => {
+ assert.deepEqual((await series.create({ name: ' ' })).errors, ['name is required'])
+ const bad = await series.create({ name: 'Fine', ordering: -3 })
+ assert.equal(bad.ok, false)
+ assert.match(bad.errors.join(' '), /ordering must be an integer/)
+})
+
+test('deleting a series answers with how many definitions it detached', async () => {
+ // The whole consequence of this delete is about the rows it does NOT delete,
+ // so the count is the answer rather than a detail.
+ const created = await series.create({ name: 'Winter Arc' })
+ store.rows.get(created.series.id).definition_count = 3
+
+ const removed = await series.remove(created.series.id)
+ assert.equal(removed.ok, true)
+ assert.equal(removed.detached, 3)
+ assert.equal(await seriesDb.getById(created.series.id), null)
+})
+
+test('acting on a series that is not there is a 404, never a 500', async () => {
+ assert.equal((await series.update(999, { name: 'x' })).status, 404)
+ assert.equal((await series.remove(999)).status, 404)
+})
+
+test('ordering places a series among the others, and defaults to zero', async () => {
+ await series.create({ name: 'Third', ordering: 30 })
+ await series.create({ name: 'First', ordering: 10 })
+ await series.create({ name: 'Unordered' })
+ const listed = await series.list()
+ assert.deepEqual(listed.map((s) => s.name), ['Unordered', 'First', 'Third'])
+})
diff --git a/server/test/eventSpec.test.js b/server/test/eventSpec.test.js
index 307cdad..5d995f6 100644
--- a/server/test/eventSpec.test.js
+++ b/server/test/eventSpec.test.js
@@ -192,13 +192,104 @@ test('a key a later phase owns is refused, not silently preserved', () => {
assert.match(phase.errors.join('\n'), /unknown key\(s\) advance .*Phase 5/)
})
-test('only the manual schedule exists in this phase', () => {
- const weekly = spec.validate({
- schedule: { kind: 'weekly', days: ['fri'], time: '20:00' },
- phases: [{ key: 'main', label: 'Main', steps: [] }],
+// ── The schedule shapes (Phase 4) ────────────────────────────────────
+//
+// Every check here is on SHAPE. What the shapes MEAN — the zone arithmetic, the
+// DST rules — is `eventRecurrence.test.js`. The split is deliberate: this file
+// answers "may this be saved", that one answers "when does it happen", and the
+// second question is only worth asking of something that passed the first.
+
+const withSchedule = (schedule) =>
+ spec.validate({ schedule, phases: [{ key: 'main', label: 'Main', steps: [] }] })
+
+test('the four closed shapes are accepted and normalised', () => {
+ assert.deepEqual(withSchedule({ kind: 'manual' }).spec.schedule, { kind: 'manual' })
+ assert.deepEqual(withSchedule({ kind: 'once', at: '2026-10-31T20:00' }).spec.schedule, {
+ kind: 'once',
+ at: '2026-10-31T20:00',
})
- assert.equal(weekly.ok, false)
- assert.match(weekly.errors.join('\n'), /recurrence arrives in Phase 4/)
+ assert.deepEqual(withSchedule({ kind: 'weekly', days: ['friday'], time: '20:00' }).spec.schedule, {
+ kind: 'weekly',
+ days: ['friday'],
+ time: '20:00',
+ })
+ assert.deepEqual(
+ withSchedule({ kind: 'monthly', nth: -1, weekday: 'friday', time: '19:30' }).spec.schedule,
+ { kind: 'monthly', nth: -1, weekday: 'friday', time: '19:30' },
+ )
+})
+
+test('the validator accepts its own output for every shape', () => {
+ // Phase 1's rule, and it is a rule about the SECOND save of any definition
+ // rather than about a round trip for its own sake: `validate` normalises, and
+ // publish re-validates what a save wrote. A normaliser that refuses what it
+ // emits makes a published definition uneditable.
+ for (const schedule of [
+ { kind: 'manual' },
+ { kind: 'once', at: '2026-10-31T20:00' },
+ { kind: 'weekly', days: ['friday', 'monday'], time: '20:00' },
+ { kind: 'monthly', nth: 4, weekday: 'friday', time: '19:30' },
+ ]) {
+ const first = withSchedule(schedule)
+ assert.equal(first.ok, true, JSON.stringify(schedule))
+ const second = withSchedule(first.spec.schedule)
+ assert.equal(second.ok, true, JSON.stringify(first.spec.schedule))
+ assert.deepEqual(second.spec.schedule, first.spec.schedule)
+ }
+})
+
+test('weekly days are normalised into week order and deduped', () => {
+ // Not tidiness. The spec is snapshotted into a version and diffed, so two
+ // orderings of the same schedule would show as an edit nobody made.
+ const result = withSchedule({ kind: 'weekly', days: ['Friday', 'monday', 'FRIDAY'], time: '20:00' })
+ assert.deepEqual(result.spec.schedule.days, ['monday', 'friday'])
+})
+
+test('a shape may not carry another shape keys', () => {
+ const result = withSchedule({ kind: 'weekly', days: ['friday'], time: '20:00', at: '2026-01-01T00:00' })
+ assert.equal(result.ok, false)
+ assert.match(result.errors.join('\n'), /unknown key\(s\) at for kind "weekly"/)
+})
+
+test('an unknown kind is refused, and the message names the four', () => {
+ const result = withSchedule({ kind: 'daily', time: '20:00' })
+ assert.equal(result.ok, false)
+ assert.match(result.errors.join('\n'), /manual, once, weekly, monthly/)
+})
+
+test('a date that is not a real day is refused', () => {
+ // The regex admits 2026-02-30 quite happily. A schedule that parses and then
+ // resolves to some other day is worse than one that is refused.
+ const result = withSchedule({ kind: 'once', at: '2026-02-30T20:00' })
+ assert.equal(result.ok, false)
+ assert.match(result.errors.join('\n'), /is not a real date/)
+})
+
+test('every malformed schedule field is named, not merely rejected', () => {
+ assert.match(withSchedule({ kind: 'once', at: 'soon' }).errors.join('\n'), /YYYY-MM-DDTHH:MM/)
+ assert.match(withSchedule({ kind: 'weekly', days: [], time: '20:00' }).errors.join('\n'), /non-empty array/)
+ assert.match(withSchedule({ kind: 'weekly', days: ['froday'], time: '20:00' }).errors.join('\n'), /unknown weekday/)
+ assert.match(withSchedule({ kind: 'weekly', days: ['friday'], time: '25:00' }).errors.join('\n'), /24-hour time/)
+ assert.match(
+ withSchedule({ kind: 'monthly', nth: 5, weekday: 'friday', time: '19:30' }).errors.join('\n'),
+ /1, 2, 3, 4 or -1/,
+ )
+ assert.match(
+ withSchedule({ kind: 'monthly', nth: 1, weekday: 'froday', time: '19:30' }).errors.join('\n'),
+ /expected one of sunday/,
+ )
+})
+
+test('a refused schedule leaves a manual one behind rather than half a recurrence', () => {
+ // `validate` collects every error and carries on, so the spec object exists
+ // even when the answer is no. A caller reading `schedule.days` of it must not
+ // find a partially built weekly.
+ const result = spec.validate({
+ schedule: { kind: 'weekly', days: ['froday'], time: '20:00' },
+ phases: [{ key: 'BAD KEY', label: '', steps: [] }],
+ })
+ assert.equal(result.ok, false)
+ assert.ok(result.errors.length > 1)
})
test('every problem is reported, not just the first', () => {
diff --git a/server/test/eventsAdmin.test.js b/server/test/eventsAdmin.test.js
index 3f589a9..62ccb82 100644
--- a/server/test/eventsAdmin.test.js
+++ b/server/test/eventsAdmin.test.js
@@ -160,6 +160,21 @@ function installStubs() {
.filter((r) => (!definitionId || r.definition_id === definitionId) && (!status || r.status === status))
.map(shapeRun)
runsDb.getById = async (id) => (store.runs.has(id) ? shapeRun(store.runs.get(id)) : undefined)
+ runsDb.listScheduledFor = async (definitionId) =>
+ [...store.runs.values()]
+ .filter((r) => r.definition_id === definitionId && r.status === 'scheduled' && !r.started_at)
+ .map((r) => ({ id: r.id, version_id: r.version_id, scheduled_for: r.scheduled_for }))
+ runsDb.repinScheduled = async (definitionId, versionId) => {
+ let moved = 0
+ for (const run of store.runs.values()) {
+ if (run.definition_id !== definitionId) continue
+ if (run.status !== 'scheduled' || run.started_at) continue
+ if (run.version_id === versionId) continue
+ run.version_id = versionId
+ moved += 1
+ }
+ return moved
+ }
runsDb.materialise = async (run) => {
const key = occurrenceKey(run.definition_id, run.scope, run.scheduled_for)
if (store.occurrences.has(key)) return null // the UNIQUE index, doing its job
@@ -595,3 +610,56 @@ test('the list filters by state, and an unknown id is 404 rather than 500', asyn
const bad = await call(ctrl.get, { params: { id: 'not-a-number' } })
assert.equal(bad.statusCode, 400)
})
+
+
+test('publishing re-pins the occurrences that have not started, and says how many', async () => {
+ // The case an operator meets on their SECOND edit of any recurring event: a
+ // fortnight of occurrences is already on the calendar, each carrying the spec
+ // as it was. Left alone, an edit reaches none of them and the only recourse --
+ // cancelling each -- makes the occurrence vanish rather than come back, because
+ // a cancelled row still holds its slot in `uq_evrun_occurrence`.
+ const created = await createDraft()
+ const id = created.body.event.id
+ await call(ctrl.publish, { params: { id: String(id) } })
+
+ const ahead = await call(ctrl.startRun, {
+ params: { id: String(id) },
+ body: { scope: 'ahead', scheduledFor: '2026-12-24T20:00:00Z' },
+ })
+ assert.equal(ahead.statusCode, 201)
+ const aheadId = ahead.body.run.id
+ const v1 = store.runs.get(aheadId).version_id
+
+ // A second occurrence, this one already under way. Its pin is what makes it
+ // explicable afterwards, so it must not move.
+ const inFlight = await call(ctrl.startRun, {
+ params: { id: String(id) },
+ body: { scope: 'inflight', scheduledFor: '2026-12-25T20:00:00Z' },
+ })
+ const inFlightId = inFlight.body.run.id
+ store.runs.get(inFlightId).status = 'running'
+ store.runs.get(inFlightId).started_at = new Date()
+
+ const republished = await call(ctrl.publish, { params: { id: String(id) } })
+ assert.equal(republished.statusCode, 200)
+ assert.equal(republished.body.version, 2)
+ assert.equal(republished.body.repinned, 1)
+
+ assert.equal(store.runs.get(aheadId).version_id, republished.body.versionId)
+ assert.notEqual(store.runs.get(aheadId).version_id, v1)
+ assert.equal(store.runs.get(inFlightId).version_id, v1)
+
+ // The move is on the run's own log, because "which version did this actually
+ // use" is the first question an audit asks.
+ const line = store.log.find((l) => l.run_id === aheadId && l.detail?.repinned)
+ assert.equal(line.detail.fromVersionId, v1)
+ assert.equal(line.detail.toVersionId, republished.body.versionId)
+})
+
+test('re-publishing with nothing scheduled ahead re-pins nothing', async () => {
+ const created = await createDraft()
+ const id = created.body.event.id
+ await call(ctrl.publish, { params: { id: String(id) } })
+ const again = await call(ctrl.publish, { params: { id: String(id) } })
+ assert.equal(again.body.repinned, 0)
+})