feat(events): schedule, recurrence and the calendar (Phase 4) #186

Merged
whitlocktech merged 1 commits from feature/events-phase-4 into edge 2026-09-03 01:58:08 +00:00
Member

Event System Phase 4 (EVENTS_PLAN.md). Docs half: RunicGateway/docs#212.

An event now happens on its own. A definition carries a recurrence, the runner turns it into occurrences a fortnight ahead, and the calendar shows the ones that exist beside the ones that will. Walked end to end on the local review stack (details at the bottom).

No schema change. Phase 1 built every column this needed — timezone, series_id, series_order, grace_seconds, event_series.ordering, and UNIQUE (definition_id, scope, scheduled_for). Phase 4 fills them.

  1. A fourteen-day materialisation horizon, with projections beyond it. Inside it an occurrence is a real row an operator can see, cancel and reschedule one at a time; beyond it the calendar forecasts from the same arithmetic, so a monthly event is still visible three weeks out instead of the calendar simply ending. The two are drawn differently on purpose — an operator treating a forecast as a booking would have been misled by the screen, not by the server.
  2. Automatic expansion is at the EMPTY scope. 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. The admin's own start route still takes any scope.
  3. DST: skip-forward, take-first. A local time the spring gap swallows steps forward to the first one that exists — 02:30 becomes 03:00, not 03:30, so the event happens as close to the authored time as the calendar allows. An hour that comes round twice takes the first. Both are recorded as detail.dstAdjusted, so nobody rediscovers daylight saving at 3am on the last Sunday in October. Neither rule ever drops an occurrence.
  4. nth is 1..4 plus -1 for "last". There is deliberately no fifth: every month has a first through fourth of every weekday, so the closed set has no absent-occurrence case to define. -1 is not a synonym for 4 — it is the shape a "last Friday" contest actually is, and a test walks three years of months to prove the claim rather than assert it in a comment.
  5. Publishing re-pins the occurrences that have not started. Asked mid-build, once the horizon made it real: on the day an editor fixes a typo there are already fourteen days of rows carrying the old spec. A version pin makes a run that has run reproducible; one that has not begun has nothing to reproduce yet. Doing nothing was worse than it looks — the operator's only recourse, cancelling each stale occurrence, leaves the slot held in uq_evrun_occurrence, so the occurrence would not come back on the new version, it would vanish. A run that has begun keeps its pin for ever.

Three things the build settled

  • now - grace is the window start, not now. An occurrence nobody ever materialised is never invented retroactively — waking up after three days down must not manufacture three days of missed history no operator could have seen or cancelled. It does not need to: because rows exist a fortnight ahead of their instant, a real outage finds them already there and the sweep marks them honestly. The horizon is what makes the missed sweep mean anything for a recurring event — the two halves of materialise need each other.
  • Publishing is the schedule switch; archiving turns it off. §E already defines ready as "a version has been published and the schedule is live", so a second enabled flag would be another answer to a question state answers, and the two would eventually disagree. Expansion reads the published version's spec, never the working copy: a half-typed weekly an author is midway through must not materialise anything.
  • Series writes are admin, editor, not admin. Naming an arc is authoring; §N2's narrow gate is about committing the deployment to a run. The delete is a real delete and the only one in this feature — a series pins nothing, series_id is ON DELETE SET NULL, and the response says how many definitions were detached, because that is the entire consequence of the act.

No date library, and why

The server's dependency tree has none — no luxon, no date-fns, no tz package — and this does not add one. Node ships the full tzdata behind Intl.DateTimeFormat, which is the same database a library would vendor a copy of and is already what isTimezone() validates against. events/recurrence.js formats an instant into a zone's wall clock and inverts that mapping by search; it is the one place an occurrence is computed, so the runner's expansion and the calendar's forecast cannot disagree about a date.

Still no cron, for §E's reason: 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. time is HH:MM, weekdays are English names, and the schedule panel is a form with a preview line that reads the whole thing back in English.

A defect this phase introduced into the test harness, and fixed

Putting the expansion leg in front of tick() made eventRunner.test.js reach the dead-port pool on every tick — the file still passed and took minutes. Stubbing findSchedulable there returned it to 0.45s. Worth naming because a suite that is merely slow reads as a suite that is fine.

Verify

  • npm test1768 tests, 1711 pass, 56 skipped, 1 fail. The one failure is engagementManifest.test.js, pre-existing and environmental (CRLF under core.autocrlf=true); edge before this branch is 1714/1662/51/1, and +54 is exactly the tests added here.
  • 54 new tests. eventRecurrence.test.js (17) is the DST fixture set this plan asked for — Berlin across both 2026 transitions, Lord Howe's thirty-minute gap, and Kolkata's half-hour no-DST offset. eventSchedule.test.js (16) covers expansion and the calendar; eventSeries.test.js (7); 7 in eventSpec.test.js; 5 in eventRunnerSql.test.js proving findSchedulable, listInWindow and repinScheduled against a real MariaDB (32/32 with a database, skipped without); 2 in eventsAdmin.test.js. Client: 354 pass, 7 new.
  • npm run routes:manifest and npm run swaggerfour routes added, none moved. check:modules and check:hosts clean. The client builds.

The live walk, on the local review stack

Server + Vite against the uomm-db container, as navadmin and navmod:

  • A weekly Europe/Berlin definition published at 20:00 local materialised itself on the next tick: 2026-09-04T18:00Z and 2026-09-11T18:00Z — 20:00 CEST, both inside the horizon. Nobody pressed anything.
  • The calendar drew the horizon line exactly where it should be: runs on the 4th and 11th, forecasts from the 18th on. A monthly Asia/Kolkata "last Friday" contest 23 days out appeared as a projection at 14:00Z = 19:30 IST.
  • The October transition, live: the same Friday event projects at 18:00Z on 16 and 23 October and 19:00Z from the 30th — the instant moves, the local clock stays 20:00.
  • The re-pin, live: edit + republish answered {"version": 2, "repinned": 2}, and both scheduled runs moved v1 → v2 while the screen showed "Berlin Friday Muster v2".
  • Cancel does not re-project: a cancelled occurrence stayed cancelled across two ticks and did not reappear as a forecast.
  • §N2's split held: navmod got 200 on the calendar and the series read, 403 on series create, update and delete; navadmin got 201. navmod's screens correctly offered neither New event nor Series.
  • A cron kind and an nth: 5 were both refused with the message naming what is allowed. A 200-day calendar window was 400.
  • Series delete answered {"ok": true, "detached": 1} and the definition survived without an arc.

One defect the walk found, fixed here: in the List view the ←/→/Today stepper did nothing — the list always runs sixty days forward from now — so three controls were visibly present and inert. They are month-view only now.


  • AI-assisted: authored with Claude Code (Claude Opus).
Event System **Phase 4** (`EVENTS_PLAN.md`). Docs half: **RunicGateway/docs#212**. > **An event now happens on its own.** A definition carries a recurrence, the runner turns it into occurrences a fortnight ahead, and the calendar shows the ones that exist beside the ones that will. Walked end to end on the local review stack (details at the bottom). **No schema change.** Phase 1 built every column this needed — `timezone`, `series_id`, `series_order`, `grace_seconds`, `event_series.ordering`, and `UNIQUE (definition_id, scope, scheduled_for)`. Phase 4 fills them. ## Five decisions, settled 2026-09-02, all as recommended 1. **A fourteen-day materialisation horizon, with projections beyond it.** Inside it an occurrence is a real row an operator can see, cancel and reschedule *one at a time*; beyond it the calendar forecasts from the same arithmetic, so a monthly event is still visible three weeks out instead of the calendar simply ending. The two are drawn differently on purpose — an operator treating a forecast as a booking would have been misled by the screen, not by the server. 2. **Automatic expansion is at the EMPTY scope.** 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. The admin's own start route still takes any scope. 3. **DST: skip-forward, take-first.** A local time the spring gap swallows steps forward to the first one that exists — 02:30 becomes **03:00**, not 03:30, so the event happens as close to the authored time as the calendar allows. An hour that comes round twice takes the **first**. Both are recorded as `detail.dstAdjusted`, so nobody rediscovers daylight saving at 3am on the last Sunday in October. **Neither rule ever drops an occurrence.** 4. **`nth` is 1..4 plus `-1` for "last".** There is deliberately no fifth: every month has a first through fourth of every weekday, so the closed set has no absent-occurrence case to define. `-1` is not a synonym for `4` — it is the shape a "last Friday" contest actually is, and a test walks three years of months to prove the claim rather than assert it in a comment. 5. **Publishing re-pins the occurrences that have not started.** Asked mid-build, once the horizon made it real: on the day an editor fixes a typo there are already fourteen days of rows carrying the old spec. A version pin makes a run that **has run** reproducible; one that has not begun has nothing to reproduce yet. Doing nothing was worse than it looks — the operator's only recourse, cancelling each stale occurrence, leaves the slot held in `uq_evrun_occurrence`, so the occurrence would not come back on the new version, it would **vanish**. A run that has begun keeps its pin for ever. ## Three things the build settled - **`now - grace` is the window start, not `now`.** An occurrence nobody ever materialised is never invented retroactively — waking up after three days down must not manufacture three days of `missed` history no operator could have seen or cancelled. It does not need to: because rows exist a fortnight ahead of their instant, a real outage finds them already there and the sweep marks them honestly. **The horizon is what makes the missed sweep mean anything for a recurring event** — the two halves of `materialise` need each other. - **Publishing is the schedule switch; archiving turns it off.** §E already defines `ready` as "a version has been published and the schedule is live", so a second enabled flag would be another answer to a question `state` answers, and the two would eventually disagree. Expansion reads the **published version's** spec, never the working copy: a half-typed `weekly` an author is midway through must not materialise anything. - **Series writes are `admin, editor`, not `admin`.** Naming an arc is authoring; §N2's narrow gate is about committing the deployment to a run. The **delete** is a real delete and the only one in this feature — a series pins nothing, `series_id` is `ON DELETE SET NULL`, and the response says how many definitions were detached, because that is the entire consequence of the act. ## No date library, and why The server's dependency tree has none — no luxon, no date-fns, no tz package — and this does not add one. Node ships the full tzdata behind `Intl.DateTimeFormat`, which is the same database a library would vendor a copy of and is already what `isTimezone()` validates against. `events/recurrence.js` formats an instant into a zone's wall clock and inverts that mapping by search; it is the **one** place an occurrence is computed, so the runner's expansion and the calendar's forecast cannot disagree about a date. Still no cron, for §E's reason: 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. `time` is `HH:MM`, weekdays are English names, and the schedule panel is a form with a preview line that reads the whole thing back in English. ## A defect this phase introduced into the test harness, and fixed Putting the expansion leg in front of `tick()` made `eventRunner.test.js` reach the dead-port pool on **every tick** — the file still passed and took **minutes**. Stubbing `findSchedulable` there returned it to **0.45s**. Worth naming because a suite that is merely *slow* reads as a suite that is fine. ## Verify - `npm test` — **1768 tests, 1711 pass, 56 skipped, 1 fail.** The one failure is `engagementManifest.test.js`, pre-existing and environmental (CRLF under `core.autocrlf=true`); `edge` before this branch is 1714/1662/51/1, and +54 is exactly the tests added here. - **54 new tests.** `eventRecurrence.test.js` (17) is the **DST fixture set this plan asked for** — Berlin across both 2026 transitions, Lord Howe's thirty-minute gap, and Kolkata's half-hour no-DST offset. `eventSchedule.test.js` (16) covers expansion and the calendar; `eventSeries.test.js` (7); 7 in `eventSpec.test.js`; 5 in `eventRunnerSql.test.js` proving `findSchedulable`, `listInWindow` and `repinScheduled` **against a real MariaDB** (32/32 with a database, skipped without); 2 in `eventsAdmin.test.js`. Client: **354 pass**, 7 new. - `npm run routes:manifest` and `npm run swagger` — **four routes added, none moved.** `check:modules` and `check:hosts` clean. The client builds. ### The live walk, on the local review stack Server + Vite against the `uomm-db` container, as `navadmin` and `navmod`: - A weekly `Europe/Berlin` definition published at 20:00 local **materialised itself** on the next tick: `2026-09-04T18:00Z` and `2026-09-11T18:00Z` — 20:00 CEST, both inside the horizon. Nobody pressed anything. - **The calendar drew the horizon line exactly where it should be:** runs on the 4th and 11th, forecasts from the 18th on. A monthly `Asia/Kolkata` "last Friday" contest 23 days out appeared as a **projection** at `14:00Z` = 19:30 IST. - **The October transition, live:** the same Friday event projects at `18:00Z` on 16 and 23 October and `19:00Z` from the 30th — the instant moves, the local clock stays 20:00. - **The re-pin, live:** edit + republish answered `{"version": 2, "repinned": 2}`, and both scheduled runs moved v1 → v2 while the screen showed *"Berlin Friday Muster v2"*. - **Cancel does not re-project:** a cancelled occurrence stayed cancelled across two ticks and did not reappear as a forecast. - **§N2's split held:** `navmod` got **200** on the calendar and the series read, **403** on series create, update and delete; `navadmin` got **201**. `navmod`'s screens correctly offered neither *New event* nor *Series*. - A cron `kind` and an `nth: 5` were both refused with the message naming what is allowed. A 200-day calendar window was **400**. - Series delete answered `{"ok": true, "detached": 1}` and the definition survived without an arc. **One defect the walk found, fixed here:** in the **List** view the ←/→/Today stepper did nothing — the list always runs sixty days forward from now — so three controls were visibly present and inert. They are month-view only now. --- - [x] AI-assisted: authored with Claude Code (Claude Opus).
wtclaude added 1 commit 2026-09-02 21:11:40 +00:00
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
6e73660b52
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>
whitlocktech merged commit 9c23c5fd0e into edge 2026-09-03 01:58:08 +00:00
whitlocktech deleted branch feature/events-phase-4 2026-09-03 01:58:09 +00:00
Sign in to join this conversation.
No description provided.