Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -14,26 +15,68 @@ 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 (
weekdays.length === WEEKDAY_PRESET.length && WEEKDAY_PRESET.every((d) => weekdays.includes(d))
)
}

/** 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
Expand All @@ -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
Expand All @@ -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' }] : []),
]

Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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([])
})
Expand Down
Loading
Loading