From 0c66cdf5fc70476e341e53feb48a511ad22a087f Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 19 Jun 2026 19:17:04 -0700 Subject: [PATCH 1/3] feat(scheduled-tasks): expose Google Calendar-style recurrence options Add a per-day weekly toggle (repeat on arbitrary weekdays), monthly nth-/last-weekday anchoring, and a yearly frequency to the scheduled task modal, closing the gap with a calendar app's recurrence picker. The recurrence UI compiles to cron, so this is front-end only: croner already speaks the nth/last-weekday (#/#L) syntax, and the display path normalizes #L to cronstrue's L so labels read "last Monday" not "null". --- .../task-modal/recurrence-section.tsx | 115 ++++++++++++++++-- .../scheduled-tasks/utils/recurrence.test.ts | 81 +++++++++++- .../scheduled-tasks/utils/recurrence.ts | 51 +++++++- .../sim/lib/workflows/schedules/utils.test.ts | 10 ++ apps/sim/lib/workflows/schedules/utils.ts | 8 +- 5 files changed, 247 insertions(+), 18 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/recurrence-section.tsx b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/recurrence-section.tsx index d74ca387fb0..a880a9f5d27 100644 --- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/recurrence-section.tsx +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/recurrence-section.tsx @@ -2,8 +2,9 @@ import { useRef } from 'react' import { format } from 'date-fns' -import { ChipDatePicker, ChipModalField, ChipModalSeparator, Switch } from '@/components/emcn' +import { Chip, ChipDatePicker, ChipModalField, ChipModalSeparator, Switch } from '@/components/emcn' import type { + MonthlyMode, Recurrence, RecurrenceFrequency, } from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/recurrence' @@ -14,8 +15,22 @@ const DEFAULT_END_AFTER_COUNT = 10 /** Cadence a task falls back to when the user first flips on recurrence. */ const DEFAULT_RECURRING_FREQUENCY = 'daily' +/** Sunday-first weekday order with single-letter labels and full names for a11y. */ +const WEEKDAYS = [ + { value: 0, short: 'S', name: 'Sunday' }, + { value: 1, short: 'M', name: 'Monday' }, + { value: 2, short: 'T', name: 'Tuesday' }, + { value: 3, short: 'W', name: 'Wednesday' }, + { value: 4, short: 'T', name: 'Thursday' }, + { value: 5, short: 'F', name: 'Friday' }, + { value: 6, short: 'S', name: 'Saturday' }, +] as const + +/** Ordinal words for the 1st–5th weekday-of-month, matching a calendar app's labels. */ +const ORDINALS = ['first', 'second', 'third', 'fourth', 'fifth'] as const + /** The frequency presets the dropdown authors, keyed by a synthetic option value. */ -type FrequencyOption = 'daily' | 'weekly' | 'weekdays' | 'monthly' | 'custom' +type FrequencyOption = 'daily' | 'weekly' | 'weekdays' | 'monthly' | 'yearly' | 'custom' function isWeekdayPreset(weekdays: number[]): boolean { return ( @@ -23,17 +38,40 @@ function isWeekdayPreset(weekdays: number[]): boolean { ) } -/** Collapses a recurring recurrence into the single dropdown value that represents it. */ +/** + * Collapses a recurring recurrence into the single dropdown value that + * represents it. `once` maps to the default cadence as an exhaustiveness + * fallback: callers gate on `isRecurring`, so it never reaches here at runtime, + * but the dropdown can't represent it — mapping it keeps the return type + * `FrequencyOption` without a cast. + */ function frequencyOptionFor(recurrence: Recurrence): FrequencyOption { if (recurrence.frequency === 'weekly') return isWeekdayPreset(recurrence.weekdays) ? 'weekdays' : 'weekly' - // Exhaustiveness fallback: callers gate on `isRecurring`, so `once` never - // reaches here at runtime, but the dropdown can't represent it — mapping it to - // a recurring default keeps the return type `FrequencyOption` without a cast. + if (recurrence.frequency === 'monthly') return 'monthly' + if (recurrence.frequency === 'yearly') return 'yearly' + if (recurrence.frequency === 'custom') return 'custom' if (recurrence.frequency === 'once') return DEFAULT_RECURRING_FREQUENCY return recurrence.frequency } +/** + * The monthly sub-options, derived from the launch date the same way a calendar + * app offers them: repeat on the day number, on the ordinal weekday of the + * month (e.g. the third Tuesday), or on the last weekday of the month. All three + * are always offered so the picked mode is always representable, even when the + * launch date is changed afterward via the footer date picker. + */ +function monthlyModeOptions(launch: Date): Array<{ value: MonthlyMode; label: string }> { + const weekdayName = format(launch, 'EEEE') + const ordinal = ORDINALS[Math.ceil(launch.getDate() / 7) - 1] + return [ + { value: 'day-of-month', label: `On day ${format(launch, 'd')}` }, + { value: 'nth-weekday', label: `On the ${ordinal} ${weekdayName}` }, + { value: 'last-weekday', label: `On the last ${weekdayName}` }, + ] +} + interface RecurrenceSectionProps { recurrence: Recurrence onChange: (recurrence: Recurrence) => void @@ -44,8 +82,9 @@ interface RecurrenceSectionProps { /** * The repeat + end controls for a scheduled task, rendered as a body section * below the prompt: a "Recurring" {@link Switch} that toggles a one-time launch - * into a repeat, and — once on — the frequency preset and how it ends (never, on - * a date, or after N runs). + * into a repeat, and — once on — the frequency preset, its cadence detail (the + * weekly day toggles or the monthly anchor), and how it ends (never, on a date, + * or after N runs). * * Composed as a sibling between the prompt body and footer; it owns its own * leading separator and mirrors {@link ChipModalBody}'s spacing @@ -66,12 +105,14 @@ export function RecurrenceSection({ recurrence, onChange, launchDate }: Recurren const launch = new Date(`${launchDate}T00:00`) const isRecurring = recurrence.frequency !== 'once' + const selectedWeekdays = recurrence.weekdays.length > 0 ? recurrence.weekdays : [launch.getDay()] const frequencyOptions = [ { value: 'daily', label: 'Daily' }, - { value: 'weekly', label: `Weekly on ${format(launch, 'EEE')}` }, + { value: 'weekly', label: 'Weekly' }, { value: 'weekdays', label: 'Weekdays' }, - { value: 'monthly', label: `Monthly on the ${format(launch, 'do')}` }, + { value: 'monthly', label: 'Monthly' }, + { value: 'yearly', label: `Yearly on ${format(launch, 'MMM d')}` }, ...(recurrence.frequency === 'custom' ? [{ value: 'custom', label: 'Custom' }] : []), ] @@ -109,13 +150,32 @@ export function RecurrenceSection({ recurrence, onChange, launchDate }: Recurren }) return case 'monthly': - onChange({ ...recurrence, frequency: 'monthly', weekdays: [], cron: undefined }) + onChange({ + ...recurrence, + frequency: 'monthly', + weekdays: [], + monthlyMode: 'day-of-month', + cron: undefined, + }) + return + case 'yearly': + onChange({ ...recurrence, frequency: 'yearly', weekdays: [], cron: undefined }) return case 'custom': onChange({ ...recurrence, frequency: 'custom' }) } } + /** Toggles a weekday on or off, never letting the last selected day be cleared. */ + const handleWeekdayToggle = (day: number) => { + const isSelected = selectedWeekdays.includes(day) + if (isSelected && selectedWeekdays.length === 1) return + const weekdays = isSelected + ? selectedWeekdays.filter((d) => d !== day) + : [...selectedWeekdays, day].sort((a, b) => a - b) + onChange({ ...recurrence, weekdays }) + } + const handleEndChange = (value: string) => { if (value === 'never') onChange({ ...recurrence, end: { type: 'never' } }) else if (value === 'on') @@ -144,6 +204,39 @@ export function RecurrenceSection({ recurrence, onChange, launchDate }: Recurren onChange={handleFrequencyChange} /> + {recurrence.frequency === 'weekly' && ( + +
+ {WEEKDAYS.map((weekday) => { + const selected = selectedWeekdays.includes(weekday.value) + return ( + handleWeekdayToggle(weekday.value)} + > + {weekday.short} + + ) + })} +
+
+ )} + + {recurrence.frequency === 'monthly' && ( + onChange({ ...recurrence, monthlyMode: value as MonthlyMode })} + /> + )} + { ).toBe('5 7 15 * *') }) + it('builds a monthly nth-weekday expression (2026-06-15 is the third Monday)', () => { + expect( + recurrenceToCron( + { frequency: 'monthly', weekdays: [], monthlyMode: 'nth-weekday', end: { type: 'never' } }, + '2026-06-15', + '09:30' + ) + ).toBe('30 9 * * 1#3') + }) + + it('builds a monthly last-weekday expression', () => { + expect( + recurrenceToCron( + { frequency: 'monthly', weekdays: [], monthlyMode: 'last-weekday', end: { type: 'never' } }, + '2026-06-29', + '09:30' + ) + ).toBe('30 9 * * 1#L') + }) + + it('builds a yearly expression from the launch month and day', () => { + expect( + recurrenceToCron( + { frequency: 'yearly', weekdays: [], end: { type: 'never' } }, + '2026-06-15', + '09:30' + ) + ).toBe('30 9 15 6 *') + }) + it('preserves a custom expression verbatim', () => { expect( recurrenceToCron( @@ -124,15 +154,47 @@ describe('cronToRecurrence', () => { expect(weekly.frequency).toBe('weekly') expect(weekly.weekdays).toEqual([1, 3]) + const monthly = cronToRecurrence({ + cronExpression: '5 7 15 * *', + maxRuns: null, + endsAt: null, + anchor, + timezone: 'UTC', + }).recurrence + expect(monthly.frequency).toBe('monthly') + expect(monthly.monthlyMode).toBe('day-of-month') + }) + + it('recovers monthly nth-weekday, monthly last-weekday, and yearly cadences', () => { + const nthWeekday = cronToRecurrence({ + cronExpression: '30 9 * * 1#3', + maxRuns: null, + endsAt: null, + anchor, + timezone: 'UTC', + }).recurrence + expect(nthWeekday.frequency).toBe('monthly') + expect(nthWeekday.monthlyMode).toBe('nth-weekday') + + const lastWeekday = cronToRecurrence({ + cronExpression: '30 9 * * 1#L', + maxRuns: null, + endsAt: null, + anchor, + timezone: 'UTC', + }).recurrence + expect(lastWeekday.frequency).toBe('monthly') + expect(lastWeekday.monthlyMode).toBe('last-weekday') + expect( cronToRecurrence({ - cronExpression: '5 7 15 * *', + cronExpression: '30 9 15 6 *', maxRuns: null, endsAt: null, anchor, timezone: 'UTC', }).recurrence.frequency - ).toBe('monthly') + ).toBe('yearly') }) it('falls back to custom for an expression it did not author', () => { @@ -209,6 +271,21 @@ describe('expandOccurrences', () => { expect(occurrences.map((d) => d.toISOString())).toEqual(['2026-06-03T12:00:00.000Z']) }) + it('materializes a monthly nth-weekday cron (third Monday of each month)', () => { + const occurrences = expandOccurrences({ + cronExpression: '30 9 * * 1#3', + timezone: 'UTC', + rangeStart: new Date('2026-06-01T00:00:00Z'), + rangeEnd: new Date('2026-08-31T23:59:59Z'), + from: new Date('2026-05-31T00:00:00Z'), + }) + expect(occurrences.map((d) => d.toISOString())).toEqual([ + '2026-06-15T09:30:00.000Z', + '2026-07-20T09:30:00.000Z', + '2026-08-17T09:30:00.000Z', + ]) + }) + it('returns nothing for an invalid expression instead of throwing', () => { expect(expandOccurrences({ ...base, cronExpression: 'not-a-cron' })).toEqual([]) }) diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/recurrence.ts b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/recurrence.ts index e814a3dc2b8..ffa0bac5543 100644 --- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/recurrence.ts +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/recurrence.ts @@ -6,7 +6,18 @@ import { zonedWallClock, zonedWallClockToUtc } from '@/lib/core/utils/timezone' * preserves a cron expression the UI did not author (e.g. a task created * conversationally) so editing never silently rewrites it. */ -export type RecurrenceFrequency = 'once' | 'daily' | 'weekly' | 'monthly' | 'custom' +export type RecurrenceFrequency = 'once' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'custom' + +/** + * How a monthly recurrence anchors within the month, mirroring a calendar app's + * monthly sub-options. `day-of-month` repeats on the launch date's day number + * (e.g. the 15th); `nth-weekday` on the same ordinal weekday (e.g. the third + * Tuesday, croner `2#3`); `last-weekday` on the final weekday of that kind (e.g. + * the last Tuesday, croner `2#L`). The weekday and ordinal are read from the + * launch date at cron-build time — on edit the launch date is an actual + * occurrence, so the mode round-trips to the same day. + */ +export type MonthlyMode = 'day-of-month' | 'nth-weekday' | 'last-weekday' /** When a recurrence stops, mirroring the three calendar-app end options. */ export type RecurrenceEnd = @@ -18,6 +29,8 @@ export interface Recurrence { frequency: RecurrenceFrequency /** Weekly only: weekdays 0 (Sun) – 6 (Sat). Empty falls back to the launch day's weekday. */ weekdays: number[] + /** Monthly only: how it anchors within the month. Defaults to `day-of-month`. */ + monthlyMode?: MonthlyMode end: RecurrenceEnd /** `custom` only: the raw cron expression, passed through unchanged on save. */ cron?: string @@ -57,8 +70,19 @@ export function recurrenceToCron( const days = recurrence.weekdays.length > 0 ? recurrence.weekdays : [launchDay.getUTCDay()] return `${minute} ${hour} * * ${[...new Set(days)].sort((a, b) => a - b).join(',')}` } - case 'monthly': - return `${minute} ${hour} ${launchDay.getUTCDate()} * *` + case 'monthly': { + const weekday = launchDay.getUTCDay() + switch (recurrence.monthlyMode ?? 'day-of-month') { + case 'nth-weekday': + return `${minute} ${hour} * * ${weekday}#${Math.ceil(launchDay.getUTCDate() / 7)}` + case 'last-weekday': + return `${minute} ${hour} * * ${weekday}#L` + default: + return `${minute} ${hour} ${launchDay.getUTCDate()} * *` + } + } + case 'yearly': + return `${minute} ${hour} ${launchDay.getUTCDate()} ${launchDay.getUTCMonth() + 1} *` } } @@ -157,11 +181,30 @@ export function cronToRecurrence(params: { const weekdays = dayOfWeek.split(',').map(Number) return { recurrence: { frequency: 'weekly', weekdays, end }, launchTime } } + if (dayOfMonth === '*' && /^[0-6]#[1-5]$/.test(dayOfWeek)) { + return { + recurrence: { frequency: 'monthly', weekdays: [], monthlyMode: 'nth-weekday', end }, + launchTime, + } + } + if (dayOfMonth === '*' && /^[0-6]#L$/.test(dayOfWeek)) { + return { + recurrence: { frequency: 'monthly', weekdays: [], monthlyMode: 'last-weekday', end }, + launchTime, + } + } if (isNumeric(dayOfMonth) && dayOfWeek === '*') { - return { recurrence: { frequency: 'monthly', weekdays: [], end }, launchTime } + return { + recurrence: { frequency: 'monthly', weekdays: [], monthlyMode: 'day-of-month', end }, + launchTime, + } } } + if (numbersAreValid && isNumeric(dayOfMonth) && isNumeric(month) && dayOfWeek === '*') { + return { recurrence: { frequency: 'yearly', weekdays: [], end }, launchTime } + } + return { recurrence: { frequency: 'custom', weekdays: [], end, cron: cronExpression }, launchTime, diff --git a/apps/sim/lib/workflows/schedules/utils.test.ts b/apps/sim/lib/workflows/schedules/utils.test.ts index 338a5f9132d..d8e888ed0cf 100644 --- a/apps/sim/lib/workflows/schedules/utils.test.ts +++ b/apps/sim/lib/workflows/schedules/utils.test.ts @@ -517,6 +517,16 @@ describe('Schedule Utilities', () => { expect(parseCronToHumanReadable('30 14 15 * *')).toContain('15') }) + it.concurrent('should describe the nth weekday of the month', () => { + expect(parseCronToHumanReadable('30 9 * * 1#3')).toContain('third Monday') + }) + + it.concurrent("should describe croner's last-weekday syntax without a null ordinal", () => { + const result = parseCronToHumanReadable('30 9 * * 1#L') + expect(result).toContain('last Monday') + expect(result).not.toContain('null') + }) + it.concurrent('should include timezone information when provided', () => { const resultPT = parseCronToHumanReadable('0 9 * * *', 'America/Los_Angeles') // Intl.DateTimeFormat returns PST or PDT depending on DST diff --git a/apps/sim/lib/workflows/schedules/utils.ts b/apps/sim/lib/workflows/schedules/utils.ts index 2bcc2b10a00..f8a2ae7b4ab 100644 --- a/apps/sim/lib/workflows/schedules/utils.ts +++ b/apps/sim/lib/workflows/schedules/utils.ts @@ -501,14 +501,20 @@ function getTimezoneAbbreviation(timezone: string): string { * Converts a cron expression to a human-readable string format * Uses the cronstrue library for accurate parsing of complex cron expressions * + * Croner spells the last weekday of the month as `#L` (e.g. `1#L`) while + * cronstrue spells the same thing `L`; the expression is normalized to + * cronstrue's syntax for display only, so the stored cron keeps croner's syntax + * and scheduling stays correct. + * * @param cronExpression - The cron expression to parse * @param timezone - Optional IANA timezone string to include in the description * @returns Human-readable description of the schedule */ export const parseCronToHumanReadable = (cronExpression: string, timezone?: string): string => { try { + const forDisplay = cronExpression.replace(/([0-7])#L\b/g, '$1L') const baseDescription = cronstrue - .toString(cronExpression, { + .toString(forDisplay, { use24HourTimeFormat: false, verbose: false, }) From d4f5bc0f75e05adc42d32252bb939e41b2386048 Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 19 Jun 2026 19:26:24 -0700 Subject: [PATCH 2/3] fix(scheduled-tasks): preserve monthly anchor when reselecting Monthly Selecting Monthly from the frequency dropdown hard-reset monthlyMode to day-of-month, silently dropping a previously chosen nth-/last-weekday anchor when switching cadence away and back. Preserve the existing mode on reselect, mirroring how the last recurring cadence is restored across the recurring toggle. --- .../components/task-modal/recurrence-section.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/recurrence-section.tsx b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/recurrence-section.tsx index a880a9f5d27..19be0d0154d 100644 --- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/recurrence-section.tsx +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/recurrence-section.tsx @@ -154,7 +154,7 @@ export function RecurrenceSection({ recurrence, onChange, launchDate }: Recurren ...recurrence, frequency: 'monthly', weekdays: [], - monthlyMode: 'day-of-month', + monthlyMode: recurrence.monthlyMode ?? 'day-of-month', cron: undefined, }) return From 9859dfaf32dcce1f960aebd59ae278bc927b03ae Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 19 Jun 2026 22:57:18 -0700 Subject: [PATCH 3/3] fix(scheduled-tasks): fold 5th-occurrence monthly into last-weekday; align weekday-digit parsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review edge cases in the monthly recurrence anchors: - The picker no longer offers a fifth weekday (a 5th occurrence is always the month's last), and recurrenceToCron clamps any nth-weekday that resolves to a 5th occurrence to #L — so a launch date drifting to day 29-31 can never emit #5 and silently skip months without one. - cronToRecurrence accepts croner's alternate Sunday digit (7) for the #/#L monthly anchors, matching parseCronToHumanReadable's normalizer; externally-authored 7#L crons now round-trip (canonicalized to 0#L). - #5 crons are left as custom pass-through so their month-skipping behavior is preserved verbatim rather than rewritten. --- .../task-modal/recurrence-section.tsx | 31 +++++++++---- .../scheduled-tasks/utils/recurrence.test.ts | 45 +++++++++++++++++++ .../scheduled-tasks/utils/recurrence.ts | 21 +++++++-- 3 files changed, 84 insertions(+), 13 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/recurrence-section.tsx b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/recurrence-section.tsx index 19be0d0154d..fa680db9bf5 100644 --- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/recurrence-section.tsx +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/recurrence-section.tsx @@ -58,18 +58,23 @@ function frequencyOptionFor(recurrence: Recurrence): FrequencyOption { /** * The monthly sub-options, derived from the launch date the same way a calendar * app offers them: repeat on the day number, on the ordinal weekday of the - * month (e.g. the third Tuesday), or on the last weekday of the month. All three - * are always offered so the picked mode is always representable, even when the - * launch date is changed afterward via the footer date picker. + * month (e.g. the third Tuesday), or on the last weekday of the month. + * + * The ordinal anchor is offered only for the 1st–4th occurrence: a 5th + * occurrence is always the month's last weekday, so — like a calendar app — it + * is folded into the "last weekday" option rather than offering a "fifth" that + * would silently skip months without a 5th occurrence. */ function monthlyModeOptions(launch: Date): Array<{ value: MonthlyMode; label: string }> { const weekdayName = format(launch, 'EEEE') - const ordinal = ORDINALS[Math.ceil(launch.getDate() / 7) - 1] - return [ + const ordinal = Math.ceil(launch.getDate() / 7) + const options: Array<{ value: MonthlyMode; label: string }> = [ { value: 'day-of-month', label: `On day ${format(launch, 'd')}` }, - { value: 'nth-weekday', label: `On the ${ordinal} ${weekdayName}` }, - { value: 'last-weekday', label: `On the last ${weekdayName}` }, ] + if (ordinal <= 4) + options.push({ value: 'nth-weekday', label: `On the ${ORDINALS[ordinal - 1]} ${weekdayName}` }) + options.push({ value: 'last-weekday', label: `On the last ${weekdayName}` }) + return options } interface RecurrenceSectionProps { @@ -107,6 +112,14 @@ export function RecurrenceSection({ recurrence, onChange, launchDate }: Recurren const isRecurring = recurrence.frequency !== 'once' const selectedWeekdays = recurrence.weekdays.length > 0 ? recurrence.weekdays : [launch.getDay()] + const monthlyOptions = monthlyModeOptions(launch) + const monthlyMode = recurrence.monthlyMode ?? 'day-of-month' + // If the launch date drifted to a 5th occurrence, the nth anchor is no longer + // offered; fall back to "last weekday", which is exactly what it compiles to. + const monthlyValue = monthlyOptions.some((option) => option.value === monthlyMode) + ? monthlyMode + : 'last-weekday' + const frequencyOptions = [ { value: 'daily', label: 'Daily' }, { value: 'weekly', label: 'Weekly' }, @@ -231,8 +244,8 @@ export function RecurrenceSection({ recurrence, onChange, launchDate }: Recurren onChange({ ...recurrence, monthlyMode: value as MonthlyMode })} /> )} diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/recurrence.test.ts b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/recurrence.test.ts index a3bfc591286..1d98d9f2198 100644 --- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/recurrence.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/recurrence.test.ts @@ -64,6 +64,17 @@ describe('recurrenceToCron', () => { ).toBe('30 9 * * 1#L') }) + it('clamps a 5th-occurrence nth-weekday to last-weekday so no month is skipped', () => { + // 2026-06-29 is the fifth Monday of June; `#5` would skip months without one. + expect( + recurrenceToCron( + { frequency: 'monthly', weekdays: [], monthlyMode: 'nth-weekday', end: { type: 'never' } }, + '2026-06-29', + '09:30' + ) + ).toBe('30 9 * * 1#L') + }) + it('builds a yearly expression from the launch month and day', () => { expect( recurrenceToCron( @@ -197,6 +208,40 @@ describe('cronToRecurrence', () => { ).toBe('yearly') }) + it("accepts croner's alternate Sunday digit (7) for monthly weekday anchors", () => { + const nth = cronToRecurrence({ + cronExpression: '30 9 * * 7#3', + maxRuns: null, + endsAt: null, + anchor, + timezone: 'UTC', + }).recurrence + expect(nth.frequency).toBe('monthly') + expect(nth.monthlyMode).toBe('nth-weekday') + + const last = cronToRecurrence({ + cronExpression: '30 9 * * 7#L', + maxRuns: null, + endsAt: null, + anchor, + timezone: 'UTC', + }).recurrence + expect(last.frequency).toBe('monthly') + expect(last.monthlyMode).toBe('last-weekday') + }) + + it('leaves a 5th-occurrence (#5) cron as custom so its month-skipping is preserved', () => { + const { recurrence } = cronToRecurrence({ + cronExpression: '30 9 * * 1#5', + maxRuns: null, + endsAt: null, + anchor, + timezone: 'UTC', + }) + expect(recurrence.frequency).toBe('custom') + expect(recurrence.cron).toBe('30 9 * * 1#5') + }) + it('falls back to custom for an expression it did not author', () => { const { recurrence } = cronToRecurrence({ cronExpression: '*/5 * * * *', diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/recurrence.ts b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/recurrence.ts index ffa0bac5543..8c2c4661122 100644 --- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/recurrence.ts +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/recurrence.ts @@ -73,8 +73,16 @@ export function recurrenceToCron( case 'monthly': { const weekday = launchDay.getUTCDay() switch (recurrence.monthlyMode ?? 'day-of-month') { - case 'nth-weekday': - return `${minute} ${hour} * * ${weekday}#${Math.ceil(launchDay.getUTCDate() / 7)}` + case 'nth-weekday': { + // A 5th occurrence is always the last weekday of the month; emit `#L` + // rather than `#5` so no month without a 5th occurrence is silently + // skipped (the picker only offers nth for the 1st–4th, but the launch + // date can still drift to a 5th via the footer date picker). + const nth = Math.ceil(launchDay.getUTCDate() / 7) + return nth >= 5 + ? `${minute} ${hour} * * ${weekday}#L` + : `${minute} ${hour} * * ${weekday}#${nth}` + } case 'last-weekday': return `${minute} ${hour} * * ${weekday}#L` default: @@ -181,13 +189,18 @@ export function cronToRecurrence(params: { const weekdays = dayOfWeek.split(',').map(Number) return { recurrence: { frequency: 'weekly', weekdays, end }, launchTime } } - if (dayOfMonth === '*' && /^[0-6]#[1-5]$/.test(dayOfWeek)) { + // Accept croner's alternate Sunday digit (`7`) so externally-authored + // `7#…` crons round-trip; the picker canonicalizes them to `0#…` on save. + // A 5th occurrence (`#5`) is intentionally NOT matched — it falls through to + // `custom` so its month-skipping behavior is preserved verbatim rather than + // silently rewritten to `#L`. + if (dayOfMonth === '*' && /^[0-7]#[1-4]$/.test(dayOfWeek)) { return { recurrence: { frequency: 'monthly', weekdays: [], monthlyMode: 'nth-weekday', end }, launchTime, } } - if (dayOfMonth === '*' && /^[0-6]#L$/.test(dayOfWeek)) { + if (dayOfMonth === '*' && /^[0-7]#L$/.test(dayOfWeek)) { return { recurrence: { frequency: 'monthly', weekdays: [], monthlyMode: 'last-weekday', end }, launchTime,