/** * Cron Next Runs * * Computes upcoming run times for a standard 5-field cron expression * (minute, hour, day-of-month, month, day-of-week). Supports `*`, lists * (`1,2,3`), ranges (`1-5`), and step values (`*\/15`, `1-5/2`). * * Implementation note: this is a minute-resolution scanner — adequate for * preview UIs (showing the next few runs). It does not attempt to support * non-standard fields (seconds, year, `L`, `W`, `#`). */ function parseField(field: string, min: number, max: number): number[] { const values = new Set(); for (const part of field.split(',')) { const stepMatch = part.match(/^(.+)\/(\d+)$/); const step = stepMatch ? parseInt(stepMatch[2], 10) : 1; const range = stepMatch ? stepMatch[1] : part; if (!Number.isFinite(step) || step < 1) continue; if (range === '*') { for (let i = min; i <= max; i += step) values.add(i); } else if (range.includes('-')) { const [start, end] = range.split('-').map(Number); if (Number.isFinite(start) && Number.isFinite(end)) { for (let i = start; i <= end; i += step) values.add(i); } } else { const n = parseInt(range, 10); if (Number.isFinite(n)) values.add(n); } } return Array.from(values).sort((a, b) => a - b); } /** * Compute the next `count` run times of a cron expression starting after * `from` (exclusive). Returns an empty array for invalid expressions. */ export function getNextRuns(cron: string, count: number, from: Date = new Date()): Date[] { if (!cron || typeof cron !== 'string' || count <= 0) return []; const fields = cron.trim().split(/\s+/); if (fields.length !== 5) return []; const minuteValues = parseField(fields[0], 0, 59); const hourValues = parseField(fields[1], 0, 23); const domValues = parseField(fields[2], 1, 31); const monthValues = parseField(fields[3], 1, 12); const dowValues = parseField(fields[4], 0, 6); if ( minuteValues.length === 0 || hourValues.length === 0 || (fields[2] !== '*' && domValues.length === 0) || (fields[3] !== '*' && monthValues.length === 0) || (fields[4] !== '*' && dowValues.length === 0) ) { return []; } const minuteSet = new Set(minuteValues); const hourSet = new Set(hourValues); const domSet = fields[2] === '*' ? null : new Set(domValues); const monthSet = fields[3] === '*' ? null : new Set(monthValues); const dowSet = fields[4] === '*' ? null : new Set(dowValues); const runs: Date[] = []; const cursor = new Date(from); cursor.setSeconds(0, 0); cursor.setMinutes(cursor.getMinutes() + 1); // cap at ~1 year of minutes — far enough for monthly schedules, // tight enough that broken expressions return quickly. const maxIterations = 366 * 24 * 60; for (let i = 0; i < maxIterations && runs.length < count; i++) { const m = cursor.getMinutes(); const h = cursor.getHours(); const d = cursor.getDate(); const mo = cursor.getMonth() + 1; const w = cursor.getDay(); if ( minuteSet.has(m) && hourSet.has(h) && (domSet === null || domSet.has(d)) && (monthSet === null || monthSet.has(mo)) && (dowSet === null || dowSet.has(w)) ) { runs.push(new Date(cursor)); } cursor.setMinutes(cursor.getMinutes() + 1); } return runs; } /** * Default human-friendly formatter for a next-run Date. * Example: "Mon, Jan 5 at 9:00 AM" */ export function formatNextRun(date: Date): string { const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; const months = [ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', ]; const day = days[date.getDay()]; const month = months[date.getMonth()]; const d = date.getDate(); const h = date.getHours(); const m = date.getMinutes().toString().padStart(2, '0'); const period = h >= 12 ? 'PM' : 'AM'; const hour = h === 0 ? 12 : h > 12 ? h - 12 : h; return `${day}, ${month} ${d} at ${hour}:${m} ${period}`; }