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

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