Skip to content

Commit 0c66cdf

Browse files
committed
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".
1 parent 7349bf4 commit 0c66cdf

5 files changed

Lines changed: 247 additions & 18 deletions

File tree

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

Lines changed: 104 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,63 @@ 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. 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.
64+
*/
65+
function monthlyModeOptions(launch: Date): Array<{ value: MonthlyMode; label: string }> {
66+
const weekdayName = format(launch, 'EEEE')
67+
const ordinal = ORDINALS[Math.ceil(launch.getDate() / 7) - 1]
68+
return [
69+
{ 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}` },
72+
]
73+
}
74+
3775
interface RecurrenceSectionProps {
3876
recurrence: Recurrence
3977
onChange: (recurrence: Recurrence) => void
@@ -44,8 +82,9 @@ interface RecurrenceSectionProps {
4482
/**
4583
* The repeat + end controls for a scheduled task, rendered as a body section
4684
* 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).
85+
* into a repeat, and — once on — the frequency preset, its cadence detail (the
86+
* weekly day toggles or the monthly anchor), and how it ends (never, on a date,
87+
* or after N runs).
4988
*
5089
* Composed as a sibling between the prompt body and footer; it owns its own
5190
* leading separator and mirrors {@link ChipModalBody}'s spacing
@@ -66,12 +105,14 @@ export function RecurrenceSection({ recurrence, onChange, launchDate }: Recurren
66105

67106
const launch = new Date(`${launchDate}T00:00`)
68107
const isRecurring = recurrence.frequency !== 'once'
108+
const selectedWeekdays = recurrence.weekdays.length > 0 ? recurrence.weekdays : [launch.getDay()]
69109

70110
const frequencyOptions = [
71111
{ value: 'daily', label: 'Daily' },
72-
{ value: 'weekly', label: `Weekly on ${format(launch, 'EEE')}` },
112+
{ value: 'weekly', label: 'Weekly' },
73113
{ value: 'weekdays', label: 'Weekdays' },
74-
{ value: 'monthly', label: `Monthly on the ${format(launch, 'do')}` },
114+
{ value: 'monthly', label: 'Monthly' },
115+
{ value: 'yearly', label: `Yearly on ${format(launch, 'MMM d')}` },
75116
...(recurrence.frequency === 'custom' ? [{ value: 'custom', label: 'Custom' }] : []),
76117
]
77118

@@ -109,13 +150,32 @@ export function RecurrenceSection({ recurrence, onChange, launchDate }: Recurren
109150
})
110151
return
111152
case 'monthly':
112-
onChange({ ...recurrence, frequency: 'monthly', weekdays: [], cron: undefined })
153+
onChange({
154+
...recurrence,
155+
frequency: 'monthly',
156+
weekdays: [],
157+
monthlyMode: 'day-of-month',
158+
cron: undefined,
159+
})
160+
return
161+
case 'yearly':
162+
onChange({ ...recurrence, frequency: 'yearly', weekdays: [], cron: undefined })
113163
return
114164
case 'custom':
115165
onChange({ ...recurrence, frequency: 'custom' })
116166
}
117167
}
118168

169+
/** Toggles a weekday on or off, never letting the last selected day be cleared. */
170+
const handleWeekdayToggle = (day: number) => {
171+
const isSelected = selectedWeekdays.includes(day)
172+
if (isSelected && selectedWeekdays.length === 1) return
173+
const weekdays = isSelected
174+
? selectedWeekdays.filter((d) => d !== day)
175+
: [...selectedWeekdays, day].sort((a, b) => a - b)
176+
onChange({ ...recurrence, weekdays })
177+
}
178+
119179
const handleEndChange = (value: string) => {
120180
if (value === 'never') onChange({ ...recurrence, end: { type: 'never' } })
121181
else if (value === 'on')
@@ -144,6 +204,39 @@ export function RecurrenceSection({ recurrence, onChange, launchDate }: Recurren
144204
onChange={handleFrequencyChange}
145205
/>
146206

207+
{recurrence.frequency === 'weekly' && (
208+
<ChipModalField type='custom' title='Repeat on'>
209+
<div className='flex gap-1'>
210+
{WEEKDAYS.map((weekday) => {
211+
const selected = selectedWeekdays.includes(weekday.value)
212+
return (
213+
<Chip
214+
key={weekday.value}
215+
active={selected}
216+
flush
217+
className='min-w-0 flex-1 justify-center'
218+
aria-pressed={selected}
219+
aria-label={weekday.name}
220+
onClick={() => handleWeekdayToggle(weekday.value)}
221+
>
222+
{weekday.short}
223+
</Chip>
224+
)
225+
})}
226+
</div>
227+
</ChipModalField>
228+
)}
229+
230+
{recurrence.frequency === 'monthly' && (
231+
<ChipModalField
232+
type='dropdown'
233+
title='On'
234+
value={recurrence.monthlyMode ?? 'day-of-month'}
235+
options={monthlyModeOptions(launch)}
236+
onChange={(value) => onChange({ ...recurrence, monthlyMode: value as MonthlyMode })}
237+
/>
238+
)}
239+
147240
<ChipModalField
148241
type='dropdown'
149242
title='Ends'

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

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,36 @@ 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('builds a yearly expression from the launch month and day', () => {
68+
expect(
69+
recurrenceToCron(
70+
{ frequency: 'yearly', weekdays: [], end: { type: 'never' } },
71+
'2026-06-15',
72+
'09:30'
73+
)
74+
).toBe('30 9 15 6 *')
75+
})
76+
4777
it('preserves a custom expression verbatim', () => {
4878
expect(
4979
recurrenceToCron(
@@ -124,15 +154,47 @@ describe('cronToRecurrence', () => {
124154
expect(weekly.frequency).toBe('weekly')
125155
expect(weekly.weekdays).toEqual([1, 3])
126156

157+
const monthly = cronToRecurrence({
158+
cronExpression: '5 7 15 * *',
159+
maxRuns: null,
160+
endsAt: null,
161+
anchor,
162+
timezone: 'UTC',
163+
}).recurrence
164+
expect(monthly.frequency).toBe('monthly')
165+
expect(monthly.monthlyMode).toBe('day-of-month')
166+
})
167+
168+
it('recovers monthly nth-weekday, monthly last-weekday, and yearly cadences', () => {
169+
const nthWeekday = cronToRecurrence({
170+
cronExpression: '30 9 * * 1#3',
171+
maxRuns: null,
172+
endsAt: null,
173+
anchor,
174+
timezone: 'UTC',
175+
}).recurrence
176+
expect(nthWeekday.frequency).toBe('monthly')
177+
expect(nthWeekday.monthlyMode).toBe('nth-weekday')
178+
179+
const lastWeekday = cronToRecurrence({
180+
cronExpression: '30 9 * * 1#L',
181+
maxRuns: null,
182+
endsAt: null,
183+
anchor,
184+
timezone: 'UTC',
185+
}).recurrence
186+
expect(lastWeekday.frequency).toBe('monthly')
187+
expect(lastWeekday.monthlyMode).toBe('last-weekday')
188+
127189
expect(
128190
cronToRecurrence({
129-
cronExpression: '5 7 15 * *',
191+
cronExpression: '30 9 15 6 *',
130192
maxRuns: null,
131193
endsAt: null,
132194
anchor,
133195
timezone: 'UTC',
134196
}).recurrence.frequency
135-
).toBe('monthly')
197+
).toBe('yearly')
136198
})
137199

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

274+
it('materializes a monthly nth-weekday cron (third Monday of each month)', () => {
275+
const occurrences = expandOccurrences({
276+
cronExpression: '30 9 * * 1#3',
277+
timezone: 'UTC',
278+
rangeStart: new Date('2026-06-01T00:00:00Z'),
279+
rangeEnd: new Date('2026-08-31T23:59:59Z'),
280+
from: new Date('2026-05-31T00:00:00Z'),
281+
})
282+
expect(occurrences.map((d) => d.toISOString())).toEqual([
283+
'2026-06-15T09:30:00.000Z',
284+
'2026-07-20T09:30:00.000Z',
285+
'2026-08-17T09:30:00.000Z',
286+
])
287+
})
288+
212289
it('returns nothing for an invalid expression instead of throwing', () => {
213290
expect(expandOccurrences({ ...base, cronExpression: 'not-a-cron' })).toEqual([])
214291
})

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

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,18 @@ import { zonedWallClock, zonedWallClockToUtc } from '@/lib/core/utils/timezone'
66
* preserves a cron expression the UI did not author (e.g. a task created
77
* conversationally) so editing never silently rewrites it.
88
*/
9-
export type RecurrenceFrequency = 'once' | 'daily' | 'weekly' | 'monthly' | 'custom'
9+
export type RecurrenceFrequency = 'once' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'custom'
10+
11+
/**
12+
* How a monthly recurrence anchors within the month, mirroring a calendar app's
13+
* monthly sub-options. `day-of-month` repeats on the launch date's day number
14+
* (e.g. the 15th); `nth-weekday` on the same ordinal weekday (e.g. the third
15+
* Tuesday, croner `2#3`); `last-weekday` on the final weekday of that kind (e.g.
16+
* the last Tuesday, croner `2#L`). The weekday and ordinal are read from the
17+
* launch date at cron-build time — on edit the launch date is an actual
18+
* occurrence, so the mode round-trips to the same day.
19+
*/
20+
export type MonthlyMode = 'day-of-month' | 'nth-weekday' | 'last-weekday'
1021

1122
/** When a recurrence stops, mirroring the three calendar-app end options. */
1223
export type RecurrenceEnd =
@@ -18,6 +29,8 @@ export interface Recurrence {
1829
frequency: RecurrenceFrequency
1930
/** Weekly only: weekdays 0 (Sun) – 6 (Sat). Empty falls back to the launch day's weekday. */
2031
weekdays: number[]
32+
/** Monthly only: how it anchors within the month. Defaults to `day-of-month`. */
33+
monthlyMode?: MonthlyMode
2134
end: RecurrenceEnd
2235
/** `custom` only: the raw cron expression, passed through unchanged on save. */
2336
cron?: string
@@ -57,8 +70,19 @@ export function recurrenceToCron(
5770
const days = recurrence.weekdays.length > 0 ? recurrence.weekdays : [launchDay.getUTCDay()]
5871
return `${minute} ${hour} * * ${[...new Set(days)].sort((a, b) => a - b).join(',')}`
5972
}
60-
case 'monthly':
61-
return `${minute} ${hour} ${launchDay.getUTCDate()} * *`
73+
case 'monthly': {
74+
const weekday = launchDay.getUTCDay()
75+
switch (recurrence.monthlyMode ?? 'day-of-month') {
76+
case 'nth-weekday':
77+
return `${minute} ${hour} * * ${weekday}#${Math.ceil(launchDay.getUTCDate() / 7)}`
78+
case 'last-weekday':
79+
return `${minute} ${hour} * * ${weekday}#L`
80+
default:
81+
return `${minute} ${hour} ${launchDay.getUTCDate()} * *`
82+
}
83+
}
84+
case 'yearly':
85+
return `${minute} ${hour} ${launchDay.getUTCDate()} ${launchDay.getUTCMonth() + 1} *`
6286
}
6387
}
6488

@@ -157,11 +181,30 @@ export function cronToRecurrence(params: {
157181
const weekdays = dayOfWeek.split(',').map(Number)
158182
return { recurrence: { frequency: 'weekly', weekdays, end }, launchTime }
159183
}
184+
if (dayOfMonth === '*' && /^[0-6]#[1-5]$/.test(dayOfWeek)) {
185+
return {
186+
recurrence: { frequency: 'monthly', weekdays: [], monthlyMode: 'nth-weekday', end },
187+
launchTime,
188+
}
189+
}
190+
if (dayOfMonth === '*' && /^[0-6]#L$/.test(dayOfWeek)) {
191+
return {
192+
recurrence: { frequency: 'monthly', weekdays: [], monthlyMode: 'last-weekday', end },
193+
launchTime,
194+
}
195+
}
160196
if (isNumeric(dayOfMonth) && dayOfWeek === '*') {
161-
return { recurrence: { frequency: 'monthly', weekdays: [], end }, launchTime }
197+
return {
198+
recurrence: { frequency: 'monthly', weekdays: [], monthlyMode: 'day-of-month', end },
199+
launchTime,
200+
}
162201
}
163202
}
164203

204+
if (numbersAreValid && isNumeric(dayOfMonth) && isNumeric(month) && dayOfWeek === '*') {
205+
return { recurrence: { frequency: 'yearly', weekdays: [], end }, launchTime }
206+
}
207+
165208
return {
166209
recurrence: { frequency: 'custom', weekdays: [], end, cron: cronExpression },
167210
launchTime,

apps/sim/lib/workflows/schedules/utils.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -517,6 +517,16 @@ describe('Schedule Utilities', () => {
517517
expect(parseCronToHumanReadable('30 14 15 * *')).toContain('15')
518518
})
519519

520+
it.concurrent('should describe the nth weekday of the month', () => {
521+
expect(parseCronToHumanReadable('30 9 * * 1#3')).toContain('third Monday')
522+
})
523+
524+
it.concurrent("should describe croner's last-weekday syntax without a null ordinal", () => {
525+
const result = parseCronToHumanReadable('30 9 * * 1#L')
526+
expect(result).toContain('last Monday')
527+
expect(result).not.toContain('null')
528+
})
529+
520530
it.concurrent('should include timezone information when provided', () => {
521531
const resultPT = parseCronToHumanReadable('0 9 * * *', 'America/Los_Angeles')
522532
// Intl.DateTimeFormat returns PST or PDT depending on DST

0 commit comments

Comments
 (0)