Files
website/server/test/eventRecurrence.test.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

274 lines
13 KiB
JavaScript

// ── Occurrence arithmetic (EVENTS.md §E, Phase 4) ──────────────────────────
//
// The plan asks for DST-crossing cases as EXPLICIT FIXTURES, and this file is
// them. The reason it is worth a test file of its own is that every failure here
// is silent in production: an event computed an hour off, or dropped for one
// week a year, looks exactly like an event that happened correctly until an
// operator is standing in the wrong place at the wrong time.
//
// The zone data is Node's own tzdata behind `Intl`, so these fixtures assert
// against the real transitions rather than against a hand-written offset table:
//
// • Europe/Berlin, 2026-03-29 — CET (+1) to CEST (+2). 02:00 to 03:00 does not
// exist. A weekly 02:30 event is the case the org lead decided.
// • Europe/Berlin, 2026-10-25 — CEST (+2) back to CET (+1). 02:00 to 03:00
// happens twice.
// • Asia/Kolkata — +05:30, no DST at all, and a half-hour offset, which is
// what catches an implementation that assumed whole hours.
// • Australia/Lord_Howe — a THIRTY-MINUTE DST shift, which is what catches one
// that assumed the gap is always an hour.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test, after } = require('node:test')
const assert = require('node:assert/strict')
const r = require('../src/events/recurrence')
const db = require('../src/utils/db')
after(() => db.close())
/** What an instant reads as on the wall in a zone — the assertion that matters. */
const wall = (zone, at) => {
const p = r.wallPartsAt(zone, at instanceof Date ? at.getTime() : at)
const pad = (n) => String(n).padStart(2, '0')
return `${p.y}-${pad(p.m)}-${pad(p.d)} ${pad(p.h)}:${pad(p.mi)}`
}
const walls = (occurrences, zone) => occurrences.map((o) => wall(zone, o.at))
// ── The rule the whole feature rests on ────────────────────────────────────
test('a weekly event keeps its LOCAL time across a DST boundary', () => {
// The single most important assertion in this file. Friday 20:00 in Berlin is
// 19:00 UTC in winter and 18:00 UTC in summer, and it is 20:00 on the wall on
// every one of those Fridays. A recurrence computed in UTC would put half the
// year an hour out, which is exactly what §E forbids.
const schedule = { kind: 'weekly', days: ['friday'], time: '20:00' }
const found = r.occurrencesBetween(schedule, 'Europe/Berlin', Date.UTC(2026, 2, 20), Date.UTC(2026, 3, 11))
assert.deepEqual(walls(found, 'Europe/Berlin'), [
'2026-03-20 20:00',
'2026-03-27 20:00',
'2026-04-03 20:00',
'2026-04-10 20:00',
])
// And the UTC instants really did move, which is what proves the zone was
// consulted rather than the arithmetic accidentally agreeing.
assert.equal(found[1].at.toISOString(), '2026-03-27T19:00:00.000Z')
assert.equal(found[2].at.toISOString(), '2026-04-03T18:00:00.000Z')
})
test('a weekly event keeps its local time across the October transition too', () => {
const schedule = { kind: 'weekly', days: ['friday'], time: '20:00' }
const found = r.occurrencesBetween(schedule, 'Europe/Berlin', Date.UTC(2026, 9, 20), Date.UTC(2026, 10, 7))
assert.deepEqual(walls(found, 'Europe/Berlin'), [
'2026-10-23 20:00',
'2026-10-30 20:00',
'2026-11-06 20:00',
])
assert.equal(found[0].at.toISOString(), '2026-10-23T18:00:00.000Z')
assert.equal(found[1].at.toISOString(), '2026-10-30T19:00:00.000Z')
})
// ── The two DST rules, as decided ──────────────────────────────────────────
test('a local time the spring gap swallows moves FORWARD to the first one that exists', () => {
// 2026-03-29 in Berlin: 02:00 becomes 03:00 and 02:30 never happens. The
// decision is the first valid instant — 03:00 — rather than "shift by the gap"
// (03:30): the event happens as close to the authored time as the calendar
// allows.
const resolved = r.resolveWall('Europe/Berlin', 2026, 3, 29, 2, 30)
assert.equal(resolved.adjusted, 'gap')
assert.equal(wall('Europe/Berlin', resolved.at), '2026-03-29 03:00')
assert.equal(resolved.at.toISOString(), '2026-03-29T01:00:00.000Z')
assert.equal(resolved.shiftMinutes, 30)
})
test('a local time that happens twice takes the FIRST of them', () => {
// 2026-10-25 in Berlin: 02:30 comes round at 00:30Z (+2, still CEST) and again
// at 01:30Z (+1, now CET). The first is the answer, and the second must not be
// — an event that fired at the later one would be an hour late by the clock
// the author wrote it against.
const resolved = r.resolveWall('Europe/Berlin', 2026, 10, 25, 2, 30)
assert.equal(resolved.adjusted, 'ambiguous')
assert.equal(resolved.at.toISOString(), '2026-10-25T00:30:00.000Z')
assert.equal(wall('Europe/Berlin', resolved.at), '2026-10-25 02:30')
// Both instants really do read 02:30 — the fixture is only meaningful if the
// ambiguity is real.
const both = r.instantsForWall('Europe/Berlin', Date.UTC(2026, 9, 25, 2, 30))
assert.equal(both.length, 2)
assert.equal(both[0], Date.UTC(2026, 9, 25, 0, 30))
assert.equal(both[1], Date.UTC(2026, 9, 25, 1, 30))
})
test('a weekly event in the gap still happens that week — it is never dropped', () => {
// The rule that makes the gap decision worth having. A Sunday 02:30 event in
// Berlin happens on 29 March like every other Sunday; it simply happens at
// 03:00.
const schedule = { kind: 'weekly', days: ['sunday'], time: '02:30' }
const found = r.occurrencesBetween(schedule, 'Europe/Berlin', Date.UTC(2026, 2, 20), Date.UTC(2026, 3, 6))
assert.deepEqual(walls(found, 'Europe/Berlin'), [
'2026-03-22 02:30',
'2026-03-29 03:00',
'2026-04-05 02:30',
])
assert.equal(found[1].adjusted, 'gap')
assert.equal(found[0].adjusted, null)
})
test('a thirty-minute DST shift resolves too — the gap is not always an hour', () => {
// Lord Howe Island shifts by 30 minutes (+10:30 to +11:00). 2026-10-04 has no
// 02:15 local. An implementation that assumed a whole-hour gap gets this wrong.
const resolved = r.resolveWall('Australia/Lord_Howe', 2026, 10, 4, 2, 15)
assert.equal(resolved.adjusted, 'gap')
assert.equal(wall('Australia/Lord_Howe', resolved.at), '2026-10-04 02:30')
})
test('a zone with no DST at all is left completely alone', () => {
// Asia/Kolkata is +05:30 all year, and the half hour is the point: an
// implementation carrying whole-hour offsets around would be 30 minutes out
// here, every day, without any transition to blame.
const schedule = { kind: 'weekly', days: ['friday'], time: '19:30' }
const found = r.occurrencesBetween(schedule, 'Asia/Kolkata', Date.UTC(2026, 2, 20), Date.UTC(2026, 3, 11))
assert.equal(found.length, 4)
for (const o of found) {
assert.equal(o.adjusted, null)
assert.equal(wall('Asia/Kolkata', o.at).slice(11), '19:30')
assert.equal(o.at.toISOString().slice(11, 16), '14:00')
}
})
// ── The shapes ─────────────────────────────────────────────────────────────
test('`once` produces its single occurrence, and only inside the window', () => {
const schedule = { kind: 'once', at: '2026-10-31T20:00' }
const inside = r.occurrencesBetween(schedule, 'Europe/Berlin', Date.UTC(2026, 9, 1), Date.UTC(2026, 10, 1))
assert.equal(inside.length, 1)
assert.equal(wall('Europe/Berlin', inside[0].at), '2026-10-31 20:00')
const outside = r.occurrencesBetween(schedule, 'Europe/Berlin', Date.UTC(2026, 10, 1), Date.UTC(2026, 11, 1))
assert.deepEqual(outside, [])
})
test('`weekly` honours every named day, in week order', () => {
const schedule = { kind: 'weekly', days: ['saturday', 'wednesday'], time: '18:00' }
const found = r.occurrencesBetween(schedule, 'UTC', Date.UTC(2026, 5, 1), Date.UTC(2026, 5, 15))
assert.deepEqual(walls(found, 'UTC'), [
'2026-06-03 18:00',
'2026-06-06 18:00',
'2026-06-10 18:00',
'2026-06-13 18:00',
])
})
test('`monthly` with nth: -1 is the LAST weekday, which is not always the fourth', () => {
// The whole reason -1 exists. May 2026 has five Fridays and July 2026 has five;
// in those months "last" and "fourth" are different days, and a fishing contest
// on the last Friday is exactly that shape.
const last = r.occurrencesBetween(
{ kind: 'monthly', nth: -1, weekday: 'friday', time: '19:00' },
'UTC',
Date.UTC(2026, 4, 1),
Date.UTC(2026, 8, 1),
)
const fourth = r.occurrencesBetween(
{ kind: 'monthly', nth: 4, weekday: 'friday', time: '19:00' },
'UTC',
Date.UTC(2026, 4, 1),
Date.UTC(2026, 8, 1),
)
// August 2026 has four Fridays, so "last" and "fourth" agree there and
// disagree in May and July. That the two lists share a member is the point:
// -1 is not a synonym for 4, and it is not a synonym for "different" either.
assert.deepEqual(walls(last, 'UTC'), [
'2026-05-29 19:00',
'2026-06-26 19:00',
'2026-07-31 19:00',
'2026-08-28 19:00',
])
assert.deepEqual(walls(fourth, 'UTC'), [
'2026-05-22 19:00',
'2026-06-26 19:00',
'2026-07-24 19:00',
'2026-08-28 19:00',
])
assert.notDeepEqual(walls(last, 'UTC'), walls(fourth, 'UTC'))
})
test('every month has a first through fourth of every weekday', () => {
// The claim the closed set rests on: because there is no `nth: 5`, there is no
// absent-occurrence case to define. Checked across three years rather than
// asserted in a comment.
for (let year = 2026; year <= 2028; year += 1) {
for (let month = 1; month <= 12; month += 1) {
for (let weekday = 0; weekday <= 6; weekday += 1) {
for (const nth of [1, 2, 3, 4, -1]) {
const day = r.nthWeekdayDay(year, month, weekday, nth)
assert.ok(day, `${year}-${month} weekday ${weekday} nth ${nth} should exist`)
}
}
}
}
})
test('`manual` is not a recurrence and expands to nothing', () => {
assert.deepEqual(r.occurrencesBetween({ kind: 'manual' }, 'UTC', Date.UTC(2026, 0, 1), Date.UTC(2027, 0, 1)), [])
})
// ── Bounds and refusals ────────────────────────────────────────────────────
test('an inverted or empty window answers with nothing rather than throwing', () => {
const schedule = { kind: 'weekly', days: ['friday'], time: '20:00' }
assert.deepEqual(r.occurrencesBetween(schedule, 'UTC', Date.UTC(2026, 5, 1), Date.UTC(2026, 4, 1)), [])
assert.deepEqual(r.occurrencesBetween(schedule, 'UTC', Date.UTC(2026, 5, 1), Date.UTC(2026, 5, 1)), [])
})
test('a malformed schedule expands to nothing rather than to a wrong instant', () => {
// These shapes cannot come through `spec.js`, but they can come from a row
// written directly into the database — and the runner must not turn one into a
// world change at an invented time.
assert.deepEqual(r.occurrencesBetween({ kind: 'weekly', days: [], time: '20:00' }, 'UTC', 0, 1e12), [])
assert.deepEqual(r.occurrencesBetween({ kind: 'weekly', days: ['friday'], time: '25:00' }, 'UTC', 0, 1e12), [])
assert.deepEqual(r.occurrencesBetween({ kind: 'monthly', nth: 9, weekday: 'friday', time: '19:00' }, 'UTC', 0, 1e12), [])
assert.deepEqual(r.occurrencesBetween({ kind: 'once', at: 'tomorrow' }, 'UTC', 0, 1e12), [])
assert.deepEqual(r.occurrencesBetween(null, 'UTC', 0, 1e12), [])
})
test('the expansion is bounded, so a wide window cannot become an outage', () => {
const schedule = {
kind: 'weekly',
days: ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'],
time: '12:00',
}
const found = r.occurrencesBetween(schedule, 'UTC', Date.UTC(2020, 0, 1), Date.UTC(2030, 0, 1))
assert.equal(found.length, r.MAX_OCCURRENCES)
const smaller = r.occurrencesBetween(schedule, 'UTC', Date.UTC(2026, 0, 1), Date.UTC(2027, 0, 1), { limit: 10 })
assert.equal(smaller.length, 10)
})
test('nextOccurrence looks forward and finds nothing when there is nothing', () => {
const weekly = r.nextOccurrence({ kind: 'weekly', days: ['friday'], time: '20:00' }, 'UTC', Date.UTC(2026, 5, 1))
assert.equal(wall('UTC', weekly.at), '2026-06-05 20:00')
// A `once` already in the past has no next occurrence, which is what stops a
// one-off event being re-materialised for ever.
assert.equal(r.nextOccurrence({ kind: 'once', at: '2020-01-01T12:00' }, 'UTC', Date.UTC(2026, 5, 1)), null)
assert.equal(r.nextOccurrence({ kind: 'manual' }, 'UTC', Date.UTC(2026, 5, 1)), null)
})
test('describe says the schedule back in words, in the event own zone', () => {
assert.equal(
r.describe({ kind: 'weekly', days: ['friday', 'saturday'], time: '20:00' }, 'Europe/Berlin'),
'Every Friday and Saturday at 20:00 (Europe/Berlin)',
)
assert.equal(
r.describe({ kind: 'monthly', nth: -1, weekday: 'friday', time: '19:30' }, 'Asia/Kolkata'),
'The last Friday of every month at 19:30 (Asia/Kolkata)',
)
assert.equal(r.describe({ kind: 'manual' }), 'Started by hand')
})