Skip to content

Commit 9859dfa

Browse files
committed
fix(scheduled-tasks): fold 5th-occurrence monthly into last-weekday; align weekday-digit parsing
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.
1 parent d4f5bc0 commit 9859dfa

3 files changed

Lines changed: 84 additions & 13 deletions

File tree

apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/recurrence-section.tsx

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -58,18 +58,23 @@ function frequencyOptionFor(recurrence: Recurrence): FrequencyOption {
5858
/**
5959
* The monthly sub-options, derived from the launch date the same way a calendar
6060
* app offers them: repeat on the day number, on the ordinal weekday of the
61-
* month (e.g. the third Tuesday), or on the last weekday of the month. All three
62-
* are always offered so the picked mode is always representable, even when the
63-
* launch date is changed afterward via the footer date picker.
61+
* month (e.g. the third Tuesday), or on the last weekday of the month.
62+
*
63+
* The ordinal anchor is offered only for the 1st–4th occurrence: a 5th
64+
* occurrence is always the month's last weekday, so — like a calendar app — it
65+
* is folded into the "last weekday" option rather than offering a "fifth" that
66+
* would silently skip months without a 5th occurrence.
6467
*/
6568
function monthlyModeOptions(launch: Date): Array<{ value: MonthlyMode; label: string }> {
6669
const weekdayName = format(launch, 'EEEE')
67-
const ordinal = ORDINALS[Math.ceil(launch.getDate() / 7) - 1]
68-
return [
70+
const ordinal = Math.ceil(launch.getDate() / 7)
71+
const options: Array<{ value: MonthlyMode; label: string }> = [
6972
{ value: 'day-of-month', label: `On day ${format(launch, 'd')}` },
70-
{ value: 'nth-weekday', label: `On the ${ordinal} ${weekdayName}` },
71-
{ value: 'last-weekday', label: `On the last ${weekdayName}` },
7273
]
74+
if (ordinal <= 4)
75+
options.push({ value: 'nth-weekday', label: `On the ${ORDINALS[ordinal - 1]} ${weekdayName}` })
76+
options.push({ value: 'last-weekday', label: `On the last ${weekdayName}` })
77+
return options
7378
}
7479

7580
interface RecurrenceSectionProps {
@@ -107,6 +112,14 @@ export function RecurrenceSection({ recurrence, onChange, launchDate }: Recurren
107112
const isRecurring = recurrence.frequency !== 'once'
108113
const selectedWeekdays = recurrence.weekdays.length > 0 ? recurrence.weekdays : [launch.getDay()]
109114

115+
const monthlyOptions = monthlyModeOptions(launch)
116+
const monthlyMode = recurrence.monthlyMode ?? 'day-of-month'
117+
// If the launch date drifted to a 5th occurrence, the nth anchor is no longer
118+
// offered; fall back to "last weekday", which is exactly what it compiles to.
119+
const monthlyValue = monthlyOptions.some((option) => option.value === monthlyMode)
120+
? monthlyMode
121+
: 'last-weekday'
122+
110123
const frequencyOptions = [
111124
{ value: 'daily', label: 'Daily' },
112125
{ value: 'weekly', label: 'Weekly' },
@@ -231,8 +244,8 @@ export function RecurrenceSection({ recurrence, onChange, launchDate }: Recurren
231244
<ChipModalField
232245
type='dropdown'
233246
title='On'
234-
value={recurrence.monthlyMode ?? 'day-of-month'}
235-
options={monthlyModeOptions(launch)}
247+
value={monthlyValue}
248+
options={monthlyOptions}
236249
onChange={(value) => onChange({ ...recurrence, monthlyMode: value as MonthlyMode })}
237250
/>
238251
)}

apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/recurrence.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,17 @@ describe('recurrenceToCron', () => {
6464
).toBe('30 9 * * 1#L')
6565
})
6666

67+
it('clamps a 5th-occurrence nth-weekday to last-weekday so no month is skipped', () => {
68+
// 2026-06-29 is the fifth Monday of June; `#5` would skip months without one.
69+
expect(
70+
recurrenceToCron(
71+
{ frequency: 'monthly', weekdays: [], monthlyMode: 'nth-weekday', end: { type: 'never' } },
72+
'2026-06-29',
73+
'09:30'
74+
)
75+
).toBe('30 9 * * 1#L')
76+
})
77+
6778
it('builds a yearly expression from the launch month and day', () => {
6879
expect(
6980
recurrenceToCron(
@@ -197,6 +208,40 @@ describe('cronToRecurrence', () => {
197208
).toBe('yearly')
198209
})
199210

211+
it("accepts croner's alternate Sunday digit (7) for monthly weekday anchors", () => {
212+
const nth = cronToRecurrence({
213+
cronExpression: '30 9 * * 7#3',
214+
maxRuns: null,
215+
endsAt: null,
216+
anchor,
217+
timezone: 'UTC',
218+
}).recurrence
219+
expect(nth.frequency).toBe('monthly')
220+
expect(nth.monthlyMode).toBe('nth-weekday')
221+
222+
const last = cronToRecurrence({
223+
cronExpression: '30 9 * * 7#L',
224+
maxRuns: null,
225+
endsAt: null,
226+
anchor,
227+
timezone: 'UTC',
228+
}).recurrence
229+
expect(last.frequency).toBe('monthly')
230+
expect(last.monthlyMode).toBe('last-weekday')
231+
})
232+
233+
it('leaves a 5th-occurrence (#5) cron as custom so its month-skipping is preserved', () => {
234+
const { recurrence } = cronToRecurrence({
235+
cronExpression: '30 9 * * 1#5',
236+
maxRuns: null,
237+
endsAt: null,
238+
anchor,
239+
timezone: 'UTC',
240+
})
241+
expect(recurrence.frequency).toBe('custom')
242+
expect(recurrence.cron).toBe('30 9 * * 1#5')
243+
})
244+
200245
it('falls back to custom for an expression it did not author', () => {
201246
const { recurrence } = cronToRecurrence({
202247
cronExpression: '*/5 * * * *',

apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/recurrence.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,16 @@ export function recurrenceToCron(
7373
case 'monthly': {
7474
const weekday = launchDay.getUTCDay()
7575
switch (recurrence.monthlyMode ?? 'day-of-month') {
76-
case 'nth-weekday':
77-
return `${minute} ${hour} * * ${weekday}#${Math.ceil(launchDay.getUTCDate() / 7)}`
76+
case 'nth-weekday': {
77+
// A 5th occurrence is always the last weekday of the month; emit `#L`
78+
// rather than `#5` so no month without a 5th occurrence is silently
79+
// skipped (the picker only offers nth for the 1st–4th, but the launch
80+
// date can still drift to a 5th via the footer date picker).
81+
const nth = Math.ceil(launchDay.getUTCDate() / 7)
82+
return nth >= 5
83+
? `${minute} ${hour} * * ${weekday}#L`
84+
: `${minute} ${hour} * * ${weekday}#${nth}`
85+
}
7886
case 'last-weekday':
7987
return `${minute} ${hour} * * ${weekday}#L`
8088
default:
@@ -181,13 +189,18 @@ export function cronToRecurrence(params: {
181189
const weekdays = dayOfWeek.split(',').map(Number)
182190
return { recurrence: { frequency: 'weekly', weekdays, end }, launchTime }
183191
}
184-
if (dayOfMonth === '*' && /^[0-6]#[1-5]$/.test(dayOfWeek)) {
192+
// Accept croner's alternate Sunday digit (`7`) so externally-authored
193+
// `7#…` crons round-trip; the picker canonicalizes them to `0#…` on save.
194+
// A 5th occurrence (`#5`) is intentionally NOT matched — it falls through to
195+
// `custom` so its month-skipping behavior is preserved verbatim rather than
196+
// silently rewritten to `#L`.
197+
if (dayOfMonth === '*' && /^[0-7]#[1-4]$/.test(dayOfWeek)) {
185198
return {
186199
recurrence: { frequency: 'monthly', weekdays: [], monthlyMode: 'nth-weekday', end },
187200
launchTime,
188201
}
189202
}
190-
if (dayOfMonth === '*' && /^[0-6]#L$/.test(dayOfWeek)) {
203+
if (dayOfMonth === '*' && /^[0-7]#L$/.test(dayOfWeek)) {
191204
return {
192205
recurrence: { frequency: 'monthly', weekdays: [], monthlyMode: 'last-weekday', end },
193206
launchTime,

0 commit comments

Comments
 (0)