import { cronMatchesDay } from "./cron.js"; import { resolveWallToInstant } from "./materialize.js"; import { slotNominal, sortedHours, sortedMinutes, type PreparedSchedule } from "./schedule.js"; import { toInstantMs, wallOfInstant } from "./timezone.js"; import type { WallClock } from "./timezone.js"; import type { Occurrence } from "./schedule.js"; export interface NextOccurrencesOptions { /** * When true (default) a slot whose fire instant is exactly `from` is * skipped; when false it is included. The default keeps re-generation * idempotent: re-running generation from the last emitted instant never * re-emits it. */ readonly exclusive?: boolean; } /** Nominal calendar day (local midnight reading) containing `epochMs`. */ function startNominalDay(epochMs: number, zone: string): WallClock { const wall = wallOfInstant(epochMs, zone); return { year: wall.year, month: wall.month, day: wall.day, hour: 0, minute: 0, second: 0 }; } /** Advance a nominal calendar day by one, purely calendar arithmetic. */ function nextNominalDay(day: WallClock): WallClock { const next = new Date(Date.UTC(day.year, day.month - 1, day.day + 1)); return { year: next.getUTCFullYear(), month: next.getUTCMonth() + 1, day: next.getUTCDate(), hour: 0, minute: 0, second: 0, }; } /** * The next `count` occurrences at or after `from`, evaluated in the schedule * zone and materialized to UTC instants. * * Each local day's matching slots are emitted in ascending local order; a * slot's materialization (gap shift-forward, fold earlier-instant) may collide * with another slot's instant — such collisions are deduplicated so each * materialized instant appears exactly once (occurrence key stays unique). * Generation is capped at `maxYears` of local days to bound work for * sparse schedules. */ export function nextOccurrencesPrepared( prepared: PreparedSchedule, from: Date | number | string, count: number, options?: NextOccurrencesOptions, maxYears = 8, ): Occurrence[] { if (!Number.isInteger(count) || count <= 0) { throw new RangeError(`count must be a positive integer, got ${count}`); } const zone = prepared.timezone; const cron = prepared.cron; const fromMs = toInstantMs(from); const exclusive = options?.exclusive ?? true; const hours = sortedHours(cron); const minutes = sortedMinutes(cron); const out: Occurrence[] = []; const seen = new Set(); // Index of the emitted occurrence per fire instant, so a later nominal slot // collapsing onto an already-emitted instant can take over the label. const indexAt = new Map(); const dayLimit = Math.ceil(maxYears * 366); // Iterate NOMINAL calendar days (not materialized midnights) so a skipped // local day (Pacific/Apia 2011-12-30) still contributes its slots: every // slot of that day materializes to the first valid instant after the gap and // collapses onto a single occurrence there. let day = startNominalDay(fromMs, zone); // Once `count` occurrences exist, generation may continue only far enough to // finish the collapse set of the LAST emitted instant — later slots of that // same instant must still be able to take over its nominal label — and then // stops. Occurrences are emitted in ascending instant order, so the first // slot materializing strictly after the boundary ends generation. let boundary: number | null = null; for (let dayIndex = 0; dayIndex < dayLimit; dayIndex++) { if (cronMatchesDay(cron, day.year, day.month, day.day)) { for (const hour of hours) { for (const minute of minutes) { const slotWall: WallClock = { year: day.year, month: day.month, day: day.day, hour, minute, second: 0, }; const fireAt = resolveWallToInstant(slotWall, zone); if (exclusive ? fireAt <= fromMs : fireAt < fromMs) continue; if (boundary !== null && fireAt > boundary) return out; if (seen.has(fireAt)) { // Collapse: several nominal slots materialized onto one instant // (post-gap). Exactly one occurrence survives per instant, carrying // the LATEST collapsed nominal slot — the conservative value for // the last_fired_nominal_local never-refire gate. const index = indexAt.get(fireAt); if (index !== undefined) { out[index] = { nominalLocal: slotNominal(day.year, day.month, day.day, hour, minute), fireAtUtc: fireAt, }; } continue; } seen.add(fireAt); indexAt.set(fireAt, out.length); out.push({ nominalLocal: slotNominal(day.year, day.month, day.day, hour, minute), fireAtUtc: fireAt, }); if (out.length >= count) boundary = fireAt; } } } day = nextNominalDay(day); } return out; }