From 061c1dff4eab6923ce741d203f0224ebdb8706c1 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Wed, 15 Oct 2025 11:37:42 -0700 Subject: [PATCH] fix(schedules): offload next run calculation to croner (#1640) * fix(schedules): offload next run calculation to croner * fix localstorage dependent tests * address greptile comment --- apps/sim/app/api/schedules/route.ts | 3 +- .../components/schedule/schedule-config.tsx | 215 ++++++----- .../workflow-block/workflow-block.tsx | 130 +++---- apps/sim/background/schedule-execution.ts | 8 +- apps/sim/lib/schedules/utils.test.ts | 360 +++++++++++++----- apps/sim/lib/schedules/utils.ts | 339 +++++------------ apps/sim/stores/panel/console/store.test.ts | 5 + apps/sim/vitest.setup.ts | 13 + bun.lock | 5 +- package.json | 1 + 10 files changed, 569 insertions(+), 510 deletions(-) diff --git a/apps/sim/app/api/schedules/route.ts b/apps/sim/app/api/schedules/route.ts index ecbd7756d..0c9a5489d 100644 --- a/apps/sim/app/api/schedules/route.ts +++ b/apps/sim/app/api/schedules/route.ts @@ -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( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/schedule/schedule-config.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/schedule/schedule-config.tsx index 4b7ff9c99..372e11a3c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/schedule/schedule-config.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/schedule/schedule-config.tsx @@ -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(null) - const [scheduleId, setScheduleId] = useState(null) - const [nextRunAt, setNextRunAt] = useState(null) - const [lastRanAt, setLastRanAt] = useState(null) - const [cronExpression, setCronExpression] = useState(null) - const [timezone, setTimezone] = useState('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({ <>
{scheduleTiming}
-
Next run: {formatDateTime(new Date(nextRunAt), timezone)}
- {lastRanAt &&
Last run: {formatDateTime(new Date(lastRanAt), timezone)}
} +
+ Next run: {formatDateTime(new Date(scheduleData.nextRunAt), scheduleData.timezone)} +
+ {scheduleData.lastRanAt && ( +
+ Last run: {formatDateTime(new Date(scheduleData.lastRanAt), scheduleData.timezone)} +
+ )}
) @@ -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 => { + const handleSaveSchedule = useCallback(async (): Promise => { 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 => { - if (isPreview || !scheduleId || disabled) return false + const handleDeleteSchedule = useCallback(async (): Promise => { + 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 (
e.stopPropagation()}> @@ -399,7 +432,7 @@ export function ScheduleConfig({ blockId={blockId} onSave={handleSaveSchedule} onDelete={handleDeleteSchedule} - scheduleId={scheduleId} + scheduleId={scheduleData.id} />
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx index 09ef321ee..4891fceab 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx @@ -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(() => { diff --git a/apps/sim/background/schedule-execution.ts b/apps/sim/background/schedule-execution.ts index 691f8255a..24709b432 100644 --- a/apps/sim/background/schedule-execution.ts +++ b/apps/sim/background/schedule-execution.ts @@ -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 diff --git a/apps/sim/lib/schedules/utils.test.ts b/apps/sim/lib/schedules/utils.test.ts index 5e3b81810..1e9033de8 100644 --- a/apps/sim/lib/schedules/utils.test.ts +++ b/apps/sim/lib/schedules/utils.test.ts @@ -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: " + 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() }) }) diff --git a/apps/sim/lib/schedules/utils.ts b/apps/sim/lib/schedules/utils.ts index 0745f8d13..c66003ce6 100644 --- a/apps/sim/lib/schedules/utils.ts +++ b/apps/sim/lib/schedules/utils.ts @@ -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 = { + '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)}` } diff --git a/apps/sim/stores/panel/console/store.test.ts b/apps/sim/stores/panel/console/store.test.ts index 9ccc3cf56..0094e1ce0 100644 --- a/apps/sim/stores/panel/console/store.test.ts +++ b/apps/sim/stores/panel/console/store.test.ts @@ -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', () => { diff --git a/apps/sim/vitest.setup.ts b/apps/sim/vitest.setup.ts index a2d1c0d7a..2299bb622 100644 --- a/apps/sim/vitest.setup.ts +++ b/apps/sim/vitest.setup.ts @@ -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) => ({ diff --git a/bun.lock b/bun.lock index 196e59be8..f45b06834 100644 --- a/bun.lock +++ b/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=="], diff --git a/package.json b/package.json index cedfa906d..58805530c 100644 --- a/package.json +++ b/package.json @@ -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",