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:
@@ -8,6 +8,11 @@ import {
|
||||
payloadFromForm,
|
||||
blankPhase,
|
||||
blankStep,
|
||||
describeSchedule,
|
||||
scheduleFromForm,
|
||||
SCHEDULE_KINDS,
|
||||
MONTHLY_NTHS,
|
||||
WEEKDAYS,
|
||||
} from '../../../lib/eventAuthoring.js'
|
||||
|
||||
// Admin → Events → the definition editor (EVENTS.md §I, Phase 3).
|
||||
@@ -191,7 +196,13 @@ export default function EventEditor() {
|
||||
const result = await api.admin.publishEvent(id)
|
||||
setEvent(result.event)
|
||||
setVersions(await api.admin.listEventVersions(id).then((r) => r.versions || []))
|
||||
setNotice(`Published as v${result.version}.`)
|
||||
// The re-pin count is said out loud, because an editor who does not know
|
||||
// their fix reached next Friday finds out on Friday.
|
||||
setNotice(
|
||||
result.repinned
|
||||
? `Published as v${result.version}. ${result.repinned} scheduled occurrence${result.repinned === 1 ? '' : 's'} moved to it.`
|
||||
: `Published as v${result.version}.`,
|
||||
)
|
||||
} catch (err) {
|
||||
setProblems(err.body?.errors || [err.message])
|
||||
} finally {
|
||||
@@ -329,13 +340,94 @@ export default function EventEditor() {
|
||||
</div>
|
||||
|
||||
{/* ── Schedule ── */}
|
||||
{/*
|
||||
Four closed shapes rendered as a form, never a cron string. A cron
|
||||
expression is the one field an operator cannot proofread, and the whole
|
||||
point of the closed set is that this panel can be read back in English —
|
||||
which is what the preview line under it does.
|
||||
*/}
|
||||
<div className="panel-flat" style={{ padding: 14, marginBottom: 14 }}>
|
||||
<h3 className="sans" style={{ margin: '0 0 6px', fontSize: '0.92rem' }}>Schedule</h3>
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.82rem' }}>
|
||||
<strong>Started by hand.</strong> Recurrence — once, weekly, monthly on the nth weekday —
|
||||
is computed in the event’s own timezone and arrives in the next phase, with the calendar.
|
||||
Until then an occurrence exists because somebody pressed <em>Start now</em>, and the
|
||||
schedule shape a definition may carry is deliberately the single one the runner honours.
|
||||
<h3 className="sans" style={{ margin: '0 0 10px', fontSize: '0.92rem' }}>Schedule</h3>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit,minmax(180px,1fr))', gap: 12 }}>
|
||||
<label>
|
||||
<span className="field-label">Repeats</span>
|
||||
<select className="select" value={form.scheduleKind} disabled={archived}
|
||||
onChange={(e) => set({ scheduleKind: e.target.value })}>
|
||||
{SCHEDULE_KINDS.map((k) => <option key={k.value} value={k.value}>{k.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{form.scheduleKind === 'once' && (
|
||||
<label>
|
||||
<span className="field-label">Date and time</span>
|
||||
<input className="input" type="datetime-local" value={form.scheduleAt} disabled={archived}
|
||||
onChange={(e) => set({ scheduleAt: e.target.value.slice(0, 16) })} />
|
||||
</label>
|
||||
)}
|
||||
|
||||
{form.scheduleKind === 'monthly' && (
|
||||
<>
|
||||
<label>
|
||||
<span className="field-label">Week</span>
|
||||
<select className="select" value={form.scheduleNth} disabled={archived}
|
||||
onChange={(e) => set({ scheduleNth: e.target.value })}>
|
||||
{MONTHLY_NTHS.map((n) => <option key={n.value} value={n.value}>{n.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Weekday</span>
|
||||
<select className="select" value={form.scheduleWeekday} disabled={archived}
|
||||
onChange={(e) => set({ scheduleWeekday: e.target.value })}>
|
||||
{WEEKDAYS.map((d) => (
|
||||
<option key={d} value={d}>{d.charAt(0).toUpperCase() + d.slice(1)}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(form.scheduleKind === 'weekly' || form.scheduleKind === 'monthly') && (
|
||||
<label>
|
||||
<span className="field-label">Time</span>
|
||||
<input className="input" type="time" value={form.scheduleTime} disabled={archived}
|
||||
onChange={(e) => set({ scheduleTime: e.target.value })} />
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{form.scheduleKind === 'weekly' && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<span className="field-label">Days</span>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 6 }}>
|
||||
{WEEKDAYS.map((day) => {
|
||||
const on = (form.scheduleDays || []).includes(day)
|
||||
return (
|
||||
<button key={day} type="button" className="pill" disabled={archived}
|
||||
aria-pressed={on}
|
||||
style={{ fontSize: '0.72rem', opacity: on ? 1 : 0.45 }}
|
||||
onClick={() => set({
|
||||
scheduleDays: on
|
||||
? form.scheduleDays.filter((d) => d !== day)
|
||||
: WEEKDAYS.filter((d) => d === day || form.scheduleDays.includes(d)),
|
||||
})}>
|
||||
{day.charAt(0).toUpperCase() + day.slice(1, 3)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="sans" style={{ margin: '12px 0 0', fontSize: '0.84rem' }}>
|
||||
{describeSchedule(scheduleFromForm(form), form.timezone)}
|
||||
</p>
|
||||
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '8px 0 0' }}>
|
||||
Times are the event’s own, in <code>{form.timezone}</code> — not the reader’s. A
|
||||
recurring schedule goes live when the definition is published and stops when it is
|
||||
archived; occurrences become real runs a fortnight before they happen, and the
|
||||
calendar forecasts the rest. A time that daylight saving skips moves forward to the next
|
||||
one that exists, and an hour that happens twice takes the first.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user