Merge pull request 'feat(events): schedule, recurrence and the calendar (Phase 4)' (#186) from feature/events-phase-4 into edge

Reviewed-on: #186
This commit is contained in:
2026-09-03 01:58:06 +00:00
30 changed files with 3722 additions and 77 deletions

View File

@@ -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. */}
<Route path="events" element={<EventsAdmin />} />
<Route path="events/calendar" element={<EventsCalendar />} />
<Route path="events/runs/:runId" element={<EventRun />} />
<Route path="events/new" element={<EventEditor />} />
<Route path="events/:id" element={<EventEditor />} />

View File

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

View File

@@ -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()

View File

@@ -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',
}

View File

@@ -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() {
</div>
{/* ── 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.
*/}
<div className="panel-flat" style={{ padding: 14, marginBottom: 14 }}>
<h3 className="sans" style={{ margin: '0 0 6px', fontSize: '0.92rem' }}>Schedule</h3>
<p className="sans dim" style={{ margin: 0, fontSize: '0.82rem' }}>
<strong>Started by hand.</strong> Recurrence once, weekly, monthly on the nth weekday
is computed in the event&rsquo;s own timezone and arrives in the next phase, with the calendar.
Until then an occurrence exists because somebody pressed <em>Start now</em>, and the
schedule shape a definition may carry is deliberately the single one the runner honours.
<h3 className="sans" style={{ margin: '0 0 10px', fontSize: '0.92rem' }}>Schedule</h3>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit,minmax(180px,1fr))', gap: 12 }}>
<label>
<span className="field-label">Repeats</span>
<select className="select" value={form.scheduleKind} disabled={archived}
onChange={(e) => set({ scheduleKind: e.target.value })}>
{SCHEDULE_KINDS.map((k) => <option key={k.value} value={k.value}>{k.label}</option>)}
</select>
</label>
{form.scheduleKind === 'once' && (
<label>
<span className="field-label">Date and time</span>
<input className="input" type="datetime-local" value={form.scheduleAt} disabled={archived}
onChange={(e) => set({ scheduleAt: e.target.value.slice(0, 16) })} />
</label>
)}
{form.scheduleKind === 'monthly' && (
<>
<label>
<span className="field-label">Week</span>
<select className="select" value={form.scheduleNth} disabled={archived}
onChange={(e) => set({ scheduleNth: e.target.value })}>
{MONTHLY_NTHS.map((n) => <option key={n.value} value={n.value}>{n.label}</option>)}
</select>
</label>
<label>
<span className="field-label">Weekday</span>
<select className="select" value={form.scheduleWeekday} disabled={archived}
onChange={(e) => set({ scheduleWeekday: e.target.value })}>
{WEEKDAYS.map((d) => (
<option key={d} value={d}>{d.charAt(0).toUpperCase() + d.slice(1)}</option>
))}
</select>
</label>
</>
)}
{(form.scheduleKind === 'weekly' || form.scheduleKind === 'monthly') && (
<label>
<span className="field-label">Time</span>
<input className="input" type="time" value={form.scheduleTime} disabled={archived}
onChange={(e) => set({ scheduleTime: e.target.value })} />
</label>
)}
</div>
{form.scheduleKind === 'weekly' && (
<div style={{ marginTop: 12 }}>
<span className="field-label">Days</span>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 6 }}>
{WEEKDAYS.map((day) => {
const on = (form.scheduleDays || []).includes(day)
return (
<button key={day} type="button" className="pill" disabled={archived}
aria-pressed={on}
style={{ fontSize: '0.72rem', opacity: on ? 1 : 0.45 }}
onClick={() => set({
scheduleDays: on
? form.scheduleDays.filter((d) => d !== day)
: WEEKDAYS.filter((d) => d === day || form.scheduleDays.includes(d)),
})}>
{day.charAt(0).toUpperCase() + day.slice(1, 3)}
</button>
)
})}
</div>
</div>
)}
<p className="sans" style={{ margin: '12px 0 0', fontSize: '0.84rem' }}>
{describeSchedule(scheduleFromForm(form), form.timezone)}
</p>
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '8px 0 0' }}>
Times are the event&rsquo;s own, in <code>{form.timezone}</code> not the reader&rsquo;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.
</p>
</div>

View File

@@ -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() {
<p className="sans" style={{ margin: 0, fontSize: '0.86rem', color: 'var(--muted)', maxWidth: 620 }}>
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 <Link to="/admin/events/calendar">calendar</Link> is where those
occurrences are read.
</p>
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-end' }}>
<label>
@@ -121,6 +123,9 @@ export default function EventsAdmin() {
<option value="archived">Archived</option>
</select>
</label>
<Link className="pill" style={{ fontSize: '0.74rem' }} to="/admin/events/calendar">
Calendar
</Link>
{mayAuthor && (
<Link className="pill" style={{ fontSize: '0.74rem' }} to="/admin/events/new">
New event

View File

@@ -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 <Loading />
if (error && !data) return <ErrorState error={error} onRetry={load} />
const horizon = data?.horizon ? new Date(data.horizon) : null
return (
<>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10, alignItems: 'center', marginBottom: 12 }}>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
{/*
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' && (
<>
<button type="button" className="pill" style={{ fontSize: '0.72rem' }} onClick={() => step(-1)}>
&larr;
</button>
<strong className="sans" style={{ fontSize: '0.95rem', minWidth: 150, textAlign: 'center' }}>
{`${MONTH_NAMES[month]} ${year}`}
</strong>
<button type="button" className="pill" style={{ fontSize: '0.72rem' }} onClick={() => step(1)}>
&rarr;
</button>
<button type="button" className="pill" style={{ fontSize: '0.72rem' }}
disabled={year === today.getFullYear() && month === today.getMonth()}
onClick={() => { setYear(today.getFullYear()); setMonth(today.getMonth()) }}>
Today
</button>
</>
)}
{view === 'list' && (
<strong className="sans" style={{ fontSize: '0.95rem' }}>The next 60 days</strong>
)}
</div>
<div style={{ display: 'flex', gap: 6, marginLeft: 'auto', alignItems: 'center' }}>
<select className="select" value={seriesId} onChange={(e) => setSeriesId(e.target.value)}
style={{ minWidth: 170 }}>
<option value="">Every series</option>
{series.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
<button type="button" className="pill" aria-pressed={view === 'month'}
style={{ fontSize: '0.72rem', opacity: view === 'month' ? 1 : 0.5 }}
onClick={() => setView('month')}>
Month
</button>
<button type="button" className="pill" aria-pressed={view === 'list'}
style={{ fontSize: '0.72rem', opacity: view === 'list' ? 1 : 0.5 }}
onClick={() => setView('list')}>
List
</button>
{mayAuthor && (
<button type="button" className="pill" aria-pressed={managingSeries}
style={{ fontSize: '0.72rem', opacity: managingSeries ? 1 : 0.6 }}
onClick={() => setManagingSeries((v) => !v)}>
Series
</button>
)}
<Link to="/admin/events" className="pill" style={{ fontSize: '0.72rem' }}>Events</Link>
</div>
</div>
{/* The legend is not optional. The whole screen rests on the reader
knowing that a dashed entry is not a booking. */}
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 12px' }}>
<span style={{ ...chip, borderStyle: 'solid' }}>Scheduled run</span> is a real occurrence with
a console it can be opened, paused and cancelled.{' '}
<span style={{ ...chip, borderStyle: 'dashed', opacity: 0.7 }}>Forecast</span> 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.`}
</p>
{managingSeries && <SeriesManager series={series} onChanged={load} />}
{data?.truncated && (
<p className="sans" style={{ fontSize: '0.8rem', color: '#d9c184' }}>
This window has more than the calendar will draw. Narrow it by series, or page to a
shorter span.
</p>
)}
{view === 'month' ? (
<div className="panel-flat" style={{ padding: 10 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7,1fr)', gap: 4 }}>
{['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map((d) => (
<div key={d} className="sans dim" style={{ fontSize: '0.72rem', textAlign: 'center', padding: '2px 0' }}>
{d}
</div>
))}
{grid.map((day) => {
const entries = byDay.get(readerDayKey(day)) || []
const outside = day.getMonth() !== month
const isToday = readerDayKey(day) === readerDayKey(today)
return (
<div key={day.toISOString()}
style={{
minHeight: 84,
padding: 4,
borderRadius: 4,
border: isToday ? '1px solid var(--accent, #8fc79a)' : '1px solid transparent',
background: outside ? 'transparent' : 'rgba(255,255,255,0.03)',
opacity: outside ? 0.4 : 1,
}}>
<div className="sans dim" style={{ fontSize: '0.7rem', marginBottom: 3 }}>
{day.getDate()}
</div>
{entries.map((entry) => <EntryChip key={entryKey(entry)} entry={entry} />)}
</div>
)
})}
</div>
</div>
) : (
<div className="panel-flat" style={{ padding: 4 }}>
{(data?.entries || []).length === 0 ? (
<p className="sans dim" style={{ padding: 14, margin: 0, fontSize: '0.84rem' }}>
Nothing is scheduled in the next sixty days.{' '}
{mayAuthor && <Link to="/admin/events/new">Author an event</Link>}
</p>
) : (
<table className="table">
<thead>
<tr>
<th>When</th>
<th>Event</th>
<th>Series</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{(data?.entries || []).map((entry) => (
<tr key={entryKey(entry)} style={{ opacity: isProjected(entry) ? 0.7 : 1 }}>
<td className="sans" style={{ fontSize: '0.82rem', whiteSpace: 'nowrap' }}>
{new Date(entry.scheduledFor).toLocaleDateString()}{' '}
<span className="dim">
{localTime(entry.scheduledFor, entry.timezone)} {entry.timezone}
</span>
</td>
<td className="sans" style={{ fontSize: '0.84rem' }}>
{entry.runId ? (
<Link to={`/admin/events/runs/${entry.runId}`}>{entry.title}</Link>
) : (
<Link to={`/admin/events/${entry.definitionId}`}>{entry.title}</Link>
)}
{entry.adjusted === 'gap' && (
<span className="dim" title="Daylight saving skips the time this was authored at, so it moves forward to the next one that exists">
{' '}(clocks change)
</span>
)}
</td>
<td className="sans dim" style={{ fontSize: '0.8rem' }}>{entry.seriesName || '—'}</td>
<td className="sans" style={{ fontSize: '0.8rem', color: STATUS_COLOR[entry.status] }}>
{isProjected(entry) ? <span className="dim">Forecast</span> : runStatusWord(entry.status)}
{entry.waitingSteps > 0 && (
<span style={{ color: '#d9c184' }}> · waiting on a person</span>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
</>
)
}
// 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 (
<div className="panel-flat" style={{ padding: 14, marginBottom: 12 }}>
<h3 className="sans" style={{ margin: '0 0 4px', fontSize: '0.92rem' }}>Series</h3>
<p className="sans dim" style={{ margin: '0 0 10px', fontSize: '0.78rem' }}>
An arc several events form together. The order here is where a series sits among the
others; where an event sits <em>within</em> its arc is that event&rsquo;s own order, in the
editor.
</p>
{problem && (
<p className="sans" style={{ fontSize: '0.8rem', color: '#d98b84' }}>{problem}</p>
)}
{series.map((s) => (
<div key={s.id} style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 6 }}>
<input className="input" defaultValue={s.name} disabled={busy} style={{ flex: 1 }}
onBlur={(e) => {
const next = e.target.value.trim()
if (next && next !== s.name) {
run(() => api.admin.updateEventSeries(s.id, { name: next, description: s.description, ordering: s.ordering }))
}
}} />
<input className="input" type="number" defaultValue={s.ordering} disabled={busy}
style={{ width: 72 }} aria-label={`Order of ${s.name}`}
onBlur={(e) => {
const next = Number(e.target.value)
if (Number.isInteger(next) && next !== s.ordering) {
run(() => api.admin.updateEventSeries(s.id, { name: s.name, description: s.description, ordering: next }))
}
}} />
<span className="sans dim" style={{ fontSize: '0.76rem', minWidth: 70 }}>
{s.definitionCount} event{s.definitionCount === 1 ? '' : 's'}
</span>
<button type="button" className="pill" disabled={busy} style={{ fontSize: '0.7rem' }}
onClick={() => {
// The count is in the question, because the consequence of this
// delete is entirely about the rows it does not delete.
const ask = s.definitionCount
? `Delete "${s.name}"? ${s.definitionCount} event(s) will keep their content and lose this series.`
: `Delete "${s.name}"?`
// eslint-disable-next-line no-alert
if (window.confirm(ask)) run(() => api.admin.deleteEventSeries(s.id))
}}>
Delete
</button>
</div>
))}
<div style={{ display: 'flex', gap: 8, marginTop: 10 }}>
<input className="input" value={name} placeholder="New series name" disabled={busy}
style={{ flex: 1 }} onChange={(e) => setName(e.target.value)} />
<button type="button" className="pill" disabled={busy || !name.trim()} style={{ fontSize: '0.72rem' }}
onClick={() => run(async () => {
await api.admin.createEventSeries({ name: name.trim() })
setName('')
})}>
Add
</button>
</div>
</div>
)
}
function EntryChip({ entry }) {
const projected = isProjected(entry)
const to = entry.runId ? `/admin/events/runs/${entry.runId}` : `/admin/events/${entry.definitionId}`
return (
<Link
to={to}
className="sans"
title={`${entry.title}${localTime(entry.scheduledFor, entry.timezone)} ${entry.timezone}${projected ? ' (forecast)' : `${runStatusWord(entry.status)}`}`}
style={{
display: 'block',
fontSize: '0.7rem',
padding: '1px 4px',
marginBottom: 2,
borderRadius: 3,
borderLeft: `2px ${projected ? 'dashed' : 'solid'} ${STATUS_COLOR[entry.status] || 'var(--accent, #8fc79a)'}`,
background: projected ? 'transparent' : 'rgba(255,255,255,0.05)',
opacity: projected ? 0.7 : 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
textDecoration: 'none',
}}>
<span className="dim">{localTime(entry.scheduledFor, entry.timezone)}</span> {entry.title}
{entry.waitingSteps > 0 && <span style={{ color: '#d9c184' }}> </span>}
</Link>
)
}

View File

@@ -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)
})

View File

@@ -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",

View File

@@ -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"

View File

@@ -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,
}

View File

@@ -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 ──

View File

@@ -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 }

View File

@@ -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,

View File

@@ -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) }
}
/**

View File

@@ -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,

View File

@@ -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,
},
})

View File

@@ -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 }

View File

@@ -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 }

View File

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

View File

@@ -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" } } } } */

View File

@@ -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,

View File

@@ -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"
}
}
}

View File

@@ -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')
})

View File

@@ -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

View File

@@ -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)
})

View File

@@ -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')
})

View File

@@ -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'])
})

View File

@@ -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', () => {

View File

@@ -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)
})