feat(events): schedule, recurrence and the calendar (Phase 4)
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:
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user