mirror of https://github.com/langgenius/dify.git
feat(trigger-schedule): simplify timezone handling with user-centric approach (#24401)
This commit is contained in:
parent
4084ade86c
commit
e78903302f
|
|
@ -16,7 +16,8 @@ const MonthlyDaysSelector = ({ selectedDays, onChange }: MonthlyDaysSelectorProp
|
|||
const newSelected = current.includes(day)
|
||||
? 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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
import type { ScheduleTriggerNodeType } from './types'
|
||||
|
||||
// Unified default values for trigger schedule
|
||||
export const getDefaultScheduleConfig = (): Partial<ScheduleTriggerNodeType> => ({
|
||||
mode: 'visual',
|
||||
frequency: 'weekly',
|
||||
enabled: true,
|
||||
visual_config: {
|
||||
time: '11:30 AM',
|
||||
weekdays: ['sun'],
|
||||
on_minute: 0,
|
||||
monthly_days: [1],
|
||||
},
|
||||
})
|
||||
|
||||
export const getDefaultVisualConfig = () => ({
|
||||
time: '11:30 AM',
|
||||
weekdays: ['sun'],
|
||||
on_minute: 0,
|
||||
monthly_days: [1],
|
||||
})
|
||||
|
|
@ -4,6 +4,7 @@ import type { ScheduleTriggerNodeType } from './types'
|
|||
import { ALL_CHAT_AVAILABLE_BLOCKS, ALL_COMPLETION_AVAILABLE_BLOCKS } from '@/app/components/workflow/blocks'
|
||||
import { 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 []
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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 []
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,281 +0,0 @@
|
|||
import {
|
||||
convertTimeToUTC,
|
||||
convertUTCToUserTimezone,
|
||||
formatDateInTimezone,
|
||||
getCurrentTimeInTimezone,
|
||||
isUTCFormat,
|
||||
isUserFormat,
|
||||
} from './timezone-utils'
|
||||
|
||||
describe('timezone-utils', () => {
|
||||
describe('convertTimeToUTC', () => {
|
||||
test('converts Eastern time to UTC correctly', () => {
|
||||
const easternTime = '2:30 PM'
|
||||
const timezone = 'America/New_York'
|
||||
|
||||
const result = convertTimeToUTC(easternTime, timezone)
|
||||
|
||||
expect(result).toMatch(/^([01]?\d|2[0-3]):[0-5]\d$/)
|
||||
})
|
||||
|
||||
test('converts UTC time to UTC correctly', () => {
|
||||
const utcTime = '2:30 PM'
|
||||
const timezone = 'UTC'
|
||||
|
||||
const result = convertTimeToUTC(utcTime, timezone)
|
||||
|
||||
expect(result).toBe('14:30')
|
||||
})
|
||||
|
||||
test('handles midnight correctly', () => {
|
||||
const midnightTime = '12:00 AM'
|
||||
const timezone = 'UTC'
|
||||
|
||||
const result = convertTimeToUTC(midnightTime, timezone)
|
||||
|
||||
expect(result).toBe('00:00')
|
||||
})
|
||||
|
||||
test('handles noon correctly', () => {
|
||||
const noonTime = '12:00 PM'
|
||||
const timezone = 'UTC'
|
||||
|
||||
const result = convertTimeToUTC(noonTime, timezone)
|
||||
|
||||
expect(result).toBe('12:00')
|
||||
})
|
||||
|
||||
test('handles Pacific time to UTC', () => {
|
||||
const pacificTime = '9:15 AM'
|
||||
const timezone = 'America/Los_Angeles'
|
||||
|
||||
const result = convertTimeToUTC(pacificTime, timezone)
|
||||
|
||||
expect(result).toMatch(/^([01]?\d|2[0-3]):[0-5]\d$/)
|
||||
})
|
||||
|
||||
test('handles malformed time gracefully', () => {
|
||||
const invalidTime = 'invalid time'
|
||||
const timezone = 'UTC'
|
||||
|
||||
const result = convertTimeToUTC(invalidTime, timezone)
|
||||
|
||||
expect(result).toBe(invalidTime)
|
||||
})
|
||||
})
|
||||
|
||||
describe('convertUTCToUserTimezone', () => {
|
||||
test('converts UTC to Eastern time correctly', () => {
|
||||
const utcTime = '19:30'
|
||||
const timezone = 'America/New_York'
|
||||
|
||||
const result = convertUTCToUserTimezone(utcTime, timezone)
|
||||
|
||||
expect(result).toMatch(/^([1-9]|1[0-2]):[0-5]\d (AM|PM)$/)
|
||||
})
|
||||
|
||||
test('converts UTC to UTC correctly', () => {
|
||||
const utcTime = '14:30'
|
||||
const timezone = 'UTC'
|
||||
|
||||
const result = convertUTCToUserTimezone(utcTime, timezone)
|
||||
|
||||
expect(result).toBe('2:30 PM')
|
||||
})
|
||||
|
||||
test('handles midnight UTC correctly', () => {
|
||||
const utcTime = '00:00'
|
||||
const timezone = 'UTC'
|
||||
|
||||
const result = convertUTCToUserTimezone(utcTime, timezone)
|
||||
|
||||
expect(result).toBe('12:00 AM')
|
||||
})
|
||||
|
||||
test('handles noon UTC correctly', () => {
|
||||
const utcTime = '12:00'
|
||||
const timezone = 'UTC'
|
||||
|
||||
const result = convertUTCToUserTimezone(utcTime, timezone)
|
||||
|
||||
expect(result).toBe('12:00 PM')
|
||||
})
|
||||
|
||||
test('handles UTC to Pacific time', () => {
|
||||
const utcTime = '17:15'
|
||||
const timezone = 'America/Los_Angeles'
|
||||
|
||||
const result = convertUTCToUserTimezone(utcTime, timezone)
|
||||
|
||||
expect(result).toMatch(/^([1-9]|1[0-2]):[0-5]\d (AM|PM)$/)
|
||||
})
|
||||
|
||||
test('handles malformed UTC time gracefully', () => {
|
||||
const invalidTime = 'invalid'
|
||||
const timezone = 'UTC'
|
||||
|
||||
const result = convertUTCToUserTimezone(invalidTime, timezone)
|
||||
|
||||
expect(result).toBe(invalidTime)
|
||||
})
|
||||
})
|
||||
|
||||
describe('timezone conversion round trip', () => {
|
||||
test('UTC round trip conversion', () => {
|
||||
const originalTime = '2:30 PM'
|
||||
const timezone = 'UTC'
|
||||
|
||||
const utcTime = convertTimeToUTC(originalTime, timezone)
|
||||
const backToUserTime = convertUTCToUserTimezone(utcTime, timezone)
|
||||
|
||||
expect(backToUserTime).toBe(originalTime)
|
||||
})
|
||||
|
||||
test('different timezones produce valid results', () => {
|
||||
const originalTime = '9:00 AM'
|
||||
const timezones = ['America/New_York', 'America/Los_Angeles', 'Europe/London', 'Asia/Tokyo']
|
||||
|
||||
timezones.forEach((timezone) => {
|
||||
const utcTime = convertTimeToUTC(originalTime, timezone)
|
||||
const backToUserTime = convertUTCToUserTimezone(utcTime, timezone)
|
||||
|
||||
expect(utcTime).toMatch(/^\d{2}:\d{2}$/)
|
||||
expect(backToUserTime).toMatch(/^\d{1,2}:\d{2} (AM|PM)$/)
|
||||
})
|
||||
})
|
||||
|
||||
test('edge cases produce valid formats', () => {
|
||||
const edgeCases = ['12:00 AM', '12:00 PM', '11:59 PM', '12:01 AM']
|
||||
const timezone = 'America/New_York'
|
||||
|
||||
edgeCases.forEach((time) => {
|
||||
const utcTime = convertTimeToUTC(time, timezone)
|
||||
const backToUserTime = convertUTCToUserTimezone(utcTime, timezone)
|
||||
|
||||
expect(utcTime).toMatch(/^\d{2}:\d{2}$/)
|
||||
expect(backToUserTime).toMatch(/^\d{1,2}:\d{2} (AM|PM)$/)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('isUTCFormat', () => {
|
||||
test('identifies valid UTC format', () => {
|
||||
expect(isUTCFormat('14:30')).toBe(true)
|
||||
expect(isUTCFormat('00:00')).toBe(true)
|
||||
expect(isUTCFormat('23:59')).toBe(true)
|
||||
})
|
||||
|
||||
test('rejects invalid UTC format', () => {
|
||||
expect(isUTCFormat('2:30 PM')).toBe(false)
|
||||
expect(isUTCFormat('14:3')).toBe(false)
|
||||
expect(isUTCFormat('25:00')).toBe(true)
|
||||
expect(isUTCFormat('invalid')).toBe(false)
|
||||
expect(isUTCFormat('')).toBe(false)
|
||||
expect(isUTCFormat('1:30')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isUserFormat', () => {
|
||||
test('identifies valid user format', () => {
|
||||
expect(isUserFormat('2:30 PM')).toBe(true)
|
||||
expect(isUserFormat('12:00 AM')).toBe(true)
|
||||
expect(isUserFormat('11:59 PM')).toBe(true)
|
||||
expect(isUserFormat('1:00 AM')).toBe(true)
|
||||
})
|
||||
|
||||
test('rejects invalid user format', () => {
|
||||
expect(isUserFormat('14:30')).toBe(false)
|
||||
expect(isUserFormat('2:30')).toBe(false)
|
||||
expect(isUserFormat('2:30 XM')).toBe(false)
|
||||
expect(isUserFormat('25:00 PM')).toBe(true)
|
||||
expect(isUserFormat('invalid')).toBe(false)
|
||||
expect(isUserFormat('')).toBe(false)
|
||||
expect(isUserFormat('2:3 PM')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCurrentTimeInTimezone', () => {
|
||||
test('returns current time in specified timezone', () => {
|
||||
const utcTime = getCurrentTimeInTimezone('UTC')
|
||||
const easternTime = getCurrentTimeInTimezone('America/New_York')
|
||||
const pacificTime = getCurrentTimeInTimezone('America/Los_Angeles')
|
||||
|
||||
expect(utcTime).toBeInstanceOf(Date)
|
||||
expect(easternTime).toBeInstanceOf(Date)
|
||||
expect(pacificTime).toBeInstanceOf(Date)
|
||||
})
|
||||
|
||||
test('handles invalid timezone gracefully', () => {
|
||||
const result = getCurrentTimeInTimezone('Invalid/Timezone')
|
||||
expect(result).toBeInstanceOf(Date)
|
||||
})
|
||||
|
||||
test('timezone differences are reasonable', () => {
|
||||
const utcTime = getCurrentTimeInTimezone('UTC')
|
||||
const easternTime = getCurrentTimeInTimezone('America/New_York')
|
||||
|
||||
const timeDiff = Math.abs(utcTime.getTime() - easternTime.getTime())
|
||||
expect(timeDiff).toBeLessThan(24 * 60 * 60 * 1000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatDateInTimezone', () => {
|
||||
const testDate = new Date('2024-03-15T14:30:00.000Z')
|
||||
|
||||
test('formats date with weekday by default', () => {
|
||||
const result = formatDateInTimezone(testDate, 'UTC')
|
||||
|
||||
expect(result).toContain('March')
|
||||
expect(result).toContain('15')
|
||||
expect(result).toContain('2024')
|
||||
expect(result).toContain('2:30 PM')
|
||||
})
|
||||
|
||||
test('formats date without weekday when specified', () => {
|
||||
const result = formatDateInTimezone(testDate, 'UTC', false)
|
||||
|
||||
expect(result).toContain('March')
|
||||
expect(result).toContain('15')
|
||||
expect(result).toContain('2024')
|
||||
expect(result).toContain('2:30 PM')
|
||||
})
|
||||
|
||||
test('formats date in different timezones', () => {
|
||||
const utcResult = formatDateInTimezone(testDate, 'UTC')
|
||||
const easternResult = formatDateInTimezone(testDate, 'America/New_York')
|
||||
|
||||
expect(utcResult).toContain('2:30 PM')
|
||||
expect(easternResult).toMatch(/\d{1,2}:\d{2} (AM|PM)/)
|
||||
})
|
||||
|
||||
test('handles invalid timezone gracefully', () => {
|
||||
const result = formatDateInTimezone(testDate, 'Invalid/Timezone')
|
||||
expect(typeof result).toBe('string')
|
||||
expect(result.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('error handling and edge cases', () => {
|
||||
test('convertTimeToUTC handles empty strings', () => {
|
||||
expect(convertTimeToUTC('', 'UTC')).toBe('')
|
||||
expect(convertTimeToUTC('2:30 PM', '')).toBe('2:30 PM')
|
||||
})
|
||||
|
||||
test('convertUTCToUserTimezone handles empty strings', () => {
|
||||
expect(convertUTCToUserTimezone('', 'UTC')).toBe('')
|
||||
expect(convertUTCToUserTimezone('14:30', '')).toBe('14:30')
|
||||
})
|
||||
|
||||
test('convertTimeToUTC handles malformed input parts', () => {
|
||||
expect(convertTimeToUTC('2:PM', 'UTC')).toBe('2:PM')
|
||||
expect(convertTimeToUTC('2:30', 'UTC')).toBe('2:30')
|
||||
expect(convertTimeToUTC('ABC:30 PM', 'UTC')).toBe('ABC:30 PM')
|
||||
})
|
||||
|
||||
test('convertUTCToUserTimezone handles malformed UTC input', () => {
|
||||
expect(convertUTCToUserTimezone('AB:30', 'UTC')).toBe('AB:30')
|
||||
expect(convertUTCToUserTimezone('14:', 'UTC')).toBe('14:')
|
||||
expect(convertUTCToUserTimezone('14:XX', 'UTC')).toBe('14:XX')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,131 +0,0 @@
|
|||
export const convertTimeToUTC = (time: string, userTimezone: string): string => {
|
||||
try {
|
||||
const [timePart, period] = time.split(' ')
|
||||
if (!timePart || !period) return time
|
||||
|
||||
const [hour, minute] = timePart.split(':')
|
||||
if (!hour || !minute) return time
|
||||
|
||||
let hour24 = Number.parseInt(hour, 10)
|
||||
const minuteNum = Number.parseInt(minute, 10)
|
||||
|
||||
if (Number.isNaN(hour24) || Number.isNaN(minuteNum)) return time
|
||||
|
||||
if (period === 'PM' && hour24 !== 12) hour24 += 12
|
||||
if (period === 'AM' && hour24 === 12) hour24 = 0
|
||||
|
||||
if (userTimezone === 'UTC')
|
||||
return `${String(hour24).padStart(2, '0')}:${String(minuteNum).padStart(2, '0')}`
|
||||
|
||||
const today = new Date()
|
||||
const year = today.getFullYear()
|
||||
const month = today.getMonth()
|
||||
const day = today.getDate()
|
||||
|
||||
const userTime = new Date(year, month, day, hour24, minuteNum)
|
||||
|
||||
const tempFormatter = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: userTimezone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
})
|
||||
|
||||
const userTimeInTz = tempFormatter.format(userTime).replace(', ', 'T')
|
||||
const userTimeDate = new Date(userTimeInTz)
|
||||
const offset = userTime.getTime() - userTimeDate.getTime()
|
||||
const utcTime = new Date(userTime.getTime() + offset)
|
||||
|
||||
return `${String(utcTime.getHours()).padStart(2, '0')}:${String(utcTime.getMinutes()).padStart(2, '0')}`
|
||||
}
|
||||
catch {
|
||||
return time
|
||||
}
|
||||
}
|
||||
|
||||
export const convertUTCToUserTimezone = (utcTime: string, userTimezone: string): string => {
|
||||
try {
|
||||
const [hour, minute] = utcTime.split(':')
|
||||
if (!hour || !minute) return utcTime
|
||||
|
||||
const hourNum = Number.parseInt(hour, 10)
|
||||
const minuteNum = Number.parseInt(minute, 10)
|
||||
|
||||
if (Number.isNaN(hourNum) || Number.isNaN(minuteNum)) return utcTime
|
||||
|
||||
const today = new Date()
|
||||
const dateStr = today.toISOString().split('T')[0]
|
||||
const utcDate = new Date(`${dateStr}T${String(hourNum).padStart(2, '0')}:${String(minuteNum).padStart(2, '0')}:00.000Z`)
|
||||
|
||||
return utcDate.toLocaleTimeString('en-US', {
|
||||
timeZone: userTimezone,
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
})
|
||||
}
|
||||
catch {
|
||||
return utcTime
|
||||
}
|
||||
}
|
||||
|
||||
export const isUTCFormat = (time: string): boolean => {
|
||||
return /^\d{2}:\d{2}$/.test(time)
|
||||
}
|
||||
|
||||
export const isUserFormat = (time: string): boolean => {
|
||||
return /^\d{1,2}:\d{2} (AM|PM)$/.test(time)
|
||||
}
|
||||
|
||||
const getTimezoneOffset = (timezone: string): number => {
|
||||
try {
|
||||
const now = new Date()
|
||||
const utc = new Date(now.toLocaleString('en-US', { timeZone: 'UTC' }))
|
||||
const target = new Date(now.toLocaleString('en-US', { timeZone: timezone }))
|
||||
return (target.getTime() - utc.getTime()) / (1000 * 60)
|
||||
}
|
||||
catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
export const getCurrentTimeInTimezone = (timezone: string): Date => {
|
||||
try {
|
||||
const now = new Date()
|
||||
const utcTime = now.getTime() + (now.getTimezoneOffset() * 60000)
|
||||
const targetTime = new Date(utcTime + (getTimezoneOffset(timezone) * 60000))
|
||||
return targetTime
|
||||
}
|
||||
catch {
|
||||
return new Date()
|
||||
}
|
||||
}
|
||||
|
||||
export const formatDateInTimezone = (date: Date, timezone: string, includeWeekday: boolean = true): string => {
|
||||
try {
|
||||
const dateOptions: Intl.DateTimeFormatOptions = {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
timeZone: timezone,
|
||||
}
|
||||
|
||||
if (includeWeekday)
|
||||
dateOptions.weekday = 'short'
|
||||
|
||||
const timeOptions: Intl.DateTimeFormatOptions = {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
timeZone: timezone,
|
||||
}
|
||||
|
||||
return `${date.toLocaleDateString('en-US', dateOptions)} ${date.toLocaleTimeString('en-US', timeOptions)}`
|
||||
}
|
||||
catch {
|
||||
return date.toLocaleString()
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue