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.
This commit is contained in:
Waleed
2026-06-19 23:35:02 -07:00
committed by GitHub
parent 1248f8eef4
commit ce283fa1c9
5 changed files with 319 additions and 19 deletions
@@ -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,45 @@ 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.
*
* 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 = Math.ceil(launch.getDate() / 7)
const options: Array<{ value: MonthlyMode; label: string }> = [
{ value: 'day-of-month', label: `On day ${format(launch, 'd')}` },
]
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 {
recurrence: Recurrence
onChange: (recurrence: Recurrence) => void
@@ -44,8 +87,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 +110,22 @@ 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 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 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 +163,32 @@ export function RecurrenceSection({ recurrence, onChange, launchDate }: Recurren
})
return
case 'monthly':
onChange({ ...recurrence, frequency: 'monthly', weekdays: [], cron: undefined })
onChange({
...recurrence,
frequency: 'monthly',
weekdays: [],
monthlyMode: recurrence.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 +217,39 @@ export function RecurrenceSection({ recurrence, onChange, launchDate }: Recurren
onChange={handleFrequencyChange}
/>
{recurrence.frequency === 'weekly' && (
<ChipModalField type='custom' title='Repeat on'>
<div className='flex gap-1'>
{WEEKDAYS.map((weekday) => {
const selected = selectedWeekdays.includes(weekday.value)
return (
<Chip
key={weekday.value}
active={selected}
flush
className='min-w-0 flex-1 justify-center'
aria-pressed={selected}
aria-label={weekday.name}
onClick={() => handleWeekdayToggle(weekday.value)}
>
{weekday.short}
</Chip>
)
})}
</div>
</ChipModalField>
)}
{recurrence.frequency === 'monthly' && (
<ChipModalField
type='dropdown'
title='On'
value={monthlyValue}
options={monthlyOptions}
onChange={(value) => onChange({ ...recurrence, monthlyMode: value as MonthlyMode })}
/>
)}
<ChipModalField
type='dropdown'
title='Ends'
@@ -44,6 +44,47 @@ describe('recurrenceToCron', () => {
).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('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(
{ 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 +165,81 @@ 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("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', () => {
@@ -209,6 +316,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([])
})
@@ -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,27 @@ 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': {
// 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:
return `${minute} ${hour} ${launchDay.getUTCDate()} * *`
}
}
case 'yearly':
return `${minute} ${hour} ${launchDay.getUTCDate()} ${launchDay.getUTCMonth() + 1} *`
}
}
@@ -157,9 +189,33 @@ export function cronToRecurrence(params: {
const weekdays = dayOfWeek.split(',').map(Number)
return { recurrence: { frequency: 'weekly', weekdays, end }, launchTime }
}
if (isNumeric(dayOfMonth) && dayOfWeek === '*') {
return { recurrence: { frequency: 'monthly', weekdays: [], end }, launchTime }
// 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-7]#L$/.test(dayOfWeek)) {
return {
recurrence: { frequency: 'monthly', weekdays: [], monthlyMode: 'last-weekday', end },
launchTime,
}
}
if (isNumeric(dayOfMonth) && dayOfWeek === '*') {
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 {
@@ -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
+7 -1
View File
@@ -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 `<weekday>#L` (e.g. `1#L`) while
* cronstrue spells the same thing `<weekday>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,
})