import { cronMatchesDay, type ParsedCron } from "./cron.js"; import { formatNominal } from "./nominal.js"; import { resolveWallToInstant } from "./materialize.js"; import { canonicalTimezone, wallOfInstant } from "./timezone.js"; import { parseCron } from "./cron.js"; import type { NominalLocal } from "./timezone.js"; /** A schedule's time specification: five-field cron in an explicit IANA zone. */ export interface ScheduleSpec { /** Five-field cron expression, e.g. `*\/15 * * * *`. */ readonly cron: string; /** Explicit IANA zone id — never server-local. `UTC` is allowed. */ readonly timezone: string; } /** A materialized occurrence: nominal local slot plus its UTC fire instant. */ export interface Occurrence { /** Local date-time of this slot in the schedule zone, e.g. `2024-03-10T02:30`. */ nominalLocal: NominalLocal; /** Materialized UTC fire instant in epoch milliseconds. */ fireAtUtc: number; } /** A schedule validated once, reusable across calls. */ export interface PreparedSchedule { readonly cron: ParsedCron; readonly timezone: string; readonly spec: ScheduleSpec; } /** * Validate a spec (cron + zone) once. Throws `InvalidCronError`, * `ImpossibleScheduleError` or `InvalidTimezoneError` on bad input. */ export function prepareSchedule(spec: ScheduleSpec): PreparedSchedule { return { cron: parseCron(spec.cron), timezone: canonicalTimezone(spec.timezone), spec: { cron: spec.cron, timezone: spec.timezone }, }; } /** Hour values sorted ascending for deterministic day-slot iteration. */ export function sortedHours(cron: ParsedCron): number[] { return [...cron.hour.values].sort((a, b) => a - b); } /** Builds the nominal-local string for one slot on a matching local day. */ export function slotNominal(year: number, month: number, day: number, hour: number, minute: number): NominalLocal { const y = String(year).padStart(4, "0"); const mo = String(month).padStart(2, "0"); const d = String(day).padStart(2, "0"); const h = String(hour).padStart(2, "0"); const mi = String(minute).padStart(2, "0"); return `${y}-${mo}-${d}T${h}:${mi}`; } /** * Iterate matching local days at or after a starting local day. Returns each * matching day's wall clock at local midnight (materialized to an instant so * the caller can step by absolute time across DST). */ export function* matchingLocalDays( cron: ParsedCron, startDayEpoch: number, zone: string, maxDays: number, ): Generator<{ wall: ReturnType; midnightInstant: number }> { let cursor = startDayEpoch; for (let i = 0; i < maxDays; i++) { const wall = wallOfInstant(cursor, zone); if (cronMatchesDay(cron, wall.year, wall.month, wall.day)) { yield { wall, midnightInstant: cursor }; } cursor = stepOneLocalDay(cursor, zone); } } /** Advance to the next local midnight, robust across 23/24/25-hour days. */ export function stepOneLocalDay(dayEpoch: number, zone: string): number { const nextApprox = dayEpoch + 24 * 3600_000; const nextWall = wallOfInstant(nextApprox, zone); return resolveWallToInstant( { year: nextWall.year, month: nextWall.month, day: nextWall.day, hour: 0, minute: 0, second: 0 }, zone, ); } export { formatNominal }; /** Minute values sorted ascending for deterministic day-slot iteration. */ export function sortedMinutes(cron: ParsedCron): number[] { return [...cron.minute.values].sort((a, b) => a - b); }