fix(ui): match cron preview day field semantics

This commit is contained in:
Dotta committed 2026-06-09 23:07:39 +00:00
1 parent 33b6f9704c
commit 94bd823f86
2 files changed
+30 -4

No files matched your search

+10
View File
@@ -39,6 +39,16 @@ describe("nextCronFires", () => {
]);
});
it("uses OR semantics when day-of-month and day-of-week are both restricted", () => {
const after = new Date("2026-06-09T10:00:00Z");
const fires = nextCronFires("0 9 15 * 5", 3, { after, timeZone: "UTC" });
expect(fires.map((d) => d.toISOString())).toEqual([
"2026-06-12T09:00:00.000Z",
"2026-06-15T09:00:00.000Z",
"2026-06-19T09:00:00.000Z",
]);
});
it("interprets cron fields in the trigger timezone", () => {
// 14:00 in New York (EDT, UTC-4 in June) is 18:00 UTC.
const after = new Date("2026-06-09T10:00:00Z");
+20 -4
View File
@@ -25,6 +25,8 @@ interface ParsedCron {
daysOfMonth: number[];
months: number[];
daysOfWeek: number[];
daysOfMonthWildcard: boolean;
daysOfWeekWildcard: boolean;
}
interface FieldSpec {
@@ -101,18 +103,26 @@ function assertBounds(value: number, spec: FieldSpec): void {
}
}
function coversEntireField(values: number[], spec: FieldSpec): boolean {
return values.length === spec.max - spec.min + 1 && values[0] === spec.min && values.at(-1) === spec.max;
}
/** Parse a 5-field cron expression. Returns null when it can't be parsed. */
export function parseCronExpression(expression: string | null | undefined): ParsedCron | null {
if (!expression) return null;
const tokens = expression.trim().split(/\s+/);
if (tokens.length !== 5) return null;
try {
const daysOfMonth = parseField(tokens[2]!, FIELD_SPECS[2]!);
const daysOfWeek = parseField(tokens[4]!, FIELD_SPECS[4]!);
return {
minutes: parseField(tokens[0]!, FIELD_SPECS[0]!),
hours: parseField(tokens[1]!, FIELD_SPECS[1]!),
daysOfMonth: parseField(tokens[2]!, FIELD_SPECS[2]!),
daysOfMonth,
months: parseField(tokens[3]!, FIELD_SPECS[3]!),
daysOfWeek: parseField(tokens[4]!, FIELD_SPECS[4]!),
daysOfWeek,
daysOfMonthWildcard: coversEntireField(daysOfMonth, FIELD_SPECS[2]!),
daysOfWeekWildcard: coversEntireField(daysOfWeek, FIELD_SPECS[4]!),
};
} catch {
return null;
@@ -154,12 +164,18 @@ function getZonedMinuteParts(date: Date, timeZone: string): ZonedParts {
}
function matches(cron: ParsedCron, parts: ZonedParts): boolean {
const dayOfMonthMatches = cron.daysOfMonth.includes(parts.day);
const dayOfWeekMatches = cron.daysOfWeek.includes(parts.weekday);
const dayMatches =
!cron.daysOfMonthWildcard && !cron.daysOfWeekWildcard
? dayOfMonthMatches || dayOfWeekMatches
: dayOfMonthMatches && dayOfWeekMatches;
return (
cron.minutes.includes(parts.minute) &&
cron.hours.includes(parts.hour) &&
cron.daysOfMonth.includes(parts.day) &&
cron.months.includes(parts.month) &&
cron.daysOfWeek.includes(parts.weekday)
dayMatches
);
}