mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
+117
-11
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user