// ── 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') })