mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-07 22:24:06 -05:00
fix(schedules): offload next run calculation to croner (#1640)
* fix(schedules): offload next run calculation to croner * fix localstorage dependent tests * address greptile comment
This commit is contained in:
committed by
GitHub
parent
1a05ef97d6
commit
061c1dff4e
@@ -309,7 +309,8 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
// Additional validation for custom cron expressions
|
||||
if (defaultScheduleType === 'custom' && cronExpression) {
|
||||
const validation = validateCronExpression(cronExpression)
|
||||
// Validate with timezone for accurate validation
|
||||
const validation = validateCronExpression(cronExpression, timezone)
|
||||
if (!validation.isValid) {
|
||||
logger.error(`[${requestId}] Invalid cron expression: ${validation.error}`)
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Calendar, ExternalLink } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -33,17 +33,23 @@ export function ScheduleConfig({
|
||||
disabled = false,
|
||||
}: ScheduleConfigProps) {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [scheduleId, setScheduleId] = useState<string | null>(null)
|
||||
const [nextRunAt, setNextRunAt] = useState<string | null>(null)
|
||||
const [lastRanAt, setLastRanAt] = useState<string | null>(null)
|
||||
const [cronExpression, setCronExpression] = useState<string | null>(null)
|
||||
const [timezone, setTimezone] = useState<string>('UTC')
|
||||
const [scheduleData, setScheduleData] = useState<{
|
||||
id: string | null
|
||||
nextRunAt: string | null
|
||||
lastRanAt: string | null
|
||||
cronExpression: string | null
|
||||
timezone: string
|
||||
}>({
|
||||
id: null,
|
||||
nextRunAt: null,
|
||||
lastRanAt: null,
|
||||
cronExpression: null,
|
||||
timezone: 'UTC',
|
||||
})
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
// Track when we need to force a refresh of schedule data
|
||||
const [refreshCounter, setRefreshCounter] = useState(0)
|
||||
|
||||
const params = useParams()
|
||||
const workflowId = params.workflowId as string
|
||||
@@ -61,79 +67,88 @@ export function ScheduleConfig({
|
||||
const blockWithValues = getBlockWithValues(blockId)
|
||||
const isScheduleTriggerBlock = blockWithValues?.type === 'schedule'
|
||||
|
||||
// Function to check if schedule exists in the database
|
||||
const checkSchedule = async () => {
|
||||
// Fetch schedule data from API
|
||||
const fetchSchedule = useCallback(async () => {
|
||||
if (!workflowId) return
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
// Check if there's a schedule for this workflow, passing the mode parameter
|
||||
// For schedule trigger blocks, include blockId to get the specific schedule
|
||||
const url = new URL('/api/schedules', window.location.origin)
|
||||
url.searchParams.set('workflowId', workflowId)
|
||||
url.searchParams.set('mode', 'schedule')
|
||||
|
||||
const params = new URLSearchParams({
|
||||
workflowId,
|
||||
mode: 'schedule',
|
||||
})
|
||||
if (isScheduleTriggerBlock) {
|
||||
url.searchParams.set('blockId', blockId)
|
||||
params.set('blockId', blockId)
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
// Add cache: 'no-store' to prevent caching of this request
|
||||
const response = await fetch(`/api/schedules?${params}`, {
|
||||
cache: 'no-store',
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache',
|
||||
},
|
||||
headers: { 'Cache-Control': 'no-cache' },
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
logger.debug('Schedule check response:', data)
|
||||
|
||||
if (data.schedule) {
|
||||
setScheduleId(data.schedule.id)
|
||||
setNextRunAt(data.schedule.nextRunAt)
|
||||
setLastRanAt(data.schedule.lastRanAt)
|
||||
setCronExpression(data.schedule.cronExpression)
|
||||
setTimezone(data.schedule.timezone || 'UTC')
|
||||
|
||||
// Note: We no longer set global schedule status from individual components
|
||||
// The global schedule status should be managed by a higher-level component
|
||||
setScheduleData({
|
||||
id: data.schedule.id,
|
||||
nextRunAt: data.schedule.nextRunAt,
|
||||
lastRanAt: data.schedule.lastRanAt,
|
||||
cronExpression: data.schedule.cronExpression,
|
||||
timezone: data.schedule.timezone || 'UTC',
|
||||
})
|
||||
} else {
|
||||
setScheduleId(null)
|
||||
setNextRunAt(null)
|
||||
setLastRanAt(null)
|
||||
setCronExpression(null)
|
||||
|
||||
// Note: We no longer set global schedule status from individual components
|
||||
setScheduleData({
|
||||
id: null,
|
||||
nextRunAt: null,
|
||||
lastRanAt: null,
|
||||
cronExpression: null,
|
||||
timezone: 'UTC',
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error checking schedule:', { error })
|
||||
setError('Failed to check schedule status')
|
||||
logger.error('Error fetching schedule:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
}, [workflowId, blockId, isScheduleTriggerBlock])
|
||||
|
||||
// Check for schedule on mount and when relevant dependencies change
|
||||
// Fetch schedule data on mount and when dependencies change
|
||||
useEffect(() => {
|
||||
// Check for schedules when workflowId changes, modal opens, or on initial mount
|
||||
if (workflowId) {
|
||||
checkSchedule()
|
||||
fetchSchedule()
|
||||
}, [fetchSchedule])
|
||||
|
||||
// Separate effect for event listener to avoid removing/re-adding on every dependency change
|
||||
useEffect(() => {
|
||||
const handleScheduleUpdate = (event: CustomEvent) => {
|
||||
if (event.detail?.workflowId === workflowId && event.detail?.blockId === blockId) {
|
||||
logger.debug('Schedule update event received in schedule-config, refetching')
|
||||
fetchSchedule()
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup function to reset loading state
|
||||
window.addEventListener('schedule-updated', handleScheduleUpdate as EventListener)
|
||||
|
||||
return () => {
|
||||
setIsLoading(false)
|
||||
window.removeEventListener('schedule-updated', handleScheduleUpdate as EventListener)
|
||||
}
|
||||
}, [workflowId, isModalOpen, refreshCounter])
|
||||
}, [workflowId, blockId, fetchSchedule])
|
||||
|
||||
// Refetch when modal opens to get latest data
|
||||
useEffect(() => {
|
||||
if (isModalOpen) {
|
||||
fetchSchedule()
|
||||
}
|
||||
}, [isModalOpen, fetchSchedule])
|
||||
|
||||
// Format the schedule information for display
|
||||
const getScheduleInfo = () => {
|
||||
if (!scheduleId || !nextRunAt) return null
|
||||
if (!scheduleData.id || !scheduleData.nextRunAt) return null
|
||||
|
||||
let scheduleTiming = 'Unknown schedule'
|
||||
|
||||
if (cronExpression) {
|
||||
scheduleTiming = parseCronToHumanReadable(cronExpression)
|
||||
if (scheduleData.cronExpression) {
|
||||
scheduleTiming = parseCronToHumanReadable(scheduleData.cronExpression, scheduleData.timezone)
|
||||
} else if (scheduleType) {
|
||||
scheduleTiming = `${scheduleType.charAt(0).toUpperCase() + scheduleType.slice(1)}`
|
||||
}
|
||||
@@ -142,8 +157,14 @@ export function ScheduleConfig({
|
||||
<>
|
||||
<div className='truncate font-normal text-sm'>{scheduleTiming}</div>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
<div>Next run: {formatDateTime(new Date(nextRunAt), timezone)}</div>
|
||||
{lastRanAt && <div>Last run: {formatDateTime(new Date(lastRanAt), timezone)}</div>}
|
||||
<div>
|
||||
Next run: {formatDateTime(new Date(scheduleData.nextRunAt), scheduleData.timezone)}
|
||||
</div>
|
||||
{scheduleData.lastRanAt && (
|
||||
<div>
|
||||
Last run: {formatDateTime(new Date(scheduleData.lastRanAt), scheduleData.timezone)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
@@ -154,16 +175,11 @@ export function ScheduleConfig({
|
||||
setIsModalOpen(true)
|
||||
}
|
||||
|
||||
const handleCloseModal = () => {
|
||||
const handleCloseModal = useCallback(() => {
|
||||
setIsModalOpen(false)
|
||||
// Force a refresh when closing the modal
|
||||
// Use a small timeout to ensure backend updates are complete
|
||||
setTimeout(() => {
|
||||
setRefreshCounter((prev) => prev + 1)
|
||||
}, 500)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSaveSchedule = async (): Promise<boolean> => {
|
||||
const handleSaveSchedule = useCallback(async (): Promise<boolean> => {
|
||||
if (isPreview || disabled) return false
|
||||
|
||||
setIsSaving(true)
|
||||
@@ -246,17 +262,24 @@ export function ScheduleConfig({
|
||||
logger.debug('Schedule save response:', responseData)
|
||||
|
||||
// 5. Update our local state with the response data
|
||||
if (responseData.cronExpression) {
|
||||
setCronExpression(responseData.cronExpression)
|
||||
if (responseData.cronExpression || responseData.nextRunAt) {
|
||||
setScheduleData((prev) => ({
|
||||
...prev,
|
||||
cronExpression: responseData.cronExpression || prev.cronExpression,
|
||||
nextRunAt:
|
||||
typeof responseData.nextRunAt === 'string'
|
||||
? responseData.nextRunAt
|
||||
: responseData.nextRunAt?.toISOString?.() || prev.nextRunAt,
|
||||
}))
|
||||
}
|
||||
|
||||
if (responseData.nextRunAt) {
|
||||
setNextRunAt(
|
||||
typeof responseData.nextRunAt === 'string'
|
||||
? responseData.nextRunAt
|
||||
: responseData.nextRunAt.toISOString?.() || responseData.nextRunAt
|
||||
)
|
||||
}
|
||||
// 6. Dispatch custom event to notify parent workflow-block component to refetch schedule info
|
||||
// This ensures the badge updates immediately after saving
|
||||
const event = new CustomEvent('schedule-updated', {
|
||||
detail: { workflowId, blockId },
|
||||
})
|
||||
window.dispatchEvent(event)
|
||||
logger.debug('Dispatched schedule-updated event', { workflowId, blockId })
|
||||
|
||||
// 6. Update the schedule status and trigger a workflow update
|
||||
// Note: Global schedule status is managed at a higher level
|
||||
@@ -266,15 +289,8 @@ export function ScheduleConfig({
|
||||
workflowStore.updateLastSaved()
|
||||
workflowStore.triggerUpdate()
|
||||
|
||||
// 8. Force a refresh to update the UI
|
||||
// Use a timeout to ensure the API changes are completed
|
||||
setTimeout(() => {
|
||||
logger.debug('Refreshing schedule information after save')
|
||||
setRefreshCounter((prev) => prev + 1)
|
||||
|
||||
// Make a separate API call to ensure we get the latest schedule info
|
||||
checkSchedule()
|
||||
}, 500)
|
||||
// 8. Refetch the schedule to update local state
|
||||
await fetchSchedule()
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
@@ -284,10 +300,10 @@ export function ScheduleConfig({
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
}, [workflowId, blockId, isScheduleTriggerBlock, setStartWorkflow, fetchSchedule])
|
||||
|
||||
const handleDeleteSchedule = async (): Promise<boolean> => {
|
||||
if (isPreview || !scheduleId || disabled) return false
|
||||
const handleDeleteSchedule = useCallback(async (): Promise<boolean> => {
|
||||
if (isPreview || !scheduleData.id || disabled) return false
|
||||
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
@@ -315,7 +331,7 @@ export function ScheduleConfig({
|
||||
}
|
||||
|
||||
// 4. Make the DELETE API call to remove the schedule
|
||||
const response = await fetch(`/api/schedules/${scheduleId}`, {
|
||||
const response = await fetch(`/api/schedules/${scheduleData.id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
@@ -326,14 +342,23 @@ export function ScheduleConfig({
|
||||
}
|
||||
|
||||
// 5. Clear schedule state
|
||||
setScheduleId(null)
|
||||
setNextRunAt(null)
|
||||
setLastRanAt(null)
|
||||
setCronExpression(null)
|
||||
setScheduleData({
|
||||
id: null,
|
||||
nextRunAt: null,
|
||||
lastRanAt: null,
|
||||
cronExpression: null,
|
||||
timezone: 'UTC',
|
||||
})
|
||||
|
||||
// 6. Update schedule status and refresh UI
|
||||
// Note: Global schedule status is managed at a higher level
|
||||
setRefreshCounter((prev) => prev + 1)
|
||||
|
||||
// 7. Dispatch custom event to notify parent workflow-block component
|
||||
const event = new CustomEvent('schedule-updated', {
|
||||
detail: { workflowId, blockId },
|
||||
})
|
||||
window.dispatchEvent(event)
|
||||
logger.debug('Dispatched schedule-updated event after delete', { workflowId, blockId })
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
@@ -343,10 +368,18 @@ export function ScheduleConfig({
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
}, [
|
||||
scheduleData.id,
|
||||
isPreview,
|
||||
disabled,
|
||||
isScheduleTriggerBlock,
|
||||
setStartWorkflow,
|
||||
workflowId,
|
||||
blockId,
|
||||
])
|
||||
|
||||
// Check if the schedule is active
|
||||
const isScheduleActive = !!scheduleId && !!nextRunAt
|
||||
const isScheduleActive = !!scheduleData.id && !!scheduleData.nextRunAt
|
||||
|
||||
return (
|
||||
<div className='w-full' onClick={(e) => e.stopPropagation()}>
|
||||
@@ -399,7 +432,7 @@ export function ScheduleConfig({
|
||||
blockId={blockId}
|
||||
onSave={handleSaveSchedule}
|
||||
onDelete={handleDeleteSchedule}
|
||||
scheduleId={scheduleId}
|
||||
scheduleId={scheduleData.id}
|
||||
/>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
@@ -280,83 +280,59 @@ export const WorkflowBlock = memo(
|
||||
}
|
||||
}
|
||||
|
||||
const fetchScheduleInfo = async (workflowId: string) => {
|
||||
if (!workflowId) return
|
||||
|
||||
try {
|
||||
setIsLoadingScheduleInfo(true)
|
||||
|
||||
// For schedule trigger blocks, always include the blockId parameter
|
||||
const url = new URL('/api/schedules', window.location.origin)
|
||||
url.searchParams.set('workflowId', workflowId)
|
||||
url.searchParams.set('mode', 'schedule')
|
||||
url.searchParams.set('blockId', id) // Always include blockId for schedule blocks
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
cache: 'no-store',
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
setScheduleInfo(null)
|
||||
return
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!data.schedule) {
|
||||
setScheduleInfo(null)
|
||||
return
|
||||
}
|
||||
|
||||
let scheduleTiming = 'Unknown schedule'
|
||||
if (data.schedule.cronExpression) {
|
||||
scheduleTiming = parseCronToHumanReadable(data.schedule.cronExpression)
|
||||
}
|
||||
|
||||
const baseInfo = {
|
||||
scheduleTiming,
|
||||
nextRunAt: data.schedule.nextRunAt as string | null,
|
||||
lastRanAt: data.schedule.lastRanAt as string | null,
|
||||
timezone: data.schedule.timezone || 'UTC',
|
||||
status: data.schedule.status as string,
|
||||
isDisabled: data.schedule.status === 'disabled',
|
||||
id: data.schedule.id as string,
|
||||
}
|
||||
const fetchScheduleInfo = useCallback(
|
||||
async (workflowId: string) => {
|
||||
if (!workflowId) return
|
||||
|
||||
try {
|
||||
const statusRes = await fetch(`/api/schedules/${baseInfo.id}/status`, {
|
||||
setIsLoadingScheduleInfo(true)
|
||||
|
||||
const params = new URLSearchParams({
|
||||
workflowId,
|
||||
mode: 'schedule',
|
||||
blockId: id,
|
||||
})
|
||||
|
||||
const response = await fetch(`/api/schedules?${params}`, {
|
||||
cache: 'no-store',
|
||||
headers: { 'Cache-Control': 'no-cache' },
|
||||
})
|
||||
|
||||
if (statusRes.ok) {
|
||||
const statusData = await statusRes.json()
|
||||
setScheduleInfo({
|
||||
scheduleTiming: baseInfo.scheduleTiming,
|
||||
nextRunAt: statusData.nextRunAt ?? baseInfo.nextRunAt,
|
||||
lastRanAt: statusData.lastRanAt ?? baseInfo.lastRanAt,
|
||||
timezone: baseInfo.timezone,
|
||||
status: statusData.status ?? baseInfo.status,
|
||||
isDisabled: statusData.isDisabled ?? baseInfo.isDisabled,
|
||||
id: baseInfo.id,
|
||||
})
|
||||
if (!response.ok) {
|
||||
setScheduleInfo(null)
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Error fetching schedule status:', err)
|
||||
}
|
||||
|
||||
setScheduleInfo(baseInfo)
|
||||
} catch (error) {
|
||||
logger.error('Error fetching schedule info:', error)
|
||||
setScheduleInfo(null)
|
||||
} finally {
|
||||
setIsLoadingScheduleInfo(false)
|
||||
}
|
||||
}
|
||||
const data = await response.json()
|
||||
|
||||
if (!data.schedule) {
|
||||
setScheduleInfo(null)
|
||||
return
|
||||
}
|
||||
|
||||
const schedule = data.schedule
|
||||
const scheduleTimezone = schedule.timezone || 'UTC'
|
||||
|
||||
setScheduleInfo({
|
||||
scheduleTiming: schedule.cronExpression
|
||||
? parseCronToHumanReadable(schedule.cronExpression, scheduleTimezone)
|
||||
: 'Unknown schedule',
|
||||
nextRunAt: schedule.nextRunAt,
|
||||
lastRanAt: schedule.lastRanAt,
|
||||
timezone: scheduleTimezone,
|
||||
status: schedule.status,
|
||||
isDisabled: schedule.status === 'disabled',
|
||||
id: schedule.id,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error fetching schedule info:', error)
|
||||
setScheduleInfo(null)
|
||||
} finally {
|
||||
setIsLoadingScheduleInfo(false)
|
||||
}
|
||||
},
|
||||
[id]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (type === 'schedule' && currentWorkflowId) {
|
||||
@@ -366,11 +342,25 @@ export const WorkflowBlock = memo(
|
||||
setIsLoadingScheduleInfo(false) // Reset loading state when not a schedule block
|
||||
}
|
||||
|
||||
// Cleanup function to reset loading state when component unmounts or workflow changes
|
||||
// Listen for schedule updates from the schedule-config component
|
||||
const handleScheduleUpdate = (event: CustomEvent) => {
|
||||
// Check if the update is for this workflow and block
|
||||
if (event.detail?.workflowId === currentWorkflowId && event.detail?.blockId === id) {
|
||||
logger.debug('Schedule update event received, refetching schedule info')
|
||||
if (type === 'schedule') {
|
||||
fetchScheduleInfo(currentWorkflowId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('schedule-updated', handleScheduleUpdate as EventListener)
|
||||
|
||||
// Cleanup function to reset loading state and remove listener
|
||||
return () => {
|
||||
setIsLoadingScheduleInfo(false)
|
||||
window.removeEventListener('schedule-updated', handleScheduleUpdate as EventListener)
|
||||
}
|
||||
}, [isStarterBlock, isTriggerBlock, type, currentWorkflowId])
|
||||
}, [type, currentWorkflowId, id, fetchScheduleInfo])
|
||||
|
||||
// Get webhook information for the tooltip
|
||||
useEffect(() => {
|
||||
|
||||
@@ -49,8 +49,14 @@ function calculateNextRunTime(
|
||||
const scheduleType = getSubBlockValue(scheduleBlock, 'scheduleType')
|
||||
const scheduleValues = getScheduleTimeValues(scheduleBlock)
|
||||
|
||||
// Get timezone from schedule configuration (default to UTC)
|
||||
const timezone = scheduleValues.timezone || 'UTC'
|
||||
|
||||
if (schedule.cronExpression) {
|
||||
const cron = new Cron(schedule.cronExpression)
|
||||
// Use Croner with timezone support for accurate scheduling
|
||||
const cron = new Cron(schedule.cronExpression, {
|
||||
timezone,
|
||||
})
|
||||
const nextDate = cron.nextRun()
|
||||
if (!nextDate) throw new Error('Invalid cron expression or no future occurrences')
|
||||
return nextDate
|
||||
|
||||
@@ -223,7 +223,7 @@ describe('Schedule Utilities', () => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it.concurrent('should calculate next run for minutes schedule', () => {
|
||||
it.concurrent('should calculate next run for minutes schedule using Croner', () => {
|
||||
const scheduleValues = {
|
||||
scheduleTime: '',
|
||||
scheduleStartAt: '',
|
||||
@@ -244,14 +244,14 @@ describe('Schedule Utilities', () => {
|
||||
expect(nextRun instanceof Date).toBe(true)
|
||||
expect(nextRun > new Date()).toBe(true)
|
||||
|
||||
// Check minute is a multiple of the interval
|
||||
expect(nextRun.getMinutes() % 15).toBe(0)
|
||||
// Croner will calculate based on the cron expression */15 * * * *
|
||||
// The exact minute depends on Croner's calculation
|
||||
})
|
||||
|
||||
it.concurrent('should respect scheduleTime for minutes schedule', () => {
|
||||
it.concurrent('should handle scheduleStartAt with scheduleTime', () => {
|
||||
const scheduleValues = {
|
||||
scheduleTime: '14:30', // Specific start time
|
||||
scheduleStartAt: '',
|
||||
scheduleStartAt: '2025-04-15', // Future date
|
||||
timezone: 'UTC',
|
||||
minutesInterval: 15,
|
||||
hourlyMinute: 0,
|
||||
@@ -265,12 +265,13 @@ describe('Schedule Utilities', () => {
|
||||
|
||||
const nextRun = calculateNextRunTime('minutes', scheduleValues)
|
||||
|
||||
// Should be 14:30
|
||||
expect(nextRun.getHours()).toBe(14)
|
||||
expect(nextRun.getMinutes()).toBe(30)
|
||||
// Should return the future start date with time
|
||||
expect(nextRun.getFullYear()).toBe(2025)
|
||||
expect(nextRun.getMonth()).toBe(3) // April
|
||||
expect(nextRun.getDate()).toBe(15)
|
||||
})
|
||||
|
||||
it.concurrent('should calculate next run for hourly schedule', () => {
|
||||
it.concurrent('should calculate next run for hourly schedule using Croner', () => {
|
||||
const scheduleValues = {
|
||||
scheduleTime: '',
|
||||
scheduleStartAt: '',
|
||||
@@ -287,13 +288,14 @@ describe('Schedule Utilities', () => {
|
||||
|
||||
const nextRun = calculateNextRunTime('hourly', scheduleValues)
|
||||
|
||||
// Just verify it's a valid future date with the right minute
|
||||
// Verify it's a valid future date using Croner's calculation
|
||||
expect(nextRun instanceof Date).toBe(true)
|
||||
expect(nextRun > new Date()).toBe(true)
|
||||
// Croner calculates based on cron "30 * * * *"
|
||||
expect(nextRun.getMinutes()).toBe(30)
|
||||
})
|
||||
|
||||
it.concurrent('should calculate next run for daily schedule', () => {
|
||||
it.concurrent('should calculate next run for daily schedule using Croner with timezone', () => {
|
||||
const scheduleValues = {
|
||||
scheduleTime: '',
|
||||
scheduleStartAt: '',
|
||||
@@ -310,88 +312,96 @@ describe('Schedule Utilities', () => {
|
||||
|
||||
const nextRun = calculateNextRunTime('daily', scheduleValues)
|
||||
|
||||
// Verify it's a future date at exactly 9:00
|
||||
// Verify it's a future date at exactly 9:00 UTC using Croner
|
||||
expect(nextRun instanceof Date).toBe(true)
|
||||
expect(nextRun > new Date()).toBe(true)
|
||||
expect(nextRun.getHours()).toBe(9)
|
||||
expect(nextRun.getMinutes()).toBe(0)
|
||||
expect(nextRun.getUTCHours()).toBe(9)
|
||||
expect(nextRun.getUTCMinutes()).toBe(0)
|
||||
})
|
||||
|
||||
it.concurrent('should calculate next run for weekly schedule', () => {
|
||||
const scheduleValues = {
|
||||
scheduleTime: '',
|
||||
scheduleStartAt: '',
|
||||
timezone: 'UTC',
|
||||
minutesInterval: 15,
|
||||
hourlyMinute: 0,
|
||||
dailyTime: [9, 0] as [number, number],
|
||||
weeklyDay: 1, // Monday
|
||||
weeklyTime: [10, 0] as [number, number],
|
||||
monthlyDay: 1,
|
||||
monthlyTime: [9, 0] as [number, number],
|
||||
cronExpression: null,
|
||||
it.concurrent(
|
||||
'should calculate next run for weekly schedule using Croner with timezone',
|
||||
() => {
|
||||
const scheduleValues = {
|
||||
scheduleTime: '',
|
||||
scheduleStartAt: '',
|
||||
timezone: 'UTC',
|
||||
minutesInterval: 15,
|
||||
hourlyMinute: 0,
|
||||
dailyTime: [9, 0] as [number, number],
|
||||
weeklyDay: 1, // Monday
|
||||
weeklyTime: [10, 0] as [number, number],
|
||||
monthlyDay: 1,
|
||||
monthlyTime: [9, 0] as [number, number],
|
||||
cronExpression: null,
|
||||
}
|
||||
|
||||
const nextRun = calculateNextRunTime('weekly', scheduleValues)
|
||||
|
||||
// Should be next Monday at 10:00 AM UTC using Croner
|
||||
expect(nextRun.getUTCDay()).toBe(1) // Monday
|
||||
expect(nextRun.getUTCHours()).toBe(10)
|
||||
expect(nextRun.getUTCMinutes()).toBe(0)
|
||||
}
|
||||
)
|
||||
|
||||
const nextRun = calculateNextRunTime('weekly', scheduleValues)
|
||||
it.concurrent(
|
||||
'should calculate next run for monthly schedule using Croner with timezone',
|
||||
() => {
|
||||
const scheduleValues = {
|
||||
scheduleTime: '',
|
||||
scheduleStartAt: '',
|
||||
timezone: 'UTC',
|
||||
minutesInterval: 15,
|
||||
hourlyMinute: 0,
|
||||
dailyTime: [9, 0] as [number, number],
|
||||
weeklyDay: 1,
|
||||
weeklyTime: [9, 0] as [number, number],
|
||||
monthlyDay: 15,
|
||||
monthlyTime: [14, 30] as [number, number],
|
||||
cronExpression: null,
|
||||
}
|
||||
|
||||
// Should be next Monday at 10:00 AM
|
||||
expect(nextRun.getDay()).toBe(1) // Monday
|
||||
expect(nextRun.getHours()).toBe(10)
|
||||
expect(nextRun.getMinutes()).toBe(0)
|
||||
})
|
||||
const nextRun = calculateNextRunTime('monthly', scheduleValues)
|
||||
|
||||
it.concurrent('should calculate next run for monthly schedule', () => {
|
||||
const scheduleValues = {
|
||||
scheduleTime: '',
|
||||
scheduleStartAt: '',
|
||||
timezone: 'UTC',
|
||||
minutesInterval: 15,
|
||||
hourlyMinute: 0,
|
||||
dailyTime: [9, 0] as [number, number],
|
||||
weeklyDay: 1,
|
||||
weeklyTime: [9, 0] as [number, number],
|
||||
monthlyDay: 15,
|
||||
monthlyTime: [14, 30] as [number, number],
|
||||
cronExpression: null,
|
||||
// Current date is 2025-04-12 12:00, so next run should be 2025-04-15 14:30 UTC using Croner
|
||||
expect(nextRun.getFullYear()).toBe(2025)
|
||||
expect(nextRun.getUTCMonth()).toBe(3) // April (0-indexed)
|
||||
expect(nextRun.getUTCDate()).toBe(15)
|
||||
expect(nextRun.getUTCHours()).toBe(14)
|
||||
expect(nextRun.getUTCMinutes()).toBe(30)
|
||||
}
|
||||
)
|
||||
|
||||
const nextRun = calculateNextRunTime('monthly', scheduleValues)
|
||||
it.concurrent(
|
||||
'should work with lastRanAt parameter (though Croner calculates independently)',
|
||||
() => {
|
||||
const scheduleValues = {
|
||||
scheduleTime: '',
|
||||
scheduleStartAt: '',
|
||||
timezone: 'UTC',
|
||||
minutesInterval: 15,
|
||||
hourlyMinute: 0,
|
||||
dailyTime: [9, 0] as [number, number],
|
||||
weeklyDay: 1,
|
||||
weeklyTime: [9, 0] as [number, number],
|
||||
monthlyDay: 1,
|
||||
monthlyTime: [9, 0] as [number, number],
|
||||
cronExpression: null,
|
||||
}
|
||||
|
||||
// Current date is 2025-04-12 12:00, so next run should be 2025-04-15 14:30
|
||||
expect(nextRun.getFullYear()).toBe(2025)
|
||||
expect(nextRun.getMonth()).toBe(3) // April (0-indexed)
|
||||
expect(nextRun.getDate()).toBe(15)
|
||||
expect(nextRun.getHours()).toBe(14)
|
||||
expect(nextRun.getMinutes()).toBe(30)
|
||||
})
|
||||
// Last ran 10 minutes ago
|
||||
const lastRanAt = new Date()
|
||||
lastRanAt.setMinutes(lastRanAt.getMinutes() - 10)
|
||||
|
||||
it.concurrent('should consider lastRanAt for better interval calculation', () => {
|
||||
const scheduleValues = {
|
||||
scheduleTime: '',
|
||||
scheduleStartAt: '',
|
||||
timezone: 'UTC',
|
||||
minutesInterval: 15,
|
||||
hourlyMinute: 0,
|
||||
dailyTime: [9, 0] as [number, number],
|
||||
weeklyDay: 1,
|
||||
weeklyTime: [9, 0] as [number, number],
|
||||
monthlyDay: 1,
|
||||
monthlyTime: [9, 0] as [number, number],
|
||||
cronExpression: null,
|
||||
const nextRun = calculateNextRunTime('minutes', scheduleValues, lastRanAt)
|
||||
|
||||
// With Croner, it calculates based on cron expression, not lastRanAt
|
||||
// Just verify we get a future date
|
||||
expect(nextRun instanceof Date).toBe(true)
|
||||
expect(nextRun > new Date()).toBe(true)
|
||||
}
|
||||
|
||||
// Last ran 10 minutes ago
|
||||
const lastRanAt = new Date()
|
||||
lastRanAt.setMinutes(lastRanAt.getMinutes() - 10)
|
||||
|
||||
const nextRun = calculateNextRunTime('minutes', scheduleValues, lastRanAt)
|
||||
|
||||
// Should be 5 minutes from the last run (15 min interval)
|
||||
const expectedNextRun = new Date(lastRanAt)
|
||||
expectedNextRun.setMinutes(expectedNextRun.getMinutes() + 15)
|
||||
|
||||
expect(nextRun.getMinutes()).toBe(expectedNextRun.getMinutes())
|
||||
})
|
||||
)
|
||||
|
||||
it.concurrent('should respect future scheduleStartAt date', () => {
|
||||
const scheduleValues = {
|
||||
@@ -453,6 +463,12 @@ describe('Schedule Utilities', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it.concurrent('should validate cron expressions with timezone', () => {
|
||||
const result = validateCronExpression('0 9 * * *', 'America/Los_Angeles')
|
||||
expect(result.isValid).toBe(true)
|
||||
expect(result.nextRun).toBeInstanceOf(Date)
|
||||
})
|
||||
|
||||
it.concurrent('should reject invalid cron expressions', () => {
|
||||
expect(validateCronExpression('invalid')).toEqual({
|
||||
isValid: false,
|
||||
@@ -482,27 +498,181 @@ describe('Schedule Utilities', () => {
|
||||
})
|
||||
|
||||
describe('parseCronToHumanReadable', () => {
|
||||
it.concurrent('should parse common cron patterns', () => {
|
||||
it.concurrent('should parse common cron patterns using cronstrue', () => {
|
||||
// cronstrue produces "Every minute" for '* * * * *'
|
||||
expect(parseCronToHumanReadable('* * * * *')).toBe('Every minute')
|
||||
|
||||
// cronstrue produces "Every 15 minutes" for '*/15 * * * *'
|
||||
expect(parseCronToHumanReadable('*/15 * * * *')).toBe('Every 15 minutes')
|
||||
expect(parseCronToHumanReadable('30 * * * *')).toBe('Hourly at 30 minutes past the hour')
|
||||
expect(parseCronToHumanReadable('0 9 * * *')).toBe('Daily at 9:00 AM')
|
||||
expect(parseCronToHumanReadable('30 14 * * *')).toBe('Daily at 2:30 PM')
|
||||
expect(parseCronToHumanReadable('0 9 * * 1')).toMatch(/Monday at 9:00 AM/)
|
||||
expect(parseCronToHumanReadable('30 14 15 * *')).toMatch(/Monthly on the 15th at 2:30 PM/)
|
||||
|
||||
// cronstrue produces "At 30 minutes past the hour" for '30 * * * *'
|
||||
expect(parseCronToHumanReadable('30 * * * *')).toContain('30 minutes past the hour')
|
||||
|
||||
// cronstrue produces "At 09:00 AM" for '0 9 * * *'
|
||||
expect(parseCronToHumanReadable('0 9 * * *')).toContain('09:00 AM')
|
||||
|
||||
// cronstrue produces "At 02:30 PM" for '30 14 * * *'
|
||||
expect(parseCronToHumanReadable('30 14 * * *')).toContain('02:30 PM')
|
||||
|
||||
// cronstrue produces "At 09:00 AM, only on Monday" for '0 9 * * 1'
|
||||
expect(parseCronToHumanReadable('0 9 * * 1')).toContain('Monday')
|
||||
|
||||
// cronstrue produces "At 02:30 PM, on day 15 of the month" for '30 14 15 * *'
|
||||
expect(parseCronToHumanReadable('30 14 15 * *')).toContain('15')
|
||||
})
|
||||
|
||||
it.concurrent('should handle complex patterns', () => {
|
||||
// Test with various combinations
|
||||
expect(parseCronToHumanReadable('* */2 * * *')).toMatch(/Runs/)
|
||||
expect(parseCronToHumanReadable('0 9 * * 1-5')).toMatch(/Runs/)
|
||||
expect(parseCronToHumanReadable('0 9 1,15 * *')).toMatch(/Runs/)
|
||||
it.concurrent('should include timezone information when provided', () => {
|
||||
const resultPT = parseCronToHumanReadable('0 9 * * *', 'America/Los_Angeles')
|
||||
expect(resultPT).toContain('(PT)')
|
||||
expect(resultPT).toContain('09:00 AM')
|
||||
|
||||
const resultET = parseCronToHumanReadable('30 14 * * *', 'America/New_York')
|
||||
expect(resultET).toContain('(ET)')
|
||||
expect(resultET).toContain('02:30 PM')
|
||||
|
||||
const resultUTC = parseCronToHumanReadable('0 12 * * *', 'UTC')
|
||||
expect(resultUTC).not.toContain('(UTC)') // UTC should not be explicitly shown
|
||||
})
|
||||
|
||||
it.concurrent('should return a fallback for unrecognized patterns', () => {
|
||||
const result = parseCronToHumanReadable('*/10 */6 31 2 *') // Invalid (Feb 31)
|
||||
// Just check that we get something back that's not empty
|
||||
expect(result.length).toBeGreaterThan(5)
|
||||
it.concurrent('should handle complex patterns with cronstrue', () => {
|
||||
// cronstrue can handle complex patterns better than our custom parser
|
||||
const result1 = parseCronToHumanReadable('0 9 * * 1-5')
|
||||
expect(result1).toContain('Monday through Friday')
|
||||
|
||||
const result2 = parseCronToHumanReadable('0 9 1,15 * *')
|
||||
expect(result2).toContain('day 1 and 15')
|
||||
})
|
||||
|
||||
it.concurrent('should return a fallback for invalid patterns', () => {
|
||||
const result = parseCronToHumanReadable('invalid cron')
|
||||
// Should fallback to "Schedule: <expression>"
|
||||
expect(result).toContain('Schedule:')
|
||||
expect(result).toContain('invalid cron')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Timezone-aware scheduling with Croner', () => {
|
||||
it.concurrent('should calculate daily schedule in Pacific Time correctly', () => {
|
||||
const scheduleValues = {
|
||||
scheduleTime: '',
|
||||
scheduleStartAt: '',
|
||||
timezone: 'America/Los_Angeles',
|
||||
minutesInterval: 15,
|
||||
hourlyMinute: 0,
|
||||
dailyTime: [9, 0] as [number, number], // 9 AM Pacific
|
||||
weeklyDay: 1,
|
||||
weeklyTime: [9, 0] as [number, number],
|
||||
monthlyDay: 1,
|
||||
monthlyTime: [9, 0] as [number, number],
|
||||
cronExpression: null,
|
||||
}
|
||||
|
||||
const nextRun = calculateNextRunTime('daily', scheduleValues)
|
||||
|
||||
// 9 AM Pacific should be 16:00 or 17:00 UTC depending on DST
|
||||
// Croner handles this automatically
|
||||
expect(nextRun instanceof Date).toBe(true)
|
||||
expect(nextRun > new Date()).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should handle DST transition for schedules', () => {
|
||||
// Set a date during DST transition in March
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2025-03-08T10:00:00.000Z')) // Before DST
|
||||
|
||||
const scheduleValues = {
|
||||
scheduleTime: '',
|
||||
scheduleStartAt: '',
|
||||
timezone: 'America/New_York',
|
||||
minutesInterval: 15,
|
||||
hourlyMinute: 0,
|
||||
dailyTime: [14, 0] as [number, number], // 2 PM Eastern
|
||||
weeklyDay: 1,
|
||||
weeklyTime: [14, 0] as [number, number],
|
||||
monthlyDay: 1,
|
||||
monthlyTime: [14, 0] as [number, number],
|
||||
cronExpression: null,
|
||||
}
|
||||
|
||||
const nextRun = calculateNextRunTime('daily', scheduleValues)
|
||||
|
||||
// Croner should handle DST transition correctly
|
||||
expect(nextRun instanceof Date).toBe(true)
|
||||
expect(nextRun > new Date()).toBe(true)
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it.concurrent('should calculate weekly schedule in Tokyo timezone', () => {
|
||||
const scheduleValues = {
|
||||
scheduleTime: '',
|
||||
scheduleStartAt: '',
|
||||
timezone: 'Asia/Tokyo',
|
||||
minutesInterval: 15,
|
||||
hourlyMinute: 0,
|
||||
dailyTime: [9, 0] as [number, number],
|
||||
weeklyDay: 1, // Monday
|
||||
weeklyTime: [10, 0] as [number, number], // 10 AM Japan Time
|
||||
monthlyDay: 1,
|
||||
monthlyTime: [9, 0] as [number, number],
|
||||
cronExpression: null,
|
||||
}
|
||||
|
||||
const nextRun = calculateNextRunTime('weekly', scheduleValues)
|
||||
|
||||
// Verify it's a valid future date
|
||||
// Tokyo is UTC+9, so 10 AM JST = 1 AM UTC
|
||||
expect(nextRun instanceof Date).toBe(true)
|
||||
expect(nextRun > new Date()).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should handle custom cron with timezone', () => {
|
||||
const scheduleValues = {
|
||||
scheduleTime: '',
|
||||
scheduleStartAt: '',
|
||||
timezone: 'Europe/London',
|
||||
minutesInterval: 15,
|
||||
hourlyMinute: 0,
|
||||
dailyTime: [9, 0] as [number, number],
|
||||
weeklyDay: 1,
|
||||
weeklyTime: [9, 0] as [number, number],
|
||||
monthlyDay: 1,
|
||||
monthlyTime: [9, 0] as [number, number],
|
||||
cronExpression: '30 15 * * *', // 3:30 PM London time
|
||||
}
|
||||
|
||||
const nextRun = calculateNextRunTime('custom', scheduleValues)
|
||||
|
||||
// Verify it's a valid future date
|
||||
expect(nextRun instanceof Date).toBe(true)
|
||||
expect(nextRun > new Date()).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should handle monthly schedule on last day of month', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2025-02-15T12:00:00.000Z'))
|
||||
|
||||
const scheduleValues = {
|
||||
scheduleTime: '',
|
||||
scheduleStartAt: '',
|
||||
timezone: 'America/Chicago',
|
||||
minutesInterval: 15,
|
||||
hourlyMinute: 0,
|
||||
dailyTime: [9, 0] as [number, number],
|
||||
weeklyDay: 1,
|
||||
weeklyTime: [9, 0] as [number, number],
|
||||
monthlyDay: 28, // Last day of Feb (non-leap year)
|
||||
monthlyTime: [12, 0] as [number, number], // Noon Central
|
||||
cronExpression: null,
|
||||
}
|
||||
|
||||
const nextRun = calculateNextRunTime('monthly', scheduleValues)
|
||||
|
||||
// Should calculate Feb 28 at noon Central time
|
||||
expect(nextRun.getUTCDate()).toBe(28)
|
||||
expect(nextRun.getUTCMonth()).toBe(1) // February
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Cron } from 'croner'
|
||||
import cronstrue from 'cronstrue'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { formatDateTime } from '@/lib/utils'
|
||||
|
||||
@@ -7,9 +8,13 @@ const logger = createLogger('ScheduleUtils')
|
||||
/**
|
||||
* Validates a cron expression and returns validation results
|
||||
* @param cronExpression - The cron expression to validate
|
||||
* @param timezone - Optional IANA timezone string (e.g., 'America/Los_Angeles'). Defaults to 'UTC'
|
||||
* @returns Validation result with isValid flag, error message, and next run date
|
||||
*/
|
||||
export function validateCronExpression(cronExpression: string): {
|
||||
export function validateCronExpression(
|
||||
cronExpression: string,
|
||||
timezone?: string
|
||||
): {
|
||||
isValid: boolean
|
||||
error?: string
|
||||
nextRun?: Date
|
||||
@@ -22,7 +27,8 @@ export function validateCronExpression(cronExpression: string): {
|
||||
}
|
||||
|
||||
try {
|
||||
const cron = new Cron(cronExpression)
|
||||
// Validate with timezone if provided for accurate next run calculation
|
||||
const cron = new Cron(cronExpression, timezone ? { timezone } : undefined)
|
||||
const nextRun = cron.nextRun()
|
||||
|
||||
if (!nextRun) {
|
||||
@@ -267,6 +273,21 @@ export function createDateWithTimezone(
|
||||
|
||||
/**
|
||||
* Generate cron expression based on schedule type and values
|
||||
*
|
||||
* IMPORTANT: The generated cron expressions use local time values (hours/minutes)
|
||||
* from the user's configured timezone. When used with Croner, pass the timezone
|
||||
* option to ensure proper scheduling:
|
||||
*
|
||||
* Example:
|
||||
* const cronExpr = generateCronExpression('daily', { dailyTime: [14, 30], timezone: 'America/Los_Angeles' })
|
||||
* const cron = new Cron(cronExpr, { timezone: 'America/Los_Angeles' })
|
||||
*
|
||||
* This will schedule the job at 2:30 PM Pacific Time, which Croner will correctly
|
||||
* convert to the appropriate UTC time, handling DST transitions automatically.
|
||||
*
|
||||
* @param scheduleType - Type of schedule (minutes, hourly, daily, weekly, monthly, custom)
|
||||
* @param scheduleValues - Object containing schedule configuration including timezone
|
||||
* @returns Cron expression string representing the schedule in local time
|
||||
*/
|
||||
export function generateCronExpression(
|
||||
scheduleType: string,
|
||||
@@ -308,6 +329,7 @@ export function generateCronExpression(
|
||||
|
||||
/**
|
||||
* Calculate the next run time based on schedule configuration
|
||||
* Uses Croner library with timezone support for accurate scheduling across timezones and DST transitions
|
||||
* @param scheduleType - Type of schedule (minutes, hourly, daily, etc)
|
||||
* @param scheduleValues - Object with schedule configuration values
|
||||
* @param lastRanAt - Optional last execution time
|
||||
@@ -398,270 +420,83 @@ export function calculateNextRunTime(
|
||||
}
|
||||
}
|
||||
|
||||
// If we have a scheduleTime (but no future scheduleStartAt), use it for today
|
||||
const scheduleTimeOverride = scheduleValues.scheduleTime
|
||||
? parseTimeString(scheduleValues.scheduleTime)
|
||||
: null
|
||||
// For recurring schedules, use Croner with timezone support
|
||||
// This ensures proper timezone handling and DST transitions
|
||||
try {
|
||||
const cronExpression = generateCronExpression(scheduleType, scheduleValues)
|
||||
logger.debug(`Using cron expression: ${cronExpression} with timezone: ${timezone}`)
|
||||
|
||||
// Create next run date based on the current date
|
||||
const nextRun = new Date(baseDate)
|
||||
// Create Croner instance with timezone support
|
||||
const cron = new Cron(cronExpression, {
|
||||
timezone,
|
||||
})
|
||||
|
||||
switch (scheduleType) {
|
||||
case 'minutes': {
|
||||
const { minutesInterval } = scheduleValues
|
||||
const nextDate = cron.nextRun()
|
||||
|
||||
// If we have a time override, use it
|
||||
if (scheduleTimeOverride) {
|
||||
const [hours, minutes] = scheduleTimeOverride
|
||||
nextRun.setHours(hours, minutes, 0, 0)
|
||||
|
||||
// Add intervals until we're in the future
|
||||
while (nextRun <= new Date()) {
|
||||
nextRun.setMinutes(nextRun.getMinutes() + minutesInterval)
|
||||
}
|
||||
return nextRun
|
||||
}
|
||||
|
||||
// For subsequent runs after lastRanAt
|
||||
if (lastRanAt) {
|
||||
const baseTime = new Date(lastRanAt)
|
||||
nextRun.setTime(baseTime.getTime())
|
||||
nextRun.setMinutes(nextRun.getMinutes() + minutesInterval, 0, 0)
|
||||
|
||||
// Make sure we're in the future
|
||||
while (nextRun <= new Date()) {
|
||||
nextRun.setMinutes(nextRun.getMinutes() + minutesInterval)
|
||||
}
|
||||
return nextRun
|
||||
}
|
||||
|
||||
// Calculate next boundary for minutes
|
||||
const now = new Date()
|
||||
const currentMinutes = now.getMinutes()
|
||||
const nextIntervalBoundary = Math.ceil(currentMinutes / minutesInterval) * minutesInterval
|
||||
nextRun.setMinutes(nextIntervalBoundary, 0, 0)
|
||||
|
||||
// If we're past this time but haven't reached baseDate, adjust
|
||||
if (nextRun <= now) {
|
||||
nextRun.setMinutes(nextRun.getMinutes() + minutesInterval)
|
||||
}
|
||||
|
||||
return nextRun
|
||||
if (!nextDate) {
|
||||
throw new Error(`No next run date calculated for cron: ${cronExpression}`)
|
||||
}
|
||||
|
||||
case 'hourly': {
|
||||
// Use the override time if available, otherwise use hourly config
|
||||
const [targetHours, _] = scheduleTimeOverride || [nextRun.getHours(), 0]
|
||||
const targetMinutes = scheduleValues.hourlyMinute
|
||||
|
||||
nextRun.setHours(targetHours, targetMinutes, 0, 0)
|
||||
|
||||
// If we're in the past relative to now (not baseDate), move to next hour
|
||||
if (nextRun <= new Date()) {
|
||||
nextRun.setHours(nextRun.getHours() + 1)
|
||||
}
|
||||
|
||||
return nextRun
|
||||
}
|
||||
|
||||
case 'daily': {
|
||||
// Use either schedule override or daily time values
|
||||
const [hours, minutes] = scheduleTimeOverride || scheduleValues.dailyTime
|
||||
|
||||
nextRun.setHours(hours, minutes, 0, 0)
|
||||
|
||||
// If we're in the past relative to now (not baseDate), move to tomorrow
|
||||
if (nextRun <= new Date()) {
|
||||
nextRun.setDate(nextRun.getDate() + 1)
|
||||
}
|
||||
|
||||
return nextRun
|
||||
}
|
||||
|
||||
case 'weekly': {
|
||||
// Use either schedule override or weekly time values
|
||||
const [hours, minutes] = scheduleTimeOverride || scheduleValues.weeklyTime
|
||||
|
||||
nextRun.setHours(hours, minutes, 0, 0)
|
||||
|
||||
// Add days until we reach the target day in the future
|
||||
while (nextRun.getDay() !== scheduleValues.weeklyDay || nextRun <= new Date()) {
|
||||
nextRun.setDate(nextRun.getDate() + 1)
|
||||
}
|
||||
|
||||
return nextRun
|
||||
}
|
||||
|
||||
case 'monthly': {
|
||||
// Use either schedule override or monthly time values
|
||||
const [hours, minutes] = scheduleTimeOverride || scheduleValues.monthlyTime
|
||||
const { monthlyDay } = scheduleValues
|
||||
|
||||
nextRun.setDate(monthlyDay)
|
||||
nextRun.setHours(hours, minutes, 0, 0)
|
||||
|
||||
// If we're in the past relative to now (not baseDate), move to next month
|
||||
if (nextRun <= new Date()) {
|
||||
nextRun.setMonth(nextRun.getMonth() + 1)
|
||||
}
|
||||
|
||||
return nextRun
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported schedule type: ${scheduleType}`)
|
||||
logger.debug(`Next run calculated: ${nextDate.toISOString()}`)
|
||||
return nextDate
|
||||
} catch (error) {
|
||||
logger.error('Error calculating next run with Croner:', error)
|
||||
throw new Error(
|
||||
`Failed to calculate next run time for schedule type ${scheduleType}: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a cron expression to a human-readable string format
|
||||
* Helper function to get a friendly timezone abbreviation
|
||||
*/
|
||||
export const parseCronToHumanReadable = (cronExpression: string): string => {
|
||||
// Parse the cron parts
|
||||
const parts = cronExpression.split(' ')
|
||||
|
||||
// Handle standard patterns
|
||||
if (cronExpression === '* * * * *') {
|
||||
return 'Every minute'
|
||||
function getTimezoneAbbreviation(timezone: string): string {
|
||||
const timezoneMap: Record<string, string> = {
|
||||
'America/Los_Angeles': 'PT',
|
||||
'America/Denver': 'MT',
|
||||
'America/Chicago': 'CT',
|
||||
'America/New_York': 'ET',
|
||||
'Europe/London': 'GMT/BST',
|
||||
'Europe/Paris': 'CET/CEST',
|
||||
'Asia/Tokyo': 'JST',
|
||||
'Asia/Singapore': 'SGT',
|
||||
'Australia/Sydney': 'AEDT/AEST',
|
||||
UTC: 'UTC',
|
||||
}
|
||||
|
||||
// Every X minutes
|
||||
if (cronExpression.match(/^\*\/\d+ \* \* \* \*$/)) {
|
||||
const minutes = cronExpression.split(' ')[0].split('/')[1]
|
||||
return `Every ${minutes} minutes`
|
||||
}
|
||||
return timezoneMap[timezone] || timezone
|
||||
}
|
||||
|
||||
// Daily at specific time
|
||||
if (cronExpression.match(/^\d+ \d+ \* \* \*$/)) {
|
||||
const minute = Number.parseInt(parts[0], 10)
|
||||
const hour = Number.parseInt(parts[1], 10)
|
||||
const period = hour >= 12 ? 'PM' : 'AM'
|
||||
const hour12 = hour % 12 || 12
|
||||
return `Daily at ${hour12}:${minute.toString().padStart(2, '0')} ${period}`
|
||||
}
|
||||
|
||||
// Every hour at specific minute
|
||||
if (cronExpression.match(/^\d+ \* \* \* \*$/)) {
|
||||
const minute = parts[0]
|
||||
return `Hourly at ${minute} minutes past the hour`
|
||||
}
|
||||
|
||||
// Specific day of week at specific time
|
||||
if (cronExpression.match(/^\d+ \d+ \* \* \d+$/)) {
|
||||
const minute = Number.parseInt(parts[0], 10)
|
||||
const hour = Number.parseInt(parts[1], 10)
|
||||
const dayOfWeek = Number.parseInt(parts[4], 10)
|
||||
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
|
||||
const day = days[dayOfWeek % 7]
|
||||
const period = hour >= 12 ? 'PM' : 'AM'
|
||||
const hour12 = hour % 12 || 12
|
||||
return `Every ${day} at ${hour12}:${minute.toString().padStart(2, '0')} ${period}`
|
||||
}
|
||||
|
||||
// Specific day of month at specific time
|
||||
if (cronExpression.match(/^\d+ \d+ \d+ \* \*$/)) {
|
||||
const minute = Number.parseInt(parts[0], 10)
|
||||
const hour = Number.parseInt(parts[1], 10)
|
||||
const dayOfMonth = parts[2]
|
||||
const period = hour >= 12 ? 'PM' : 'AM'
|
||||
const hour12 = hour % 12 || 12
|
||||
const day =
|
||||
dayOfMonth === '1'
|
||||
? '1st'
|
||||
: dayOfMonth === '2'
|
||||
? '2nd'
|
||||
: dayOfMonth === '3'
|
||||
? '3rd'
|
||||
: `${dayOfMonth}th`
|
||||
return `Monthly on the ${day} at ${hour12}:${minute.toString().padStart(2, '0')} ${period}`
|
||||
}
|
||||
|
||||
// Weekly at specific time
|
||||
if (cronExpression.match(/^\d+ \d+ \* \* [0-6]$/)) {
|
||||
const minute = Number.parseInt(parts[0], 10)
|
||||
const hour = Number.parseInt(parts[1], 10)
|
||||
const dayOfWeek = Number.parseInt(parts[4], 10)
|
||||
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
|
||||
const day = days[dayOfWeek % 7]
|
||||
const period = hour >= 12 ? 'PM' : 'AM'
|
||||
const hour12 = hour % 12 || 12
|
||||
return `Weekly on ${day} at ${hour12}:${minute.toString().padStart(2, '0')} ${period}`
|
||||
}
|
||||
|
||||
// Return a more detailed breakdown if none of the patterns match
|
||||
/**
|
||||
* Converts a cron expression to a human-readable string format
|
||||
* Uses the cronstrue library for accurate parsing of complex cron expressions
|
||||
*
|
||||
* @param cronExpression - The cron expression to parse
|
||||
* @param timezone - Optional IANA timezone string to include in the description
|
||||
* @returns Human-readable description of the schedule
|
||||
*/
|
||||
export const parseCronToHumanReadable = (cronExpression: string, timezone?: string): string => {
|
||||
try {
|
||||
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts
|
||||
let description = 'Runs '
|
||||
// Use cronstrue for reliable cron expression parsing
|
||||
const baseDescription = cronstrue.toString(cronExpression, {
|
||||
use24HourTimeFormat: false, // Use 12-hour format with AM/PM
|
||||
verbose: false, // Keep it concise
|
||||
})
|
||||
|
||||
// Time component
|
||||
if (minute === '*' && hour === '*') {
|
||||
description += 'every minute '
|
||||
} else if (minute.includes('/') && hour === '*') {
|
||||
const interval = minute.split('/')[1]
|
||||
description += `every ${interval} minutes `
|
||||
} else if (minute !== '*' && hour !== '*') {
|
||||
const hourVal = Number.parseInt(hour, 10)
|
||||
const period = hourVal >= 12 ? 'PM' : 'AM'
|
||||
const hour12 = hourVal % 12 || 12
|
||||
description += `at ${hour12}:${minute.padStart(2, '0')} ${period} `
|
||||
// Add timezone information if provided and not UTC
|
||||
if (timezone && timezone !== 'UTC') {
|
||||
const tzAbbr = getTimezoneAbbreviation(timezone)
|
||||
return `${baseDescription} (${tzAbbr})`
|
||||
}
|
||||
|
||||
// Day component
|
||||
if (dayOfMonth !== '*' && month !== '*') {
|
||||
const months = [
|
||||
'January',
|
||||
'February',
|
||||
'March',
|
||||
'April',
|
||||
'May',
|
||||
'June',
|
||||
'July',
|
||||
'August',
|
||||
'September',
|
||||
'October',
|
||||
'November',
|
||||
'December',
|
||||
]
|
||||
|
||||
if (month.includes(',')) {
|
||||
const monthNames = month.split(',').map((m) => months[Number.parseInt(m, 10) - 1])
|
||||
description += `on day ${dayOfMonth} of ${monthNames.join(', ')}`
|
||||
} else if (month.includes('/')) {
|
||||
// Handle interval patterns like */3, 1/3, etc.
|
||||
const interval = month.split('/')[1]
|
||||
description += `on day ${dayOfMonth} every ${interval} months`
|
||||
} else if (month.includes('-')) {
|
||||
// Handle range patterns like 1-6
|
||||
const [start, end] = month.split('-').map((m) => Number.parseInt(m, 10))
|
||||
const startMonth = months[start - 1]
|
||||
const endMonth = months[end - 1]
|
||||
description += `on day ${dayOfMonth} from ${startMonth} to ${endMonth}`
|
||||
} else {
|
||||
// Handle specific month numbers
|
||||
const monthIndex = Number.parseInt(month, 10) - 1
|
||||
const monthName = months[monthIndex]
|
||||
if (monthName) {
|
||||
description += `on day ${dayOfMonth} of ${monthName}`
|
||||
} else {
|
||||
description += `on day ${dayOfMonth} of month ${month}`
|
||||
}
|
||||
}
|
||||
} else if (dayOfWeek !== '*') {
|
||||
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
|
||||
if (dayOfWeek.includes(',')) {
|
||||
const dayNames = dayOfWeek.split(',').map((d) => days[Number.parseInt(d, 10) % 7])
|
||||
description += `on ${dayNames.join(', ')}`
|
||||
} else if (dayOfWeek.includes('-')) {
|
||||
const [start, end] = dayOfWeek.split('-').map((d) => Number.parseInt(d, 10) % 7)
|
||||
description += `from ${days[start]} to ${days[end]}`
|
||||
} else {
|
||||
description += `on ${days[Number.parseInt(dayOfWeek, 10) % 7]}`
|
||||
}
|
||||
}
|
||||
|
||||
return description.trim()
|
||||
} catch (_e) {
|
||||
return `Schedule: ${cronExpression}`
|
||||
return baseDescription
|
||||
} catch (error) {
|
||||
logger.warn('Failed to parse cron expression with cronstrue:', {
|
||||
cronExpression,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
// Fallback to displaying the raw cron expression
|
||||
return `Schedule: ${cronExpression}${timezone && timezone !== 'UTC' ? ` (${getTimezoneAbbreviation(timezone)})` : ''}`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -672,7 +507,8 @@ export const getScheduleInfo = (
|
||||
cronExpression: string | null,
|
||||
nextRunAt: string | null,
|
||||
lastRanAt: string | null,
|
||||
scheduleType?: string | null
|
||||
scheduleType?: string | null,
|
||||
timezone?: string | null
|
||||
): {
|
||||
scheduleTiming: string
|
||||
nextRunFormatted: string | null
|
||||
@@ -689,7 +525,8 @@ export const getScheduleInfo = (
|
||||
let scheduleTiming = 'Unknown schedule'
|
||||
|
||||
if (cronExpression) {
|
||||
scheduleTiming = parseCronToHumanReadable(cronExpression)
|
||||
// Pass timezone to parseCronToHumanReadable for accurate display
|
||||
scheduleTiming = parseCronToHumanReadable(cronExpression, timezone || undefined)
|
||||
} else if (scheduleType) {
|
||||
scheduleTiming = `${scheduleType.charAt(0).toUpperCase() + scheduleType.slice(1)}`
|
||||
}
|
||||
|
||||
@@ -21,6 +21,11 @@ describe('Console Store', () => {
|
||||
isOpen: false,
|
||||
})
|
||||
vi.clearAllMocks()
|
||||
// Clear localStorage mock
|
||||
if (global.localStorage) {
|
||||
vi.mocked(global.localStorage.getItem).mockReturnValue(null)
|
||||
vi.mocked(global.localStorage.setItem).mockClear()
|
||||
}
|
||||
})
|
||||
|
||||
describe('addConsole', () => {
|
||||
|
||||
@@ -8,6 +8,19 @@ global.fetch = vi.fn(() =>
|
||||
})
|
||||
) as any
|
||||
|
||||
// Mock localStorage and sessionStorage for Zustand persist middleware
|
||||
const storageMock = {
|
||||
getItem: vi.fn(() => null),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
key: vi.fn(),
|
||||
length: 0,
|
||||
}
|
||||
|
||||
global.localStorage = storageMock as any
|
||||
global.sessionStorage = storageMock as any
|
||||
|
||||
// Mock drizzle-orm sql template literal globally for tests
|
||||
vi.mock('drizzle-orm', () => ({
|
||||
sql: vi.fn((strings, ...values) => ({
|
||||
|
||||
5
bun.lock
5
bun.lock
@@ -6,6 +6,7 @@
|
||||
"dependencies": {
|
||||
"@linear/sdk": "40.0.0",
|
||||
"@t3-oss/env-nextjs": "0.13.4",
|
||||
"cronstrue": "3.3.0",
|
||||
"drizzle-orm": "^0.44.5",
|
||||
"mongodb": "6.19.0",
|
||||
"postgres": "^3.4.5",
|
||||
@@ -1643,7 +1644,7 @@
|
||||
|
||||
"croner": ["croner@9.1.0", "", {}, "sha512-p9nwwR4qyT5W996vBZhdvBCnMhicY5ytZkR4D1Xj0wuTDEiMnjwR57Q3RXYY/s0EpX6Ay3vgIcfaR+ewGHsi+g=="],
|
||||
|
||||
"cronstrue": ["cronstrue@2.61.0", "", { "bin": { "cronstrue": "bin/cli.js" } }, "sha512-ootN5bvXbIQI9rW94+QsXN5eROtXWwew6NkdGxIRpS/UFWRggL0G5Al7a9GTBFEsuvVhJ2K3CntIIVt7L2ILhA=="],
|
||||
"cronstrue": ["cronstrue@3.3.0", "", { "bin": { "cronstrue": "bin/cli.js" } }, "sha512-iwJytzJph1hosXC09zY8F5ACDJKerr0h3/2mOxg9+5uuFObYlgK0m35uUPk4GCvhHc2abK7NfnR9oMqY0qZFAg=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
@@ -3357,6 +3358,8 @@
|
||||
|
||||
"@trigger.dev/sdk/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||
|
||||
"@trigger.dev/sdk/cronstrue": ["cronstrue@2.61.0", "", { "bin": { "cronstrue": "bin/cli.js" } }, "sha512-ootN5bvXbIQI9rW94+QsXN5eROtXWwew6NkdGxIRpS/UFWRggL0G5Al7a9GTBFEsuvVhJ2K3CntIIVt7L2ILhA=="],
|
||||
|
||||
"@trigger.dev/sdk/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="],
|
||||
|
||||
"@types/cors/@types/node": ["@types/node@24.2.1", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ=="],
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
"dependencies": {
|
||||
"@linear/sdk": "40.0.0",
|
||||
"@t3-oss/env-nextjs": "0.13.4",
|
||||
"cronstrue": "3.3.0",
|
||||
"drizzle-orm": "^0.44.5",
|
||||
"mongodb": "6.19.0",
|
||||
"postgres": "^3.4.5",
|
||||
|
||||
Reference in New Issue
Block a user