Files
website/server/src/events/recurrence.js
wtclaude 6e73660b52
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 37s
PR Checks / client-build (pull_request) Successful in 43s
PR Checks / server-tests (pull_request) Successful in 13m26s
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>
2026-09-02 16:10:16 -05:00

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,
}