/** * Minimal 5-field cron "next run" calculator, shared by the event scheduler * (SchedulesService) and the wipe scheduler (WipesService). * * Supports `*` and exact numeric fields (minute hour day-of-month month * day-of-week). Walks minute-by-minute up to 366 days ahead. Returns null on a * malformed expression or if no match is found within a year. * * NOTE: the expression is evaluated in **UTC**. A per-schedule `timezone` * column exists on both schedule tables but is NOT yet honored here — fixing it * properly needs a timezone-aware cron library; tracked as a shared follow-up. */ export function nextCronDate(expr: string, after: Date): Date | null { const parts = expr.trim().split(/\s+/); if (parts.length !== 5) return null; const [minuteExpr, hourExpr, domExpr, monthExpr, dowExpr] = parts; const matches = (e: string, value: number): boolean => { if (e === '*') return true; return parseInt(e, 10) === value; }; // Walk minute-by-minute up to 366 days forward to find the next match. const candidate = new Date(after.getTime() + 60_000); // advance at least 1 minute candidate.setSeconds(0, 0); const limit = new Date(after.getTime() + 366 * 24 * 60 * 60 * 1000); while (candidate < limit) { const min = candidate.getUTCMinutes(); const hour = candidate.getUTCHours(); const dom = candidate.getUTCDate(); const month = candidate.getUTCMonth() + 1; // 1-12 const dow = candidate.getUTCDay(); // 0=Sun if ( matches(minuteExpr, min) && matches(hourExpr, hour) && matches(domExpr, dom) && matches(monthExpr, month) && matches(dowExpr, dow) ) { return candidate; } candidate.setTime(candidate.getTime() + 60_000); } return null; }