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:
@@ -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)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user