feat: implement multi-select monthly trigger schedule (#24247)

This commit is contained in:
lyzno1 2025-08-20 21:23:30 +08:00 committed by GitHub
parent 5303b50737
commit 6eaea64b3f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 737 additions and 39 deletions

View File

@ -0,0 +1,265 @@
import React from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { useTranslation } from 'react-i18next'
import MonthlyDaysSelector from '../components/monthly-days-selector'
jest.mock('react-i18next')
const mockUseTranslation = useTranslation as jest.MockedFunction<typeof useTranslation>
const mockTranslation = {
t: (key: string) => {
const translations: Record<string, string> = {
'workflow.nodes.triggerSchedule.days': 'Days',
'workflow.nodes.triggerSchedule.lastDay': 'Last',
'workflow.nodes.triggerSchedule.lastDayTooltip': 'Last day of month',
}
return translations[key] || key
},
}
beforeEach(() => {
mockUseTranslation.mockReturnValue(mockTranslation as any)
})
describe('MonthlyDaysSelector', () => {
describe('Single selection', () => {
test('renders with single selected day', () => {
const onChange = jest.fn()
render(
<MonthlyDaysSelector
selectedDays={[15]}
onChange={onChange}
/>,
)
const button15 = screen.getByRole('button', { name: '15' })
expect(button15).toHaveClass('border-util-colors-blue-brand-blue-brand-600')
})
test('calls onChange when day is clicked', () => {
const onChange = jest.fn()
render(
<MonthlyDaysSelector
selectedDays={[15]}
onChange={onChange}
/>,
)
fireEvent.click(screen.getByRole('button', { name: '20' }))
expect(onChange).toHaveBeenCalledWith([15, 20])
})
test('handles last day selection', () => {
const onChange = jest.fn()
render(
<MonthlyDaysSelector
selectedDays={['last']}
onChange={onChange}
/>,
)
const lastButton = screen.getByRole('button', { name: 'Last' })
expect(lastButton).toHaveClass('border-util-colors-blue-brand-blue-brand-600')
})
})
describe('Multi-select functionality', () => {
test('renders with multiple selected days', () => {
const onChange = jest.fn()
render(
<MonthlyDaysSelector
selectedDays={[1, 15, 30]}
onChange={onChange}
/>,
)
expect(screen.getByRole('button', { name: '1' })).toHaveClass('border-util-colors-blue-brand-blue-brand-600')
expect(screen.getByRole('button', { name: '15' })).toHaveClass('border-util-colors-blue-brand-blue-brand-600')
expect(screen.getByRole('button', { name: '30' })).toHaveClass('border-util-colors-blue-brand-blue-brand-600')
})
test('adds day to selection when clicked', () => {
const onChange = jest.fn()
render(
<MonthlyDaysSelector
selectedDays={[1, 15]}
onChange={onChange}
/>,
)
fireEvent.click(screen.getByRole('button', { name: '20' }))
expect(onChange).toHaveBeenCalledWith([1, 15, 20])
})
test('removes day from selection when clicked', () => {
const onChange = jest.fn()
render(
<MonthlyDaysSelector
selectedDays={[1, 15, 20]}
onChange={onChange}
/>,
)
fireEvent.click(screen.getByRole('button', { name: '15' }))
expect(onChange).toHaveBeenCalledWith([1, 20])
})
test('handles last day selection', () => {
const onChange = jest.fn()
render(
<MonthlyDaysSelector
selectedDays={[1, 'last']}
onChange={onChange}
/>,
)
expect(screen.getByRole('button', { name: 'Last' })).toHaveClass('border-util-colors-blue-brand-blue-brand-600')
fireEvent.click(screen.getByRole('button', { name: 'Last' }))
expect(onChange).toHaveBeenCalledWith([1])
})
test('handles empty selection array', () => {
const onChange = jest.fn()
render(
<MonthlyDaysSelector
selectedDays={[]}
onChange={onChange}
/>,
)
fireEvent.click(screen.getByRole('button', { name: '10' }))
expect(onChange).toHaveBeenCalledWith([10])
})
test('supports mixed selection of numbers and last day', () => {
const onChange = jest.fn()
render(
<MonthlyDaysSelector
selectedDays={[5, 15, 'last']}
onChange={onChange}
/>,
)
expect(screen.getByRole('button', { name: '5' })).toHaveClass('border-util-colors-blue-brand-blue-brand-600')
expect(screen.getByRole('button', { name: '15' })).toHaveClass('border-util-colors-blue-brand-blue-brand-600')
expect(screen.getByRole('button', { name: 'Last' })).toHaveClass('border-util-colors-blue-brand-blue-brand-600')
})
})
describe('Component structure', () => {
test('renders all day buttons from 1 to 31', () => {
render(
<MonthlyDaysSelector
selectedDays={[1]}
onChange={jest.fn()}
/>,
)
for (let i = 1; i <= 31; i++)
expect(screen.getByRole('button', { name: i.toString() })).toBeInTheDocument()
})
test('renders last day button', () => {
render(
<MonthlyDaysSelector
selectedDays={[1]}
onChange={jest.fn()}
/>,
)
expect(screen.getByRole('button', { name: 'Last' })).toBeInTheDocument()
})
test('displays correct label', () => {
render(
<MonthlyDaysSelector
selectedDays={[1]}
onChange={jest.fn()}
/>,
)
expect(screen.getByText('Days')).toBeInTheDocument()
})
test('applies correct grid layout', () => {
const { container } = render(
<MonthlyDaysSelector
selectedDays={[1]}
onChange={jest.fn()}
/>,
)
const gridRows = container.querySelectorAll('.grid-cols-7')
expect(gridRows).toHaveLength(5)
})
})
describe('Accessibility', () => {
test('buttons are keyboard accessible', () => {
const onChange = jest.fn()
render(
<MonthlyDaysSelector
selectedDays={[15]}
onChange={onChange}
/>,
)
const button = screen.getByRole('button', { name: '20' })
button.focus()
expect(document.activeElement).toBe(button)
})
test('last day button has tooltip', () => {
render(
<MonthlyDaysSelector
selectedDays={['last']}
onChange={jest.fn()}
/>,
)
expect(screen.getByRole('button', { name: 'Last' })).toBeInTheDocument()
})
test('selected state is visually distinct', () => {
render(
<MonthlyDaysSelector
selectedDays={[15]}
onChange={jest.fn()}
/>,
)
const selectedButton = screen.getByRole('button', { name: '15' })
const unselectedButton = screen.getByRole('button', { name: '16' })
expect(selectedButton).toHaveClass('border-util-colors-blue-brand-blue-brand-600')
expect(unselectedButton).toHaveClass('border-divider-subtle')
})
})
describe('Default behavior', () => {
test('handles interaction correctly', () => {
const onChange = jest.fn()
render(
<MonthlyDaysSelector
selectedDays={[15]}
onChange={onChange}
/>,
)
fireEvent.click(screen.getByRole('button', { name: '20' }))
expect(onChange).toHaveBeenCalledWith([15, 20])
})
})
})

View File

@ -0,0 +1,221 @@
import { getNextExecutionTimes } from '../utils/execution-time-calculator'
import type { ScheduleTriggerNodeType } from '../types'
const createMonthlyConfig = (monthlyDays: (number | 'last')[], time = '10:30 AM'): ScheduleTriggerNodeType => ({
mode: 'visual',
frequency: 'monthly',
visual_config: {
time,
monthly_days: monthlyDays,
},
timezone: 'UTC',
enabled: true,
id: 'test',
type: 'trigger-schedule',
data: {},
position: { x: 0, y: 0 },
})
describe('Monthly Multi-Select Execution Time Calculator', () => {
beforeEach(() => {
jest.useFakeTimers()
jest.setSystemTime(new Date('2024-01-15T08:00:00Z'))
})
afterEach(() => {
jest.useRealTimers()
})
describe('Multi-select functionality', () => {
test('calculates execution times for multiple days in same month', () => {
const config = createMonthlyConfig([1, 15, 30])
const times = getNextExecutionTimes(config, 5)
expect(times).toHaveLength(5)
expect(times[0].getDate()).toBe(30)
expect(times[0].getMonth()).toBe(0)
expect(times[1].getDate()).toBe(1)
expect(times[1].getMonth()).toBe(1)
expect(times[2].getDate()).toBe(15)
expect(times[2].getMonth()).toBe(1)
})
test('handles last day with multiple selections', () => {
const config = createMonthlyConfig([1, 'last'])
const times = getNextExecutionTimes(config, 4)
expect(times[0].getDate()).toBe(31)
expect(times[0].getMonth()).toBe(0)
expect(times[1].getDate()).toBe(1)
expect(times[1].getMonth()).toBe(1)
expect(times[2].getDate()).toBe(29)
expect(times[2].getMonth()).toBe(1)
})
test('skips invalid days in months with fewer days', () => {
const config = createMonthlyConfig([30, 31])
jest.setSystemTime(new Date('2024-01-01T08:00:00Z'))
const times = getNextExecutionTimes(config, 6)
const febTimes = times.filter(t => t.getMonth() === 1)
expect(febTimes.length).toBeGreaterThan(0)
expect(febTimes[0].getDate()).toBe(29)
})
test('sorts execution times chronologically', () => {
const config = createMonthlyConfig([25, 5, 15])
const times = getNextExecutionTimes(config, 6)
for (let i = 1; i < times.length; i++)
expect(times[i].getTime()).toBeGreaterThan(times[i - 1].getTime())
})
test('handles single day selection', () => {
const config = createMonthlyConfig([15])
const times = getNextExecutionTimes(config, 3)
expect(times).toHaveLength(3)
expect(times[0].getDate()).toBe(15)
expect(times[1].getDate()).toBe(15)
expect(times[2].getDate()).toBe(15)
for (let i = 1; i < times.length; i++)
expect(times[i].getTime()).toBeGreaterThan(times[i - 1].getTime())
})
})
describe('Single day configuration', () => {
test('supports single day selection', () => {
const config = createMonthlyConfig([15])
const times = getNextExecutionTimes(config, 3)
expect(times).toHaveLength(3)
expect(times[0].getDate()).toBe(15)
expect(times[1].getDate()).toBe(15)
expect(times[2].getDate()).toBe(15)
})
test('supports last day selection', () => {
const config = createMonthlyConfig(['last'])
const times = getNextExecutionTimes(config, 3)
expect(times[0].getDate()).toBe(31)
expect(times[0].getMonth()).toBe(0)
expect(times[1].getDate()).toBe(29)
expect(times[1].getMonth()).toBe(1)
})
test('falls back to day 1 when no configuration provided', () => {
const config: ScheduleTriggerNodeType = {
mode: 'visual',
frequency: 'monthly',
visual_config: {
time: '10:30 AM',
},
timezone: 'UTC',
enabled: true,
id: 'test',
type: 'trigger-schedule',
data: {},
position: { x: 0, y: 0 },
}
const times = getNextExecutionTimes(config, 2)
expect(times).toHaveLength(2)
expect(times[0].getDate()).toBe(1)
expect(times[1].getDate()).toBe(1)
})
})
describe('Edge cases', () => {
test('handles empty monthly_days array', () => {
const config = createMonthlyConfig([])
const times = getNextExecutionTimes(config, 2)
expect(times).toHaveLength(2)
expect(times[0].getDate()).toBe(1)
expect(times[1].getDate()).toBe(1)
})
test('handles execution time that has already passed today', () => {
jest.setSystemTime(new Date('2024-01-15T12:00:00Z'))
const config = createMonthlyConfig([15], '10:30 AM')
const times = getNextExecutionTimes(config, 2)
expect(times[0].getMonth()).toBe(1)
expect(times[0].getDate()).toBe(15)
})
test('limits search to reasonable number of months', () => {
const config = createMonthlyConfig([29, 30, 31])
jest.setSystemTime(new Date('2024-03-01T08:00:00Z'))
const times = getNextExecutionTimes(config, 50)
expect(times.length).toBeGreaterThan(0)
expect(times.length).toBeLessThanOrEqual(50)
})
test('handles duplicate days in selection', () => {
const config = createMonthlyConfig([15, 15, 15])
const times = getNextExecutionTimes(config, 4)
const uniqueDates = new Set(times.map(t => t.getTime()))
expect(uniqueDates.size).toBe(times.length)
})
test('correctly handles leap year February', () => {
const config = createMonthlyConfig([29])
jest.setSystemTime(new Date('2024-01-01T08:00:00Z'))
const times = getNextExecutionTimes(config, 3)
expect(times[0].getDate()).toBe(29)
expect(times[0].getMonth()).toBe(0)
expect(times[1].getDate()).toBe(29)
expect(times[1].getMonth()).toBe(1)
})
test('handles non-leap year February', () => {
const config = createMonthlyConfig([29])
jest.setSystemTime(new Date('2023-01-01T08:00:00Z'))
const times = getNextExecutionTimes(config, 3)
expect(times[0].getDate()).toBe(29)
expect(times[0].getMonth()).toBe(0)
expect(times[1].getDate()).toBe(28)
expect(times[1].getMonth()).toBe(1)
})
})
describe('Time handling', () => {
test('respects specified execution time', () => {
const config = createMonthlyConfig([1], '2:45 PM')
const times = getNextExecutionTimes(config, 1)
expect(times[0].getHours()).toBe(14)
expect(times[0].getMinutes()).toBe(45)
})
test('handles AM/PM conversion correctly', () => {
const configAM = createMonthlyConfig([1], '6:30 AM')
const configPM = createMonthlyConfig([1], '6:30 PM')
const timesAM = getNextExecutionTimes(configAM, 1)
const timesPM = getNextExecutionTimes(configPM, 1)
expect(timesAM[0].getHours()).toBe(6)
expect(timesPM[0].getHours()).toBe(18)
})
test('handles 12 AM and 12 PM correctly', () => {
const config12AM = createMonthlyConfig([1], '12:00 AM')
const config12PM = createMonthlyConfig([1], '12:00 PM')
const times12AM = getNextExecutionTimes(config12AM, 1)
const times12PM = getNextExecutionTimes(config12PM, 1)
expect(times12AM[0].getHours()).toBe(0)
expect(times12PM[0].getHours()).toBe(12)
})
})
})

View File

@ -0,0 +1,172 @@
import nodeDefault from '../default'
const mockT = (key: string, options?: any) => {
const translations: Record<string, string> = {
'workflow.errorMsg.fieldRequired': `${options?.field} is required`,
'workflow.nodes.triggerSchedule.monthlyDay': 'Monthly Day',
'workflow.nodes.triggerSchedule.invalidMonthlyDay': 'Invalid monthly day',
'workflow.nodes.triggerSchedule.time': 'Time',
'workflow.nodes.triggerSchedule.invalidTimeFormat': 'Invalid time format',
}
return translations[key] || key
}
describe('Monthly Validation', () => {
describe('Single day validation', () => {
test('validates single day selection', () => {
const config = {
mode: 'visual' as const,
frequency: 'monthly' as const,
visual_config: {
time: '10:30 AM',
monthly_days: [15],
},
timezone: 'UTC',
enabled: true,
}
const result = nodeDefault.checkValid(config, mockT)
expect(result.isValid).toBe(true)
expect(result.errorMessage).toBe('')
})
test('validates last day selection', () => {
const config = {
mode: 'visual' as const,
frequency: 'monthly' as const,
visual_config: {
time: '10:30 AM',
monthly_days: ['last' as const],
},
timezone: 'UTC',
enabled: true,
}
const result = nodeDefault.checkValid(config, mockT)
expect(result.isValid).toBe(true)
expect(result.errorMessage).toBe('')
})
})
describe('Multi-day validation', () => {
test('validates multiple day selection', () => {
const config = {
mode: 'visual' as const,
frequency: 'monthly' as const,
visual_config: {
time: '10:30 AM',
monthly_days: [1, 15, 30],
},
timezone: 'UTC',
enabled: true,
}
const result = nodeDefault.checkValid(config, mockT)
expect(result.isValid).toBe(true)
expect(result.errorMessage).toBe('')
})
test('validates mixed selection with last day', () => {
const config = {
mode: 'visual' as const,
frequency: 'monthly' as const,
visual_config: {
time: '10:30 AM',
monthly_days: [1, 15, 'last' as const],
},
timezone: 'UTC',
enabled: true,
}
const result = nodeDefault.checkValid(config, mockT)
expect(result.isValid).toBe(true)
expect(result.errorMessage).toBe('')
})
test('rejects empty array', () => {
const config = {
mode: 'visual' as const,
frequency: 'monthly' as const,
visual_config: {
time: '10:30 AM',
monthly_days: [],
},
timezone: 'UTC',
enabled: true,
}
const result = nodeDefault.checkValid(config, mockT)
expect(result.isValid).toBe(false)
expect(result.errorMessage).toBe('Monthly Day is required')
})
test('rejects invalid day in array', () => {
const config = {
mode: 'visual' as const,
frequency: 'monthly' as const,
visual_config: {
time: '10:30 AM',
monthly_days: [1, 35, 15],
},
timezone: 'UTC',
enabled: true,
}
const result = nodeDefault.checkValid(config, mockT)
expect(result.isValid).toBe(false)
expect(result.errorMessage).toBe('Invalid monthly day')
})
})
describe('Edge cases', () => {
test('requires monthly configuration', () => {
const config = {
mode: 'visual' as const,
frequency: 'monthly' as const,
visual_config: {
time: '10:30 AM',
},
timezone: 'UTC',
enabled: true,
}
const result = nodeDefault.checkValid(config, mockT)
expect(result.isValid).toBe(false)
expect(result.errorMessage).toBe('Monthly Day is required')
})
test('validates time format along with monthly_days', () => {
const config = {
mode: 'visual' as const,
frequency: 'monthly' as const,
visual_config: {
time: 'invalid-time',
monthly_days: [1, 15],
},
timezone: 'UTC',
enabled: true,
}
const result = nodeDefault.checkValid(config, mockT)
expect(result.isValid).toBe(false)
expect(result.errorMessage).toBe('Invalid time format')
})
test('handles very large arrays', () => {
const config = {
mode: 'visual' as const,
frequency: 'monthly' as const,
visual_config: {
time: '10:30 AM',
monthly_days: Array.from({ length: 31 }, (_, i) => i + 1),
},
timezone: 'UTC',
enabled: true,
}
const result = nodeDefault.checkValid(config, mockT)
expect(result.isValid).toBe(true)
expect(result.errorMessage).toBe('')
})
})
})

View File

@ -4,13 +4,23 @@ import { RiQuestionLine } from '@remixicon/react'
import Tooltip from '@/app/components/base/tooltip'
type MonthlyDaysSelectorProps = {
selectedDay: number | 'last'
onChange: (day: number | 'last') => void
selectedDays: (number | 'last')[]
onChange: (days: (number | 'last')[]) => void
}
const MonthlyDaysSelector = ({ selectedDay, onChange }: MonthlyDaysSelectorProps) => {
const MonthlyDaysSelector = ({ selectedDays, onChange }: MonthlyDaysSelectorProps) => {
const { t } = useTranslation()
const handleDayClick = (day: number | 'last') => {
const current = selectedDays || []
const newSelected = current.includes(day)
? current.filter(d => d !== day)
: [...current, day]
onChange(newSelected)
}
const isDaySelected = (day: number | 'last') => selectedDays?.includes(day) || false
const days = Array.from({ length: 31 }, (_, i) => i + 1)
const rows = [
days.slice(0, 7),
@ -33,11 +43,11 @@ const MonthlyDaysSelector = ({ selectedDay, onChange }: MonthlyDaysSelectorProps
<button
key={day}
type="button"
onClick={() => onChange(day)}
onClick={() => handleDayClick(day)}
className={`rounded-lg border bg-components-option-card-option-bg py-1 text-xs transition-colors ${
day === 'last' ? 'col-span-2 min-w-0' : ''
} ${
selectedDay === day
isDaySelected(day)
? 'border-util-colors-blue-brand-blue-brand-600 text-text-secondary'
: 'border-divider-subtle text-text-tertiary hover:border-divider-regular hover:text-text-secondary'
}`}

View File

@ -77,14 +77,22 @@ const validateMonthlyConfig = (config: any, t: any): string => {
const i18nPrefix = 'workflow.errorMsg'
if (!config.monthly_day)
const getMonthlyDays = (): (number | 'last')[] => {
if (Array.isArray(config.monthly_days) && config.monthly_days.length > 0)
return config.monthly_days
return []
}
const monthlyDays = getMonthlyDays()
if (monthlyDays.length === 0)
return t(`${i18nPrefix}.fieldRequired`, { field: t('workflow.nodes.triggerSchedule.monthlyDay') })
if (config.monthly_day !== 'last'
&& (typeof config.monthly_day !== 'number'
|| config.monthly_day < 1
|| config.monthly_day > 31))
return t('workflow.nodes.triggerSchedule.invalidMonthlyDay')
for (const day of monthlyDays) {
if (day !== 'last' && (typeof day !== 'number' || day < 1 || day > 31))
return t('workflow.nodes.triggerSchedule.invalidMonthlyDay')
}
return ''
}

View File

@ -141,13 +141,13 @@ const Panel: FC<NodePanelProps<ScheduleTriggerNodeType>> = ({
{inputs.frequency === 'monthly' && (
<MonthlyDaysSelector
selectedDay={inputs.visual_config?.monthly_day || 1}
onChange={(day) => {
selectedDays={inputs.visual_config?.monthly_days || [1]}
onChange={(days) => {
const newInputs = {
...inputs,
visual_config: {
...inputs.visual_config,
monthly_day: day,
monthly_days: days,
},
}
setInputs(newInputs)

View File

@ -11,7 +11,7 @@ export type VisualConfig = {
weekdays?: string[]
recur_every?: number
recur_unit?: 'hours' | 'minutes'
monthly_day?: number | 'last'
monthly_days?: (number | 'last')[]
}
export type ScheduleTriggerNodeType = CommonNodeType & {

View File

@ -87,7 +87,14 @@ export const getNextExecutionTimes = (data: ScheduleTriggerNodeType, count: numb
}
}
else if (data.frequency === 'monthly') {
const selectedDay = data.visual_config?.monthly_day || 1
const getSelectedDays = (): (number | 'last')[] => {
if (data.visual_config?.monthly_days && data.visual_config.monthly_days.length > 0)
return data.visual_config.monthly_days
return [1]
}
const selectedDays = [...new Set(getSelectedDays())]
const [time, period] = defaultTime.split(' ')
const [hour, minute] = time.split(':')
let displayHour = Number.parseInt(hour)
@ -97,38 +104,53 @@ export const getNextExecutionTimes = (data: ScheduleTriggerNodeType, count: numb
const now = getCurrentTime()
let monthOffset = 0
const currentMonthExecution = (() => {
const hasValidCurrentMonthExecution = selectedDays.some((selectedDay) => {
const currentMonth = new Date(now.getFullYear(), now.getMonth(), 1)
const daysInMonth = new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 0).getDate()
let targetDay: number
if (selectedDay === 'last')
targetDay = daysInMonth
else
targetDay = Math.min(selectedDay as number, daysInMonth)
if (selectedDay === 'last') {
const lastDayOfMonth = new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 0).getDate()
targetDay = lastDayOfMonth
}
else {
targetDay = Math.min(selectedDay as number, new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 0).getDate())
}
const execution = new Date(currentMonth.getFullYear(), currentMonth.getMonth(), targetDay, displayHour, Number.parseInt(minute), 0, 0)
return execution > now
})
return new Date(currentMonth.getFullYear(), currentMonth.getMonth(), targetDay, displayHour, Number.parseInt(minute), 0, 0)
})()
if (currentMonthExecution <= now)
if (!hasValidCurrentMonthExecution)
monthOffset = 1
for (let i = 0; i < count; i++) {
const targetMonth = new Date(now.getFullYear(), now.getMonth() + monthOffset + i, 1)
let targetDay: number
let monthsChecked = 0
if (selectedDay === 'last') {
const lastDayOfMonth = new Date(targetMonth.getFullYear(), targetMonth.getMonth() + 1, 0).getDate()
targetDay = lastDayOfMonth
}
else {
targetDay = Math.min(selectedDay as number, new Date(targetMonth.getFullYear(), targetMonth.getMonth() + 1, 0).getDate())
while (times.length < count && monthsChecked < 24) {
const targetMonth = new Date(now.getFullYear(), now.getMonth() + monthOffset + monthsChecked, 1)
const daysInMonth = new Date(targetMonth.getFullYear(), targetMonth.getMonth() + 1, 0).getDate()
const monthlyExecutions: Date[] = []
for (const selectedDay of selectedDays) {
let targetDay: number
if (selectedDay === 'last')
targetDay = daysInMonth
else
targetDay = Math.min(selectedDay as number, daysInMonth)
const nextExecution = new Date(targetMonth.getFullYear(), targetMonth.getMonth(), targetDay, displayHour, Number.parseInt(minute), 0, 0)
if (nextExecution > now)
monthlyExecutions.push(nextExecution)
}
const nextExecution = new Date(targetMonth.getFullYear(), targetMonth.getMonth(), targetDay, displayHour, Number.parseInt(minute), 0, 0)
times.push(nextExecution)
monthlyExecutions.sort((a, b) => a.getTime() - b.getTime())
for (const execution of monthlyExecutions) {
if (times.length >= count) break
times.push(execution)
}
monthsChecked++
}
}
else if (data.frequency === 'once') {