Skip to content

Commit ce283fa

Browse files
authored
feat(scheduled-tasks): expose Google Calendar-style recurrence options (#5146)
* 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". * 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. * 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 1248f8e commit ce283fa

5 files changed

Lines changed: 318 additions & 18 deletions

File tree

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

Lines changed: 117 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22

33
import { useRef } from 'react'
44
import { format } from 'date-fns'
5-
import { ChipDatePicker, ChipModalField, ChipModalSeparator, Switch } from '@/components/emcn'
5+
import { Chip, ChipDatePicker, ChipModalField, ChipModalSeparator, Switch } from '@/components/emcn'
66
import type {
7+
MonthlyMode,
78
Recurrence,
89
RecurrenceFrequency,
910
} from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/recurrence'
@@ -14,26 +15,68 @@ const DEFAULT_END_AFTER_COUNT = 10
1415
/** Cadence a task falls back to when the user first flips on recurrence. */
1516
const DEFAULT_RECURRING_FREQUENCY = 'daily'
1617

18+
/** Sunday-first weekday order with single-letter labels and full names for a11y. */
19+
const WEEKDAYS = [
20+
{ value: 0, short: 'S', name: 'Sunday' },
21+
{ value: 1, short: 'M', name: 'Monday' },
22+
{ value: 2, short: 'T', name: 'Tuesday' },
23+
{ value: 3, short: 'W', name: 'Wednesday' },
24+
{ value: 4, short: 'T', name: 'Thursday' },
25+
{ value: 5, short: 'F', name: 'Friday' },
26+
{ value: 6, short: 'S', name: 'Saturday' },
27+
] as const
28+
29+
/** Ordinal words for the 1st–5th weekday-of-month, matching a calendar app's labels. */
30+
const ORDINALS = ['first', 'second', 'third', 'fourth', 'fifth'] as const
31+
1732
/** The frequency presets the dropdown authors, keyed by a synthetic option value. */
18-
type FrequencyOption = 'daily' | 'weekly' | 'weekdays' | 'monthly' | 'custom'
33+
type FrequencyOption = 'daily' | 'weekly' | 'weekdays' | 'monthly' | 'yearly' | 'custom'
1934

2035
function isWeekdayPreset(weekdays: number[]): boolean {
2136
return (
2237
weekdays.length === WEEKDAY_PRESET.length && WEEKDAY_PRESET.every((d) => weekdays.includes(d))
2338
)
2439
}
2540

26-
/** Collapses a recurring recurrence into the single dropdown value that represents it. */
41+
/**
42+
* Collapses a recurring recurrence into the single dropdown value that
43+
* represents it. `once` maps to the default cadence as an exhaustiveness
44+
* fallback: callers gate on `isRecurring`, so it never reaches here at runtime,
45+
* but the dropdown can't represent it — mapping it keeps the return type
46+
* `FrequencyOption` without a cast.
47+
*/
2748
function frequencyOptionFor(recurrence: Recurrence): FrequencyOption {
2849
if (recurrence.frequency === 'weekly')
2950
return isWeekdayPreset(recurrence.weekdays) ? 'weekdays' : 'weekly'
30-
// Exhaustiveness fallback: callers gate on `isRecurring`, so `once` never
31-
// reaches here at runtime, but the dropdown can't represent it — mapping it to
32-
// a recurring default keeps the return type `FrequencyOption` without a cast.
51+
if (recurrence.frequency === 'monthly') return 'monthly'
52+
if (recurrence.frequency === 'yearly') return 'yearly'
53+
if (recurrence.frequency === 'custom') return 'custom'
3354
if (recurrence.frequency === 'once') return DEFAULT_RECURRING_FREQUENCY
3455
return recurrence.frequency
3556
}
3657

58+
/**
59+
* The monthly sub-options, derived from the launch date the same way a calendar
60+
* 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.
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.
67+
*/
68+
function monthlyModeOptions(launch: Date): Array<{ value: MonthlyMode; label: string }> {
69+
const weekdayName = format(launch, 'EEEE')
70+
const ordinal = Math.ceil(launch.getDate() / 7)
71+
const options: Array<{ value: MonthlyMode; label: string }> = [
72+
{ value: 'day-of-month', label: `On day ${format(launch, 'd')}` },
73+
]
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
78+
}
79+
3780
interface RecurrenceSectionProps {
3881
recurrence: Recurrence
3982
onChange: (recurrence: Recurrence) => void
@@ -44,8 +87,9 @@ interface RecurrenceSectionProps {
4487
/**
4588
* The repeat + end controls for a scheduled task, rendered as a body section
4689
* below the prompt: a "Recurring" {@link Switch} that toggles a one-time launch
47-
* into a repeat, and — once on — the frequency preset and how it ends (never, on
48-
* a date, or after N runs).
90+
* into a repeat, and — once on — the frequency preset, its cadence detail (the
91+
* weekly day toggles or the monthly anchor), and how it ends (never, on a date,
92+
* or after N runs).
4993
*
5094
* Composed as a sibling between the prompt body and footer; it owns its own
5195
* leading separator and mirrors {@link ChipModalBody}'s spacing
@@ -66,12 +110,22 @@ export function RecurrenceSection({ recurrence, onChange, launchDate }: Recurren
66110

67111
const launch = new Date(`${launchDate}T00:00`)
68112
const isRecurring = recurrence.frequency !== 'once'
113+
const selectedWeekdays = recurrence.weekdays.length > 0 ? recurrence.weekdays : [launch.getDay()]
114+
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'
69122

70123
const frequencyOptions = [
71124
{ value: 'daily', label: 'Daily' },
72-
{ value: 'weekly', label: `Weekly on ${format(launch, 'EEE')}` },
125+
{ value: 'weekly', label: 'Weekly' },
73126
{ value: 'weekdays', label: 'Weekdays' },
74-
{ value: 'monthly', label: `Monthly on the ${format(launch, 'do')}` },
127+
{ value: 'monthly', label: 'Monthly' },
128+
{ value: 'yearly', label: `Yearly on ${format(launch, 'MMM d')}` },
75129
...(recurrence.frequency === 'custom' ? [{ value: 'custom', label: 'Custom' }] : []),
76130
]
77131

@@ -109,13 +163,32 @@ export function RecurrenceSection({ recurrence, onChange, launchDate }: Recurren
109163
})
110164
return
111165
case 'monthly':
112-
onChange({ ...recurrence, frequency: 'monthly', weekdays: [], cron: undefined })
166+
onChange({
167+
...recurrence,
168+
frequency: 'monthly',
169+
weekdays: [],
170+
monthlyMode: recurrence.monthlyMode ?? 'day-of-month',
171+
cron: undefined,
172+
})
173+
return
174+
case 'yearly':
175+
onChange({ ...recurrence, frequency: 'yearly', weekdays: [], cron: undefined })
113176
return
114177
case 'custom':
115178
onChange({ ...recurrence, frequency: 'custom' })
116179
}
117180
}
118181

182+
/** Toggles a weekday on or off, never letting the last selected day be cleared. */
183+
const handleWeekdayToggle = (day: number) => {
184+
const isSelected = selectedWeekdays.includes(day)
185+
if (isSelected && selectedWeekdays.length === 1) return
186+
const weekdays = isSelected
187+
? selectedWeekdays.filter((d) => d !== day)
188+
: [...selectedWeekdays, day].sort((a, b) => a - b)
189+
onChange({ ...recurrence, weekdays })
190+
}
191+
119192
const handleEndChange = (value: string) => {
120193
if (value === 'never') onChange({ ...recurrence, end: { type: 'never' } })
121194
else if (value === 'on')
@@ -144,6 +217,39 @@ export function RecurrenceSection({ recurrence, onChange, launchDate }: Recurren
144217
onChange={handleFrequencyChange}
145218
/>
146219

220+
{recurrence.frequency === 'weekly' && (
221+
<ChipModalField type='custom' title='Repeat on'>
222+
<div className='flex gap-1'>
223+
{WEEKDAYS.map((weekday) => {
224+
const selected = selectedWeekdays.includes(weekday.value)
225+
return (
226+
<Chip
227+
key={weekday.value}
228+
active={selected}
229+
flush
230+
className='min-w-0 flex-1 justify-center'
231+
aria-pressed={selected}
232+
aria-label={weekday.name}
233+
onClick={() => handleWeekdayToggle(weekday.value)}
234+
>
235+
{weekday.short}
236+
</Chip>
237+
)
238+
})}
239+
</div>
240+
</ChipModalField>
241+
)}
242+
243+
{recurrence.frequency === 'monthly' && (
244+
<ChipModalField
245+
type='dropdown'
246+
title='On'
247+
value={monthlyValue}
248+
options={monthlyOptions}
249+
onChange={(value) => onChange({ ...recurrence, monthlyMode: value as MonthlyMode })}
250+
/>
251+
)}
252+
147253
<ChipModalField
148254
type='dropdown'
149255
title='Ends'

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

Lines changed: 124 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,47 @@ describe('recurrenceToCron', () => {
4444
).toBe('5 7 15 * *')
4545
})
4646

47+
it('builds a monthly nth-weekday expression (2026-06-15 is the third Monday)', () => {
48+
expect(
49+
recurrenceToCron(
50+
{ frequency: 'monthly', weekdays: [], monthlyMode: 'nth-weekday', end: { type: 'never' } },
51+
'2026-06-15',
52+
'09:30'
53+
)
54+
).toBe('30 9 * * 1#3')
55+
})
56+
57+
it('builds a monthly last-weekday expression', () => {
58+
expect(
59+
recurrenceToCron(
60+
{ frequency: 'monthly', weekdays: [], monthlyMode: 'last-weekday', end: { type: 'never' } },
61+
'2026-06-29',
62+
'09:30'
63+
)
64+
).toBe('30 9 * * 1#L')
65+
})
66+
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+
78+
it('builds a yearly expression from the launch month and day', () => {
79+
expect(
80+
recurrenceToCron(
81+
{ frequency: 'yearly', weekdays: [], end: { type: 'never' } },
82+
'2026-06-15',
83+
'09:30'
84+
)
85+
).toBe('30 9 15 6 *')
86+
})
87+
4788
it('preserves a custom expression verbatim', () => {
4889
expect(
4990
recurrenceToCron(
@@ -124,15 +165,81 @@ describe('cronToRecurrence', () => {
124165
expect(weekly.frequency).toBe('weekly')
125166
expect(weekly.weekdays).toEqual([1, 3])
126167

168+
const monthly = cronToRecurrence({
169+
cronExpression: '5 7 15 * *',
170+
maxRuns: null,
171+
endsAt: null,
172+
anchor,
173+
timezone: 'UTC',
174+
}).recurrence
175+
expect(monthly.frequency).toBe('monthly')
176+
expect(monthly.monthlyMode).toBe('day-of-month')
177+
})
178+
179+
it('recovers monthly nth-weekday, monthly last-weekday, and yearly cadences', () => {
180+
const nthWeekday = cronToRecurrence({
181+
cronExpression: '30 9 * * 1#3',
182+
maxRuns: null,
183+
endsAt: null,
184+
anchor,
185+
timezone: 'UTC',
186+
}).recurrence
187+
expect(nthWeekday.frequency).toBe('monthly')
188+
expect(nthWeekday.monthlyMode).toBe('nth-weekday')
189+
190+
const lastWeekday = cronToRecurrence({
191+
cronExpression: '30 9 * * 1#L',
192+
maxRuns: null,
193+
endsAt: null,
194+
anchor,
195+
timezone: 'UTC',
196+
}).recurrence
197+
expect(lastWeekday.frequency).toBe('monthly')
198+
expect(lastWeekday.monthlyMode).toBe('last-weekday')
199+
127200
expect(
128201
cronToRecurrence({
129-
cronExpression: '5 7 15 * *',
202+
cronExpression: '30 9 15 6 *',
130203
maxRuns: null,
131204
endsAt: null,
132205
anchor,
133206
timezone: 'UTC',
134207
}).recurrence.frequency
135-
).toBe('monthly')
208+
).toBe('yearly')
209+
})
210+
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')
136243
})
137244

138245
it('falls back to custom for an expression it did not author', () => {
@@ -209,6 +316,21 @@ describe('expandOccurrences', () => {
209316
expect(occurrences.map((d) => d.toISOString())).toEqual(['2026-06-03T12:00:00.000Z'])
210317
})
211318

319+
it('materializes a monthly nth-weekday cron (third Monday of each month)', () => {
320+
const occurrences = expandOccurrences({
321+
cronExpression: '30 9 * * 1#3',
322+
timezone: 'UTC',
323+
rangeStart: new Date('2026-06-01T00:00:00Z'),
324+
rangeEnd: new Date('2026-08-31T23:59:59Z'),
325+
from: new Date('2026-05-31T00:00:00Z'),
326+
})
327+
expect(occurrences.map((d) => d.toISOString())).toEqual([
328+
'2026-06-15T09:30:00.000Z',
329+
'2026-07-20T09:30:00.000Z',
330+
'2026-08-17T09:30:00.000Z',
331+
])
332+
})
333+
212334
it('returns nothing for an invalid expression instead of throwing', () => {
213335
expect(expandOccurrences({ ...base, cronExpression: 'not-a-cron' })).toEqual([])
214336
})

0 commit comments

Comments
 (0)