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>
336 lines
13 KiB
JavaScript
336 lines
13 KiB
JavaScript
// ── 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,
|
|
}
|