mirror of
https://github.com/langgenius/dify.git
synced 2026-04-28 20:17:29 +08:00
feat(trigger-schedule): simplify timezone handling with user-centric approach (#24401)
This commit is contained in:
parent
4084ade86c
commit
e78903302f
@ -16,7 +16,8 @@ const MonthlyDaysSelector = ({ selectedDays, onChange }: MonthlyDaysSelectorProp
|
|||||||
const newSelected = current.includes(day)
|
const newSelected = current.includes(day)
|
||||||
? current.filter(d => d !== day)
|
? current.filter(d => d !== day)
|
||||||
: [...current, day]
|
: [...current, day]
|
||||||
onChange(newSelected)
|
// Ensure at least one day is selected (consistent with WeekdaySelector)
|
||||||
|
onChange(newSelected.length > 0 ? newSelected : [day])
|
||||||
}
|
}
|
||||||
|
|
||||||
const isDaySelected = (day: number | 'last') => selectedDays?.includes(day) || false
|
const isDaySelected = (day: number | 'last') => selectedDays?.includes(day) || false
|
||||||
|
|||||||
@ -0,0 +1,21 @@
|
|||||||
|
import type { ScheduleTriggerNodeType } from './types'
|
||||||
|
|
||||||
|
// Unified default values for trigger schedule
|
||||||
|
export const getDefaultScheduleConfig = (): Partial<ScheduleTriggerNodeType> => ({
|
||||||
|
mode: 'visual',
|
||||||
|
frequency: 'weekly',
|
||||||
|
enabled: true,
|
||||||
|
visual_config: {
|
||||||
|
time: '11:30 AM',
|
||||||
|
weekdays: ['sun'],
|
||||||
|
on_minute: 0,
|
||||||
|
monthly_days: [1],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const getDefaultVisualConfig = () => ({
|
||||||
|
time: '11:30 AM',
|
||||||
|
weekdays: ['sun'],
|
||||||
|
on_minute: 0,
|
||||||
|
monthly_days: [1],
|
||||||
|
})
|
||||||
@ -4,6 +4,7 @@ import type { ScheduleTriggerNodeType } from './types'
|
|||||||
import { ALL_CHAT_AVAILABLE_BLOCKS, ALL_COMPLETION_AVAILABLE_BLOCKS } from '@/app/components/workflow/blocks'
|
import { ALL_CHAT_AVAILABLE_BLOCKS, ALL_COMPLETION_AVAILABLE_BLOCKS } from '@/app/components/workflow/blocks'
|
||||||
import { isValidCronExpression } from './utils/cron-parser'
|
import { isValidCronExpression } from './utils/cron-parser'
|
||||||
import { getNextExecutionTimes } from './utils/execution-time-calculator'
|
import { getNextExecutionTimes } from './utils/execution-time-calculator'
|
||||||
|
import { getDefaultScheduleConfig } from './constants'
|
||||||
const isValidTimeFormat = (time: string): boolean => {
|
const isValidTimeFormat = (time: string): boolean => {
|
||||||
const timeRegex = /^(0?\d|1[0-2]):[0-5]\d (AM|PM)$/
|
const timeRegex = /^(0?\d|1[0-2]):[0-5]\d (AM|PM)$/
|
||||||
if (!timeRegex.test(time)) return false
|
if (!timeRegex.test(time)) return false
|
||||||
@ -104,16 +105,10 @@ const validateVisualConfig = (payload: ScheduleTriggerNodeType, t: any): string
|
|||||||
|
|
||||||
const nodeDefault: NodeDefault<ScheduleTriggerNodeType> = {
|
const nodeDefault: NodeDefault<ScheduleTriggerNodeType> = {
|
||||||
defaultValue: {
|
defaultValue: {
|
||||||
mode: 'visual',
|
...getDefaultScheduleConfig(),
|
||||||
frequency: 'weekly',
|
|
||||||
cron_expression: '',
|
cron_expression: '',
|
||||||
visual_config: {
|
timezone: 'UTC',
|
||||||
time: '11:30 AM',
|
} as ScheduleTriggerNodeType,
|
||||||
weekdays: ['sun'],
|
|
||||||
},
|
|
||||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
getAvailablePrevNodes(_isChatMode: boolean) {
|
getAvailablePrevNodes(_isChatMode: boolean) {
|
||||||
return []
|
return []
|
||||||
},
|
},
|
||||||
|
|||||||
@ -78,6 +78,7 @@ const Panel: FC<NodePanelProps<ScheduleTriggerNodeType>> = ({
|
|||||||
</label>
|
</label>
|
||||||
<TimePicker
|
<TimePicker
|
||||||
notClearable={true}
|
notClearable={true}
|
||||||
|
timezone={inputs.timezone}
|
||||||
value={inputs.visual_config?.time
|
value={inputs.visual_config?.time
|
||||||
? dayjs(`1/1/2000 ${inputs.visual_config.time}`)
|
? dayjs(`1/1/2000 ${inputs.visual_config.time}`)
|
||||||
: dayjs('1/1/2000 11:30 AM')
|
: dayjs('1/1/2000 11:30 AM')
|
||||||
|
|||||||
@ -5,17 +5,17 @@ export type ScheduleMode = 'visual' | 'cron'
|
|||||||
export type ScheduleFrequency = 'hourly' | 'daily' | 'weekly' | 'monthly'
|
export type ScheduleFrequency = 'hourly' | 'daily' | 'weekly' | 'monthly'
|
||||||
|
|
||||||
export type VisualConfig = {
|
export type VisualConfig = {
|
||||||
time?: string
|
time?: string // User timezone time format: "2:30 PM"
|
||||||
weekdays?: string[]
|
weekdays?: string[] // ['mon', 'tue', 'wed'] for weekly frequency
|
||||||
on_minute?: number
|
on_minute?: number // 0-59 for hourly frequency
|
||||||
monthly_days?: (number | 'last')[]
|
monthly_days?: (number | 'last')[] // [1, 15, 'last'] for monthly frequency
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ScheduleTriggerNodeType = CommonNodeType & {
|
export type ScheduleTriggerNodeType = CommonNodeType & {
|
||||||
mode: ScheduleMode
|
mode: ScheduleMode // 'visual' or 'cron' configuration mode
|
||||||
frequency: ScheduleFrequency
|
frequency: ScheduleFrequency // 'hourly' | 'daily' | 'weekly' | 'monthly'
|
||||||
cron_expression?: string
|
cron_expression?: string // Cron expression when mode is 'cron'
|
||||||
visual_config?: VisualConfig
|
visual_config?: VisualConfig // User-friendly configuration when mode is 'visual'
|
||||||
timezone: string
|
timezone: string // User profile timezone (e.g., 'Asia/Shanghai', 'America/New_York')
|
||||||
enabled: boolean
|
enabled: boolean // Whether the trigger is active
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,63 +2,29 @@ import { useCallback, useMemo } from 'react'
|
|||||||
import type { ScheduleFrequency, ScheduleMode, ScheduleTriggerNodeType } from './types'
|
import type { ScheduleFrequency, ScheduleMode, ScheduleTriggerNodeType } from './types'
|
||||||
import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud'
|
import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud'
|
||||||
import { useNodesReadOnly } from '@/app/components/workflow/hooks'
|
import { useNodesReadOnly } from '@/app/components/workflow/hooks'
|
||||||
import { convertTimeToUTC, convertUTCToUserTimezone, isUTCFormat, isUserFormat } from './utils/timezone-utils'
|
import { useAppContext } from '@/context/app-context'
|
||||||
|
import { getDefaultVisualConfig } from './constants'
|
||||||
|
|
||||||
const useConfig = (id: string, payload: ScheduleTriggerNodeType) => {
|
const useConfig = (id: string, payload: ScheduleTriggerNodeType) => {
|
||||||
const { nodesReadOnly: readOnly } = useNodesReadOnly()
|
const { nodesReadOnly: readOnly } = useNodesReadOnly()
|
||||||
|
|
||||||
|
const { userProfile } = useAppContext()
|
||||||
|
|
||||||
const frontendPayload = useMemo(() => {
|
const frontendPayload = useMemo(() => {
|
||||||
const basePayload = {
|
return {
|
||||||
...payload,
|
...payload,
|
||||||
mode: payload.mode || 'visual',
|
mode: payload.mode || 'visual',
|
||||||
frequency: payload.frequency || 'weekly',
|
frequency: payload.frequency || 'weekly',
|
||||||
timezone: payload.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
|
timezone: userProfile.timezone || 'UTC',
|
||||||
enabled: payload.enabled !== undefined ? payload.enabled : true,
|
enabled: payload.enabled !== undefined ? payload.enabled : true,
|
||||||
}
|
|
||||||
|
|
||||||
// 只有当时间是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: {
|
visual_config: {
|
||||||
time: '11:30 AM',
|
...getDefaultVisualConfig(),
|
||||||
weekdays: ['sun'],
|
|
||||||
...payload.visual_config,
|
...payload.visual_config,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}, [payload])
|
}, [payload, userProfile.timezone])
|
||||||
|
|
||||||
const { inputs, setInputs } = useNodeCrud<ScheduleTriggerNodeType>(id, frontendPayload, {
|
const { inputs, setInputs } = useNodeCrud<ScheduleTriggerNodeType>(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 handleModeChange = useCallback((mode: ScheduleMode) => {
|
||||||
const newInputs = {
|
const newInputs = {
|
||||||
|
|||||||
@ -110,7 +110,7 @@ const matchesCron = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const parseCronExpression = (cronExpression: string): Date[] => {
|
export const parseCronExpression = (cronExpression: string, timezone: string = 'UTC'): Date[] => {
|
||||||
if (!cronExpression || cronExpression.trim() === '')
|
if (!cronExpression || cronExpression.trim() === '')
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@ -122,38 +122,34 @@ export const parseCronExpression = (cronExpression: string): Date[] => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const nextTimes: Date[] = []
|
const nextTimes: Date[] = []
|
||||||
|
|
||||||
|
// Get user timezone current time - no browser timezone involved
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
|
const userTimeStr = now.toLocaleString('en-CA', {
|
||||||
|
timeZone: timezone,
|
||||||
|
hour12: false,
|
||||||
|
})
|
||||||
|
const [dateStr, timeStr] = userTimeStr.split(', ')
|
||||||
|
const [year, monthNum, day] = dateStr.split('-').map(Number)
|
||||||
|
const [nowHour, nowMinute, nowSecond] = timeStr.split(':').map(Number)
|
||||||
|
const userToday = new Date(year, monthNum - 1, day, 0, 0, 0, 0)
|
||||||
|
const userCurrentTime = new Date(year, monthNum - 1, day, nowHour, nowMinute, nowSecond)
|
||||||
|
|
||||||
// Start from next minute
|
|
||||||
const startTime = new Date(now)
|
|
||||||
startTime.setMinutes(startTime.getMinutes() + 1)
|
|
||||||
startTime.setSeconds(0, 0)
|
|
||||||
|
|
||||||
// For monthly expressions (like "15 10 1 * *"), we need to check more months
|
|
||||||
// For weekly expressions, we need to check more weeks
|
|
||||||
// Use a smarter approach: check up to 12 months for monthly patterns
|
|
||||||
const isMonthlyPattern = dayOfMonth !== '*' && dayOfWeek === '*'
|
const isMonthlyPattern = dayOfMonth !== '*' && dayOfWeek === '*'
|
||||||
const isWeeklyPattern = dayOfMonth === '*' && dayOfWeek !== '*'
|
const isWeeklyPattern = dayOfMonth === '*' && dayOfWeek !== '*'
|
||||||
|
|
||||||
let searchMonths = 12
|
let searchMonths = 12
|
||||||
if (isWeeklyPattern) searchMonths = 3 // 3 months should cover 12+ weeks
|
if (isWeeklyPattern) searchMonths = 3
|
||||||
else if (!isMonthlyPattern) searchMonths = 2 // For daily/hourly patterns
|
else if (!isMonthlyPattern) searchMonths = 2
|
||||||
|
|
||||||
// Check across multiple months
|
|
||||||
for (let monthOffset = 0; monthOffset < searchMonths && nextTimes.length < 5; monthOffset++) {
|
for (let monthOffset = 0; monthOffset < searchMonths && nextTimes.length < 5; monthOffset++) {
|
||||||
const checkMonth = new Date(startTime.getFullYear(), startTime.getMonth() + monthOffset, 1)
|
const checkMonth = new Date(userToday.getFullYear(), userToday.getMonth() + monthOffset, 1)
|
||||||
|
|
||||||
// Get the number of days in this month
|
|
||||||
const daysInMonth = new Date(checkMonth.getFullYear(), checkMonth.getMonth() + 1, 0).getDate()
|
const daysInMonth = new Date(checkMonth.getFullYear(), checkMonth.getMonth() + 1, 0).getDate()
|
||||||
|
|
||||||
// Check each day in this month
|
|
||||||
for (let day = 1; day <= daysInMonth && nextTimes.length < 5; day++) {
|
for (let day = 1; day <= daysInMonth && nextTimes.length < 5; day++) {
|
||||||
const checkDate = new Date(checkMonth.getFullYear(), checkMonth.getMonth(), day)
|
const checkDate = new Date(checkMonth.getFullYear(), checkMonth.getMonth(), day)
|
||||||
|
|
||||||
// For each day, check the specific hour and minute from cron
|
|
||||||
// This is more efficient than checking all hours/minutes
|
|
||||||
if (minute !== '*' && hour !== '*') {
|
if (minute !== '*' && hour !== '*') {
|
||||||
// Extract specific minute and hour values
|
|
||||||
const minuteValues = expandCronField(minute, 0, 59)
|
const minuteValues = expandCronField(minute, 0, 59)
|
||||||
const hourValues = expandCronField(hour, 0, 23)
|
const hourValues = expandCronField(hour, 0, 23)
|
||||||
|
|
||||||
@ -161,23 +157,19 @@ export const parseCronExpression = (cronExpression: string): Date[] => {
|
|||||||
for (const m of minuteValues) {
|
for (const m of minuteValues) {
|
||||||
checkDate.setHours(h, m, 0, 0)
|
checkDate.setHours(h, m, 0, 0)
|
||||||
|
|
||||||
// Skip if this time is before our start time
|
// Only add if execution time is in the future and matches cron pattern
|
||||||
if (checkDate <= now) continue
|
if (checkDate > userCurrentTime && matchesCron(checkDate, minute, hour, dayOfMonth, month, dayOfWeek))
|
||||||
|
|
||||||
if (matchesCron(checkDate, minute, hour, dayOfMonth, month, dayOfWeek))
|
|
||||||
nextTimes.push(new Date(checkDate))
|
nextTimes.push(new Date(checkDate))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
// Fallback for complex expressions with wildcards
|
|
||||||
for (let h = 0; h < 24 && nextTimes.length < 5; h++) {
|
for (let h = 0; h < 24 && nextTimes.length < 5; h++) {
|
||||||
for (let m = 0; m < 60 && nextTimes.length < 5; m++) {
|
for (let m = 0; m < 60 && nextTimes.length < 5; m++) {
|
||||||
checkDate.setHours(h, m, 0, 0)
|
checkDate.setHours(h, m, 0, 0)
|
||||||
|
|
||||||
if (checkDate <= now) continue
|
// Only add if execution time is in the future and matches cron pattern
|
||||||
|
if (checkDate > userCurrentTime && matchesCron(checkDate, minute, hour, dayOfMonth, month, dayOfWeek))
|
||||||
if (matchesCron(checkDate, minute, hour, dayOfMonth, month, dayOfWeek))
|
|
||||||
nextTimes.push(new Date(checkDate))
|
nextTimes.push(new Date(checkDate))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -187,7 +179,7 @@ export const parseCronExpression = (cronExpression: string): Date[] => {
|
|||||||
|
|
||||||
return nextTimes.sort((a, b) => a.getTime() - b.getTime()).slice(0, 5)
|
return nextTimes.sort((a, b) => a.getTime() - b.getTime()).slice(0, 5)
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,769 +1,144 @@
|
|||||||
import { formatExecutionTime, getDefaultDateTime, getFormattedExecutionTimes, getNextExecutionTime, getNextExecutionTimes } from './execution-time-calculator'
|
import {
|
||||||
|
getDefaultDateTime,
|
||||||
|
getNextExecutionTime,
|
||||||
|
getNextExecutionTimes,
|
||||||
|
} from './execution-time-calculator'
|
||||||
import type { ScheduleTriggerNodeType } from '../types'
|
import type { ScheduleTriggerNodeType } from '../types'
|
||||||
|
|
||||||
const createMockData = (overrides: Partial<ScheduleTriggerNodeType> = {}): ScheduleTriggerNodeType => ({
|
const createMockData = (overrides: Partial<ScheduleTriggerNodeType> = {}): ScheduleTriggerNodeType => ({
|
||||||
id: 'test-node',
|
id: 'test-node',
|
||||||
type: 'schedule-trigger',
|
type: 'schedule-trigger',
|
||||||
mode: 'visual',
|
mode: 'visual',
|
||||||
frequency: 'weekly',
|
frequency: 'daily',
|
||||||
visual_config: {
|
visual_config: {
|
||||||
time: '11:30 AM',
|
time: '2:30 PM',
|
||||||
weekdays: ['sun'],
|
|
||||||
},
|
},
|
||||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
timezone: 'UTC',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
...overrides,
|
...overrides,
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('execution-time-calculator', () => {
|
describe('execution-time-calculator', () => {
|
||||||
beforeEach(() => {
|
beforeAll(() => {
|
||||||
jest.useFakeTimers()
|
jest.useFakeTimers()
|
||||||
jest.setSystemTime(new Date(2024, 0, 15, 10, 0, 0))
|
jest.setSystemTime(new Date('2024-01-15T10:00:00Z'))
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterAll(() => {
|
||||||
jest.useRealTimers()
|
jest.useRealTimers()
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('formatExecutionTime', () => {
|
describe('getDefaultDateTime', () => {
|
||||||
const testTimezone = 'America/New_York'
|
it('returns consistent default datetime', () => {
|
||||||
|
const defaultDate = getDefaultDateTime()
|
||||||
test('formats time with weekday by default', () => {
|
expect(defaultDate.getFullYear()).toBe(2024)
|
||||||
const date = new Date(2024, 0, 16, 14, 30)
|
expect(defaultDate.getMonth()).toBe(0)
|
||||||
const result = formatExecutionTime(date, testTimezone)
|
expect(defaultDate.getDate()).toBe(2)
|
||||||
|
|
||||||
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, testTimezone, false)
|
|
||||||
|
|
||||||
expect(result).not.toMatch(/^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/)
|
|
||||||
expect(result).toContain('January 16, 2024')
|
|
||||||
})
|
|
||||||
|
|
||||||
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(utcResult).toBeDefined()
|
|
||||||
expect(easternResult).toBeDefined()
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('getNextExecutionTimes - hourly frequency', () => {
|
describe('daily frequency', () => {
|
||||||
test('calculates hourly executions at specified minute', () => {
|
it('generates daily executions at configured time', () => {
|
||||||
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 current minute less than target minute', () => {
|
|
||||||
jest.setSystemTime(new Date(2024, 0, 15, 10, 15, 0))
|
|
||||||
|
|
||||||
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 daily executions', () => {
|
|
||||||
const data = createMockData({
|
const data = createMockData({
|
||||||
frequency: 'daily',
|
frequency: 'daily',
|
||||||
visual_config: { time: '2:30 PM' },
|
visual_config: { time: '9:15 AM' },
|
||||||
|
timezone: 'UTC',
|
||||||
})
|
})
|
||||||
|
|
||||||
const result = getNextExecutionTimes(data, 3)
|
const result = getNextExecutionTimes(data, 2)
|
||||||
|
|
||||||
expect(result).toHaveLength(3)
|
expect(result).toHaveLength(2)
|
||||||
result.forEach((date) => {
|
expect(result[0].getHours()).toBe(9)
|
||||||
expect(date.getHours()).toBe(14)
|
expect(result[0].getMinutes()).toBe(15)
|
||||||
expect(date.getMinutes()).toBe(30)
|
|
||||||
})
|
|
||||||
expect(result[1].getDate()).toBe(result[0].getDate() + 1)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('handles past time by moving to next day', () => {
|
it('skips today if time has passed', () => {
|
||||||
jest.setSystemTime(new Date(2024, 0, 15, 15, 0, 0))
|
jest.setSystemTime(new Date('2024-01-15T15:00:00Z'))
|
||||||
|
|
||||||
const data = createMockData({
|
const data = createMockData({
|
||||||
frequency: 'daily',
|
frequency: 'daily',
|
||||||
visual_config: { time: '2:30 PM' },
|
visual_config: { time: '2:30 PM' },
|
||||||
|
timezone: 'UTC',
|
||||||
})
|
})
|
||||||
|
|
||||||
const result = getNextExecutionTimes(data, 1)
|
const result = getNextExecutionTimes(data, 2)
|
||||||
|
expect(result[0].getDate()).toBe(16) // Tomorrow
|
||||||
expect(result[0].getDate()).toBe(16)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('handles AM/PM conversion correctly', () => {
|
|
||||||
const dataAM = createMockData({
|
|
||||||
frequency: 'daily',
|
|
||||||
visual_config: { time: '12:00 AM' },
|
|
||||||
})
|
|
||||||
const dataPM = createMockData({
|
|
||||||
frequency: 'daily',
|
|
||||||
visual_config: { time: '12:00 PM' },
|
|
||||||
})
|
|
||||||
|
|
||||||
const resultAM = getNextExecutionTimes(dataAM, 1)
|
|
||||||
const resultPM = getNextExecutionTimes(dataPM, 1)
|
|
||||||
|
|
||||||
expect(resultAM[0].getHours()).toBe(0)
|
|
||||||
expect(resultPM[0].getHours()).toBe(12)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('getNextExecutionTimes - weekly frequency', () => {
|
describe('hourly frequency', () => {
|
||||||
test('calculates weekly executions for multiple days', () => {
|
it('generates hourly executions', () => {
|
||||||
const data = createMockData({
|
const data = createMockData({
|
||||||
frequency: 'weekly',
|
frequency: 'hourly',
|
||||||
visual_config: {
|
visual_config: { on_minute: 30 },
|
||||||
time: '2:30 PM',
|
timezone: 'UTC',
|
||||||
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: {
|
|
||||||
time: '2:30 PM',
|
|
||||||
weekdays: ['sun'],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = getNextExecutionTimes(data, 3)
|
|
||||||
|
|
||||||
expect(result).toHaveLength(3)
|
|
||||||
result.forEach((date) => {
|
|
||||||
expect(date.getDay()).toBe(0)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test('handles current day execution', () => {
|
|
||||||
jest.setSystemTime(new Date(2024, 0, 15, 10, 0))
|
|
||||||
|
|
||||||
const data = createMockData({
|
|
||||||
frequency: 'weekly',
|
|
||||||
visual_config: {
|
|
||||||
time: '2:30 PM',
|
|
||||||
weekdays: ['mon'],
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const result = getNextExecutionTimes(data, 2)
|
const result = getNextExecutionTimes(data, 2)
|
||||||
|
|
||||||
expect(result[0].getDate()).toBe(15)
|
expect(result).toHaveLength(2)
|
||||||
expect(result[1].getDate()).toBe(22)
|
expect(result[0].getMinutes()).toBe(30)
|
||||||
})
|
expect(result[1].getMinutes()).toBe(30)
|
||||||
|
|
||||||
test('sorts results chronologically', () => {
|
|
||||||
const data = createMockData({
|
|
||||||
frequency: 'weekly',
|
|
||||||
visual_config: {
|
|
||||||
time: '9:00 AM',
|
|
||||||
weekdays: ['fri', 'mon', 'wed'],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = getNextExecutionTimes(data, 6)
|
|
||||||
|
|
||||||
for (let i = 1; i < result.length; i++)
|
|
||||||
expect(result[i].getTime()).toBeGreaterThan(result[i - 1].getTime())
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('getNextExecutionTimes - monthly frequency', () => {
|
describe('cron mode', () => {
|
||||||
test('calculates monthly executions for specific day', () => {
|
it('handles valid cron expressions', () => {
|
||||||
const data = createMockData({
|
|
||||||
frequency: 'monthly',
|
|
||||||
visual_config: {
|
|
||||||
time: '2:30 PM',
|
|
||||||
monthly_days: [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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test('handles last day of month', () => {
|
|
||||||
const data = createMockData({
|
|
||||||
frequency: 'monthly',
|
|
||||||
visual_config: {
|
|
||||||
time: '11:30 AM',
|
|
||||||
monthly_days: ['last'],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = getNextExecutionTimes(data, 4)
|
|
||||||
|
|
||||||
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('handles multiple monthly days', () => {
|
|
||||||
const data = createMockData({
|
|
||||||
frequency: 'monthly',
|
|
||||||
visual_config: {
|
|
||||||
time: '10:00 AM',
|
|
||||||
monthly_days: [1, 15, 'last'],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = getNextExecutionTimes(data, 6)
|
|
||||||
|
|
||||||
expect(result).toHaveLength(6)
|
|
||||||
result.forEach((date) => {
|
|
||||||
expect(date.getHours()).toBe(10)
|
|
||||||
expect(date.getMinutes()).toBe(0)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test('defaults to day 1 when monthly_days not specified', () => {
|
|
||||||
const data = createMockData({
|
|
||||||
frequency: 'monthly',
|
|
||||||
visual_config: { time: '10:00 AM' },
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = getNextExecutionTimes(data, 2)
|
|
||||||
|
|
||||||
result.forEach((date) => {
|
|
||||||
expect(date.getDate()).toBe(1)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('getNextExecutionTimes - cron mode', () => {
|
|
||||||
test('uses cron parser for cron expressions', () => {
|
|
||||||
const data = createMockData({
|
const data = createMockData({
|
||||||
mode: 'cron',
|
mode: 'cron',
|
||||||
cron_expression: '0 12 * * *',
|
cron_expression: '30 14 * * *',
|
||||||
|
timezone: 'UTC',
|
||||||
})
|
})
|
||||||
|
|
||||||
const result = getNextExecutionTimes(data, 3)
|
const result = getNextExecutionTimes(data, 2)
|
||||||
|
|
||||||
expect(result).toHaveLength(3)
|
expect(result.length).toBeGreaterThan(0)
|
||||||
result.forEach((date) => {
|
if (result.length > 0) {
|
||||||
expect(date.getHours()).toBe(12)
|
expect(result[0].getHours()).toBe(14)
|
||||||
expect(date.getMinutes()).toBe(0)
|
expect(result[0].getMinutes()).toBe(30)
|
||||||
})
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test('returns empty array for invalid cron expression', () => {
|
it('returns empty for invalid cron', () => {
|
||||||
const data = createMockData({
|
const data = createMockData({
|
||||||
mode: 'cron',
|
mode: 'cron',
|
||||||
cron_expression: 'invalid',
|
cron_expression: 'invalid',
|
||||||
})
|
|
||||||
|
|
||||||
const result = getNextExecutionTimes(data, 5)
|
|
||||||
|
|
||||||
expect(result).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
test('returns empty array for missing cron expression', () => {
|
|
||||||
const data = createMockData({
|
|
||||||
mode: 'cron',
|
|
||||||
cron_expression: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = getNextExecutionTimes(data, 5)
|
|
||||||
|
|
||||||
expect(result).toEqual([])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('getNextExecutionTimes - fallback behavior', () => {
|
|
||||||
test('handles unknown frequency by returning next days', () => {
|
|
||||||
const data = createMockData({
|
|
||||||
frequency: 'unknown' as any,
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = getNextExecutionTimes(data, 3)
|
|
||||||
|
|
||||||
expect(result).toHaveLength(3)
|
|
||||||
expect(result[0].getDate()).toBe(16)
|
|
||||||
expect(result[1].getDate()).toBe(17)
|
|
||||||
expect(result[2].getDate()).toBe(18)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('getFormattedExecutionTimes', () => {
|
|
||||||
test('formats daily execution times without weekday', () => {
|
|
||||||
const data = createMockData({
|
|
||||||
frequency: 'daily',
|
|
||||||
visual_config: { time: '2:30 PM' },
|
|
||||||
timezone: 'UTC',
|
timezone: 'UTC',
|
||||||
})
|
})
|
||||||
|
|
||||||
const result = getFormattedExecutionTimes(data, 2)
|
const result = getNextExecutionTimes(data, 2)
|
||||||
|
|
||||||
expect(result).toHaveLength(2)
|
|
||||||
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', () => {
|
|
||||||
const data = createMockData({
|
|
||||||
frequency: 'weekly',
|
|
||||||
visual_config: {
|
|
||||||
time: '2:30 PM',
|
|
||||||
weekdays: ['sun'],
|
|
||||||
},
|
|
||||||
timezone: 'UTC',
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = getFormattedExecutionTimes(data, 2)
|
|
||||||
|
|
||||||
expect(result).toHaveLength(2)
|
|
||||||
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: { on_minute: 15 },
|
|
||||||
timezone: 'UTC',
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = getFormattedExecutionTimes(data, 1)
|
|
||||||
|
|
||||||
expect(result).toHaveLength(1)
|
|
||||||
expect(result[0]).not.toMatch(/^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('returns empty array when no execution times', () => {
|
|
||||||
const data = createMockData({
|
|
||||||
mode: 'cron',
|
|
||||||
cron_expression: 'invalid',
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = getFormattedExecutionTimes(data, 5)
|
|
||||||
|
|
||||||
expect(result).toEqual([])
|
expect(result).toEqual([])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('getNextExecutionTime', () => {
|
describe('getNextExecutionTime', () => {
|
||||||
test('returns first formatted execution time', () => {
|
it('returns formatted time string', () => {
|
||||||
const data = createMockData({
|
|
||||||
frequency: 'daily',
|
|
||||||
visual_config: { time: '2:30 PM' },
|
|
||||||
timezone: 'UTC',
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = getNextExecutionTime(data)
|
|
||||||
|
|
||||||
expect(result).toContain('2024')
|
|
||||||
})
|
|
||||||
|
|
||||||
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).toContain('2024')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('applies correct weekday formatting based on frequency', () => {
|
|
||||||
const weeklyData = createMockData({
|
|
||||||
frequency: 'weekly',
|
|
||||||
visual_config: {
|
|
||||||
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(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({
|
|
||||||
frequency: 'daily',
|
|
||||||
visual_config: undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = getNextExecutionTimes(data, 1)
|
|
||||||
|
|
||||||
expect(result).toHaveLength(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('handles malformed time strings gracefully', () => {
|
|
||||||
const data = createMockData({
|
|
||||||
frequency: 'daily',
|
|
||||||
visual_config: { time: 'invalid time' },
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(() => getNextExecutionTimes(data, 1)).not.toThrow()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('returns reasonable defaults for zero count', () => {
|
|
||||||
const data = createMockData({
|
|
||||||
frequency: 'daily',
|
|
||||||
visual_config: { time: '2:30 PM' },
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = getNextExecutionTimes(data, 0)
|
|
||||||
|
|
||||||
expect(result).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
test('hourly frequency handles missing on_minute', () => {
|
|
||||||
const data = createMockData({
|
|
||||||
frequency: 'hourly',
|
|
||||||
visual_config: {},
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = getNextExecutionTimes(data, 1)
|
|
||||||
|
|
||||||
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({
|
const data = createMockData({
|
||||||
frequency: 'daily',
|
frequency: 'daily',
|
||||||
visual_config: { time: '3:15 PM' },
|
visual_config: { time: '3:15 PM' },
|
||||||
timezone: 'UTC',
|
timezone: 'UTC',
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(data.visual_config?.time).toBe('3:15 PM')
|
const result = getNextExecutionTime(data)
|
||||||
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', () => {
|
expect(result).toContain('3:15 PM')
|
||||||
const data = createMockData({
|
expect(result).toContain('2024')
|
||||||
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', () => {
|
describe('edge cases', () => {
|
||||||
test('execution times are calculated in user timezone', () => {
|
it('handles zero count', () => {
|
||||||
const easternData = createMockData({
|
const data = createMockData()
|
||||||
frequency: 'daily',
|
const result = getNextExecutionTimes(data, 0)
|
||||||
visual_config: { time: '12:00 PM' },
|
expect(result).toEqual([])
|
||||||
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', () => {
|
it('handles missing visual_config', () => {
|
||||||
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({
|
const data = createMockData({
|
||||||
frequency: 'daily',
|
visual_config: undefined,
|
||||||
visual_config: { time: '11:59 PM' },
|
|
||||||
timezone: 'Pacific/Honolulu',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const result = getNextExecutionTimes(data, 1)
|
expect(() => getNextExecutionTimes(data, 1)).not.toThrow()
|
||||||
|
|
||||||
expect(result).toHaveLength(1)
|
|
||||||
expect(result[0].getHours()).toBe(23)
|
|
||||||
expect(result[0].getMinutes()).toBe(59)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,16 +1,42 @@
|
|||||||
import type { ScheduleTriggerNodeType } from '../types'
|
import type { ScheduleTriggerNodeType } from '../types'
|
||||||
import { isValidCronExpression, parseCronExpression } from './cron-parser'
|
import { isValidCronExpression, parseCronExpression } from './cron-parser'
|
||||||
import { formatDateInTimezone, getCurrentTimeInTimezone } from './timezone-utils'
|
|
||||||
|
|
||||||
const getCurrentTime = (timezone?: string): Date => {
|
// Get current time completely in user timezone, no browser timezone involved
|
||||||
return timezone ? getCurrentTimeInTimezone(timezone) : new Date()
|
const getUserTimezoneCurrentTime = (timezone: string): Date => {
|
||||||
|
const now = new Date()
|
||||||
|
const userTimeStr = now.toLocaleString('en-CA', {
|
||||||
|
timeZone: timezone,
|
||||||
|
hour12: false,
|
||||||
|
})
|
||||||
|
const [dateStr, timeStr] = userTimeStr.split(', ')
|
||||||
|
const [year, month, day] = dateStr.split('-').map(Number)
|
||||||
|
const [hour, minute, second] = timeStr.split(':').map(Number)
|
||||||
|
return new Date(year, month - 1, day, hour, minute, second)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format date that is already in user timezone, no timezone conversion
|
||||||
|
const formatUserTimezoneDate = (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,
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${date.toLocaleDateString('en-US', dateOptions)} ${date.toLocaleTimeString('en-US', timeOptions)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to get default datetime - consistent with base DatePicker
|
// Helper function to get default datetime - consistent with base DatePicker
|
||||||
export const getDefaultDateTime = (): Date => {
|
export const getDefaultDateTime = (): Date => {
|
||||||
const defaultDate = new Date()
|
const defaultDate = new Date(2024, 0, 2, 11, 30, 0, 0)
|
||||||
defaultDate.setHours(11, 30, 0, 0)
|
|
||||||
defaultDate.setDate(defaultDate.getDate() + 1)
|
|
||||||
return defaultDate
|
return defaultDate
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -18,27 +44,36 @@ export const getNextExecutionTimes = (data: ScheduleTriggerNodeType, count: numb
|
|||||||
if (data.mode === 'cron') {
|
if (data.mode === 'cron') {
|
||||||
if (!data.cron_expression || !isValidCronExpression(data.cron_expression))
|
if (!data.cron_expression || !isValidCronExpression(data.cron_expression))
|
||||||
return []
|
return []
|
||||||
return parseCronExpression(data.cron_expression).slice(0, count)
|
return parseCronExpression(data.cron_expression, data.timezone).slice(0, count)
|
||||||
}
|
}
|
||||||
|
|
||||||
const times: Date[] = []
|
const times: Date[] = []
|
||||||
const defaultTime = data.visual_config?.time || '11:30 AM'
|
const defaultTime = data.visual_config?.time || '11:30 AM'
|
||||||
|
|
||||||
|
// Get "today" in user's timezone for display purposes
|
||||||
|
const now = new Date()
|
||||||
|
const userTodayStr = now.toLocaleDateString('en-CA', { timeZone: data.timezone })
|
||||||
|
const [year, month, day] = userTodayStr.split('-').map(Number)
|
||||||
|
const userToday = new Date(year, month - 1, day, 0, 0, 0, 0)
|
||||||
|
|
||||||
if (data.frequency === 'hourly') {
|
if (data.frequency === 'hourly') {
|
||||||
const onMinute = data.visual_config?.on_minute ?? 0
|
const onMinute = data.visual_config?.on_minute ?? 0
|
||||||
const now = getCurrentTime(data.timezone)
|
|
||||||
const currentHour = now.getHours()
|
|
||||||
const currentMinute = now.getMinutes()
|
|
||||||
|
|
||||||
let nextExecution: Date
|
// Get current time completely in user timezone
|
||||||
if (currentMinute <= onMinute)
|
const userCurrentTime = getUserTimezoneCurrentTime(data.timezone)
|
||||||
nextExecution = new Date(now.getFullYear(), now.getMonth(), now.getDate(), currentHour, onMinute, 0, 0)
|
|
||||||
else
|
let hour = userCurrentTime.getHours()
|
||||||
nextExecution = new Date(now.getFullYear(), now.getMonth(), now.getDate(), currentHour + 1, onMinute, 0, 0)
|
if (userCurrentTime.getMinutes() >= onMinute)
|
||||||
|
hour += 1 // Start from next hour if current minute has passed
|
||||||
|
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
const execution = new Date(nextExecution)
|
const execution = new Date(userToday)
|
||||||
execution.setHours(nextExecution.getHours() + i)
|
execution.setHours(hour + i, onMinute, 0, 0)
|
||||||
|
// Handle day overflow
|
||||||
|
if (hour + i >= 24) {
|
||||||
|
execution.setDate(userToday.getDate() + Math.floor((hour + i) / 24))
|
||||||
|
execution.setHours((hour + i) % 24, onMinute, 0, 0)
|
||||||
|
}
|
||||||
times.push(execution)
|
times.push(execution)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -49,16 +84,19 @@ export const getNextExecutionTimes = (data: ScheduleTriggerNodeType, count: numb
|
|||||||
if (period === 'PM' && displayHour !== 12) displayHour += 12
|
if (period === 'PM' && displayHour !== 12) displayHour += 12
|
||||||
if (period === 'AM' && displayHour === 12) displayHour = 0
|
if (period === 'AM' && displayHour === 12) displayHour = 0
|
||||||
|
|
||||||
const now = getCurrentTime(data.timezone)
|
// Check if today's configured time has already passed
|
||||||
const baseExecution = new Date(now.getFullYear(), now.getMonth(), now.getDate(), displayHour, Number.parseInt(minute), 0, 0)
|
const todayExecution = new Date(userToday)
|
||||||
|
todayExecution.setHours(displayHour, Number.parseInt(minute), 0, 0)
|
||||||
|
|
||||||
// Calculate initial offset: if time has passed today, start from tomorrow
|
const userCurrentTime = getUserTimezoneCurrentTime(data.timezone)
|
||||||
const initialOffset = baseExecution <= now ? 1 : 0
|
|
||||||
|
const startOffset = todayExecution <= userCurrentTime ? 1 : 0
|
||||||
|
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
const nextExecution = new Date(baseExecution)
|
const execution = new Date(userToday)
|
||||||
nextExecution.setDate(baseExecution.getDate() + initialOffset + i)
|
execution.setDate(userToday.getDate() + startOffset + i)
|
||||||
times.push(nextExecution)
|
execution.setHours(displayHour, Number.parseInt(minute), 0, 0)
|
||||||
|
times.push(execution)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (data.frequency === 'weekly') {
|
else if (data.frequency === 'weekly') {
|
||||||
@ -71,46 +109,31 @@ export const getNextExecutionTimes = (data: ScheduleTriggerNodeType, count: numb
|
|||||||
if (period === 'PM' && displayHour !== 12) displayHour += 12
|
if (period === 'PM' && displayHour !== 12) displayHour += 12
|
||||||
if (period === 'AM' && displayHour === 12) displayHour = 0
|
if (period === 'AM' && displayHour === 12) displayHour = 0
|
||||||
|
|
||||||
const now = getCurrentTime(data.timezone)
|
// Get current time completely in user timezone
|
||||||
|
const userCurrentTime = getUserTimezoneCurrentTime(data.timezone)
|
||||||
|
|
||||||
|
let executionCount = 0
|
||||||
let weekOffset = 0
|
let weekOffset = 0
|
||||||
|
|
||||||
const currentWeekExecutions: Date[] = []
|
while (executionCount < count) {
|
||||||
for (const selectedDay of selectedDays) {
|
|
||||||
const targetDay = dayMap[selectedDay as keyof typeof dayMap]
|
|
||||||
let daysUntilNext = (targetDay - now.getDay() + 7) % 7
|
|
||||||
|
|
||||||
const nextExecutionBase = new Date(now.getFullYear(), now.getMonth(), now.getDate(), displayHour, Number.parseInt(minute), 0, 0)
|
|
||||||
|
|
||||||
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) {
|
for (const selectedDay of selectedDays) {
|
||||||
if (times.length >= count) break
|
if (executionCount >= count) break
|
||||||
|
|
||||||
const targetDay = dayMap[selectedDay as keyof typeof dayMap]
|
const targetDay = dayMap[selectedDay as keyof typeof dayMap]
|
||||||
const execution = new Date(now.getFullYear(), now.getMonth(), now.getDate(), displayHour, Number.parseInt(minute), 0, 0)
|
const execution = new Date(userToday)
|
||||||
execution.setDate(execution.getDate() + (targetDay - now.getDay() + 7) % 7 + (weekOffset + weeksChecked) * 7)
|
execution.setDate(userToday.getDate() + targetDay + (weekOffset * 7))
|
||||||
|
execution.setHours(displayHour, Number.parseInt(minute), 0, 0)
|
||||||
|
|
||||||
if (execution > now)
|
// Only add if execution time is in the future
|
||||||
|
if (execution > userCurrentTime) {
|
||||||
times.push(execution)
|
times.push(execution)
|
||||||
|
executionCount++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
weeksChecked++
|
weekOffset++
|
||||||
}
|
}
|
||||||
|
|
||||||
times.sort((a, b) => a.getTime() - b.getTime())
|
times.sort((a, b) => a.getTime() - b.getTime())
|
||||||
times.splice(count)
|
|
||||||
}
|
}
|
||||||
else if (data.frequency === 'monthly') {
|
else if (data.frequency === 'monthly') {
|
||||||
const getSelectedDays = (): (number | 'last')[] => {
|
const getSelectedDays = (): (number | 'last')[] => {
|
||||||
@ -127,36 +150,14 @@ export const getNextExecutionTimes = (data: ScheduleTriggerNodeType, count: numb
|
|||||||
if (period === 'PM' && displayHour !== 12) displayHour += 12
|
if (period === 'PM' && displayHour !== 12) displayHour += 12
|
||||||
if (period === 'AM' && displayHour === 12) displayHour = 0
|
if (period === 'AM' && displayHour === 12) displayHour = 0
|
||||||
|
|
||||||
const now = getCurrentTime(data.timezone)
|
// Get current time completely in user timezone
|
||||||
|
const userCurrentTime = getUserTimezoneCurrentTime(data.timezone)
|
||||||
|
|
||||||
|
let executionCount = 0
|
||||||
let monthOffset = 0
|
let monthOffset = 0
|
||||||
|
|
||||||
const hasValidCurrentMonthExecution = selectedDays.some((selectedDay) => {
|
while (executionCount < count) {
|
||||||
const currentMonth = new Date(now.getFullYear(), now.getMonth(), 1)
|
const targetMonth = new Date(userToday.getFullYear(), userToday.getMonth() + monthOffset, 1)
|
||||||
const daysInMonth = new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 0).getDate()
|
|
||||||
|
|
||||||
let targetDay: number
|
|
||||||
if (selectedDay === 'last') {
|
|
||||||
targetDay = 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
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!hasValidCurrentMonthExecution)
|
|
||||||
monthOffset = 1
|
|
||||||
|
|
||||||
let monthsChecked = 0
|
|
||||||
|
|
||||||
while (times.length < count && monthsChecked < 24) {
|
|
||||||
const targetMonth = new Date(now.getFullYear(), now.getMonth() + monthOffset + monthsChecked, 1)
|
|
||||||
const daysInMonth = new Date(targetMonth.getFullYear(), targetMonth.getMonth() + 1, 0).getDate()
|
const daysInMonth = new Date(targetMonth.getFullYear(), targetMonth.getMonth() + 1, 0).getDate()
|
||||||
|
|
||||||
const monthlyExecutions: Date[] = []
|
const monthlyExecutions: Date[] = []
|
||||||
@ -168,7 +169,7 @@ export const getNextExecutionTimes = (data: ScheduleTriggerNodeType, count: numb
|
|||||||
if (selectedDay === 'last') {
|
if (selectedDay === 'last') {
|
||||||
targetDay = daysInMonth
|
targetDay = daysInMonth
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
const dayNumber = selectedDay as number
|
const dayNumber = selectedDay as number
|
||||||
if (dayNumber > daysInMonth)
|
if (dayNumber > daysInMonth)
|
||||||
continue
|
continue
|
||||||
@ -181,43 +182,44 @@ export const getNextExecutionTimes = (data: ScheduleTriggerNodeType, count: numb
|
|||||||
|
|
||||||
processedDays.add(targetDay)
|
processedDays.add(targetDay)
|
||||||
|
|
||||||
const nextExecution = new Date(targetMonth.getFullYear(), targetMonth.getMonth(), targetDay, displayHour, Number.parseInt(minute), 0, 0)
|
const execution = new Date(targetMonth.getFullYear(), targetMonth.getMonth(), targetDay, displayHour, Number.parseInt(minute), 0, 0)
|
||||||
|
|
||||||
if (nextExecution > now)
|
// Only add if execution time is in the future
|
||||||
monthlyExecutions.push(nextExecution)
|
if (execution > userCurrentTime)
|
||||||
|
monthlyExecutions.push(execution)
|
||||||
}
|
}
|
||||||
|
|
||||||
monthlyExecutions.sort((a, b) => a.getTime() - b.getTime())
|
monthlyExecutions.sort((a, b) => a.getTime() - b.getTime())
|
||||||
|
|
||||||
for (const execution of monthlyExecutions) {
|
for (const execution of monthlyExecutions) {
|
||||||
if (times.length >= count) break
|
if (executionCount >= count) break
|
||||||
times.push(execution)
|
times.push(execution)
|
||||||
|
executionCount++
|
||||||
}
|
}
|
||||||
|
|
||||||
monthsChecked++
|
monthOffset++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
// Fallback for unknown frequencies
|
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
const now = getCurrentTime(data.timezone)
|
const execution = new Date(userToday)
|
||||||
const nextExecution = new Date(now.getFullYear(), now.getMonth(), now.getDate() + i + 1)
|
execution.setDate(userToday.getDate() + i)
|
||||||
times.push(nextExecution)
|
times.push(execution)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return times
|
return times
|
||||||
}
|
}
|
||||||
|
|
||||||
export const formatExecutionTime = (date: Date, timezone: string, includeWeekday: boolean = true): string => {
|
export const formatExecutionTime = (date: Date, _timezone: string, includeWeekday: boolean = true): string => {
|
||||||
return formatDateInTimezone(date, timezone, includeWeekday)
|
return formatUserTimezoneDate(date, includeWeekday)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getFormattedExecutionTimes = (data: ScheduleTriggerNodeType, count: number = 5): string[] => {
|
export const getFormattedExecutionTimes = (data: ScheduleTriggerNodeType, count: number = 5): string[] => {
|
||||||
const times = getNextExecutionTimes(data, count)
|
const times = getNextExecutionTimes(data, count)
|
||||||
|
|
||||||
return times.map((date) => {
|
return times.map((date) => {
|
||||||
const includeWeekday = data.frequency === 'weekly'
|
const includeWeekday = data.mode === 'visual' && data.frequency === 'weekly'
|
||||||
return formatExecutionTime(date, data.timezone, includeWeekday)
|
return formatExecutionTime(date, data.timezone, includeWeekday)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -225,9 +227,10 @@ export const getFormattedExecutionTimes = (data: ScheduleTriggerNodeType, count:
|
|||||||
export const getNextExecutionTime = (data: ScheduleTriggerNodeType): string => {
|
export const getNextExecutionTime = (data: ScheduleTriggerNodeType): string => {
|
||||||
const times = getFormattedExecutionTimes(data, 1)
|
const times = getFormattedExecutionTimes(data, 1)
|
||||||
if (times.length === 0) {
|
if (times.length === 0) {
|
||||||
const now = getCurrentTime(data.timezone)
|
const userCurrentTime = getUserTimezoneCurrentTime(data.timezone)
|
||||||
const includeWeekday = data.frequency === 'weekly'
|
const fallbackDate = new Date(userCurrentTime.getFullYear(), userCurrentTime.getMonth(), userCurrentTime.getDate(), 12, 0, 0, 0)
|
||||||
return formatExecutionTime(now, data.timezone, includeWeekday)
|
const includeWeekday = data.mode === 'visual' && data.frequency === 'weekly'
|
||||||
|
return formatExecutionTime(fallbackDate, data.timezone, includeWeekday)
|
||||||
}
|
}
|
||||||
return times[0]
|
return times[0]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,281 +0,0 @@
|
|||||||
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')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -1,131 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue
Block a user