From 94bd823f86cafc00ca5d0f709bb142a2ab87f4fe Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 9 Jun 2026 23:07:39 +0000 Subject: [PATCH] fix(ui): match cron preview day field semantics --- ui/src/lib/cron-fires.test.ts | 10 ++++++++++ ui/src/lib/cron-fires.ts | 24 ++++++++++++++++++++---- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/ui/src/lib/cron-fires.test.ts b/ui/src/lib/cron-fires.test.ts index 1fb7b41fda8..c2ac8403050 100644 --- a/ui/src/lib/cron-fires.test.ts +++ b/ui/src/lib/cron-fires.test.ts @@ -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"); diff --git a/ui/src/lib/cron-fires.ts b/ui/src/lib/cron-fires.ts index 553e4cee78e..efaba78ebb2 100644 --- a/ui/src/lib/cron-fires.ts +++ b/ui/src/lib/cron-fires.ts @@ -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 ); }