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:
@@ -141,7 +141,7 @@ export function formFromDefinition(event) {
|
||||
concurrencyKey: event?.concurrencyKey || '',
|
||||
graceSeconds: event?.graceSeconds ?? 900,
|
||||
timezone: event?.timezone || 'UTC',
|
||||
scheduleKind: spec.schedule?.kind || 'manual',
|
||||
...scheduleFormFrom(spec.schedule),
|
||||
phases: (spec.phases || []).map((p) => ({
|
||||
key: p.key || '',
|
||||
label: p.label || '',
|
||||
@@ -204,11 +204,136 @@ export function payloadFromForm(form) {
|
||||
concurrencyKey: form.concurrencyKey || null,
|
||||
graceSeconds: Number(form.graceSeconds),
|
||||
timezone: form.timezone,
|
||||
spec: { schedule: { kind: form.scheduleKind || 'manual' }, phases },
|
||||
spec: { schedule: scheduleFromForm(form), phases },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── The schedule (Phase 4) ─────────────────────────────────────────────────
|
||||
//
|
||||
// The four closed shapes of §E, mirrored so the form can render one and the
|
||||
// preview can describe it. `events/spec.js` and `events/recurrence.js` remain
|
||||
// the deciders — this is what makes the form a form rather than a text box, and
|
||||
// it is the whole reason the schedule is not a cron string: a closed set has a
|
||||
// dropdown, and an operator can proofread a dropdown.
|
||||
|
||||
export const WEEKDAYS = [
|
||||
'sunday',
|
||||
'monday',
|
||||
'tuesday',
|
||||
'wednesday',
|
||||
'thursday',
|
||||
'friday',
|
||||
'saturday',
|
||||
]
|
||||
|
||||
export const SCHEDULE_KINDS = [
|
||||
{ value: 'manual', label: 'Started by hand' },
|
||||
{ value: 'once', label: 'Once, at a set time' },
|
||||
{ value: 'weekly', label: 'Weekly, on chosen days' },
|
||||
{ value: 'monthly', label: 'Monthly, on the nth weekday' },
|
||||
]
|
||||
|
||||
// 1..4 and "last". There is no fifth: every month has a first through fourth of
|
||||
// every weekday, and "last" is what a month with five Fridays makes different
|
||||
// from "fourth" (org lead, 2026-09-02).
|
||||
export const MONTHLY_NTHS = [
|
||||
{ value: 1, label: 'First' },
|
||||
{ value: 2, label: 'Second' },
|
||||
{ value: 3, label: 'Third' },
|
||||
{ value: 4, label: 'Fourth' },
|
||||
{ value: -1, label: 'Last' },
|
||||
]
|
||||
|
||||
const capitalise = (s) => String(s || '').charAt(0).toUpperCase() + String(s || '').slice(1)
|
||||
|
||||
/**
|
||||
* A schedule in words, in the event's own zone.
|
||||
*
|
||||
* The server says the same thing in `events/recurrence.js#describe`, and the two
|
||||
* are allowed to differ on wording but not on meaning — this one is what an
|
||||
* author reads while they are still typing, before anything has been saved.
|
||||
*/
|
||||
export function describeSchedule(schedule, timezone = 'UTC') {
|
||||
if (!schedule || typeof schedule !== 'object') return 'No schedule'
|
||||
const nth = MONTHLY_NTHS.find((n) => n.value === Number(schedule.nth))
|
||||
switch (schedule.kind) {
|
||||
case 'manual':
|
||||
return 'Started by hand — nothing happens until an admin presses Start'
|
||||
case 'once': {
|
||||
if (!schedule.at) return 'Once — no date chosen yet'
|
||||
return `Once, on ${String(schedule.at).replace('T', ' at ')} (${timezone})`
|
||||
}
|
||||
case 'weekly': {
|
||||
const days = (schedule.days || []).map(capitalise)
|
||||
if (!days.length || !schedule.time) return 'Weekly — choose days and a time'
|
||||
const list =
|
||||
days.length === 1
|
||||
? days[0]
|
||||
: `${days.slice(0, -1).join(', ')} and ${days[days.length - 1]}`
|
||||
return `Every ${list} at ${schedule.time} (${timezone})`
|
||||
}
|
||||
case 'monthly': {
|
||||
if (!nth || !schedule.weekday || !schedule.time) {
|
||||
return 'Monthly — choose a week, a weekday and a time'
|
||||
}
|
||||
return `The ${nth.label.toLowerCase()} ${capitalise(schedule.weekday)} of every month at ${schedule.time} (${timezone})`
|
||||
}
|
||||
default:
|
||||
return 'No schedule'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The schedule half of the editor's working state.
|
||||
*
|
||||
* Every shape's fields are kept side by side rather than cleared when the kind
|
||||
* changes, so an author who clicks Weekly, then Monthly, then back has not lost
|
||||
* the days they picked. `scheduleFromForm` reads only the fields the chosen kind
|
||||
* uses, which is what keeps the request body a clean single shape.
|
||||
*/
|
||||
export function scheduleFormFrom(schedule) {
|
||||
const s = schedule || {}
|
||||
return {
|
||||
scheduleKind: s.kind || 'manual',
|
||||
scheduleAt: s.kind === 'once' ? s.at || '' : '',
|
||||
scheduleDays: s.kind === 'weekly' ? s.days || [] : [],
|
||||
scheduleNth: s.kind === 'monthly' ? String(s.nth) : '1',
|
||||
scheduleWeekday: s.kind === 'monthly' ? s.weekday || 'friday' : 'friday',
|
||||
scheduleTime: s.kind === 'weekly' || s.kind === 'monthly' ? s.time || '20:00' : '20:00',
|
||||
}
|
||||
}
|
||||
|
||||
/** The schedule the form describes, as the spec object the server expects. */
|
||||
export function scheduleFromForm(form) {
|
||||
switch (form.scheduleKind) {
|
||||
case 'once':
|
||||
return { kind: 'once', at: form.scheduleAt }
|
||||
case 'weekly':
|
||||
return { kind: 'weekly', days: form.scheduleDays || [], time: form.scheduleTime }
|
||||
case 'monthly':
|
||||
return {
|
||||
kind: 'monthly',
|
||||
nth: Number(form.scheduleNth),
|
||||
weekday: form.scheduleWeekday,
|
||||
time: form.scheduleTime,
|
||||
}
|
||||
default:
|
||||
return { kind: 'manual' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What a calendar entry is, and therefore what may be done with it.
|
||||
*
|
||||
* A `run` is a row: it has a console and somebody can cancel it. A `projected`
|
||||
* entry is arithmetic the runner has not reached yet — there is nothing to open
|
||||
* and nothing to stop, and an operator who treats one as a booking has been
|
||||
* misled by the UI rather than by the server.
|
||||
*/
|
||||
export const isProjected = (entry) => entry?.kind === 'projected'
|
||||
|
||||
/** An empty box is `{}`, not a parse error — a step may legitimately take none. */
|
||||
export function parseParams(text) {
|
||||
const raw = (text || '').trim()
|
||||
|
||||
Reference in New Issue
Block a user