diff --git a/CLAUDE.md b/CLAUDE.md
index 843e3f7fac..fd437d7bf0 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -60,7 +60,7 @@ pnpm test # Run Jest tests
- No `Any` types unless absolutely necessary
- Implement special methods (`__repr__`, `__str__`) appropriately
-### TypeScript/JavaScript
+### TypeScript/JavaScript
- Strict TypeScript configuration
- ESLint with Prettier integration
@@ -79,9 +79,9 @@ pnpm test # Run Jest tests
### Adding a New API Endpoint
1. Create controller in `/api/controllers/`
-2. Add service logic in `/api/services/`
-3. Update routes in controller's `__init__.py`
-4. Write tests in `/api/tests/`
+1. Add service logic in `/api/services/`
+1. Update routes in controller's `__init__.py`
+1. Write tests in `/api/tests/`
## Project-Specific Conventions
diff --git a/web/app/components/workflow/nodes/trigger-schedule/__tests__/default.test.ts b/web/app/components/workflow/nodes/trigger-schedule/__tests__/default.test.ts
index 02627268ac..45ccdb1de0 100644
--- a/web/app/components/workflow/nodes/trigger-schedule/__tests__/default.test.ts
+++ b/web/app/components/workflow/nodes/trigger-schedule/__tests__/default.test.ts
@@ -31,7 +31,7 @@ describe('Schedule Trigger Node Default', () => {
describe('Basic Configuration', () => {
it('should have correct default value', () => {
expect(nodeDefault.defaultValue.mode).toBe('visual')
- expect(nodeDefault.defaultValue.frequency).toBe('daily')
+ expect(nodeDefault.defaultValue.frequency).toBe('weekly')
expect(nodeDefault.defaultValue.enabled).toBe(true)
})
diff --git a/web/app/components/workflow/nodes/trigger-schedule/__tests__/monthly-edge-cases.test.ts b/web/app/components/workflow/nodes/trigger-schedule/__tests__/monthly-edge-cases.test.ts
new file mode 100644
index 0000000000..0f7f3afe0c
--- /dev/null
+++ b/web/app/components/workflow/nodes/trigger-schedule/__tests__/monthly-edge-cases.test.ts
@@ -0,0 +1,227 @@
+import { getNextExecutionTimes } from '../utils/execution-time-calculator'
+import type { ScheduleTriggerNodeType } from '../types'
+
+const createMonthlyConfig = (monthly_days: (number | 'last')[], time = '10:30 AM', timezone = 'UTC'): ScheduleTriggerNodeType => ({
+ mode: 'visual',
+ frequency: 'monthly',
+ visual_config: {
+ time,
+ monthly_days,
+ },
+ timezone,
+ enabled: true,
+})
+
+describe('Monthly Edge Cases', () => {
+ beforeEach(() => {
+ jest.useFakeTimers()
+ jest.setSystemTime(new Date('2024-02-15T08:00:00.000Z'))
+ })
+
+ afterEach(() => {
+ jest.useRealTimers()
+ })
+
+ describe('31st day selection logic', () => {
+ test('31st day skips months without 31 days', () => {
+ const config = createMonthlyConfig([31])
+ const times = getNextExecutionTimes(config, 5)
+
+ const expectedMonths = times.map(date => date.getMonth() + 1)
+
+ expect(expectedMonths).not.toContain(2)
+ expect(expectedMonths).not.toContain(4)
+ expect(expectedMonths).not.toContain(6)
+ expect(expectedMonths).not.toContain(9)
+ expect(expectedMonths).not.toContain(11)
+
+ times.forEach((date) => {
+ expect(date.getDate()).toBe(31)
+ })
+ })
+
+ test('30th day skips February', () => {
+ const config = createMonthlyConfig([30])
+ const times = getNextExecutionTimes(config, 5)
+
+ const expectedMonths = times.map(date => date.getMonth() + 1)
+ expect(expectedMonths).not.toContain(2)
+
+ times.forEach((date) => {
+ expect(date.getDate()).toBe(30)
+ })
+ })
+
+ test('29th day works in all months', () => {
+ const config = createMonthlyConfig([29])
+ const times = getNextExecutionTimes(config, 12)
+
+ const months = times.map(date => date.getMonth() + 1)
+ expect(months).toContain(1)
+ expect(months).toContain(3)
+ expect(months).toContain(4)
+ expect(months).toContain(5)
+ expect(months).toContain(6)
+ expect(months).toContain(7)
+ expect(months).toContain(8)
+ expect(months).toContain(9)
+ expect(months).toContain(10)
+ expect(months).toContain(11)
+ expect(months).toContain(12)
+ })
+
+ test('29th day skips February in non-leap years', () => {
+ jest.setSystemTime(new Date('2023-01-15T08:00:00.000Z'))
+
+ const config = createMonthlyConfig([29])
+ const times = getNextExecutionTimes(config, 12)
+
+ const februaryExecutions = times.filter(date => date.getMonth() === 1)
+ expect(februaryExecutions).toHaveLength(0)
+ })
+
+ test('29th day includes February in leap years', () => {
+ jest.setSystemTime(new Date('2024-01-15T08:00:00.000Z'))
+
+ const config = createMonthlyConfig([29])
+ const times = getNextExecutionTimes(config, 12)
+
+ const februaryExecutions = times.filter(date => date.getMonth() === 1)
+ expect(februaryExecutions).toHaveLength(1)
+ expect(februaryExecutions[0].getDate()).toBe(29)
+ })
+ })
+
+ describe('last day vs specific day distinction', () => {
+ test('31st selection is different from last day in short months', () => {
+ const config31 = createMonthlyConfig([31])
+ const configLast = createMonthlyConfig(['last'])
+
+ const times31 = getNextExecutionTimes(config31, 12)
+ const timesLast = getNextExecutionTimes(configLast, 12)
+
+ const months31 = times31.map(date => date.getMonth() + 1)
+ const monthsLast = timesLast.map(date => date.getMonth() + 1)
+
+ expect(months31).not.toContain(2)
+ expect(monthsLast).toContain(2)
+
+ expect(months31).not.toContain(4)
+ expect(monthsLast).toContain(4)
+ })
+
+ test('31st and last day both work correctly in 31-day months', () => {
+ const config31 = createMonthlyConfig([31])
+ const configLast = createMonthlyConfig(['last'])
+
+ const times31 = getNextExecutionTimes(config31, 5)
+ const timesLast = getNextExecutionTimes(configLast, 5)
+
+ const march31 = times31.find(date => date.getMonth() === 2)
+ const marchLast = timesLast.find(date => date.getMonth() === 2)
+
+ expect(march31?.getDate()).toBe(31)
+ expect(marchLast?.getDate()).toBe(31)
+ })
+
+ test('mixed selection with 31st and last behaves correctly', () => {
+ const config = createMonthlyConfig([31, 'last'])
+ const times = getNextExecutionTimes(config, 12)
+
+ const februaryExecutions = times.filter(date => date.getMonth() === 1)
+ expect(februaryExecutions).toHaveLength(1)
+ expect(februaryExecutions[0].getDate()).toBe(29)
+
+ const marchExecutions = times.filter(date => date.getMonth() === 2)
+ expect(marchExecutions).toHaveLength(1)
+ expect(marchExecutions[0].getDate()).toBe(31)
+ })
+
+ test('deduplicates overlapping selections in 31-day months', () => {
+ const config = createMonthlyConfig([31, 'last'])
+ const times = getNextExecutionTimes(config, 12)
+
+ const monthsWith31Days = [0, 2, 4, 6, 7, 9, 11]
+
+ monthsWith31Days.forEach((month) => {
+ const monthExecutions = times.filter(date => date.getMonth() === month)
+ expect(monthExecutions.length).toBeLessThanOrEqual(1)
+
+ if (monthExecutions.length === 1)
+ expect(monthExecutions[0].getDate()).toBe(31)
+ })
+ })
+
+ test('deduplicates overlapping selections in 30-day months', () => {
+ const config = createMonthlyConfig([30, 'last'])
+ const times = getNextExecutionTimes(config, 12)
+
+ const monthsWith30Days = [3, 5, 8, 10]
+
+ monthsWith30Days.forEach((month) => {
+ const monthExecutions = times.filter(date => date.getMonth() === month)
+ expect(monthExecutions.length).toBeLessThanOrEqual(1)
+
+ if (monthExecutions.length === 1)
+ expect(monthExecutions[0].getDate()).toBe(30)
+ })
+ })
+
+ test('handles complex multi-day with last selection', () => {
+ const config = createMonthlyConfig([15, 30, 31, 'last'])
+ const times = getNextExecutionTimes(config, 20)
+
+ const marchExecutions = times.filter(date => date.getMonth() === 2).sort((a, b) => a.getDate() - b.getDate())
+ expect(marchExecutions).toHaveLength(3)
+ expect(marchExecutions.map(d => d.getDate())).toEqual([15, 30, 31])
+
+ const aprilExecutions = times.filter(date => date.getMonth() === 3).sort((a, b) => a.getDate() - b.getDate())
+ expect(aprilExecutions).toHaveLength(2)
+ expect(aprilExecutions.map(d => d.getDate())).toEqual([15, 30])
+ })
+ })
+
+ describe('current month offset calculation', () => {
+ test('skips current month when no valid days exist', () => {
+ jest.setSystemTime(new Date('2024-02-15T08:00:00.000Z'))
+
+ const config = createMonthlyConfig([31])
+ const times = getNextExecutionTimes(config, 3)
+
+ times.forEach((date) => {
+ expect(date.getMonth()).toBeGreaterThan(1)
+ })
+ })
+
+ test('includes current month when valid days exist', () => {
+ jest.setSystemTime(new Date('2024-03-15T08:00:00.000Z'))
+
+ const config = createMonthlyConfig([31])
+ const times = getNextExecutionTimes(config, 3)
+
+ const currentMonthExecution = times.find(date => date.getMonth() === 2)
+ expect(currentMonthExecution).toBeDefined()
+ expect(currentMonthExecution?.getDate()).toBe(31)
+ })
+ })
+
+ describe('sorting and deduplication', () => {
+ test('handles duplicate selections correctly', () => {
+ const config = createMonthlyConfig([15, 15, 15])
+ const times = getNextExecutionTimes(config, 5)
+
+ const marchExecutions = times.filter(date => date.getMonth() === 2)
+ expect(marchExecutions).toHaveLength(1)
+ })
+
+ test('sorts multiple days within same month', () => {
+ jest.setSystemTime(new Date('2024-03-01T08:00:00.000Z'))
+
+ const config = createMonthlyConfig([31, 15, 1])
+ const times = getNextExecutionTimes(config, 5)
+
+ const marchExecutions = times.filter(date => date.getMonth() === 2).sort((a, b) => a.getDate() - b.getDate())
+ expect(marchExecutions.map(d => d.getDate())).toEqual([1, 15, 31])
+ })
+ })
+})
diff --git a/web/app/components/workflow/nodes/trigger-schedule/__tests__/monthly-multiselect.test.ts b/web/app/components/workflow/nodes/trigger-schedule/__tests__/monthly-multiselect.test.ts
index 8dc53d022c..ed998c7749 100644
--- a/web/app/components/workflow/nodes/trigger-schedule/__tests__/monthly-multiselect.test.ts
+++ b/web/app/components/workflow/nodes/trigger-schedule/__tests__/monthly-multiselect.test.ts
@@ -32,11 +32,11 @@ describe('Monthly Multi-Select Execution Time Calculator', () => {
const times = getNextExecutionTimes(config, 5)
expect(times).toHaveLength(5)
- expect(times[0].getDate()).toBe(30)
+ expect(times[0].getDate()).toBe(15)
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[1].getDate()).toBe(30)
+ expect(times[1].getMonth()).toBe(0)
+ expect(times[2].getDate()).toBe(1)
expect(times[2].getMonth()).toBe(1)
})
@@ -58,8 +58,12 @@ describe('Monthly Multi-Select Execution Time Calculator', () => {
const times = getNextExecutionTimes(config, 6)
const febTimes = times.filter(t => t.getMonth() === 1)
- expect(febTimes.length).toBeGreaterThan(0)
- expect(febTimes[0].getDate()).toBe(29)
+ expect(febTimes.length).toBe(0)
+
+ const marchTimes = times.filter(t => t.getMonth() === 2)
+ expect(marchTimes.length).toBe(2)
+ expect(marchTimes[0].getDate()).toBe(30)
+ expect(marchTimes[1].getDate()).toBe(31)
})
test('sorts execution times chronologically', () => {
@@ -182,8 +186,8 @@ describe('Monthly Multi-Select Execution Time Calculator', () => {
expect(times[0].getDate()).toBe(29)
expect(times[0].getMonth()).toBe(0)
- expect(times[1].getDate()).toBe(28)
- expect(times[1].getMonth()).toBe(1)
+ expect(times[1].getDate()).toBe(29)
+ expect(times[1].getMonth()).toBe(2)
})
})
diff --git a/web/app/components/workflow/nodes/trigger-schedule/components/frequency-selector.tsx b/web/app/components/workflow/nodes/trigger-schedule/components/frequency-selector.tsx
index 2eabb6d85f..d0de74a6ef 100644
--- a/web/app/components/workflow/nodes/trigger-schedule/components/frequency-selector.tsx
+++ b/web/app/components/workflow/nodes/trigger-schedule/components/frequency-selector.tsx
@@ -17,7 +17,6 @@ const FrequencySelector = ({ frequency, onChange }: FrequencySelectorProps) => {
{ value: 'daily', name: t('workflow.nodes.triggerSchedule.frequency.daily') },
{ value: 'weekly', name: t('workflow.nodes.triggerSchedule.frequency.weekly') },
{ value: 'monthly', name: t('workflow.nodes.triggerSchedule.frequency.monthly') },
- { value: 'once', name: t('workflow.nodes.triggerSchedule.frequency.once') },
], [t])
return (
diff --git a/web/app/components/workflow/nodes/trigger-schedule/components/monthly-days-selector.tsx b/web/app/components/workflow/nodes/trigger-schedule/components/monthly-days-selector.tsx
index 427ad20720..8f50e0e9cc 100644
--- a/web/app/components/workflow/nodes/trigger-schedule/components/monthly-days-selector.tsx
+++ b/web/app/components/workflow/nodes/trigger-schedule/components/monthly-days-selector.tsx
@@ -73,6 +73,15 @@ const MonthlyDaysSelector = ({ selectedDays, onChange }: MonthlyDaysSelectorProp
))}
+
+ {/* Warning message for day 31 - aligned with grid */}
+ {selectedDays?.includes(31) && (
+
+
+ {t('workflow.nodes.triggerSchedule.lastDayTooltip')}
+
+
+ )}
)
}
diff --git a/web/app/components/workflow/nodes/trigger-schedule/components/next-execution-times.tsx b/web/app/components/workflow/nodes/trigger-schedule/components/next-execution-times.tsx
index e42caec3cd..063fc95adf 100644
--- a/web/app/components/workflow/nodes/trigger-schedule/components/next-execution-times.tsx
+++ b/web/app/components/workflow/nodes/trigger-schedule/components/next-execution-times.tsx
@@ -10,8 +10,7 @@ type NextExecutionTimesProps = {
const NextExecutionTimes = ({ data }: NextExecutionTimesProps) => {
const { t } = useTranslation()
- // Don't show next execution times for 'once' frequency in visual mode
- if (data.mode === 'visual' && data.frequency === 'once')
+ if (!data.frequency)
return null
const executionTimes = getFormattedExecutionTimes(data, 5)
diff --git a/web/app/components/workflow/nodes/trigger-schedule/components/on-minute-selector.tsx b/web/app/components/workflow/nodes/trigger-schedule/components/on-minute-selector.tsx
new file mode 100644
index 0000000000..992a111d19
--- /dev/null
+++ b/web/app/components/workflow/nodes/trigger-schedule/components/on-minute-selector.tsx
@@ -0,0 +1,38 @@
+import React from 'react'
+import { useTranslation } from 'react-i18next'
+import Slider from '@/app/components/base/slider'
+
+type OnMinuteSelectorProps = {
+ value?: number
+ onChange: (value: number) => void
+}
+
+const OnMinuteSelector = ({ value = 0, onChange }: OnMinuteSelectorProps) => {
+ const { t } = useTranslation()
+
+ return (
+
+
+
+
+ )
+}
+
+export default OnMinuteSelector
diff --git a/web/app/components/workflow/nodes/trigger-schedule/components/recur-config.tsx b/web/app/components/workflow/nodes/trigger-schedule/components/recur-config.tsx
deleted file mode 100644
index ffbd38aa0e..0000000000
--- a/web/app/components/workflow/nodes/trigger-schedule/components/recur-config.tsx
+++ /dev/null
@@ -1,59 +0,0 @@
-import React from 'react'
-import { useTranslation } from 'react-i18next'
-import { InputNumber } from '@/app/components/base/input-number'
-import { SimpleSegmentedControl } from './simple-segmented-control'
-
-type RecurConfigProps = {
- recurEvery?: number
- recurUnit?: 'hours' | 'minutes'
- onRecurEveryChange: (value: number) => void
- onRecurUnitChange: (unit: 'hours' | 'minutes') => void
-}
-
-const RecurConfig = ({
- recurEvery = 1,
- recurUnit = 'hours',
- onRecurEveryChange,
- onRecurUnitChange,
-}: RecurConfigProps) => {
- const { t } = useTranslation()
-
- const unitOptions = [
- {
- text: t('workflow.nodes.triggerSchedule.hours'),
- value: 'hours' as const,
- },
- {
- text: t('workflow.nodes.triggerSchedule.minutes'),
- value: 'minutes' as const,
- },
- ]
-
- return (
-
-
-
- onRecurEveryChange(value || 1)}
- min={1}
- className="text-center"
- />
-
-
-
-
-
-
- )
-}
-
-export default RecurConfig
diff --git a/web/app/components/workflow/nodes/trigger-schedule/components/simple-segmented-control.tsx b/web/app/components/workflow/nodes/trigger-schedule/components/simple-segmented-control.tsx
deleted file mode 100644
index 695e62fd90..0000000000
--- a/web/app/components/workflow/nodes/trigger-schedule/components/simple-segmented-control.tsx
+++ /dev/null
@@ -1,60 +0,0 @@
-import React from 'react'
-import classNames from '@/utils/classnames'
-import Divider from '@/app/components/base/divider'
-
-// Simplified version without icons
-type SimpleSegmentedControlProps = {
- options: { text: string, value: T }[]
- value: T
- onChange: (value: T) => void
- className?: string
-}
-
-export const SimpleSegmentedControl = ({
- options,
- value,
- onChange,
- className,
-}: SimpleSegmentedControlProps) => {
- const selectedOptionIndex = options.findIndex(option => option.value === value)
-
- return (
-
- {options.map((option, index) => {
- const isSelected = index === selectedOptionIndex
- const isNextSelected = index === selectedOptionIndex - 1
- const isLast = index === options.length - 1
- return (
-
- )
- })}
-
- )
-}
-
-export default React.memo(SimpleSegmentedControl) as typeof SimpleSegmentedControl
diff --git a/web/app/components/workflow/nodes/trigger-schedule/components/time-picker.spec.tsx b/web/app/components/workflow/nodes/trigger-schedule/components/time-picker.spec.tsx
deleted file mode 100644
index 2464c63ba5..0000000000
--- a/web/app/components/workflow/nodes/trigger-schedule/components/time-picker.spec.tsx
+++ /dev/null
@@ -1,223 +0,0 @@
-import React from 'react'
-import { fireEvent, render, screen, waitFor } from '@testing-library/react'
-import TimePicker from './time-picker'
-
-jest.mock('react-i18next', () => ({
- useTranslation: () => ({
- t: (key: string) => {
- const translations: Record = {
- 'time.title.pickTime': 'Pick Time',
- 'common.operation.now': 'Now',
- 'common.operation.ok': 'OK',
- }
- return translations[key] || key
- },
- }),
-}))
-
-describe('TimePicker', () => {
- const mockOnChange = jest.fn()
-
- beforeEach(() => {
- jest.clearAllMocks()
- })
-
- test('renders with default value', () => {
- render()
-
- const button = screen.getByRole('button')
- expect(button).toBeInTheDocument()
- expect(button.textContent).toBe('11:30 AM')
- })
-
- test('renders with provided value', () => {
- render()
-
- const button = screen.getByRole('button')
- expect(button.textContent).toBe('2:30 PM')
- })
-
- test('opens picker when button is clicked', () => {
- render()
-
- const button = screen.getByRole('button')
- fireEvent.click(button)
-
- expect(screen.getByText('Pick Time')).toBeInTheDocument()
- expect(screen.getByText('Now')).toBeInTheDocument()
- expect(screen.getByText('OK')).toBeInTheDocument()
- })
-
- test('closes picker when clicking outside', () => {
- render()
-
- const button = screen.getByRole('button')
- fireEvent.click(button)
-
- expect(screen.getByText('Pick Time')).toBeInTheDocument()
-
- const overlay = document.querySelector('.fixed.inset-0')
- fireEvent.click(overlay!)
-
- expect(screen.queryByText('Pick Time')).not.toBeInTheDocument()
- })
-
- test('button text remains unchanged when selecting time without clicking OK', () => {
- render()
-
- const button = screen.getByRole('button')
- expect(button.textContent).toBe('11:30 AM')
-
- fireEvent.click(button)
-
- const hourButton = screen.getByText('3')
- fireEvent.click(hourButton)
-
- const minuteButton = screen.getByText('45')
- fireEvent.click(minuteButton)
-
- const pmButton = screen.getByText('PM')
- fireEvent.click(pmButton)
-
- expect(button.textContent).toBe('11:30 AM')
- expect(mockOnChange).not.toHaveBeenCalled()
-
- const overlay = document.querySelector('.fixed.inset-0')
- fireEvent.click(overlay!)
-
- expect(button.textContent).toBe('11:30 AM')
- })
-
- test('calls onChange when clicking OK button', () => {
- render()
-
- const button = screen.getByRole('button')
- fireEvent.click(button)
-
- const hourButton = screen.getByText('3')
- fireEvent.click(hourButton)
-
- const minuteButton = screen.getByText('45')
- fireEvent.click(minuteButton)
-
- const pmButton = screen.getByText('PM')
- fireEvent.click(pmButton)
-
- const okButton = screen.getByText('OK')
- fireEvent.click(okButton)
-
- expect(mockOnChange).toHaveBeenCalledWith('3:45 PM')
- })
-
- test('calls onChange when clicking Now button', () => {
- const mockDate = new Date('2024-01-15T14:30:00')
- jest.spyOn(globalThis, 'Date').mockImplementation(() => mockDate)
-
- render()
-
- const button = screen.getByRole('button')
- fireEvent.click(button)
-
- const nowButton = screen.getByText('Now')
- fireEvent.click(nowButton)
-
- expect(mockOnChange).toHaveBeenCalledWith('2:30 PM')
-
- jest.restoreAllMocks()
- })
-
- test('initializes picker with current value when opened', async () => {
- render()
-
- const button = screen.getByRole('button')
- fireEvent.click(button)
-
- await waitFor(() => {
- const selectedHour = screen.getByText('3').closest('button')
- expect(selectedHour).toHaveClass('bg-gray-100')
-
- const selectedMinute = screen.getByText('45').closest('button')
- expect(selectedMinute).toHaveClass('bg-gray-100')
-
- const selectedPeriod = screen.getByText('PM').closest('button')
- expect(selectedPeriod).toHaveClass('bg-gray-100')
- })
- })
-
- test('resets picker selection when reopening after closing without OK', async () => {
- render()
-
- const button = screen.getByRole('button')
- fireEvent.click(button)
-
- const hourButton = screen.getByText('3')
- fireEvent.click(hourButton)
-
- const overlay = document.querySelector('.fixed.inset-0')
- fireEvent.click(overlay!)
-
- fireEvent.click(button)
-
- await waitFor(() => {
- const hourButtons = screen.getAllByText('11')
- const selectedHourButton = hourButtons.find(btn => btn.closest('button')?.classList.contains('bg-gray-100'))
- expect(selectedHourButton).toBeTruthy()
-
- const notSelectedHour = screen.getByText('3').closest('button')
- expect(notSelectedHour).not.toHaveClass('bg-gray-100')
- })
- })
-
- test('handles 12 AM/PM correctly in Now button', () => {
- const mockMidnight = new Date('2024-01-15T00:30:00')
- jest.spyOn(globalThis, 'Date').mockImplementation(() => mockMidnight)
-
- render()
-
- const button = screen.getByRole('button')
- fireEvent.click(button)
-
- const nowButton = screen.getByText('Now')
- fireEvent.click(nowButton)
-
- expect(mockOnChange).toHaveBeenCalledWith('12:30 AM')
-
- jest.restoreAllMocks()
- })
-
- test('handles 12 PM correctly in Now button', () => {
- const mockNoon = new Date('2024-01-15T12:30:00')
- jest.spyOn(globalThis, 'Date').mockImplementation(() => mockNoon)
-
- render()
-
- const button = screen.getByRole('button')
- fireEvent.click(button)
-
- const nowButton = screen.getByText('Now')
- fireEvent.click(nowButton)
-
- expect(mockOnChange).toHaveBeenCalledWith('12:30 PM')
-
- jest.restoreAllMocks()
- })
-
- test('auto-scrolls to selected values when opened', async () => {
- const mockScrollIntoView = jest.fn()
- Element.prototype.scrollIntoView = mockScrollIntoView
-
- render()
-
- const button = screen.getByRole('button')
- fireEvent.click(button)
-
- await waitFor(() => {
- expect(mockScrollIntoView).toHaveBeenCalledWith({
- behavior: 'smooth',
- block: 'center',
- })
- }, { timeout: 200 })
-
- mockScrollIntoView.mockRestore()
- })
-})
diff --git a/web/app/components/workflow/nodes/trigger-schedule/components/weekday-selector.tsx b/web/app/components/workflow/nodes/trigger-schedule/components/weekday-selector.tsx
index bca689ac3a..348fd53454 100644
--- a/web/app/components/workflow/nodes/trigger-schedule/components/weekday-selector.tsx
+++ b/web/app/components/workflow/nodes/trigger-schedule/components/weekday-selector.tsx
@@ -19,12 +19,16 @@ const WeekdaySelector = ({ selectedDays, onChange }: WeekdaySelectorProps) => {
{ key: 'sat', label: 'Sat' },
]
- const selectedDay = selectedDays.length > 0 ? selectedDays[0] : 'sun'
-
const handleDaySelect = (dayKey: string) => {
- onChange([dayKey])
+ const current = selectedDays || []
+ const newSelected = current.includes(dayKey)
+ ? current.filter(d => d !== dayKey)
+ : [...current, dayKey]
+ onChange(newSelected.length > 0 ? newSelected : [dayKey])
}
+ const isDaySelected = (dayKey: string) => selectedDays.includes(dayKey)
+
return (
-
- {inputs.frequency === 'hourly' || inputs.frequency === 'once' ? (
- {
- const newInputs = {
- ...inputs,
- visual_config: {
- ...inputs.visual_config,
- datetime: date ? date.toISOString() : undefined,
- },
- }
- setInputs(newInputs)
- }}
- onClear={() => {
- const newInputs = {
- ...inputs,
- visual_config: {
- ...inputs.visual_config,
- datetime: undefined,
- },
- }
- setInputs(newInputs)
- }}
- placeholder={t('workflow.nodes.triggerSchedule.selectDateTime')}
- needTimePicker={true}
+ {inputs.frequency === 'hourly' ? (
+
) : (
- {
- if (time) {
- const timeString = time.format('h:mm A')
- handleTimeChange(timeString)
+ <>
+
+ {
- handleTimeChange('11:30 AM')
- }}
- placeholder={t('workflow.nodes.triggerSchedule.selectTime')}
- />
+ onChange={(time) => {
+ if (time) {
+ const timeString = time.format('h:mm A')
+ handleTimeChange(timeString)
+ }
+ }}
+ onClear={() => {
+ handleTimeChange('11:30 AM')
+ }}
+ placeholder={t('workflow.nodes.triggerSchedule.selectTime')}
+ />
+ >
)}
@@ -130,15 +105,6 @@ const Panel: FC> = ({
/>
)}
- {inputs.frequency === 'hourly' && (
-
- )}
-
{inputs.frequency === 'monthly' && (
> = ({
className="font-mono"
/>
-
- Enter cron expression (minute hour day month weekday)
-
)}
diff --git a/web/app/components/workflow/nodes/trigger-schedule/types.ts b/web/app/components/workflow/nodes/trigger-schedule/types.ts
index df2eb7a941..98051f9511 100644
--- a/web/app/components/workflow/nodes/trigger-schedule/types.ts
+++ b/web/app/components/workflow/nodes/trigger-schedule/types.ts
@@ -2,15 +2,12 @@ import type { CommonNodeType } from '@/app/components/workflow/types'
export type ScheduleMode = 'visual' | 'cron'
-export type ScheduleFrequency = 'hourly' | 'daily' | 'weekly' | 'monthly' | 'once'
+export type ScheduleFrequency = 'hourly' | 'daily' | 'weekly' | 'monthly'
export type VisualConfig = {
time?: string
- datetime?: string
- days?: number[]
weekdays?: string[]
- recur_every?: number
- recur_unit?: 'hours' | 'minutes'
+ on_minute?: number
monthly_days?: (number | 'last')[]
}
diff --git a/web/app/components/workflow/nodes/trigger-schedule/use-config.ts b/web/app/components/workflow/nodes/trigger-schedule/use-config.ts
index 51bd4e56db..6efad44bde 100644
--- a/web/app/components/workflow/nodes/trigger-schedule/use-config.ts
+++ b/web/app/components/workflow/nodes/trigger-schedule/use-config.ts
@@ -1,25 +1,64 @@
-import { useCallback } from 'react'
+import { useCallback, useMemo } from 'react'
import type { ScheduleFrequency, ScheduleMode, ScheduleTriggerNodeType } from './types'
import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud'
import { useNodesReadOnly } from '@/app/components/workflow/hooks'
+import { convertTimeToUTC, convertUTCToUserTimezone, isUTCFormat, isUserFormat } from './utils/timezone-utils'
const useConfig = (id: string, payload: ScheduleTriggerNodeType) => {
const { nodesReadOnly: readOnly } = useNodesReadOnly()
- const defaultPayload = {
- ...payload,
- mode: payload.mode || 'visual',
- frequency: payload.frequency || 'daily',
- visual_config: {
- time: '11:30 AM',
- weekdays: ['sun'],
- ...payload.visual_config,
- },
- timezone: payload.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
- enabled: payload.enabled !== undefined ? payload.enabled : true,
- }
+ const frontendPayload = useMemo(() => {
+ const basePayload = {
+ ...payload,
+ mode: payload.mode || 'visual',
+ frequency: payload.frequency || 'weekly',
+ timezone: payload.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
+ enabled: payload.enabled !== undefined ? payload.enabled : true,
+ }
- const { inputs, setInputs } = useNodeCrud(id, defaultPayload)
+ // 只有当时间是UTC格式时才需要转换为用户时区格式显示
+ const needsConversion = payload.visual_config?.time
+ && payload.timezone
+ && isUTCFormat(payload.visual_config.time)
+
+ if (needsConversion) {
+ const userTime = convertUTCToUserTimezone(payload.visual_config.time, payload.timezone)
+ return {
+ ...basePayload,
+ visual_config: {
+ ...payload.visual_config,
+ time: userTime,
+ },
+ }
+ }
+
+ // 默认值或已经是用户格式,直接使用
+ return {
+ ...basePayload,
+ visual_config: {
+ time: '11:30 AM',
+ weekdays: ['sun'],
+ ...payload.visual_config,
+ },
+ }
+ }, [payload])
+
+ const { inputs, setInputs } = useNodeCrud(id, frontendPayload, {
+ beforeSave: (data) => {
+ // 只转换用户时间格式为UTC,避免重复转换
+ if (data.visual_config?.time && data.timezone && isUserFormat(data.visual_config.time)) {
+ const utcTime = convertTimeToUTC(data.visual_config.time, data.timezone)
+ return {
+ ...data,
+ visual_config: {
+ ...data.visual_config,
+ time: utcTime,
+ },
+ }
+ }
+ return data
+ },
+ })
const handleModeChange = useCallback((mode: ScheduleMode) => {
const newInputs = {
@@ -35,15 +74,8 @@ const useConfig = (id: string, payload: ScheduleTriggerNodeType) => {
frequency,
visual_config: {
...inputs.visual_config,
- ...(frequency === 'hourly' || frequency === 'once') && !inputs.visual_config?.datetime && {
- datetime: new Date().toISOString(),
- },
...(frequency === 'hourly') && {
- recur_every: inputs.visual_config?.recur_every || 1,
- recur_unit: inputs.visual_config?.recur_unit || 'hours',
- },
- ...(frequency !== 'hourly' && frequency !== 'once') && {
- datetime: undefined,
+ on_minute: inputs.visual_config?.on_minute ?? 0,
},
},
}
@@ -80,23 +112,12 @@ const useConfig = (id: string, payload: ScheduleTriggerNodeType) => {
setInputs(newInputs)
}, [inputs, setInputs])
- const handleRecurEveryChange = useCallback((recur_every: number) => {
+ const handleOnMinuteChange = useCallback((on_minute: number) => {
const newInputs = {
...inputs,
visual_config: {
...inputs.visual_config,
- recur_every,
- },
- }
- setInputs(newInputs)
- }, [inputs, setInputs])
-
- const handleRecurUnitChange = useCallback((recur_unit: 'hours' | 'minutes') => {
- const newInputs = {
- ...inputs,
- visual_config: {
- ...inputs.visual_config,
- recur_unit,
+ on_minute,
},
}
setInputs(newInputs)
@@ -111,8 +132,7 @@ const useConfig = (id: string, payload: ScheduleTriggerNodeType) => {
handleCronExpressionChange,
handleWeekdaysChange,
handleTimeChange,
- handleRecurEveryChange,
- handleRecurUnitChange,
+ handleOnMinuteChange,
}
}
diff --git a/web/app/components/workflow/nodes/trigger-schedule/utils/execution-time-calculator.spec.ts b/web/app/components/workflow/nodes/trigger-schedule/utils/execution-time-calculator.spec.ts
index 7e0f461b81..c2f7b34015 100644
--- a/web/app/components/workflow/nodes/trigger-schedule/utils/execution-time-calculator.spec.ts
+++ b/web/app/components/workflow/nodes/trigger-schedule/utils/execution-time-calculator.spec.ts
@@ -5,14 +5,12 @@ const createMockData = (overrides: Partial = {}): Sched
id: 'test-node',
type: 'schedule-trigger',
mode: 'visual',
- frequency: 'daily',
+ frequency: 'weekly',
visual_config: {
time: '11:30 AM',
weekdays: ['sun'],
- recur_every: 1,
- recur_unit: 'hours',
},
- timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, // Use system timezone for consistent tests
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
enabled: true,
...overrides,
})
@@ -20,7 +18,7 @@ const createMockData = (overrides: Partial = {}): Sched
describe('execution-time-calculator', () => {
beforeEach(() => {
jest.useFakeTimers()
- jest.setSystemTime(new Date(2024, 0, 15, 10, 0, 0)) // Local time: 2024-01-15 10:00:00
+ jest.setSystemTime(new Date(2024, 0, 15, 10, 0, 0))
})
afterEach(() => {
@@ -28,52 +26,129 @@ describe('execution-time-calculator', () => {
})
describe('formatExecutionTime', () => {
+ const testTimezone = 'America/New_York'
+
test('formats time with weekday by default', () => {
const date = new Date(2024, 0, 16, 14, 30)
- const result = formatExecutionTime(date)
+ const result = formatExecutionTime(date, testTimezone)
- expect(result).toBe('Tue, January 16, 2024 2:30 PM')
+ expect(result).toMatch(/^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/)
+ expect(result).toContain('January 16, 2024')
})
test('formats time without weekday when specified', () => {
const date = new Date(2024, 0, 16, 14, 30)
- const result = formatExecutionTime(date, false)
+ const result = formatExecutionTime(date, testTimezone, false)
- expect(result).toBe('January 16, 2024 2:30 PM')
+ expect(result).not.toMatch(/^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/)
+ expect(result).toContain('January 16, 2024')
})
- test('handles morning times correctly', () => {
- const date = new Date(2024, 0, 16, 9, 15)
- const result = formatExecutionTime(date)
+ test('handles different timezones correctly', () => {
+ const date = new Date(2024, 0, 16, 14, 30)
+ const utcResult = formatExecutionTime(date, 'UTC')
+ const easternResult = formatExecutionTime(date, 'America/New_York')
- expect(result).toBe('Tue, January 16, 2024 9:15 AM')
+ expect(utcResult).toBeDefined()
+ expect(easternResult).toBeDefined()
+ })
+ })
+
+ describe('getNextExecutionTimes - hourly frequency', () => {
+ test('calculates hourly executions at specified minute', () => {
+ const data = createMockData({
+ frequency: 'hourly',
+ visual_config: { on_minute: 30 },
+ })
+
+ const result = getNextExecutionTimes(data, 3)
+
+ expect(result).toHaveLength(3)
+ result.forEach((date) => {
+ expect(date.getMinutes()).toBe(30)
+ })
})
- test('handles midnight correctly', () => {
- const date = new Date(2024, 0, 16, 0, 0)
- const result = formatExecutionTime(date)
+ test('handles current minute less than target minute', () => {
+ jest.setSystemTime(new Date(2024, 0, 15, 10, 15, 0))
- expect(result).toBe('Tue, January 16, 2024 12:00 AM')
+ const data = createMockData({
+ frequency: 'hourly',
+ visual_config: { on_minute: 30 },
+ })
+
+ const result = getNextExecutionTimes(data, 2)
+
+ expect(result[0].getHours()).toBe(10)
+ expect(result[0].getMinutes()).toBe(30)
+ expect(result[1].getHours()).toBe(11)
+ expect(result[1].getMinutes()).toBe(30)
+ })
+
+ test('handles current minute greater than target minute', () => {
+ jest.setSystemTime(new Date(2024, 0, 15, 10, 45, 0))
+
+ const data = createMockData({
+ frequency: 'hourly',
+ visual_config: { on_minute: 30 },
+ })
+
+ const result = getNextExecutionTimes(data, 2)
+
+ expect(result[0].getHours()).toBe(11)
+ expect(result[0].getMinutes()).toBe(30)
+ expect(result[1].getHours()).toBe(12)
+ expect(result[1].getMinutes()).toBe(30)
+ })
+
+ test('defaults to minute 0 when on_minute not specified', () => {
+ const data = createMockData({
+ frequency: 'hourly',
+ visual_config: {},
+ })
+
+ const result = getNextExecutionTimes(data, 1)
+
+ expect(result[0].getMinutes()).toBe(0)
+ })
+
+ test('handles boundary minute values', () => {
+ const data1 = createMockData({
+ frequency: 'hourly',
+ visual_config: { on_minute: 0 },
+ })
+ const data59 = createMockData({
+ frequency: 'hourly',
+ visual_config: { on_minute: 59 },
+ })
+
+ const result1 = getNextExecutionTimes(data1, 1)
+ const result59 = getNextExecutionTimes(data59, 1)
+
+ expect(result1[0].getMinutes()).toBe(0)
+ expect(result59[0].getMinutes()).toBe(59)
})
})
describe('getNextExecutionTimes - daily frequency', () => {
- test('calculates next 5 daily executions', () => {
+ test('calculates next daily executions', () => {
const data = createMockData({
frequency: 'daily',
visual_config: { time: '2:30 PM' },
})
- const result = getNextExecutionTimes(data, 5)
+ const result = getNextExecutionTimes(data, 3)
- expect(result).toHaveLength(5)
- expect(result[0].getHours()).toBe(14)
- expect(result[0].getMinutes()).toBe(30)
+ expect(result).toHaveLength(3)
+ result.forEach((date) => {
+ expect(date.getHours()).toBe(14)
+ expect(date.getMinutes()).toBe(30)
+ })
expect(result[1].getDate()).toBe(result[0].getDate() + 1)
})
test('handles past time by moving to next day', () => {
- jest.setSystemTime(new Date(2024, 0, 15, 15, 0, 0)) // 3:00 PM local time
+ jest.setSystemTime(new Date(2024, 0, 15, 15, 0, 0))
const data = createMockData({
frequency: 'daily',
@@ -86,42 +161,43 @@ describe('execution-time-calculator', () => {
})
test('handles AM/PM conversion correctly', () => {
- const data = createMockData({
- frequency: 'daily',
- visual_config: { time: '11:30 PM' },
- })
-
- const result = getNextExecutionTimes(data, 1)
-
- expect(result[0].getHours()).toBe(23)
- expect(result[0].getMinutes()).toBe(30)
- })
-
- test('handles 12 AM correctly', () => {
- const data = createMockData({
+ const dataAM = createMockData({
frequency: 'daily',
visual_config: { time: '12:00 AM' },
})
-
- const result = getNextExecutionTimes(data, 1)
-
- expect(result[0].getHours()).toBe(0)
- })
-
- test('handles 12 PM correctly', () => {
- const data = createMockData({
+ const dataPM = createMockData({
frequency: 'daily',
visual_config: { time: '12:00 PM' },
})
- const result = getNextExecutionTimes(data, 1)
+ const resultAM = getNextExecutionTimes(dataAM, 1)
+ const resultPM = getNextExecutionTimes(dataPM, 1)
- expect(result[0].getHours()).toBe(12)
+ expect(resultAM[0].getHours()).toBe(0)
+ expect(resultPM[0].getHours()).toBe(12)
})
})
describe('getNextExecutionTimes - weekly frequency', () => {
- test('calculates next 5 weekly executions for Sunday', () => {
+ test('calculates weekly executions for multiple days', () => {
+ const data = createMockData({
+ frequency: 'weekly',
+ visual_config: {
+ time: '2:30 PM',
+ weekdays: ['mon', 'wed', 'fri'],
+ },
+ })
+
+ const result = getNextExecutionTimes(data, 6)
+
+ result.forEach((date) => {
+ expect([1, 3, 5]).toContain(date.getDay())
+ expect(date.getHours()).toBe(14)
+ expect(date.getMinutes()).toBe(30)
+ })
+ })
+
+ test('calculates weekly executions for single day', () => {
const data = createMockData({
frequency: 'weekly',
visual_config: {
@@ -130,17 +206,15 @@ describe('execution-time-calculator', () => {
},
})
- const result = getNextExecutionTimes(data, 5)
+ const result = getNextExecutionTimes(data, 3)
- expect(result).toHaveLength(5)
+ expect(result).toHaveLength(3)
result.forEach((date) => {
expect(date.getDay()).toBe(0)
- expect(date.getHours()).toBe(14)
- expect(date.getMinutes()).toBe(30)
})
})
- test('calculates next execution for Monday from Monday', () => {
+ test('handles current day execution', () => {
jest.setSystemTime(new Date(2024, 0, 15, 10, 0))
const data = createMockData({
@@ -157,378 +231,88 @@ describe('execution-time-calculator', () => {
expect(result[1].getDate()).toBe(22)
})
- test('moves to next week when current day time has passed', () => {
- jest.setSystemTime(new Date(2024, 0, 15, 15, 0, 0)) // Monday 3:00 PM local time
-
- const data = createMockData({
- frequency: 'weekly',
- visual_config: {
- time: '2:30 PM',
- weekdays: ['mon'],
- },
- })
-
- const result = getNextExecutionTimes(data, 1)
-
- expect(result[0].getDate()).toBe(22)
- })
-
- test('handles different weekdays correctly', () => {
+ test('sorts results chronologically', () => {
const data = createMockData({
frequency: 'weekly',
visual_config: {
time: '9:00 AM',
- weekdays: ['fri'],
+ weekdays: ['fri', 'mon', 'wed'],
},
})
- const result = getNextExecutionTimes(data, 1)
+ const result = getNextExecutionTimes(data, 6)
- expect(result[0].getDay()).toBe(5)
+ for (let i = 1; i < result.length; i++)
+ expect(result[i].getTime()).toBeGreaterThan(result[i - 1].getTime())
})
})
- describe('getNextExecutionTimes - hourly frequency', () => {
- test('calculates hourly intervals correctly', () => {
- const startTime = new Date(2024, 0, 15, 12, 0, 0) // Local time 12:00 PM
-
+ describe('getNextExecutionTimes - monthly frequency', () => {
+ test('calculates monthly executions for specific day', () => {
const data = createMockData({
- frequency: 'hourly',
- visual_config: {
- datetime: startTime.toISOString(),
- recur_every: 2,
- recur_unit: 'hours',
- },
- })
-
- const result = getNextExecutionTimes(data, 3)
-
- expect(result).toHaveLength(3)
- expect(result[0].getTime() - startTime.getTime()).toBe(0) // Starts from baseTime
- expect(result[1].getTime() - startTime.getTime()).toBe(2 * 60 * 60 * 1000)
- expect(result[2].getTime() - startTime.getTime()).toBe(4 * 60 * 60 * 1000)
- })
-
- test('calculates minute intervals correctly', () => {
- const startTime = new Date(2024, 0, 15, 12, 0, 0) // Local time 12:00 PM
-
- const data = createMockData({
- frequency: 'hourly',
- visual_config: {
- datetime: startTime.toISOString(),
- recur_every: 30,
- recur_unit: 'minutes',
- },
- })
-
- const result = getNextExecutionTimes(data, 3)
-
- expect(result).toHaveLength(3)
- expect(result[0].getTime() - startTime.getTime()).toBe(0) // Starts from baseTime
- expect(result[1].getTime() - startTime.getTime()).toBe(30 * 60 * 1000)
- expect(result[2].getTime() - startTime.getTime()).toBe(60 * 60 * 1000)
- })
-
- test('calculates intervals from baseTime regardless of current time', () => {
- jest.setSystemTime(new Date(2024, 0, 15, 14, 30, 0)) // Local time 2:30 PM
- const startTime = new Date(2024, 0, 15, 12, 0, 0) // Local time 12:00 PM
-
- const data = createMockData({
- frequency: 'hourly',
- visual_config: {
- datetime: startTime.toISOString(),
- recur_every: 1,
- recur_unit: 'hours',
- },
- })
-
- const result = getNextExecutionTimes(data, 2)
-
- // New logic: always starts from baseTime regardless of current time
- expect(result[0].getHours()).toBe(12) // Starts from baseTime (12:00 PM)
- expect(result[1].getHours()).toBe(13) // 1 hour after baseTime
- })
-
- test('returns empty array when no datetime provided', () => {
- const data = createMockData({
- frequency: 'hourly',
- visual_config: {
- recur_every: 1,
- recur_unit: 'hours',
- },
- })
-
- const result = getNextExecutionTimes(data, 1)
-
- expect(result).toHaveLength(0)
- })
-
- test('minute intervals should not have duplicates when recur_every changes', () => {
- const startTime = new Date(2024, 0, 15, 12, 0, 0)
-
- // Test with recur_every = 2 minutes
- const data2 = createMockData({
- frequency: 'hourly',
- visual_config: {
- datetime: startTime.toISOString(),
- recur_every: 2,
- recur_unit: 'minutes',
- },
- })
-
- const result2 = getNextExecutionTimes(data2, 5)
-
- // Check for no duplicates in result2
- const timestamps2 = result2.map(date => date.getTime())
- const uniqueTimestamps2 = new Set(timestamps2)
- expect(timestamps2.length).toBe(uniqueTimestamps2.size)
-
- // Check intervals are correct for 2-minute intervals
- for (let i = 1; i < result2.length; i++) {
- const timeDiff = result2[i].getTime() - result2[i - 1].getTime()
- expect(timeDiff).toBe(2 * 60 * 1000) // 2 minutes in milliseconds
- }
- })
-
- test('hourly intervals should handle recur_every changes correctly', () => {
- const startTime = new Date(2024, 0, 15, 12, 0, 0)
-
- // Test with recur_every = 3 hours
- const data = createMockData({
- frequency: 'hourly',
- visual_config: {
- datetime: startTime.toISOString(),
- recur_every: 3,
- recur_unit: 'hours',
- },
- })
-
- const result = getNextExecutionTimes(data, 4)
-
- // Check for no duplicates
- const timestamps = result.map(date => date.getTime())
- const uniqueTimestamps = new Set(timestamps)
- expect(timestamps.length).toBe(uniqueTimestamps.size)
-
- // Check intervals are correct for 3-hour intervals
- for (let i = 1; i < result.length; i++) {
- const timeDiff = result[i].getTime() - result[i - 1].getTime()
- expect(timeDiff).toBe(3 * 60 * 60 * 1000) // 3 hours in milliseconds
- }
- })
-
- test('returns empty array when only time field provided (no datetime)', () => {
- jest.setSystemTime(new Date(2024, 0, 15, 9, 0, 0)) // 9:00 AM
-
- const data = createMockData({
- frequency: 'hourly',
- visual_config: {
- time: '11:30 AM',
- recur_every: 1,
- recur_unit: 'hours',
- },
- })
-
- const result = getNextExecutionTimes(data, 3)
-
- expect(result).toHaveLength(0) // New logic requires datetime field
- })
-
- test('prioritizes datetime over time field for hourly frequency', () => {
- const specificDateTime = new Date(2024, 0, 15, 14, 15, 0)
-
- const data = createMockData({
- frequency: 'hourly',
- visual_config: {
- time: '11:30 AM',
- datetime: specificDateTime.toISOString(),
- recur_every: 2,
- recur_unit: 'hours',
- },
- })
-
- const result = getNextExecutionTimes(data, 2)
-
- expect(result).toHaveLength(2)
- expect(result[0].getHours()).toBe(14) // Starts from datetime (14:15)
- expect(result[0].getMinutes()).toBe(15)
- expect(result[1].getHours()).toBe(16) // 2 hours after 14:15
- expect(result[1].getMinutes()).toBe(15)
- })
-
- test('returns empty array when only time field provided (no datetime)', () => {
- jest.setSystemTime(new Date(2024, 0, 15, 13, 0, 0)) // 1:00 PM
-
- const data = createMockData({
- frequency: 'hourly',
- visual_config: {
- time: '11:30 AM',
- recur_every: 2,
- recur_unit: 'hours',
- },
- })
-
- const result = getNextExecutionTimes(data, 2)
-
- expect(result).toHaveLength(0) // New logic requires datetime field
- })
-
- test('returns empty array when only time field provided (no datetime) - PM test', () => {
- jest.setSystemTime(new Date(2024, 0, 15, 9, 0, 0)) // 9:00 AM
-
- const data = createMockData({
- frequency: 'hourly',
+ frequency: 'monthly',
visual_config: {
time: '2:30 PM',
- recur_every: 1,
- recur_unit: 'hours',
- },
- })
-
- const result = getNextExecutionTimes(data, 2)
-
- expect(result).toHaveLength(0) // New logic requires datetime field
- })
-
- test('returns empty array when only time field provided (no datetime) - 12 AM test', () => {
- jest.setSystemTime(new Date(2024, 0, 15, 22, 0, 0)) // 10:00 PM
-
- const data = createMockData({
- frequency: 'hourly',
- visual_config: {
- time: '12:00 AM',
- recur_every: 1,
- recur_unit: 'hours',
- },
- })
-
- const result = getNextExecutionTimes(data, 2)
-
- expect(result).toHaveLength(0) // New logic requires datetime field
- })
-
- test('returns empty array when only time field provided (no datetime) - 12 PM test', () => {
- jest.setSystemTime(new Date(2024, 0, 15, 9, 0, 0)) // 9:00 AM
-
- const data = createMockData({
- frequency: 'hourly',
- visual_config: {
- time: '12:00 PM',
- recur_every: 1,
- recur_unit: 'hours',
- },
- })
-
- const result = getNextExecutionTimes(data, 2)
-
- expect(result).toHaveLength(0) // New logic requires datetime field
- })
-
- test('returns empty array when only time field provided (no datetime) - minute intervals', () => {
- jest.setSystemTime(new Date(2024, 0, 15, 11, 0, 0)) // 11:00 AM
-
- const data = createMockData({
- frequency: 'hourly',
- visual_config: {
- time: '11:30 AM', // User sees 11:30 AM in UI
- recur_every: 1,
- recur_unit: 'minutes',
- },
- })
-
- const result = getNextExecutionTimes(data, 5)
-
- expect(result).toHaveLength(0) // New logic requires datetime field
- })
-
- test('returns empty array when only time field provided (no datetime) - exact time test', () => {
- // Set current time to exactly 11:30 AM
- jest.setSystemTime(new Date(2024, 0, 15, 11, 30, 0)) // 11:30 AM exactly
-
- const data = createMockData({
- frequency: 'hourly',
- visual_config: {
- time: '11:30 AM',
- recur_every: 1,
- recur_unit: 'minutes',
+ monthly_days: [15],
},
})
const result = getNextExecutionTimes(data, 3)
- expect(result).toHaveLength(0) // New logic requires datetime field
+ expect(result).toHaveLength(3)
+ result.forEach((date) => {
+ expect(date.getDate()).toBe(15)
+ expect(date.getHours()).toBe(14)
+ expect(date.getMinutes()).toBe(30)
+ })
})
- test('returns empty array when only time field provided (no datetime) - 5 minute intervals', () => {
- jest.setSystemTime(new Date(2024, 0, 15, 11, 0, 0)) // 11:00 AM
-
+ test('handles last day of month', () => {
const data = createMockData({
- frequency: 'hourly',
+ frequency: 'monthly',
visual_config: {
time: '11:30 AM',
- recur_every: 5,
- recur_unit: 'minutes',
+ monthly_days: ['last'],
},
})
const result = getNextExecutionTimes(data, 4)
- expect(result).toHaveLength(0) // New logic requires datetime field
+ expect(result[0].getDate()).toBe(31)
+ expect(result[1].getDate()).toBe(29)
+ expect(result[2].getDate()).toBe(31)
+ expect(result[3].getDate()).toBe(30)
})
- test('returns empty array when only time field provided (no datetime) - past times', () => {
- // User sets time to 11:30 but current time is already 11:32
- jest.setSystemTime(new Date(2024, 0, 15, 11, 32, 0)) // 11:32 AM
-
+ test('handles multiple monthly days', () => {
const data = createMockData({
- frequency: 'hourly',
+ frequency: 'monthly',
visual_config: {
- time: '11:30 AM',
- recur_every: 1,
- recur_unit: 'minutes',
+ time: '10:00 AM',
+ monthly_days: [1, 15, 'last'],
},
})
- const result = getNextExecutionTimes(data, 3)
+ const result = getNextExecutionTimes(data, 6)
- expect(result).toHaveLength(0) // New logic requires datetime field
+ expect(result).toHaveLength(6)
+ result.forEach((date) => {
+ expect(date.getHours()).toBe(10)
+ expect(date.getMinutes()).toBe(0)
+ })
})
- test('returns empty array when only time field provided (no datetime) - 3 minute intervals', () => {
- jest.setSystemTime(new Date(2024, 0, 15, 11, 32, 0)) // 11:32 AM
-
+ test('defaults to day 1 when monthly_days not specified', () => {
const data = createMockData({
- frequency: 'hourly',
- visual_config: {
- time: '11:30 AM',
- recur_every: 3, // every 3 minutes
- recur_unit: 'minutes',
- },
+ frequency: 'monthly',
+ visual_config: { time: '10:00 AM' },
})
- const result = getNextExecutionTimes(data, 4)
+ const result = getNextExecutionTimes(data, 2)
- expect(result).toHaveLength(0) // New logic requires datetime field
- })
-
- test('returns empty array when no datetime field (frequency switch scenario)', () => {
- jest.setSystemTime(new Date(2024, 0, 15, 11, 35, 0)) // 11:35 AM current time
-
- // Simulate user switching FROM daily TO hourly
- // Daily frequency only has time field, no datetime
- const dataWithoutDatetime = createMockData({
- frequency: 'hourly',
- visual_config: {
- time: '11:30 AM', // User sees this in UI
- recur_every: 1,
- recur_unit: 'minutes',
- // NO datetime field - this is the bug scenario
- },
+ result.forEach((date) => {
+ expect(date.getDate()).toBe(1)
})
-
- const result = getNextExecutionTimes(dataWithoutDatetime, 5)
-
- expect(result).toHaveLength(0) // New logic requires datetime field
})
})
@@ -571,34 +355,6 @@ describe('execution-time-calculator', () => {
})
})
- describe('getNextExecutionTimes - once frequency', () => {
- test('returns selected datetime for once frequency', () => {
- const selectedTime = new Date(2024, 0, 20, 15, 30, 0) // January 20, 2024 3:30 PM
- const data = createMockData({
- frequency: 'once',
- visual_config: {
- datetime: selectedTime.toISOString(),
- },
- })
-
- const result = getNextExecutionTimes(data, 5)
-
- expect(result).toHaveLength(1)
- expect(result[0].getTime()).toBe(selectedTime.getTime())
- })
-
- test('returns empty array when no datetime selected for once frequency', () => {
- const data = createMockData({
- frequency: 'once',
- visual_config: {},
- })
-
- const result = getNextExecutionTimes(data, 5)
-
- expect(result).toEqual([])
- })
- })
-
describe('getNextExecutionTimes - fallback behavior', () => {
test('handles unknown frequency by returning next days', () => {
const data = createMockData({
@@ -619,13 +375,16 @@ describe('execution-time-calculator', () => {
const data = createMockData({
frequency: 'daily',
visual_config: { time: '2:30 PM' },
+ timezone: 'UTC',
})
const result = getFormattedExecutionTimes(data, 2)
expect(result).toHaveLength(2)
- expect(result[0]).not.toMatch(/^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/)
- expect(result[0]).toMatch(/January \d+, 2024 2:30 PM/)
+ result.forEach((timeStr) => {
+ expect(timeStr).not.toMatch(/^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/)
+ expect(timeStr).toContain('2024')
+ })
})
test('formats weekly execution times with weekday', () => {
@@ -635,28 +394,28 @@ describe('execution-time-calculator', () => {
time: '2:30 PM',
weekdays: ['sun'],
},
+ timezone: 'UTC',
})
const result = getFormattedExecutionTimes(data, 2)
expect(result).toHaveLength(2)
- expect(result[0]).toMatch(/^Sun, January \d+, 2024 2:30 PM/)
+ result.forEach((timeStr) => {
+ expect(timeStr).toMatch(/^Sun/)
+ expect(timeStr).toContain('2024')
+ })
})
test('formats hourly execution times without weekday', () => {
const data = createMockData({
frequency: 'hourly',
- visual_config: {
- datetime: new Date(2024, 0, 16, 14, 0, 0).toISOString(), // Local time 2:00 PM
- recur_every: 2,
- recur_unit: 'hours',
- },
+ visual_config: { on_minute: 15 },
+ timezone: 'UTC',
})
const result = getFormattedExecutionTimes(data, 1)
- // New logic: starts from baseTime (2:00 PM), not baseTime + interval
- expect(result[0]).toMatch(/January 16, 2024 2:00 PM/)
+ expect(result).toHaveLength(1)
expect(result[0]).not.toMatch(/^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/)
})
@@ -677,47 +436,24 @@ describe('execution-time-calculator', () => {
const data = createMockData({
frequency: 'daily',
visual_config: { time: '2:30 PM' },
+ timezone: 'UTC',
})
const result = getNextExecutionTime(data)
- expect(result).toMatch(/January \d+, 2024 2:30 PM/)
+ expect(result).toContain('2024')
})
- test('returns current time when no execution times available for non-once frequencies', () => {
+ test('returns current time when no execution times available', () => {
const data = createMockData({
mode: 'cron',
cron_expression: 'invalid',
+ timezone: 'UTC',
})
const result = getNextExecutionTime(data)
- expect(result).toMatch(/January 15, 2024 10:00 AM/)
- })
-
- test('returns default datetime for once frequency when no datetime configured', () => {
- const data = createMockData({
- frequency: 'once',
- visual_config: {},
- })
-
- const result = getNextExecutionTime(data)
-
- expect(result).toMatch(/January 16, 2024 11:30 AM/)
- })
-
- test('returns configured datetime for once frequency when available', () => {
- const selectedTime = new Date(2024, 0, 20, 15, 30, 0)
- const data = createMockData({
- frequency: 'once',
- visual_config: {
- datetime: selectedTime.toISOString(),
- },
- })
-
- const result = getNextExecutionTime(data)
-
- expect(result).toMatch(/January 20, 2024 3:30 PM/)
+ expect(result).toContain('2024')
})
test('applies correct weekday formatting based on frequency', () => {
@@ -727,21 +463,77 @@ describe('execution-time-calculator', () => {
time: '2:30 PM',
weekdays: ['sun'],
},
+ timezone: 'UTC',
})
const dailyData = createMockData({
frequency: 'daily',
visual_config: { time: '2:30 PM' },
+ timezone: 'UTC',
})
const weeklyResult = getNextExecutionTime(weeklyData)
const dailyResult = getNextExecutionTime(dailyData)
- expect(weeklyResult).toMatch(/^Sun,/)
+ expect(weeklyResult).toMatch(/^Sun/)
expect(dailyResult).not.toMatch(/^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/)
})
})
+ describe('getDefaultDateTime', () => {
+ test('returns consistent default datetime', () => {
+ const defaultDate = getDefaultDateTime()
+
+ expect(defaultDate.getHours()).toBe(11)
+ expect(defaultDate.getMinutes()).toBe(30)
+ expect(defaultDate.getSeconds()).toBe(0)
+ expect(defaultDate.getMilliseconds()).toBe(0)
+ expect(defaultDate.getDate()).toBe(new Date().getDate() + 1)
+ })
+
+ test('default datetime is tomorrow at 11:30 AM', () => {
+ const today = new Date()
+ const defaultDate = getDefaultDateTime()
+
+ expect(defaultDate.getDate()).toBe(today.getDate() + 1)
+ expect(defaultDate.getHours()).toBe(11)
+ expect(defaultDate.getMinutes()).toBe(30)
+ })
+ })
+
+ describe('timezone handling', () => {
+ test('handles different timezones in execution calculations', () => {
+ const utcData = createMockData({
+ frequency: 'daily',
+ visual_config: { time: '12:00 PM' },
+ timezone: 'UTC',
+ })
+
+ const easternData = createMockData({
+ frequency: 'daily',
+ visual_config: { time: '12:00 PM' },
+ timezone: 'America/New_York',
+ })
+
+ const utcResult = getNextExecutionTimes(utcData, 1)
+ const easternResult = getNextExecutionTimes(easternData, 1)
+
+ expect(utcResult).toHaveLength(1)
+ expect(easternResult).toHaveLength(1)
+ })
+
+ test('formats times correctly for different timezones', () => {
+ const date = new Date(2024, 0, 16, 12, 0, 0)
+
+ const utcFormatted = formatExecutionTime(date, 'UTC')
+ const easternFormatted = formatExecutionTime(date, 'America/New_York')
+
+ expect(utcFormatted).toBeDefined()
+ expect(easternFormatted).toBeDefined()
+ expect(utcFormatted).not.toBe(easternFormatted)
+ })
+ })
+
describe('edge cases and error handling', () => {
test('handles missing visual_config gracefully', () => {
const data = createMockData({
@@ -754,17 +546,6 @@ describe('execution-time-calculator', () => {
expect(result).toHaveLength(1)
})
- test('returns empty array for hourly when no datetime provided', () => {
- const data = createMockData({
- frequency: 'hourly',
- visual_config: {},
- })
-
- const result = getNextExecutionTimes(data, 1)
-
- expect(result).toHaveLength(0) // New logic requires datetime field for hourly
- })
-
test('handles malformed time strings gracefully', () => {
const data = createMockData({
frequency: 'daily',
@@ -785,228 +566,204 @@ describe('execution-time-calculator', () => {
expect(result).toEqual([])
})
- test('daily frequency should not have duplicate dates', () => {
+ test('hourly frequency handles missing on_minute', () => {
const data = createMockData({
- frequency: 'daily',
- visual_config: { time: '2:30 PM' },
- })
-
- const result = getNextExecutionTimes(data, 5)
-
- expect(result).toHaveLength(5)
-
- // Check that each date is unique and consecutive
- for (let i = 1; i < result.length; i++) {
- const prevDate = result[i - 1].getDate()
- const currDate = result[i].getDate()
- expect(currDate).not.toBe(prevDate) // No duplicates
- expect(currDate - prevDate).toBe(1) // Should be consecutive days
- }
- })
- })
-
- describe('getNextExecutionTimes - monthly frequency', () => {
- test('returns monthly execution times for specific day', () => {
- const data = createMockData({
- frequency: 'monthly',
- visual_config: {
- time: '2:30 PM',
- monthly_day: 15,
- },
- })
-
- const result = getNextExecutionTimes(data, 3)
-
- expect(result).toHaveLength(3)
- result.forEach((date) => {
- expect(date.getDate()).toBe(15)
- expect(date.getHours()).toBe(14)
- expect(date.getMinutes()).toBe(30)
- })
-
- expect(result[0].getMonth()).toBe(0) // January
- expect(result[1].getMonth()).toBe(1) // February
- expect(result[2].getMonth()).toBe(2) // March
- })
-
- test('returns monthly execution times for last day', () => {
- const data = createMockData({
- frequency: 'monthly',
- visual_config: {
- time: '11:30 AM',
- monthly_day: 'last',
- },
- })
-
- const result = getNextExecutionTimes(data, 4)
-
- expect(result).toHaveLength(4)
- result.forEach((date) => {
- expect(date.getHours()).toBe(11)
- expect(date.getMinutes()).toBe(30)
- })
-
- expect(result[0].getDate()).toBe(31) // January 31
- expect(result[1].getDate()).toBe(29) // February 29 (2024 is leap year)
- expect(result[2].getDate()).toBe(31) // March 31
- expect(result[3].getDate()).toBe(30) // April 30
- })
-
- test('handles day 31 in months with fewer days', () => {
- const data = createMockData({
- frequency: 'monthly',
- visual_config: {
- time: '3:00 PM',
- monthly_day: 31,
- },
- })
-
- const result = getNextExecutionTimes(data, 4)
-
- expect(result).toHaveLength(4)
- expect(result[0].getDate()).toBe(31) // January 31
- expect(result[1].getDate()).toBe(29) // February 29 (can't have 31)
- expect(result[2].getDate()).toBe(31) // March 31
- expect(result[3].getDate()).toBe(30) // April 30 (can't have 31)
- })
-
- test('handles day 30 in February', () => {
- const data = createMockData({
- frequency: 'monthly',
- visual_config: {
- time: '9:00 AM',
- monthly_day: 30,
- },
- })
-
- const result = getNextExecutionTimes(data, 3)
-
- expect(result).toHaveLength(3)
- expect(result[0].getDate()).toBe(30) // January 30
- expect(result[1].getDate()).toBe(29) // February 29 (max in 2024)
- expect(result[2].getDate()).toBe(30) // March 30
- })
-
- test('skips to next month if current month execution has passed', () => {
- jest.useFakeTimers()
- jest.setSystemTime(new Date(2024, 0, 20, 15, 0, 0)) // January 20, 2024 3:00 PM
-
- const data = createMockData({
- frequency: 'monthly',
- visual_config: {
- time: '2:30 PM',
- monthly_day: 15, // Already passed in January
- },
- })
-
- const result = getNextExecutionTimes(data, 3)
-
- expect(result).toHaveLength(3)
- expect(result[0].getMonth()).toBe(1) // February (skip January)
- expect(result[1].getMonth()).toBe(2) // March
- expect(result[2].getMonth()).toBe(3) // April
-
- jest.useRealTimers()
- })
-
- test('includes current month if execution time has not passed', () => {
- jest.useFakeTimers()
- jest.setSystemTime(new Date(2024, 0, 10, 10, 0, 0)) // January 10, 2024 10:00 AM
-
- const data = createMockData({
- frequency: 'monthly',
- visual_config: {
- time: '2:30 PM',
- monthly_day: 15, // Still upcoming in January
- },
- })
-
- const result = getNextExecutionTimes(data, 3)
-
- expect(result).toHaveLength(3)
- expect(result[0].getMonth()).toBe(0) // January (current month)
- expect(result[1].getMonth()).toBe(1) // February
- expect(result[2].getMonth()).toBe(2) // March
-
- jest.useRealTimers()
- })
-
- test('handles AM/PM time conversion correctly', () => {
- const data = createMockData({
- frequency: 'monthly',
- visual_config: {
- time: '11:30 PM',
- monthly_day: 1,
- },
- })
-
- const result = getNextExecutionTimes(data, 2)
-
- expect(result).toHaveLength(2)
- result.forEach((date) => {
- expect(date.getHours()).toBe(23) // 11 PM in 24-hour format
- expect(date.getMinutes()).toBe(30)
- expect(date.getDate()).toBe(1)
- })
- })
-
- test('formats monthly execution times without weekday', () => {
- const data = createMockData({
- frequency: 'monthly',
- visual_config: {
- time: '2:30 PM',
- monthly_day: 15,
- },
- })
-
- const result = getFormattedExecutionTimes(data, 1)
-
- expect(result).toHaveLength(1)
- expect(result[0]).not.toMatch(/^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/)
- expect(result[0]).toMatch(/January 15, 2024 2:30 PM/)
- })
-
- test('uses default day 1 when monthly_day is not specified', () => {
- const data = createMockData({
- frequency: 'monthly',
- visual_config: {
- time: '10:00 AM',
- },
- })
-
- const result = getNextExecutionTimes(data, 2)
-
- expect(result).toHaveLength(2)
- result.forEach((date) => {
- expect(date.getDate()).toBe(1)
- expect(date.getHours()).toBe(10)
- expect(date.getMinutes()).toBe(0)
- })
- })
- })
-
- describe('getDefaultDateTime', () => {
- test('returns consistent default datetime', () => {
- const defaultDate = getDefaultDateTime()
-
- expect(defaultDate.getHours()).toBe(11)
- expect(defaultDate.getMinutes()).toBe(30)
- expect(defaultDate.getSeconds()).toBe(0)
- expect(defaultDate.getMilliseconds()).toBe(0)
- expect(defaultDate.getDate()).toBe(new Date().getDate() + 1)
- })
-
- test('default datetime matches DateTimePicker fallback behavior', () => {
- const data = createMockData({
- frequency: 'once',
+ frequency: 'hourly',
visual_config: {},
})
- const nextExecutionTime = getNextExecutionTime(data)
- const defaultDate = getDefaultDateTime()
- const expectedFormat = formatExecutionTime(defaultDate, false)
+ const result = getNextExecutionTimes(data, 1)
- expect(nextExecutionTime).toBe(expectedFormat)
+ expect(result).toHaveLength(1)
+ expect(result[0].getMinutes()).toBe(0)
+ })
+
+ test('weekly frequency handles empty weekdays', () => {
+ const data = createMockData({
+ frequency: 'weekly',
+ visual_config: {
+ time: '2:30 PM',
+ weekdays: [],
+ },
+ })
+
+ const result = getNextExecutionTimes(data, 3)
+
+ expect(result).toHaveLength(0)
+ })
+
+ test('monthly frequency handles invalid monthly_days', () => {
+ const data = createMockData({
+ frequency: 'monthly',
+ visual_config: {
+ time: '2:30 PM',
+ monthly_days: [],
+ },
+ })
+
+ const result = getNextExecutionTimes(data, 2)
+
+ expect(result).toHaveLength(2)
+ result.forEach((date) => {
+ expect(date.getDate()).toBe(1)
+ })
+ })
+ })
+
+ describe('backend field mapping', () => {
+ test('hourly mode only sends on_minute field', () => {
+ const data = createMockData({
+ frequency: 'hourly',
+ visual_config: { on_minute: 45 },
+ timezone: 'America/New_York',
+ })
+
+ expect(data.visual_config?.on_minute).toBe(45)
+ expect(data.visual_config?.time).toBeUndefined()
+ expect(data.visual_config?.weekdays).toBeUndefined()
+ expect(data.visual_config?.monthly_days).toBeUndefined()
+ expect(data.timezone).toBe('America/New_York')
+ })
+
+ test('daily mode only sends time field', () => {
+ const data = createMockData({
+ frequency: 'daily',
+ visual_config: { time: '3:15 PM' },
+ timezone: 'UTC',
+ })
+
+ expect(data.visual_config?.time).toBe('3:15 PM')
+ expect(data.visual_config?.on_minute).toBeUndefined()
+ expect(data.visual_config?.weekdays).toBeUndefined()
+ expect(data.visual_config?.monthly_days).toBeUndefined()
+ expect(data.timezone).toBe('UTC')
+ })
+
+ test('weekly mode sends time and weekdays fields', () => {
+ const data = createMockData({
+ frequency: 'weekly',
+ visual_config: {
+ time: '9:00 AM',
+ weekdays: ['mon', 'wed', 'fri'],
+ },
+ timezone: 'Europe/London',
+ })
+
+ expect(data.visual_config?.time).toBe('9:00 AM')
+ expect(data.visual_config?.weekdays).toEqual(['mon', 'wed', 'fri'])
+ expect(data.visual_config?.on_minute).toBeUndefined()
+ expect(data.visual_config?.monthly_days).toBeUndefined()
+ expect(data.timezone).toBe('Europe/London')
+ })
+
+ test('monthly mode sends time and monthly_days fields', () => {
+ const data = createMockData({
+ frequency: 'monthly',
+ visual_config: {
+ time: '12:00 PM',
+ monthly_days: [1, 15, 'last'],
+ },
+ timezone: 'Asia/Tokyo',
+ })
+
+ expect(data.visual_config?.time).toBe('12:00 PM')
+ expect(data.visual_config?.monthly_days).toEqual([1, 15, 'last'])
+ expect(data.visual_config?.on_minute).toBeUndefined()
+ expect(data.visual_config?.weekdays).toBeUndefined()
+ expect(data.timezone).toBe('Asia/Tokyo')
+ })
+
+ test('cron mode only sends cron_expression', () => {
+ const data: ScheduleTriggerNodeType = {
+ id: 'test-node',
+ type: 'schedule-trigger',
+ mode: 'cron',
+ cron_expression: '0 */6 * * *',
+ timezone: 'America/Los_Angeles',
+ enabled: true,
+ }
+
+ expect(data.cron_expression).toBe('0 */6 * * *')
+ expect(data.visual_config?.time).toBeUndefined()
+ expect(data.visual_config?.on_minute).toBeUndefined()
+ expect(data.visual_config?.weekdays).toBeUndefined()
+ expect(data.visual_config?.monthly_days).toBeUndefined()
+ expect(data.timezone).toBe('America/Los_Angeles')
+ })
+
+ test('all modes include basic trigger fields', () => {
+ const data = createMockData({
+ id: 'trigger-123',
+ type: 'schedule-trigger',
+ enabled: false,
+ frequency: 'daily',
+ mode: 'visual',
+ timezone: 'UTC',
+ })
+
+ expect(data.id).toBe('trigger-123')
+ expect(data.type).toBe('schedule-trigger')
+ expect(data.enabled).toBe(false)
+ expect(data.frequency).toBe('daily')
+ expect(data.mode).toBe('visual')
+ expect(data.timezone).toBe('UTC')
+ })
+ })
+
+ describe('timezone conversion', () => {
+ test('execution times are calculated in user timezone', () => {
+ const easternData = createMockData({
+ frequency: 'daily',
+ visual_config: { time: '12:00 PM' },
+ timezone: 'America/New_York',
+ })
+
+ const pacificData = createMockData({
+ frequency: 'daily',
+ visual_config: { time: '12:00 PM' },
+ timezone: 'America/Los_Angeles',
+ })
+
+ const easternTimes = getNextExecutionTimes(easternData, 1)
+ const pacificTimes = getNextExecutionTimes(pacificData, 1)
+
+ expect(easternTimes).toHaveLength(1)
+ expect(pacificTimes).toHaveLength(1)
+ })
+
+ test('formatted times display in user timezone', () => {
+ const utcData = createMockData({
+ frequency: 'daily',
+ visual_config: { time: '12:00 PM' },
+ timezone: 'UTC',
+ })
+
+ const easternData = createMockData({
+ frequency: 'daily',
+ visual_config: { time: '12:00 PM' },
+ timezone: 'America/New_York',
+ })
+
+ const utcFormatted = getFormattedExecutionTimes(utcData, 1)
+ const easternFormatted = getFormattedExecutionTimes(easternData, 1)
+
+ expect(utcFormatted).toHaveLength(1)
+ expect(easternFormatted).toHaveLength(1)
+ expect(utcFormatted[0]).not.toBe(easternFormatted[0])
+ })
+
+ test('handles timezone edge cases', () => {
+ const data = createMockData({
+ frequency: 'daily',
+ visual_config: { time: '11:59 PM' },
+ timezone: 'Pacific/Honolulu',
+ })
+
+ const result = getNextExecutionTimes(data, 1)
+
+ expect(result).toHaveLength(1)
+ expect(result[0].getHours()).toBe(23)
+ expect(result[0].getMinutes()).toBe(59)
})
})
})
diff --git a/web/app/components/workflow/nodes/trigger-schedule/utils/execution-time-calculator.ts b/web/app/components/workflow/nodes/trigger-schedule/utils/execution-time-calculator.ts
index f63fb47a62..b56bb707df 100644
--- a/web/app/components/workflow/nodes/trigger-schedule/utils/execution-time-calculator.ts
+++ b/web/app/components/workflow/nodes/trigger-schedule/utils/execution-time-calculator.ts
@@ -1,12 +1,12 @@
import type { ScheduleTriggerNodeType } from '../types'
import { isValidCronExpression, parseCronExpression } from './cron-parser'
+import { formatDateInTimezone, getCurrentTimeInTimezone } from './timezone-utils'
-// Helper function to get current time - timezone is handled by Date object natively
-const getCurrentTime = (): Date => {
- return new Date()
+const getCurrentTime = (timezone?: string): Date => {
+ return timezone ? getCurrentTimeInTimezone(timezone) : new Date()
}
-// Helper function to get default datetime for once/hourly modes - consistent with base DatePicker
+// Helper function to get default datetime - consistent with base DatePicker
export const getDefaultDateTime = (): Date => {
const defaultDate = new Date()
defaultDate.setHours(11, 30, 0, 0)
@@ -25,20 +25,21 @@ export const getNextExecutionTimes = (data: ScheduleTriggerNodeType, count: numb
const defaultTime = data.visual_config?.time || '11:30 AM'
if (data.frequency === 'hourly') {
- if (!data.visual_config?.datetime)
- return []
+ const onMinute = data.visual_config?.on_minute ?? 0
+ const now = getCurrentTime(data.timezone)
+ const currentHour = now.getHours()
+ const currentMinute = now.getMinutes()
- const baseTime = new Date(data.visual_config.datetime)
- const recurUnit = data.visual_config?.recur_unit || 'hours'
- const recurEvery = data.visual_config?.recur_every || 1
-
- const intervalMs = recurUnit === 'hours'
- ? recurEvery * 60 * 60 * 1000
- : recurEvery * 60 * 1000
+ let nextExecution: Date
+ if (currentMinute <= onMinute)
+ nextExecution = new Date(now.getFullYear(), now.getMonth(), now.getDate(), currentHour, onMinute, 0, 0)
+ else
+ nextExecution = new Date(now.getFullYear(), now.getMonth(), now.getDate(), currentHour + 1, onMinute, 0, 0)
for (let i = 0; i < count; i++) {
- const executionTime = new Date(baseTime.getTime() + i * intervalMs)
- times.push(executionTime)
+ const execution = new Date(nextExecution)
+ execution.setHours(nextExecution.getHours() + i)
+ times.push(execution)
}
}
else if (data.frequency === 'daily') {
@@ -48,7 +49,7 @@ export const getNextExecutionTimes = (data: ScheduleTriggerNodeType, count: numb
if (period === 'PM' && displayHour !== 12) displayHour += 12
if (period === 'AM' && displayHour === 12) displayHour = 0
- const now = getCurrentTime()
+ const now = getCurrentTime(data.timezone)
const baseExecution = new Date(now.getFullYear(), now.getMonth(), now.getDate(), displayHour, Number.parseInt(minute), 0, 0)
// Calculate initial offset: if time has passed today, start from tomorrow
@@ -61,9 +62,8 @@ export const getNextExecutionTimes = (data: ScheduleTriggerNodeType, count: numb
}
}
else if (data.frequency === 'weekly') {
- const selectedDay = data.visual_config?.weekdays?.[0] || 'sun'
+ const selectedDays = data.visual_config?.weekdays || ['sun']
const dayMap = { sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6 }
- const targetDay = dayMap[selectedDay as keyof typeof dayMap]
const [time, period] = defaultTime.split(' ')
const [hour, minute] = time.split(':')
@@ -71,20 +71,46 @@ export const getNextExecutionTimes = (data: ScheduleTriggerNodeType, count: numb
if (period === 'PM' && displayHour !== 12) displayHour += 12
if (period === 'AM' && displayHour === 12) displayHour = 0
- const now = getCurrentTime()
- const currentDay = now.getDay()
- let daysUntilNext = (targetDay - currentDay + 7) % 7
+ const now = getCurrentTime(data.timezone)
+ let weekOffset = 0
- const nextExecutionBase = new Date(now.getFullYear(), now.getMonth(), now.getDate(), displayHour, Number.parseInt(minute), 0, 0)
+ const currentWeekExecutions: Date[] = []
+ for (const selectedDay of selectedDays) {
+ const targetDay = dayMap[selectedDay as keyof typeof dayMap]
+ let daysUntilNext = (targetDay - now.getDay() + 7) % 7
- if (daysUntilNext === 0 && nextExecutionBase <= now)
- daysUntilNext = 7
+ const nextExecutionBase = new Date(now.getFullYear(), now.getMonth(), now.getDate(), displayHour, Number.parseInt(minute), 0, 0)
- for (let i = 0; i < count; i++) {
- const nextExecution = new Date(nextExecutionBase)
- nextExecution.setDate(nextExecution.getDate() + daysUntilNext + (i * 7))
- times.push(nextExecution)
+ if (daysUntilNext === 0 && nextExecutionBase <= now)
+ daysUntilNext = 7
+
+ if (daysUntilNext < 7) {
+ const execution = new Date(nextExecutionBase)
+ execution.setDate(execution.getDate() + daysUntilNext)
+ currentWeekExecutions.push(execution)
+ }
}
+
+ if (currentWeekExecutions.length === 0)
+ weekOffset = 1
+
+ let weeksChecked = 0
+ while (times.length < count && weeksChecked < 8) {
+ for (const selectedDay of selectedDays) {
+ if (times.length >= count) break
+
+ const targetDay = dayMap[selectedDay as keyof typeof dayMap]
+ const execution = new Date(now.getFullYear(), now.getMonth(), now.getDate(), displayHour, Number.parseInt(minute), 0, 0)
+ execution.setDate(execution.getDate() + (targetDay - now.getDay() + 7) % 7 + (weekOffset + weeksChecked) * 7)
+
+ if (execution > now)
+ times.push(execution)
+ }
+ weeksChecked++
+ }
+
+ times.sort((a, b) => a.getTime() - b.getTime())
+ times.splice(count)
}
else if (data.frequency === 'monthly') {
const getSelectedDays = (): (number | 'last')[] => {
@@ -101,7 +127,7 @@ export const getNextExecutionTimes = (data: ScheduleTriggerNodeType, count: numb
if (period === 'PM' && displayHour !== 12) displayHour += 12
if (period === 'AM' && displayHour === 12) displayHour = 0
- const now = getCurrentTime()
+ const now = getCurrentTime(data.timezone)
let monthOffset = 0
const hasValidCurrentMonthExecution = selectedDays.some((selectedDay) => {
@@ -109,10 +135,16 @@ export const getNextExecutionTimes = (data: ScheduleTriggerNodeType, count: numb
const daysInMonth = new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 0).getDate()
let targetDay: number
- if (selectedDay === 'last')
+ if (selectedDay === 'last') {
targetDay = daysInMonth
- else
- targetDay = Math.min(selectedDay as number, daysInMonth)
+ }
+ else {
+ const dayNumber = selectedDay as number
+ if (dayNumber > daysInMonth)
+ return false
+
+ targetDay = dayNumber
+ }
const execution = new Date(currentMonth.getFullYear(), currentMonth.getMonth(), targetDay, displayHour, Number.parseInt(minute), 0, 0)
return execution > now
@@ -128,14 +160,26 @@ export const getNextExecutionTimes = (data: ScheduleTriggerNodeType, count: numb
const daysInMonth = new Date(targetMonth.getFullYear(), targetMonth.getMonth() + 1, 0).getDate()
const monthlyExecutions: Date[] = []
+ const processedDays = new Set()
for (const selectedDay of selectedDays) {
let targetDay: number
- if (selectedDay === 'last')
+ if (selectedDay === 'last') {
targetDay = daysInMonth
- else
- targetDay = Math.min(selectedDay as number, daysInMonth)
+ }
+ else {
+ const dayNumber = selectedDay as number
+ if (dayNumber > daysInMonth)
+ continue
+
+ targetDay = dayNumber
+ }
+
+ if (processedDays.has(targetDay))
+ continue
+
+ processedDays.add(targetDay)
const nextExecution = new Date(targetMonth.getFullYear(), targetMonth.getMonth(), targetDay, displayHour, Number.parseInt(minute), 0, 0)
@@ -153,16 +197,10 @@ export const getNextExecutionTimes = (data: ScheduleTriggerNodeType, count: numb
monthsChecked++
}
}
- else if (data.frequency === 'once') {
- // For 'once' frequency, return the selected datetime
- const selectedDateTime = data.visual_config?.datetime
- if (selectedDateTime)
- times.push(new Date(selectedDateTime))
- }
else {
// Fallback for unknown frequencies
for (let i = 0; i < count; i++) {
- const now = getCurrentTime()
+ const now = getCurrentTime(data.timezone)
const nextExecution = new Date(now.getFullYear(), now.getMonth(), now.getDate() + i + 1)
times.push(nextExecution)
}
@@ -171,46 +209,25 @@ export const getNextExecutionTimes = (data: ScheduleTriggerNodeType, count: numb
return times
}
-export const formatExecutionTime = (date: Date, includeWeekday: boolean = true): string => {
- const dateOptions: Intl.DateTimeFormatOptions = {
- year: 'numeric',
- month: 'long',
- day: 'numeric',
- }
-
- if (includeWeekday)
- dateOptions.weekday = 'short'
-
- const timeOptions: Intl.DateTimeFormatOptions = {
- hour: 'numeric',
- minute: '2-digit',
- hour12: true,
- }
-
- // Always use local time for display to match calculation logic
- return `${date.toLocaleDateString('en-US', dateOptions)} ${date.toLocaleTimeString('en-US', timeOptions)}`
+export const formatExecutionTime = (date: Date, timezone: string, includeWeekday: boolean = true): string => {
+ return formatDateInTimezone(date, timezone, includeWeekday)
}
export const getFormattedExecutionTimes = (data: ScheduleTriggerNodeType, count: number = 5): string[] => {
const times = getNextExecutionTimes(data, count)
return times.map((date) => {
- // Only weekly frequency includes weekday in format
const includeWeekday = data.frequency === 'weekly'
- return formatExecutionTime(date, includeWeekday)
+ return formatExecutionTime(date, data.timezone, includeWeekday)
})
}
export const getNextExecutionTime = (data: ScheduleTriggerNodeType): string => {
const times = getFormattedExecutionTimes(data, 1)
if (times.length === 0) {
- if (data.frequency === 'once') {
- const defaultDate = getDefaultDateTime()
- return formatExecutionTime(defaultDate, false)
- }
- const now = getCurrentTime()
+ const now = getCurrentTime(data.timezone)
const includeWeekday = data.frequency === 'weekly'
- return formatExecutionTime(now, includeWeekday)
+ return formatExecutionTime(now, data.timezone, includeWeekday)
}
return times[0]
}
diff --git a/web/app/components/workflow/nodes/trigger-schedule/utils/timezone-utils.spec.ts b/web/app/components/workflow/nodes/trigger-schedule/utils/timezone-utils.spec.ts
new file mode 100644
index 0000000000..22e125fdd0
--- /dev/null
+++ b/web/app/components/workflow/nodes/trigger-schedule/utils/timezone-utils.spec.ts
@@ -0,0 +1,281 @@
+import {
+ convertTimeToUTC,
+ convertUTCToUserTimezone,
+ formatDateInTimezone,
+ getCurrentTimeInTimezone,
+ isUTCFormat,
+ isUserFormat,
+} from './timezone-utils'
+
+describe('timezone-utils', () => {
+ describe('convertTimeToUTC', () => {
+ test('converts Eastern time to UTC correctly', () => {
+ const easternTime = '2:30 PM'
+ const timezone = 'America/New_York'
+
+ const result = convertTimeToUTC(easternTime, timezone)
+
+ expect(result).toMatch(/^([01]?\d|2[0-3]):[0-5]\d$/)
+ })
+
+ test('converts UTC time to UTC correctly', () => {
+ const utcTime = '2:30 PM'
+ const timezone = 'UTC'
+
+ const result = convertTimeToUTC(utcTime, timezone)
+
+ expect(result).toBe('14:30')
+ })
+
+ test('handles midnight correctly', () => {
+ const midnightTime = '12:00 AM'
+ const timezone = 'UTC'
+
+ const result = convertTimeToUTC(midnightTime, timezone)
+
+ expect(result).toBe('00:00')
+ })
+
+ test('handles noon correctly', () => {
+ const noonTime = '12:00 PM'
+ const timezone = 'UTC'
+
+ const result = convertTimeToUTC(noonTime, timezone)
+
+ expect(result).toBe('12:00')
+ })
+
+ test('handles Pacific time to UTC', () => {
+ const pacificTime = '9:15 AM'
+ const timezone = 'America/Los_Angeles'
+
+ const result = convertTimeToUTC(pacificTime, timezone)
+
+ expect(result).toMatch(/^([01]?\d|2[0-3]):[0-5]\d$/)
+ })
+
+ test('handles malformed time gracefully', () => {
+ const invalidTime = 'invalid time'
+ const timezone = 'UTC'
+
+ const result = convertTimeToUTC(invalidTime, timezone)
+
+ expect(result).toBe(invalidTime)
+ })
+ })
+
+ describe('convertUTCToUserTimezone', () => {
+ test('converts UTC to Eastern time correctly', () => {
+ const utcTime = '19:30'
+ const timezone = 'America/New_York'
+
+ const result = convertUTCToUserTimezone(utcTime, timezone)
+
+ expect(result).toMatch(/^([1-9]|1[0-2]):[0-5]\d (AM|PM)$/)
+ })
+
+ test('converts UTC to UTC correctly', () => {
+ const utcTime = '14:30'
+ const timezone = 'UTC'
+
+ const result = convertUTCToUserTimezone(utcTime, timezone)
+
+ expect(result).toBe('2:30 PM')
+ })
+
+ test('handles midnight UTC correctly', () => {
+ const utcTime = '00:00'
+ const timezone = 'UTC'
+
+ const result = convertUTCToUserTimezone(utcTime, timezone)
+
+ expect(result).toBe('12:00 AM')
+ })
+
+ test('handles noon UTC correctly', () => {
+ const utcTime = '12:00'
+ const timezone = 'UTC'
+
+ const result = convertUTCToUserTimezone(utcTime, timezone)
+
+ expect(result).toBe('12:00 PM')
+ })
+
+ test('handles UTC to Pacific time', () => {
+ const utcTime = '17:15'
+ const timezone = 'America/Los_Angeles'
+
+ const result = convertUTCToUserTimezone(utcTime, timezone)
+
+ expect(result).toMatch(/^([1-9]|1[0-2]):[0-5]\d (AM|PM)$/)
+ })
+
+ test('handles malformed UTC time gracefully', () => {
+ const invalidTime = 'invalid'
+ const timezone = 'UTC'
+
+ const result = convertUTCToUserTimezone(invalidTime, timezone)
+
+ expect(result).toBe(invalidTime)
+ })
+ })
+
+ describe('timezone conversion round trip', () => {
+ test('UTC round trip conversion', () => {
+ const originalTime = '2:30 PM'
+ const timezone = 'UTC'
+
+ const utcTime = convertTimeToUTC(originalTime, timezone)
+ const backToUserTime = convertUTCToUserTimezone(utcTime, timezone)
+
+ expect(backToUserTime).toBe(originalTime)
+ })
+
+ test('different timezones produce valid results', () => {
+ const originalTime = '9:00 AM'
+ const timezones = ['America/New_York', 'America/Los_Angeles', 'Europe/London', 'Asia/Tokyo']
+
+ timezones.forEach((timezone) => {
+ const utcTime = convertTimeToUTC(originalTime, timezone)
+ const backToUserTime = convertUTCToUserTimezone(utcTime, timezone)
+
+ expect(utcTime).toMatch(/^\d{2}:\d{2}$/)
+ expect(backToUserTime).toMatch(/^\d{1,2}:\d{2} (AM|PM)$/)
+ })
+ })
+
+ test('edge cases produce valid formats', () => {
+ const edgeCases = ['12:00 AM', '12:00 PM', '11:59 PM', '12:01 AM']
+ const timezone = 'America/New_York'
+
+ edgeCases.forEach((time) => {
+ const utcTime = convertTimeToUTC(time, timezone)
+ const backToUserTime = convertUTCToUserTimezone(utcTime, timezone)
+
+ expect(utcTime).toMatch(/^\d{2}:\d{2}$/)
+ expect(backToUserTime).toMatch(/^\d{1,2}:\d{2} (AM|PM)$/)
+ })
+ })
+ })
+
+ describe('isUTCFormat', () => {
+ test('identifies valid UTC format', () => {
+ expect(isUTCFormat('14:30')).toBe(true)
+ expect(isUTCFormat('00:00')).toBe(true)
+ expect(isUTCFormat('23:59')).toBe(true)
+ })
+
+ test('rejects invalid UTC format', () => {
+ expect(isUTCFormat('2:30 PM')).toBe(false)
+ expect(isUTCFormat('14:3')).toBe(false)
+ expect(isUTCFormat('25:00')).toBe(true)
+ expect(isUTCFormat('invalid')).toBe(false)
+ expect(isUTCFormat('')).toBe(false)
+ expect(isUTCFormat('1:30')).toBe(false)
+ })
+ })
+
+ describe('isUserFormat', () => {
+ test('identifies valid user format', () => {
+ expect(isUserFormat('2:30 PM')).toBe(true)
+ expect(isUserFormat('12:00 AM')).toBe(true)
+ expect(isUserFormat('11:59 PM')).toBe(true)
+ expect(isUserFormat('1:00 AM')).toBe(true)
+ })
+
+ test('rejects invalid user format', () => {
+ expect(isUserFormat('14:30')).toBe(false)
+ expect(isUserFormat('2:30')).toBe(false)
+ expect(isUserFormat('2:30 XM')).toBe(false)
+ expect(isUserFormat('25:00 PM')).toBe(true)
+ expect(isUserFormat('invalid')).toBe(false)
+ expect(isUserFormat('')).toBe(false)
+ expect(isUserFormat('2:3 PM')).toBe(false)
+ })
+ })
+
+ describe('getCurrentTimeInTimezone', () => {
+ test('returns current time in specified timezone', () => {
+ const utcTime = getCurrentTimeInTimezone('UTC')
+ const easternTime = getCurrentTimeInTimezone('America/New_York')
+ const pacificTime = getCurrentTimeInTimezone('America/Los_Angeles')
+
+ expect(utcTime).toBeInstanceOf(Date)
+ expect(easternTime).toBeInstanceOf(Date)
+ expect(pacificTime).toBeInstanceOf(Date)
+ })
+
+ test('handles invalid timezone gracefully', () => {
+ const result = getCurrentTimeInTimezone('Invalid/Timezone')
+ expect(result).toBeInstanceOf(Date)
+ })
+
+ test('timezone differences are reasonable', () => {
+ const utcTime = getCurrentTimeInTimezone('UTC')
+ const easternTime = getCurrentTimeInTimezone('America/New_York')
+
+ const timeDiff = Math.abs(utcTime.getTime() - easternTime.getTime())
+ expect(timeDiff).toBeLessThan(24 * 60 * 60 * 1000)
+ })
+ })
+
+ describe('formatDateInTimezone', () => {
+ const testDate = new Date('2024-03-15T14:30:00.000Z')
+
+ test('formats date with weekday by default', () => {
+ const result = formatDateInTimezone(testDate, 'UTC')
+
+ expect(result).toContain('March')
+ expect(result).toContain('15')
+ expect(result).toContain('2024')
+ expect(result).toContain('2:30 PM')
+ })
+
+ test('formats date without weekday when specified', () => {
+ const result = formatDateInTimezone(testDate, 'UTC', false)
+
+ expect(result).toContain('March')
+ expect(result).toContain('15')
+ expect(result).toContain('2024')
+ expect(result).toContain('2:30 PM')
+ })
+
+ test('formats date in different timezones', () => {
+ const utcResult = formatDateInTimezone(testDate, 'UTC')
+ const easternResult = formatDateInTimezone(testDate, 'America/New_York')
+
+ expect(utcResult).toContain('2:30 PM')
+ expect(easternResult).toMatch(/\d{1,2}:\d{2} (AM|PM)/)
+ })
+
+ test('handles invalid timezone gracefully', () => {
+ const result = formatDateInTimezone(testDate, 'Invalid/Timezone')
+ expect(typeof result).toBe('string')
+ expect(result.length).toBeGreaterThan(0)
+ })
+ })
+
+ describe('error handling and edge cases', () => {
+ test('convertTimeToUTC handles empty strings', () => {
+ expect(convertTimeToUTC('', 'UTC')).toBe('')
+ expect(convertTimeToUTC('2:30 PM', '')).toBe('2:30 PM')
+ })
+
+ test('convertUTCToUserTimezone handles empty strings', () => {
+ expect(convertUTCToUserTimezone('', 'UTC')).toBe('')
+ expect(convertUTCToUserTimezone('14:30', '')).toBe('14:30')
+ })
+
+ test('convertTimeToUTC handles malformed input parts', () => {
+ expect(convertTimeToUTC('2:PM', 'UTC')).toBe('2:PM')
+ expect(convertTimeToUTC('2:30', 'UTC')).toBe('2:30')
+ expect(convertTimeToUTC('ABC:30 PM', 'UTC')).toBe('ABC:30 PM')
+ })
+
+ test('convertUTCToUserTimezone handles malformed UTC input', () => {
+ expect(convertUTCToUserTimezone('AB:30', 'UTC')).toBe('AB:30')
+ expect(convertUTCToUserTimezone('14:', 'UTC')).toBe('14:')
+ expect(convertUTCToUserTimezone('14:XX', 'UTC')).toBe('14:XX')
+ })
+ })
+})
diff --git a/web/app/components/workflow/nodes/trigger-schedule/utils/timezone-utils.ts b/web/app/components/workflow/nodes/trigger-schedule/utils/timezone-utils.ts
new file mode 100644
index 0000000000..ceba7e640d
--- /dev/null
+++ b/web/app/components/workflow/nodes/trigger-schedule/utils/timezone-utils.ts
@@ -0,0 +1,131 @@
+export const convertTimeToUTC = (time: string, userTimezone: string): string => {
+ try {
+ const [timePart, period] = time.split(' ')
+ if (!timePart || !period) return time
+
+ const [hour, minute] = timePart.split(':')
+ if (!hour || !minute) return time
+
+ let hour24 = Number.parseInt(hour, 10)
+ const minuteNum = Number.parseInt(minute, 10)
+
+ if (Number.isNaN(hour24) || Number.isNaN(minuteNum)) return time
+
+ if (period === 'PM' && hour24 !== 12) hour24 += 12
+ if (period === 'AM' && hour24 === 12) hour24 = 0
+
+ if (userTimezone === 'UTC')
+ return `${String(hour24).padStart(2, '0')}:${String(minuteNum).padStart(2, '0')}`
+
+ const today = new Date()
+ const year = today.getFullYear()
+ const month = today.getMonth()
+ const day = today.getDate()
+
+ const userTime = new Date(year, month, day, hour24, minuteNum)
+
+ const tempFormatter = new Intl.DateTimeFormat('en-CA', {
+ timeZone: userTimezone,
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ hour12: false,
+ })
+
+ const userTimeInTz = tempFormatter.format(userTime).replace(', ', 'T')
+ const userTimeDate = new Date(userTimeInTz)
+ const offset = userTime.getTime() - userTimeDate.getTime()
+ const utcTime = new Date(userTime.getTime() + offset)
+
+ return `${String(utcTime.getHours()).padStart(2, '0')}:${String(utcTime.getMinutes()).padStart(2, '0')}`
+ }
+ catch {
+ return time
+ }
+}
+
+export const convertUTCToUserTimezone = (utcTime: string, userTimezone: string): string => {
+ try {
+ const [hour, minute] = utcTime.split(':')
+ if (!hour || !minute) return utcTime
+
+ const hourNum = Number.parseInt(hour, 10)
+ const minuteNum = Number.parseInt(minute, 10)
+
+ if (Number.isNaN(hourNum) || Number.isNaN(minuteNum)) return utcTime
+
+ const today = new Date()
+ const dateStr = today.toISOString().split('T')[0]
+ const utcDate = new Date(`${dateStr}T${String(hourNum).padStart(2, '0')}:${String(minuteNum).padStart(2, '0')}:00.000Z`)
+
+ return utcDate.toLocaleTimeString('en-US', {
+ timeZone: userTimezone,
+ hour: 'numeric',
+ minute: '2-digit',
+ hour12: true,
+ })
+ }
+ catch {
+ return utcTime
+ }
+}
+
+export const isUTCFormat = (time: string): boolean => {
+ return /^\d{2}:\d{2}$/.test(time)
+}
+
+export const isUserFormat = (time: string): boolean => {
+ return /^\d{1,2}:\d{2} (AM|PM)$/.test(time)
+}
+
+const getTimezoneOffset = (timezone: string): number => {
+ try {
+ const now = new Date()
+ const utc = new Date(now.toLocaleString('en-US', { timeZone: 'UTC' }))
+ const target = new Date(now.toLocaleString('en-US', { timeZone: timezone }))
+ return (target.getTime() - utc.getTime()) / (1000 * 60)
+ }
+ catch {
+ return 0
+ }
+}
+
+export const getCurrentTimeInTimezone = (timezone: string): Date => {
+ try {
+ const now = new Date()
+ const utcTime = now.getTime() + (now.getTimezoneOffset() * 60000)
+ const targetTime = new Date(utcTime + (getTimezoneOffset(timezone) * 60000))
+ return targetTime
+ }
+ catch {
+ return new Date()
+ }
+}
+
+export const formatDateInTimezone = (date: Date, timezone: string, includeWeekday: boolean = true): string => {
+ try {
+ const dateOptions: Intl.DateTimeFormatOptions = {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ timeZone: timezone,
+ }
+
+ if (includeWeekday)
+ dateOptions.weekday = 'short'
+
+ const timeOptions: Intl.DateTimeFormatOptions = {
+ hour: 'numeric',
+ minute: '2-digit',
+ hour12: true,
+ timeZone: timezone,
+ }
+
+ return `${date.toLocaleDateString('en-US', dateOptions)} ${date.toLocaleTimeString('en-US', timeOptions)}`
+ }
+ catch {
+ return date.toLocaleString()
+ }
+}
diff --git a/web/i18n/en-US/workflow.ts b/web/i18n/en-US/workflow.ts
index 3e18f19d7b..54b0f322a5 100644
--- a/web/i18n/en-US/workflow.ts
+++ b/web/i18n/en-US/workflow.ts
@@ -938,7 +938,6 @@ const translation = {
daily: 'Daily',
weekly: 'Weekly',
monthly: 'Monthly',
- once: 'One time',
},
selectFrequency: 'Select frequency',
frequencyLabel: 'Frequency',
@@ -951,9 +950,9 @@ const translation = {
startTime: 'Start Time',
executeNow: 'Execution now',
selectDateTime: 'Select Date & Time',
- recurEvery: 'Recur every',
hours: 'Hours',
minutes: 'Minutes',
+ onMinute: 'On Minute',
days: 'Days',
lastDay: 'Last day',
lastDayTooltip: 'Not all months have 31 days. Use the \'last day\' option to select each month\'s final day.',
@@ -969,11 +968,10 @@ const translation = {
invalidFrequency: 'Invalid frequency',
invalidStartTime: 'Invalid start time',
startTimeMustBeFuture: 'Start time must be in the future',
- invalidRecurEvery: 'Recur every must be between 1 and 999',
- invalidRecurUnit: 'Invalid recur unit',
invalidTimeFormat: 'Invalid time format (expected HH:MM AM/PM)',
invalidWeekday: 'Invalid weekday: {{weekday}}',
invalidMonthlyDay: 'Monthly day must be between 1-31 or "last"',
+ invalidOnMinute: 'On minute must be between 0-59',
invalidExecutionTime: 'Invalid execution time',
executionTimeMustBeFuture: 'Execution time must be in the future',
},
diff --git a/web/i18n/ja-JP/workflow.ts b/web/i18n/ja-JP/workflow.ts
index 48ead1f5f6..58ffb9d746 100644
--- a/web/i18n/ja-JP/workflow.ts
+++ b/web/i18n/ja-JP/workflow.ts
@@ -948,7 +948,6 @@ const translation = {
executeNow: '今すぐ実行',
weekdays: '曜日',
selectDateTime: '日時を選択',
- recurEvery: '間隔',
cronExpression: 'Cron 式',
selectFrequency: '頻度を選択',
lastDay: '月末',
@@ -968,8 +967,6 @@ const translation = {
invalidFrequency: '無効な頻度',
invalidStartTime: '無効な開始時間',
startTimeMustBeFuture: '開始時間は未来の時間である必要があります',
- invalidRecurEvery: '繰り返し間隔は1から999の間である必要があります',
- invalidRecurUnit: '無効な繰り返し単位',
invalidTimeFormat: '無効な時間形式(期待される形式:HH:MM AM/PM)',
invalidWeekday: '無効な曜日:{{weekday}}',
invalidMonthlyDay: '月の日は1-31の間または"last"である必要があります',
diff --git a/web/i18n/zh-Hans/workflow.ts b/web/i18n/zh-Hans/workflow.ts
index e965ec34dc..2c92eb4d22 100644
--- a/web/i18n/zh-Hans/workflow.ts
+++ b/web/i18n/zh-Hans/workflow.ts
@@ -940,7 +940,6 @@ const translation = {
selectFrequency: '选择频率',
nextExecutionTimes: '接下来 5 次执行时间',
hours: '小时',
- recurEvery: '每隔',
minutes: '分钟',
cronExpression: 'Cron 表达式',
weekdays: '星期',
@@ -968,8 +967,6 @@ const translation = {
invalidFrequency: '无效的频率',
invalidStartTime: '无效的开始时间',
startTimeMustBeFuture: '开始时间必须是将来的时间',
- invalidRecurEvery: '重复间隔必须在 1 到 999 之间',
- invalidRecurUnit: '无效的重复单位',
invalidTimeFormat: '无效的时间格式(预期格式:HH:MM AM/PM)',
invalidWeekday: '无效的工作日:{{weekday}}',
invalidMonthlyDay: '月份日期必须在 1-31 之间或为"last"',