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:
335
server/src/events/recurrence.js
Normal file
335
server/src/events/recurrence.js
Normal file
@@ -0,0 +1,335 @@
|
||||
// ── Occurrence arithmetic, in the event's own timezone ─────────────────────
|
||||
//
|
||||
// EVENTS.md §E, "Two scheduling decisions the calendar forces", and Phase 4 of
|
||||
// EVENTS_PLAN.md. Given a closed recurrence shape, an IANA zone and a window,
|
||||
// this file answers *which UTC instants* an event happens at. Nothing else
|
||||
// computes an occurrence; the runner materialises what this returns and the
|
||||
// calendar projects what this returns, so there is exactly one arithmetic to be
|
||||
// wrong.
|
||||
//
|
||||
// **Why there is no library here.** The server's dependency tree has no date
|
||||
// library at all — no luxon, no date-fns, no tz package (check `package.json`
|
||||
// before adding one). What it does have is Node's own full tzdata behind
|
||||
// `Intl.DateTimeFormat`, which is the same database a library would ship a copy
|
||||
// of and is already what `eventDefinitions.model.js` validates a zone name
|
||||
// against. So the arithmetic is: *format an instant into the zone's wall clock*
|
||||
// (which `Intl` does exactly) and invert that mapping by search. Everything
|
||||
// below is that one idea.
|
||||
//
|
||||
// **Why not cron.** Decided in §E and restated in the plan: there is no parser
|
||||
// in the tree, the only precedent is in the bot (another process), and a cron
|
||||
// string is the one field an operator cannot proofread. Four closed shapes
|
||||
// render as a form, and a form is checkable.
|
||||
//
|
||||
// **The two DST rules** (org lead, 2026-09-02), which exist because a weekly
|
||||
// 02:30 event in `Europe/Berlin` is a real thing an operator will author:
|
||||
//
|
||||
// - A **nonexistent** local time — the spring-forward gap — steps forward to the
|
||||
// first wall clock that does exist. 02:30 becomes 03:00, not 03:30: the event
|
||||
// happens as close to the authored time as the calendar allows.
|
||||
// - An **ambiguous** local time — the fall-back hour, which happens twice —
|
||||
// takes the FIRST, the pre-transition offset.
|
||||
//
|
||||
// Both are reported back as `adjusted`, so a run can record why its clock reads
|
||||
// oddly rather than leaving an operator to discover DST for themselves at 3am.
|
||||
// Neither rule ever drops an occurrence: a weekly event happens every week.
|
||||
|
||||
// Indexed to match `Date#getUTCDay`, which is what the civil-calendar helpers
|
||||
// below return. Names rather than numbers everywhere an operator can see them —
|
||||
// `days: ['friday']` is proofreadable and `days: [5]` is not, which is the same
|
||||
// argument that rejected cron.
|
||||
const WEEKDAYS = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday']
|
||||
|
||||
// `nth: -1` is "the last one in the month", and it is not a synonym for 4: a
|
||||
// month with five Fridays has a last Friday that is not the fourth. 1..4 always
|
||||
// exist in every month (1 + 6 + 21 = 28), so there is deliberately no fifth and
|
||||
// therefore no absent-occurrence case to define (org lead, 2026-09-02).
|
||||
const MONTHLY_NTH = [1, 2, 3, 4, -1]
|
||||
|
||||
const TIME_RE = /^([01][0-9]|2[0-3]):([0-5][0-9])$/
|
||||
const AT_RE = /^([0-9]{4})-([0-9]{2})-([0-9]{2})[T ]([01][0-9]|2[0-3]):([0-5][0-9])$/
|
||||
|
||||
const MINUTE_MS = 60_000
|
||||
const DAY_MS = 86_400_000
|
||||
|
||||
// No real DST gap exceeds two hours (Lord Howe's is 30 minutes; the largest
|
||||
// historical jumps are a day, and those are line-of-date changes rather than
|
||||
// gaps in the local clock). Four hours is a bound, not an expectation: it stops
|
||||
// a malformed zone turning the search into a hang.
|
||||
const MAX_GAP_MINUTES = 240
|
||||
|
||||
// Bounds on what one call may return. A projection window is operator-supplied
|
||||
// (the calendar's month, the horizon), and an unbounded expansion of a daily
|
||||
// schedule across a decade is how a calendar request becomes an outage.
|
||||
const MAX_OCCURRENCES = 500
|
||||
|
||||
const formatters = new Map()
|
||||
|
||||
function formatterFor(zone) {
|
||||
let f = formatters.get(zone)
|
||||
if (!f) {
|
||||
// `hourCycle: 'h23'` rather than `hour12: false`, which renders midnight as
|
||||
// hour 24 in some ICU versions and would put every midnight event on the
|
||||
// previous day.
|
||||
f = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: zone,
|
||||
hourCycle: 'h23',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})
|
||||
formatters.set(zone, f)
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
/** The wall clock an instant reads as, in this zone. */
|
||||
function wallPartsAt(zone, ms) {
|
||||
const parts = formatterFor(zone).formatToParts(new Date(ms))
|
||||
const get = (type) => Number(parts.find((p) => p.type === type)?.value)
|
||||
return {
|
||||
y: get('year'),
|
||||
m: get('month'),
|
||||
d: get('day'),
|
||||
h: get('hour'),
|
||||
mi: get('minute'),
|
||||
s: get('second'),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* That same wall clock as a number, by reading it as though it were UTC.
|
||||
*
|
||||
* This is the trick the whole file rests on: two wall clocks are equal exactly
|
||||
* when these numbers are, and `wallMs - instant` is the zone's offset at that
|
||||
* instant. It is never a real instant and must not be used as one.
|
||||
*/
|
||||
function wallMs(zone, ms) {
|
||||
const p = wallPartsAt(zone, ms)
|
||||
return Date.UTC(p.y, p.m - 1, p.d, p.h, p.mi, p.s)
|
||||
}
|
||||
|
||||
const offsetMs = (zone, ms) => wallMs(zone, ms) - ms
|
||||
|
||||
/**
|
||||
* Every instant that reads as this wall clock in this zone, earliest first.
|
||||
*
|
||||
* Ordinarily one. Two in the fall-back hour, none in the spring-forward gap —
|
||||
* and the length of this array is how the caller tells those three apart.
|
||||
*
|
||||
* Sampling the offset a day either side is what makes it correct across a
|
||||
* transition: subtracting each candidate offset gives the two instants worth
|
||||
* testing, and the test is whether the instant formats back to what was asked.
|
||||
*/
|
||||
function instantsForWall(zone, target) {
|
||||
const candidates = new Set([
|
||||
target - offsetMs(zone, target - DAY_MS),
|
||||
target - offsetMs(zone, target + DAY_MS),
|
||||
])
|
||||
const valid = []
|
||||
for (const ms of candidates) {
|
||||
if (wallMs(zone, ms) === target) valid.push(ms)
|
||||
}
|
||||
return valid.sort((a, b) => a - b)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a local wall clock to a UTC instant, applying the two DST rules.
|
||||
*
|
||||
* `{ at, adjusted, shiftMinutes }` — `adjusted` is `null` on an ordinary day,
|
||||
* `'gap'` when the authored time did not exist and was stepped forward, and
|
||||
* `'ambiguous'` when it happened twice and the first was taken.
|
||||
*/
|
||||
function resolveWall(zone, y, m, d, h, mi) {
|
||||
const target = Date.UTC(y, m - 1, d, h, mi, 0)
|
||||
const valid = instantsForWall(zone, target)
|
||||
if (valid.length === 1) return { at: new Date(valid[0]), adjusted: null, shiftMinutes: 0 }
|
||||
if (valid.length > 1) return { at: new Date(valid[0]), adjusted: 'ambiguous', shiftMinutes: 0 }
|
||||
|
||||
// The gap. Step the WALL CLOCK forward — not the instant — until it lands on
|
||||
// a time that exists, which is the first instant after the transition.
|
||||
for (let step = 1; step <= MAX_GAP_MINUTES; step += 1) {
|
||||
const shifted = instantsForWall(zone, target + step * MINUTE_MS)
|
||||
if (shifted.length) return { at: new Date(shifted[0]), adjusted: 'gap', shiftMinutes: step }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ── The civil calendar ─────────────────────────────────────────────────────
|
||||
//
|
||||
// Dates with no zone attached: "the 14th of September" as a thing to iterate,
|
||||
// before any question of what instant it starts at. `Date.UTC` is used purely
|
||||
// as calendar arithmetic here and none of these numbers is an instant.
|
||||
|
||||
const dayIndex = (y, m, d) => Date.UTC(y, m - 1, d) / DAY_MS
|
||||
|
||||
function civilFromIndex(n) {
|
||||
const dt = new Date(n * DAY_MS)
|
||||
return { y: dt.getUTCFullYear(), m: dt.getUTCMonth() + 1, d: dt.getUTCDate() }
|
||||
}
|
||||
|
||||
const weekdayOf = (y, m, d) => new Date(Date.UTC(y, m - 1, d)).getUTCDay()
|
||||
|
||||
const daysInMonth = (y, m) => new Date(Date.UTC(y, m, 0)).getUTCDate()
|
||||
|
||||
/** Is this a real date? `2026-02-30` parses as a string and is not a day. */
|
||||
const isRealDate = (y, m, d) => m >= 1 && m <= 12 && d >= 1 && d <= daysInMonth(y, m)
|
||||
|
||||
/**
|
||||
* The day of the month that is the nth (or last) given weekday.
|
||||
*
|
||||
* `nth` is 1..4 or -1. Answers `null` only for an nth that cannot exist, which
|
||||
* the validated shapes never produce — the guard is here so that a spec written
|
||||
* by hand into the database cannot make the runner throw.
|
||||
*/
|
||||
function nthWeekdayDay(y, m, weekday, nth) {
|
||||
const last = daysInMonth(y, m)
|
||||
if (nth === -1) {
|
||||
const back = (weekdayOf(y, m, last) - weekday + 7) % 7
|
||||
return last - back
|
||||
}
|
||||
const forward = (weekday - weekdayOf(y, m, 1) + 7) % 7
|
||||
const day = 1 + forward + (nth - 1) * 7
|
||||
return day <= last ? day : null
|
||||
}
|
||||
|
||||
// ── Expansion ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Every occurrence of `schedule` in `[from, to)`, earliest first.
|
||||
*
|
||||
* `[{ at: Date, adjusted, shiftMinutes }]`. `manual` answers `[]` — it is the
|
||||
* shape that means "there is no recurrence", and an admin's own
|
||||
* `POST /:id/runs` is the only thing that creates one of its occurrences.
|
||||
*
|
||||
* The window is in INSTANTS and the walk is in LOCAL DAYS, which is why each
|
||||
* walk starts a day early and ends a day late: a local day can begin up to
|
||||
* fourteen hours either side of the same UTC day.
|
||||
*/
|
||||
function occurrencesBetween(schedule, zone, from, to, { limit = MAX_OCCURRENCES } = {}) {
|
||||
const fromMs = from instanceof Date ? from.getTime() : Number(from)
|
||||
const toMs = to instanceof Date ? to.getTime() : Number(to)
|
||||
if (!Number.isFinite(fromMs) || !Number.isFinite(toMs) || toMs <= fromMs) return []
|
||||
if (!schedule || typeof schedule !== 'object') return []
|
||||
|
||||
const cap = Math.min(Math.max(Number(limit) || MAX_OCCURRENCES, 1), MAX_OCCURRENCES)
|
||||
const out = []
|
||||
const keep = (resolved) => {
|
||||
if (!resolved) return
|
||||
const t = resolved.at.getTime()
|
||||
if (t >= fromMs && t < toMs && out.length < cap) out.push(resolved)
|
||||
}
|
||||
|
||||
if (schedule.kind === 'manual') return []
|
||||
|
||||
if (schedule.kind === 'once') {
|
||||
const m = AT_RE.exec(String(schedule.at || ''))
|
||||
if (!m) return []
|
||||
keep(resolveWall(zone, Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4]), Number(m[5])))
|
||||
return out
|
||||
}
|
||||
|
||||
const time = TIME_RE.exec(String(schedule.time || ''))
|
||||
if (!time) return []
|
||||
const hour = Number(time[1])
|
||||
const minute = Number(time[2])
|
||||
|
||||
if (schedule.kind === 'weekly') {
|
||||
const wanted = new Set(
|
||||
(schedule.days || []).map((d) => WEEKDAYS.indexOf(String(d))).filter((i) => i >= 0),
|
||||
)
|
||||
if (!wanted.size) return []
|
||||
const first = wallPartsAt(zone, fromMs)
|
||||
const last = wallPartsAt(zone, toMs)
|
||||
const startDay = dayIndex(first.y, first.m, first.d) - 1
|
||||
const endDay = dayIndex(last.y, last.m, last.d) + 1
|
||||
for (let n = startDay; n <= endDay && out.length < cap; n += 1) {
|
||||
const { y, m, d } = civilFromIndex(n)
|
||||
if (wanted.has(weekdayOf(y, m, d))) keep(resolveWall(zone, y, m, d, hour, minute))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
if (schedule.kind === 'monthly') {
|
||||
const weekday = WEEKDAYS.indexOf(String(schedule.weekday))
|
||||
const nth = Number(schedule.nth)
|
||||
if (weekday < 0 || !MONTHLY_NTH.includes(nth)) return []
|
||||
const first = wallPartsAt(zone, fromMs)
|
||||
const last = wallPartsAt(zone, toMs)
|
||||
// Months as a single running count, so a window crossing a new year is not
|
||||
// a special case.
|
||||
const startMonth = first.y * 12 + (first.m - 1) - 1
|
||||
const endMonth = last.y * 12 + (last.m - 1) + 1
|
||||
for (let n = startMonth; n <= endMonth && out.length < cap; n += 1) {
|
||||
const y = Math.floor(n / 12)
|
||||
const m = (n % 12) + 1
|
||||
const day = nthWeekdayDay(y, m, weekday, nth)
|
||||
if (day) keep(resolveWall(zone, y, m, day, hour, minute))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
/** The next occurrence at or after `from`, or null. A bounded look-ahead. */
|
||||
function nextOccurrence(schedule, zone, from, { withinDays = 400 } = {}) {
|
||||
const fromMs = from instanceof Date ? from.getTime() : Number(from)
|
||||
const [first] = occurrencesBetween(schedule, zone, fromMs, fromMs + withinDays * DAY_MS, {
|
||||
limit: 1,
|
||||
})
|
||||
return first || null
|
||||
}
|
||||
|
||||
/**
|
||||
* How a schedule reads to a person, in the event's own zone.
|
||||
*
|
||||
* Server-side because two surfaces need the same sentence — the calendar's list
|
||||
* and the run's own record of why it exists — and because the client's copy in
|
||||
* `eventAuthoring.js` is a mirror that is allowed to drift on wording but not on
|
||||
* meaning.
|
||||
*/
|
||||
function describe(schedule, zone = 'UTC') {
|
||||
if (!schedule || typeof schedule !== 'object') return 'No schedule'
|
||||
const cap = (s) => String(s).charAt(0).toUpperCase() + String(s).slice(1)
|
||||
const nthLabel = { 1: 'first', 2: 'second', 3: 'third', 4: 'fourth', '-1': 'last' }
|
||||
switch (schedule.kind) {
|
||||
case 'manual':
|
||||
return 'Started by hand'
|
||||
case 'once':
|
||||
return `Once, on ${String(schedule.at).replace('T', ' ')} (${zone})`
|
||||
case 'weekly': {
|
||||
const days = (schedule.days || []).map(cap)
|
||||
const list =
|
||||
days.length <= 1
|
||||
? days.join('')
|
||||
: `${days.slice(0, -1).join(', ')} and ${days[days.length - 1]}`
|
||||
return `Every ${list} at ${schedule.time} (${zone})`
|
||||
}
|
||||
case 'monthly':
|
||||
return `The ${nthLabel[String(schedule.nth)]} ${cap(schedule.weekday)} of every month at ${schedule.time} (${zone})`
|
||||
default:
|
||||
return 'No schedule'
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
WEEKDAYS,
|
||||
MONTHLY_NTH,
|
||||
TIME_RE,
|
||||
AT_RE,
|
||||
MAX_OCCURRENCES,
|
||||
DAY_MS,
|
||||
wallPartsAt,
|
||||
offsetMs,
|
||||
instantsForWall,
|
||||
resolveWall,
|
||||
isRealDate,
|
||||
nthWeekdayDay,
|
||||
occurrencesBetween,
|
||||
nextOccurrence,
|
||||
describe,
|
||||
}
|
||||
Reference in New Issue
Block a user