computeNextCronRun function
Compute the next DateTime strictly after from that matches the cron
fields, using the process's local timezone. Walks forward minute-by-minute.
Bounded at 366 days; returns null if no match.
Standard cron semantics: when both dayOfMonth and dayOfWeek are constrained (neither is the full range), a date matches if EITHER matches.
DST: fixed-hour crons targeting a spring-forward gap skip the transition day. Wildcard-hour crons fire at the first valid minute after the gap.
Implementation
DateTime? computeNextCronRun(CronFields fields, DateTime from) {
final minuteSet = fields.minute.toSet();
final hourSet = fields.hour.toSet();
final domSet = fields.dayOfMonth.toSet();
final monthSet = fields.month.toSet();
final dowSet = fields.dayOfWeek.toSet();
// Is the field wildcarded (full range)?
final domWild = fields.dayOfMonth.length == 31;
final dowWild = fields.dayOfWeek.length == 7;
// Round up to the next whole minute (strictly after `from`).
var t = DateTime(
from.year,
from.month,
from.day,
from.hour,
from.minute,
).add(const Duration(minutes: 1));
const maxIter = 366 * 24 * 60;
for (int i = 0; i < maxIter; i++) {
final month = t.month;
if (!monthSet.contains(month)) {
// Jump to start of next month.
if (t.month == 12) {
t = DateTime(t.year + 1, 1, 1);
} else {
t = DateTime(t.year, t.month + 1, 1);
}
continue;
}
final dom = t.day;
final dow = t.weekday % 7; // DateTime weekday: Mon=1..Sun=7, cron: Sun=0.
final dayMatches = domWild && dowWild
? true
: domWild
? dowSet.contains(dow)
: dowWild
? domSet.contains(dom)
: domSet.contains(dom) || dowSet.contains(dow);
if (!dayMatches) {
// Jump to start of next day.
t = DateTime(t.year, t.month, t.day + 1);
continue;
}
if (!hourSet.contains(t.hour)) {
t = DateTime(t.year, t.month, t.day, t.hour + 1);
continue;
}
if (!minuteSet.contains(t.minute)) {
t = t.add(const Duration(minutes: 1));
continue;
}
return t;
}
return null;
}