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

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