import { Cron } from "croner"; import { DEFAULT_TZ, type Schedule } from "./types.js"; /** Next fire instant strictly after `after`, or null when the schedule is exhausted. */ export function nextFireAfter(schedule: Schedule, after: Date): Date | null { switch (schedule.kind) { case "at": { const at = parseInstant(schedule.at, "schedule.at"); return at.getTime() > after.getTime() ? at : null; } case "cron": { const cron = new Cron(schedule.expr, { timezone: schedule.tz ?? DEFAULT_TZ, paused: true }); return cron.nextRun(after) ?? null; } case "every": { if (!Number.isFinite(schedule.everyMs) || schedule.everyMs < 60_000) throw new Error("schedule.everyMs must be >= 60000"); const anchor = schedule.anchor ? parseInstant(schedule.anchor, "schedule.anchor").getTime() : 0; const t = after.getTime(); if (t < anchor) return new Date(anchor); const n = Math.floor((t - anchor) / schedule.everyMs) + 1; return new Date(anchor + n * schedule.everyMs); } } } /** First fire for a newly created/rescheduled job (inclusive of `from` for `at` in the past = null). */ export function firstFire(schedule: Schedule, from: Date): Date | null { return nextFireAfter(schedule, new Date(from.getTime() - 1)); } /** Preview of the next `count` fire instants (for the console). */ export function previewFires(schedule: Schedule, from: Date, count: number): Date[] { const out: Date[] = []; let cursor = new Date(from.getTime() - 1); for (let i = 0; i < count; i++) { const next = nextFireAfter(schedule, cursor); if (!next) break; out.push(next); cursor = next; } return out; } export function validateSchedule(schedule: Schedule): void { if (schedule.kind === "cron") { // Throws on invalid patterns or timezones. new Cron(schedule.expr, { timezone: schedule.tz ?? DEFAULT_TZ, paused: true }); } nextFireAfter(schedule, new Date(0)); } export function parseInstant(value: string, field: string): Date { const d = new Date(value); if (Number.isNaN(d.getTime())) throw new Error(`${field}: invalid ISO date '${value}'`); return d; } export function describeSchedule(schedule: Schedule): string { switch (schedule.kind) { case "at": return `una volta il ${schedule.at}`; case "cron": return `cron '${schedule.expr}' (${schedule.tz ?? DEFAULT_TZ})`; case "every": return `ogni ${formatDuration(schedule.everyMs)}`; } } export function formatDuration(ms: number): string { if (ms % 86_400_000 === 0) return `${ms / 86_400_000}g`; if (ms % 3_600_000 === 0) return `${ms / 3_600_000}h`; if (ms % 60_000 === 0) return `${ms / 60_000}m`; return `${Math.round(ms / 1000)}s`; } /** Parses "90s", "15m", "2h", "1d" or a plain number of milliseconds. */ export function parseDuration(value: string | number): number { if (typeof value === "number") return value; const m = /^\s*(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?\s*$/.exec(value); if (!m) throw new Error(`invalid duration '${value}'`); const n = Number(m[1]); const unit = m[2] ?? "ms"; const mult = { ms: 1, s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }[unit] ?? 1; return Math.round(n * mult); }