feat(trigger-schedule): simplify timezone handling with user-centric approach (#24401)

This commit is contained in:
lyzno1 2025-08-24 21:03:59 +08:00 committed by GitHub
parent 4084ade86c
commit e78903302f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 230 additions and 1288 deletions

View File

@ -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

View File

@ -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],
})

View File

@ -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<ScheduleTriggerNodeType> = {
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 []
},

View File

@ -78,6 +78,7 @@ const Panel: FC<NodePanelProps<ScheduleTriggerNodeType>> = ({
</label>
<TimePicker
notClearable={true}
timezone={inputs.timezone}
value={inputs.visual_config?.time
? dayjs(`1/1/2000 ${inputs.visual_config.time}`)
: dayjs('1/1/2000 11:30 AM')

View File

@ -5,17 +5,17 @@ export type ScheduleMode = 'visual' | 'cron'
export type ScheduleFrequency = 'hourly' | 'daily' | 'weekly' | 'monthly'
export type VisualConfig = {
time?: string
weekdays?: string[]
on_minute?: number
monthly_days?: (number | 'last')[]
time?: string // User timezone time format: "2:30 PM"
weekdays?: string[] // ['mon', 'tue', 'wed'] for weekly frequency
on_minute?: number // 0-59 for hourly frequency
monthly_days?: (number | 'last')[] // [1, 15, 'last'] for monthly frequency
}
export type ScheduleTriggerNodeType = CommonNodeType & {
mode: ScheduleMode
frequency: ScheduleFrequency
cron_expression?: string
visual_config?: VisualConfig
timezone: string
enabled: boolean
mode: ScheduleMode // 'visual' or 'cron' configuration mode
frequency: ScheduleFrequency // 'hourly' | 'daily' | 'weekly' | 'monthly'
cron_expression?: string // Cron expression when mode is 'cron'
visual_config?: VisualConfig // User-friendly configuration when mode is 'visual'
timezone: string // User profile timezone (e.g., 'Asia/Shanghai', 'America/New_York')
enabled: boolean // Whether the trigger is active
}

View File

@ -2,63 +2,29 @@ import { useCallback, useMemo } from 'react'
import type { ScheduleFrequency, ScheduleMode, ScheduleTriggerNodeType } from './types'
import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud'
import { useNodesReadOnly } from '@/app/components/workflow/hooks'
import { convertTimeToUTC, convertUTCToUserTimezone, isUTCFormat, isUserFormat } from './utils/timezone-utils'
import { useAppContext } from '@/context/app-context'
import { getDefaultVisualConfig } from './constants'
const useConfig = (id: string, payload: ScheduleTriggerNodeType) => {
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<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 { inputs, setInputs } = useNodeCrud<ScheduleTriggerNodeType>(id, frontendPayload)
const handleModeChange = useCallback((mode: ScheduleMode) => {
const newInputs = {

View File

@ -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 []
}
}

View File

@ -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> = {}): 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()
})
})
})

View File

@ -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]
}

View File

@ -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')
})
})
})

View File

@ -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()
}
}