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