feat(events): schedule, recurrence and the calendar (Phase 4)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 37s
PR Checks / client-build (pull_request) Successful in 43s
PR Checks / server-tests (pull_request) Successful in 13m26s

The four closed recurrence shapes computed in the definition's own IANA zone,
a fourteen-day materialisation horizon with projections beyond it, series as a
managed thing, and the admin calendar that replaces the plugin this feature
exists to replace. An event now happens on its own.

No schema change: Phase 1 built every column this needed.

- events/recurrence.js is the ONE place an occurrence is computed, so the
  runner's expansion and the calendar's forecast cannot disagree. No date
  library added — Node ships the tzdata one would vendor, behind Intl.
- The runner's materialise leg is now two halves: expand, then sweep. The
  window starts at `now - grace`, so an occurrence nobody could have seen is
  never invented retroactively; the horizon is what makes the missed sweep
  mean anything for a recurrence.
- Publishing is the schedule switch and archiving turns it off, and publishing
  re-pins every occurrence that has not started.
- A projection is never drawn over an instant a run occupies, so a cancelled
  occurrence does not reappear as a forecast.

54 new tests, incl. the DST fixture set the plan asked for and three new
statements proved against a real MariaDB. Suite 1768/1711/56 skipped/1 fail
(pre-existing CRLF). Walked end to end on the local review stack.

Docs: RunicGateway/docs#PENDING

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-09-02 16:10:16 -05:00
parent a481248bc0
commit 6e73660b52
30 changed files with 3722 additions and 77 deletions

View File

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