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 8f50e0e9cc..d7cce79328 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 @@ -16,7 +16,8 @@ const MonthlyDaysSelector = ({ selectedDays, onChange }: MonthlyDaysSelectorProp const newSelected = current.includes(day) ? current.filter(d => d !== 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 diff --git a/web/app/components/workflow/nodes/trigger-schedule/constants.ts b/web/app/components/workflow/nodes/trigger-schedule/constants.ts new file mode 100644 index 0000000000..b3d5fdb244 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/constants.ts @@ -0,0 +1,21 @@ +import type { ScheduleTriggerNodeType } from './types' + +// Unified default values for trigger schedule +export const getDefaultScheduleConfig = (): Partial => ({ + 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], +}) diff --git a/web/app/components/workflow/nodes/trigger-schedule/default.ts b/web/app/components/workflow/nodes/trigger-schedule/default.ts index 1abd9f5695..5de75df799 100644 --- a/web/app/components/workflow/nodes/trigger-schedule/default.ts +++ b/web/app/components/workflow/nodes/trigger-schedule/default.ts @@ -4,6 +4,7 @@ import type { ScheduleTriggerNodeType } from './types' import { ALL_CHAT_AVAILABLE_BLOCKS, ALL_COMPLETION_AVAILABLE_BLOCKS } from '@/app/components/workflow/blocks' import { isValidCronExpression } from './utils/cron-parser' import { getNextExecutionTimes } from './utils/execution-time-calculator' +import { getDefaultScheduleConfig } from './constants' const isValidTimeFormat = (time: string): boolean => { const timeRegex = /^(0?\d|1[0-2]):[0-5]\d (AM|PM)$/ if (!timeRegex.test(time)) return false @@ -104,16 +105,10 @@ const validateVisualConfig = (payload: ScheduleTriggerNodeType, t: any): string const nodeDefault: NodeDefault = { defaultValue: { - mode: 'visual', - frequency: 'weekly', + ...getDefaultScheduleConfig(), cron_expression: '', - visual_config: { - time: '11:30 AM', - weekdays: ['sun'], - }, - timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, - enabled: true, - }, + timezone: 'UTC', + } as ScheduleTriggerNodeType, getAvailablePrevNodes(_isChatMode: boolean) { return [] }, diff --git a/web/app/components/workflow/nodes/trigger-schedule/panel.tsx b/web/app/components/workflow/nodes/trigger-schedule/panel.tsx index 96e2568b32..2ae352ce21 100644 --- a/web/app/components/workflow/nodes/trigger-schedule/panel.tsx +++ b/web/app/components/workflow/nodes/trigger-schedule/panel.tsx @@ -78,6 +78,7 @@ const Panel: FC> = ({ { const { nodesReadOnly: readOnly } = useNodesReadOnly() + const { userProfile } = useAppContext() + const frontendPayload = useMemo(() => { - const basePayload = { + return { ...payload, mode: payload.mode || 'visual', frequency: payload.frequency || 'weekly', - timezone: payload.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone, + timezone: userProfile.timezone || 'UTC', 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: { - time: '11:30 AM', - weekdays: ['sun'], + ...getDefaultVisualConfig(), ...payload.visual_config, }, } - }, [payload]) + }, [payload, userProfile.timezone]) - 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 { inputs, setInputs } = useNodeCrud(id, frontendPayload) const handleModeChange = useCallback((mode: ScheduleMode) => { const newInputs = { diff --git a/web/app/components/workflow/nodes/trigger-schedule/utils/cron-parser.ts b/web/app/components/workflow/nodes/trigger-schedule/utils/cron-parser.ts index e4316ecbd2..d20b6cc0c7 100644 --- a/web/app/components/workflow/nodes/trigger-schedule/utils/cron-parser.ts +++ b/web/app/components/workflow/nodes/trigger-schedule/utils/cron-parser.ts @@ -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() === '') return [] @@ -122,38 +122,34 @@ export const parseCronExpression = (cronExpression: string): Date[] => { try { const nextTimes: Date[] = [] + + // Get user timezone current time - no browser timezone involved 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 isWeeklyPattern = dayOfMonth === '*' && dayOfWeek !== '*' let searchMonths = 12 - if (isWeeklyPattern) searchMonths = 3 // 3 months should cover 12+ weeks - else if (!isMonthlyPattern) searchMonths = 2 // For daily/hourly patterns + if (isWeeklyPattern) searchMonths = 3 + else if (!isMonthlyPattern) searchMonths = 2 - // Check across multiple months for (let monthOffset = 0; monthOffset < searchMonths && nextTimes.length < 5; monthOffset++) { - const checkMonth = new Date(startTime.getFullYear(), startTime.getMonth() + monthOffset, 1) - - // Get the number of days in this month + const checkMonth = new Date(userToday.getFullYear(), userToday.getMonth() + monthOffset, 1) 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++) { 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 !== '*') { - // Extract specific minute and hour values const minuteValues = expandCronField(minute, 0, 59) const hourValues = expandCronField(hour, 0, 23) @@ -161,23 +157,19 @@ export const parseCronExpression = (cronExpression: string): Date[] => { for (const m of minuteValues) { checkDate.setHours(h, m, 0, 0) - // Skip if this time is before our start time - if (checkDate <= now) continue - - if (matchesCron(checkDate, minute, hour, dayOfMonth, month, dayOfWeek)) + // Only add if execution time is in the future and matches cron pattern + if (checkDate > userCurrentTime && matchesCron(checkDate, minute, hour, dayOfMonth, month, dayOfWeek)) nextTimes.push(new Date(checkDate)) } } } - else { - // Fallback for complex expressions with wildcards + else { for (let h = 0; h < 24 && nextTimes.length < 5; h++) { for (let m = 0; m < 60 && nextTimes.length < 5; m++) { checkDate.setHours(h, m, 0, 0) - if (checkDate <= now) continue - - if (matchesCron(checkDate, minute, hour, dayOfMonth, month, dayOfWeek)) + // Only add if execution time is in the future and matches cron pattern + if (checkDate > userCurrentTime && matchesCron(checkDate, minute, hour, dayOfMonth, month, dayOfWeek)) 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) } - catch { + catch { return [] } } 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 c2f7b34015..d39deaace3 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 @@ -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' const createMockData = (overrides: Partial = {}): ScheduleTriggerNodeType => ({ id: 'test-node', type: 'schedule-trigger', mode: 'visual', - frequency: 'weekly', + frequency: 'daily', visual_config: { - time: '11:30 AM', - weekdays: ['sun'], + time: '2:30 PM', }, - timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + timezone: 'UTC', enabled: true, ...overrides, }) describe('execution-time-calculator', () => { - beforeEach(() => { + beforeAll(() => { 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() }) - 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, testTimezone) - - 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('getDefaultDateTime', () => { + it('returns consistent default datetime', () => { + const defaultDate = getDefaultDateTime() + expect(defaultDate.getFullYear()).toBe(2024) + expect(defaultDate.getMonth()).toBe(0) + expect(defaultDate.getDate()).toBe(2) }) }) - 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 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', () => { + describe('daily frequency', () => { + it('generates daily executions at configured time', () => { const data = createMockData({ 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) - result.forEach((date) => { - expect(date.getHours()).toBe(14) - expect(date.getMinutes()).toBe(30) - }) - expect(result[1].getDate()).toBe(result[0].getDate() + 1) + expect(result).toHaveLength(2) + expect(result[0].getHours()).toBe(9) + expect(result[0].getMinutes()).toBe(15) }) - test('handles past time by moving to next day', () => { - jest.setSystemTime(new Date(2024, 0, 15, 15, 0, 0)) + it('skips today if time has passed', () => { + jest.setSystemTime(new Date('2024-01-15T15:00:00Z')) const data = createMockData({ frequency: 'daily', visual_config: { time: '2:30 PM' }, + timezone: 'UTC', }) - const result = getNextExecutionTimes(data, 1) - - 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) + const result = getNextExecutionTimes(data, 2) + expect(result[0].getDate()).toBe(16) // Tomorrow }) }) - describe('getNextExecutionTimes - weekly frequency', () => { - test('calculates weekly executions for multiple days', () => { + describe('hourly frequency', () => { + it('generates hourly executions', () => { 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: { - 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'], - }, + frequency: 'hourly', + visual_config: { on_minute: 30 }, + timezone: 'UTC', }) const result = getNextExecutionTimes(data, 2) - expect(result[0].getDate()).toBe(15) - expect(result[1].getDate()).toBe(22) - }) - - 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()) + expect(result).toHaveLength(2) + expect(result[0].getMinutes()).toBe(30) + expect(result[1].getMinutes()).toBe(30) }) }) - describe('getNextExecutionTimes - monthly frequency', () => { - test('calculates monthly executions for specific day', () => { - 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', () => { + describe('cron mode', () => { + it('handles valid cron expressions', () => { const data = createMockData({ 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) - result.forEach((date) => { - expect(date.getHours()).toBe(12) - expect(date.getMinutes()).toBe(0) - }) + expect(result.length).toBeGreaterThan(0) + if (result.length > 0) { + expect(result[0].getHours()).toBe(14) + expect(result[0].getMinutes()).toBe(30) + } }) - test('returns empty array for invalid cron expression', () => { + it('returns empty for invalid cron', () => { const data = createMockData({ mode: 'cron', 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', }) - const result = getFormattedExecutionTimes(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) - + const result = getNextExecutionTimes(data, 2) expect(result).toEqual([]) }) }) describe('getNextExecutionTime', () => { - test('returns first formatted execution time', () => { - 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', () => { + it('returns formatted time string', () => { 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') - }) + const result = getNextExecutionTime(data) - 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') + expect(result).toContain('3:15 PM') + expect(result).toContain('2024') }) }) - 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) + describe('edge cases', () => { + it('handles zero count', () => { + const data = createMockData() + const result = getNextExecutionTimes(data, 0) + expect(result).toEqual([]) }) - 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', () => { + it('handles missing visual_config', () => { const data = createMockData({ - frequency: 'daily', - visual_config: { time: '11:59 PM' }, - timezone: 'Pacific/Honolulu', + visual_config: undefined, }) - const result = getNextExecutionTimes(data, 1) - - expect(result).toHaveLength(1) - expect(result[0].getHours()).toBe(23) - expect(result[0].getMinutes()).toBe(59) + expect(() => getNextExecutionTimes(data, 1)).not.toThrow() }) }) }) 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 b56bb707df..d228eb44e5 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,16 +1,42 @@ import type { ScheduleTriggerNodeType } from '../types' import { isValidCronExpression, parseCronExpression } from './cron-parser' -import { formatDateInTimezone, getCurrentTimeInTimezone } from './timezone-utils' -const getCurrentTime = (timezone?: string): Date => { - return timezone ? getCurrentTimeInTimezone(timezone) : new Date() +// Get current time completely in user timezone, no browser timezone involved +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 export const getDefaultDateTime = (): Date => { - const defaultDate = new Date() - defaultDate.setHours(11, 30, 0, 0) - defaultDate.setDate(defaultDate.getDate() + 1) + const defaultDate = new Date(2024, 0, 2, 11, 30, 0, 0) return defaultDate } @@ -18,27 +44,36 @@ export const getNextExecutionTimes = (data: ScheduleTriggerNodeType, count: numb if (data.mode === 'cron') { if (!data.cron_expression || !isValidCronExpression(data.cron_expression)) return [] - return parseCronExpression(data.cron_expression).slice(0, count) + return parseCronExpression(data.cron_expression, data.timezone).slice(0, count) } const times: Date[] = [] 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') { const onMinute = data.visual_config?.on_minute ?? 0 - const now = getCurrentTime(data.timezone) - const currentHour = now.getHours() - const currentMinute = now.getMinutes() - 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) + // Get current time completely in user timezone + const userCurrentTime = getUserTimezoneCurrentTime(data.timezone) + + let hour = userCurrentTime.getHours() + if (userCurrentTime.getMinutes() >= onMinute) + hour += 1 // Start from next hour if current minute has passed for (let i = 0; i < count; i++) { - const execution = new Date(nextExecution) - execution.setHours(nextExecution.getHours() + i) + const execution = new Date(userToday) + 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) } } @@ -49,16 +84,19 @@ export const getNextExecutionTimes = (data: ScheduleTriggerNodeType, count: numb if (period === 'PM' && displayHour !== 12) displayHour += 12 if (period === 'AM' && displayHour === 12) displayHour = 0 - const now = getCurrentTime(data.timezone) - const baseExecution = new Date(now.getFullYear(), now.getMonth(), now.getDate(), displayHour, Number.parseInt(minute), 0, 0) + // Check if today's configured time has already passed + 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 initialOffset = baseExecution <= now ? 1 : 0 + const userCurrentTime = getUserTimezoneCurrentTime(data.timezone) + + const startOffset = todayExecution <= userCurrentTime ? 1 : 0 for (let i = 0; i < count; i++) { - const nextExecution = new Date(baseExecution) - nextExecution.setDate(baseExecution.getDate() + initialOffset + i) - times.push(nextExecution) + const execution = new Date(userToday) + execution.setDate(userToday.getDate() + startOffset + i) + execution.setHours(displayHour, Number.parseInt(minute), 0, 0) + times.push(execution) } } 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 === '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 - const currentWeekExecutions: Date[] = [] - 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) { + while (executionCount < count) { for (const selectedDay of selectedDays) { - if (times.length >= count) break + if (executionCount >= 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) + const execution = new Date(userToday) + 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) + executionCount++ + } } - weeksChecked++ + weekOffset++ } times.sort((a, b) => a.getTime() - b.getTime()) - times.splice(count) } else if (data.frequency === 'monthly') { const getSelectedDays = (): (number | 'last')[] => { @@ -127,36 +150,14 @@ export const getNextExecutionTimes = (data: ScheduleTriggerNodeType, count: numb if (period === 'PM' && displayHour !== 12) displayHour += 12 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 - const hasValidCurrentMonthExecution = selectedDays.some((selectedDay) => { - const currentMonth = new Date(now.getFullYear(), now.getMonth(), 1) - const daysInMonth = new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 0).getDate() - - let targetDay: number - if (selectedDay === 'last') { - targetDay = daysInMonth - } - else { - 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) + while (executionCount < count) { + const targetMonth = new Date(userToday.getFullYear(), userToday.getMonth() + monthOffset, 1) const daysInMonth = new Date(targetMonth.getFullYear(), targetMonth.getMonth() + 1, 0).getDate() const monthlyExecutions: Date[] = [] @@ -168,7 +169,7 @@ export const getNextExecutionTimes = (data: ScheduleTriggerNodeType, count: numb if (selectedDay === 'last') { targetDay = daysInMonth } - else { + else { const dayNumber = selectedDay as number if (dayNumber > daysInMonth) continue @@ -181,43 +182,44 @@ export const getNextExecutionTimes = (data: ScheduleTriggerNodeType, count: numb 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) - monthlyExecutions.push(nextExecution) + // Only add if execution time is in the future + if (execution > userCurrentTime) + monthlyExecutions.push(execution) } monthlyExecutions.sort((a, b) => a.getTime() - b.getTime()) for (const execution of monthlyExecutions) { - if (times.length >= count) break + if (executionCount >= count) break times.push(execution) + executionCount++ } - monthsChecked++ + monthOffset++ } } else { - // Fallback for unknown frequencies for (let i = 0; i < count; i++) { - const now = getCurrentTime(data.timezone) - const nextExecution = new Date(now.getFullYear(), now.getMonth(), now.getDate() + i + 1) - times.push(nextExecution) + const execution = new Date(userToday) + execution.setDate(userToday.getDate() + i) + times.push(execution) } } return times } -export const formatExecutionTime = (date: Date, timezone: string, includeWeekday: boolean = true): string => { - return formatDateInTimezone(date, timezone, includeWeekday) +export const formatExecutionTime = (date: Date, _timezone: string, includeWeekday: boolean = true): string => { + return formatUserTimezoneDate(date, includeWeekday) } export const getFormattedExecutionTimes = (data: ScheduleTriggerNodeType, count: number = 5): string[] => { const times = getNextExecutionTimes(data, count) return times.map((date) => { - const includeWeekday = data.frequency === 'weekly' + const includeWeekday = data.mode === 'visual' && data.frequency === 'weekly' return formatExecutionTime(date, data.timezone, includeWeekday) }) } @@ -225,9 +227,10 @@ export const getFormattedExecutionTimes = (data: ScheduleTriggerNodeType, count: export const getNextExecutionTime = (data: ScheduleTriggerNodeType): string => { const times = getFormattedExecutionTimes(data, 1) if (times.length === 0) { - const now = getCurrentTime(data.timezone) - const includeWeekday = data.frequency === 'weekly' - return formatExecutionTime(now, data.timezone, includeWeekday) + const userCurrentTime = getUserTimezoneCurrentTime(data.timezone) + const fallbackDate = new Date(userCurrentTime.getFullYear(), userCurrentTime.getMonth(), userCurrentTime.getDate(), 12, 0, 0, 0) + const includeWeekday = data.mode === 'visual' && data.frequency === 'weekly' + return formatExecutionTime(fallbackDate, 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 deleted file mode 100644 index 22e125fdd0..0000000000 --- a/web/app/components/workflow/nodes/trigger-schedule/utils/timezone-utils.spec.ts +++ /dev/null @@ -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') - }) - }) -}) 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 deleted file mode 100644 index ceba7e640d..0000000000 --- a/web/app/components/workflow/nodes/trigger-schedule/utils/timezone-utils.ts +++ /dev/null @@ -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() - } -}