import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' import { formatDateTime, getTimezoneAbbreviation } from '@sim/utils/formatting' import { Cron } from 'croner' import cronstrue from 'cronstrue' 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, timezone?: string ): { isValid: boolean error?: string nextRun?: Date } { if (!cronExpression?.trim()) { return { isValid: false, error: 'Cron expression cannot be empty', } } try { const cron = new Cron(cronExpression, timezone ? { timezone } : undefined) const nextRun = cron.nextRun() if (!nextRun) { return { isValid: false, error: 'Cron expression produces no future occurrences', } } return { isValid: true, nextRun, } } catch (error) { return { isValid: false, error: getErrorMessage(error, 'Invalid cron expression syntax'), } } } /** Upper bound on how many excluded occurrences the next-run search skips before giving up. */ const MAX_OCCURRENCE_SKIP = 1000 /** * Computes the next run instant for a recurring schedule, skipping occurrences * the user deleted individually and stopping at the recurrence end boundary. * Returns `null` when the recurrence has no remaining run (past `endsAt`, or * every candidate within the search bound is excluded). * * Excluded occurrences are matched by exact instant, so callers must record the * cron-produced occurrence time (not a rounded value) when excluding. */ export function computeNextRunAt(params: { cronExpression: string timezone?: string from?: Date excludedDates?: string[] | null endsAt?: Date | null }): Date | null { const { cronExpression, timezone, from, excludedDates, endsAt } = params let cron: Cron try { cron = new Cron(cronExpression, timezone ? { timezone } : undefined) } catch { return null } const excluded = new Set( (excludedDates ?? []).map((iso) => new Date(iso).getTime()).filter((ms) => !Number.isNaN(ms)) ) let cursor = from ?? new Date() for (let i = 0; i < MAX_OCCURRENCE_SKIP; i++) { const next = cron.nextRun(cursor) if (!next) return null if (endsAt && next.getTime() > endsAt.getTime()) return null if (!excluded.has(next.getTime())) return next cursor = next } return null } interface SubBlockValue { value: string } export interface BlockState { type: string subBlocks: Record [key: string]: any } export const DAY_MAP: Record = { MON: 1, TUE: 2, WED: 3, THU: 4, FRI: 5, SAT: 6, SUN: 0, } /** * Safely extract a value from a block's subBlocks */ export function getSubBlockValue(block: BlockState, id: string): string { const subBlock = block.subBlocks[id] as SubBlockValue | undefined return subBlock?.value || '' } /** * Parse and extract hours and minutes from a time string * @param timeString - Time string in format "HH:MM" * @returns Array with [hours, minutes] as numbers, or [9, 0] as default */ export function parseTimeString(timeString: string | undefined | null): [number, number] { if (!timeString || !timeString.includes(':')) { return [9, 0] // Default to 9:00 AM } const [hours, minutes] = timeString.split(':').map(Number) return [Number.isNaN(hours) ? 9 : hours, Number.isNaN(minutes) ? 0 : minutes] } /** * Get time values from starter block for scheduling * @param starterBlock - The starter block containing schedule configuration * @returns Object with parsed time values */ export function getScheduleTimeValues(starterBlock: BlockState): { scheduleTime: string scheduleStartAt?: string minutesInterval: number hourlyMinute: number dailyTime: [number, number] weeklyDay: number weeklyTime: [number, number] monthlyDay: number monthlyTime: [number, number] cronExpression: string | null timezone: string } { // Extract schedule time (common field that can override others) const scheduleTime = getSubBlockValue(starterBlock, 'scheduleTime') // Extract schedule start date const scheduleStartAt = getSubBlockValue(starterBlock, 'scheduleStartAt') // Extract timezone (default to UTC) const timezone = getSubBlockValue(starterBlock, 'timezone') || 'UTC' // Get minutes interval (default to 15) const minutesIntervalStr = getSubBlockValue(starterBlock, 'minutesInterval') const minutesInterval = Number.parseInt(minutesIntervalStr) || 15 // Get hourly minute (default to 0) const hourlyMinuteStr = getSubBlockValue(starterBlock, 'hourlyMinute') const hourlyMinute = Number.parseInt(hourlyMinuteStr) || 0 // Get daily time const dailyTime = parseTimeString(getSubBlockValue(starterBlock, 'dailyTime')) // Get weekly config const weeklyDayStr = getSubBlockValue(starterBlock, 'weeklyDay') || 'MON' const weeklyDay = DAY_MAP[weeklyDayStr] || 1 const weeklyTime = parseTimeString(getSubBlockValue(starterBlock, 'weeklyDayTime')) // Get monthly config const monthlyDayStr = getSubBlockValue(starterBlock, 'monthlyDay') const monthlyDay = Number.parseInt(monthlyDayStr) || 1 const monthlyTime = parseTimeString(getSubBlockValue(starterBlock, 'monthlyTime')) const cronExpression = getSubBlockValue(starterBlock, 'cronExpression') || null return { scheduleTime, scheduleStartAt, timezone, minutesInterval, hourlyMinute, dailyTime, weeklyDay, weeklyTime, monthlyDay, monthlyTime, cronExpression, } } /** * Helper function to create a date with the specified time in the correct timezone. * This function calculates the corresponding UTC time for a given local date, * local time, and IANA timezone name, correctly handling DST. * * @param dateInput Date string or Date object representing the local date. * @param timeStr Time string in format "HH:MM" representing the local time. * @param timezone IANA timezone string (e.g., 'America/Los_Angeles', 'Europe/Paris'). Defaults to 'UTC'. * @returns Date object representing the absolute point in time (UTC). */ export function createDateWithTimezone( dateInput: string | Date, timeStr: string, timezone = 'UTC' ): Date { try { // 1. Parse the base date and target time const baseDate = typeof dateInput === 'string' ? new Date(dateInput) : new Date(dateInput) const [targetHours, targetMinutes] = parseTimeString(timeStr) // Ensure baseDate reflects the date part only, setting time to 00:00:00 in UTC // This prevents potential issues if dateInput string includes time/timezone info. const year = baseDate.getUTCFullYear() const monthIndex = baseDate.getUTCMonth() // 0-based const day = baseDate.getUTCDate() // 2. Create a tentative UTC Date object using the target date and time components // This assumes, for a moment, that the target H:M were meant for UTC. const tentativeUTCDate = new Date( Date.UTC(year, monthIndex, day, targetHours, targetMinutes, 0) ) // 3. If the target timezone is UTC, we're done. if (timezone === 'UTC') { return tentativeUTCDate } // 4. Format the tentative UTC date into the target timezone's local time components. // Use 'en-CA' locale for unambiguous YYYY-MM-DD and 24-hour format. const formatter = new Intl.DateTimeFormat('en-CA', { timeZone: timezone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', // Use 2-digit for consistency minute: '2-digit', second: '2-digit', hourCycle: 'h23', // Use 24-hour format (00-23) }) const parts = formatter.formatToParts(tentativeUTCDate) const getPart = (type: Intl.DateTimeFormatPartTypes) => parts.find((p) => p.type === type)?.value const formattedYear = Number.parseInt(getPart('year') || '0', 10) const formattedMonth = Number.parseInt(getPart('month') || '0', 10) // 1-based const formattedDay = Number.parseInt(getPart('day') || '0', 10) const formattedHour = Number.parseInt(getPart('hour') || '0', 10) const formattedMinute = Number.parseInt(getPart('minute') || '0', 10) // Create a Date object representing the local time *in the target timezone* // when the tentative UTC date occurs. // Note: month needs to be adjusted back to 0-based for Date.UTC() const actualLocalTimeInTargetZone = Date.UTC( formattedYear, formattedMonth - 1, formattedDay, formattedHour, formattedMinute, 0 // seconds ) // 5. Calculate the difference between the intended local time and the actual local time // that resulted from the tentative UTC date. This difference represents the offset // needed to adjust the UTC time. // Create the intended local time as a UTC timestamp for comparison purposes. const intendedLocalTimeAsUTC = Date.UTC(year, monthIndex, day, targetHours, targetMinutes, 0) // The offset needed for UTC time is the difference between the intended local time // and the actual local time (when both are represented as UTC timestamps). const offsetMilliseconds = intendedLocalTimeAsUTC - actualLocalTimeInTargetZone // 6. Adjust the tentative UTC date by the calculated offset. const finalUTCTimeMilliseconds = tentativeUTCDate.getTime() + offsetMilliseconds const finalDate = new Date(finalUTCTimeMilliseconds) return finalDate } catch (e) { logger.error('Error creating date with timezone:', e, { dateInput, timeStr, timezone }) // Fallback to a simple UTC interpretation on error try { const baseDate = typeof dateInput === 'string' ? new Date(dateInput) : new Date(dateInput) const [hours, minutes] = parseTimeString(timeStr) const year = baseDate.getUTCFullYear() const monthIndex = baseDate.getUTCMonth() const day = baseDate.getUTCDate() return new Date(Date.UTC(year, monthIndex, day, hours, minutes, 0)) } catch (fallbackError) { logger.error('Error during fallback date creation:', fallbackError) throw new Error( `Failed to create date with timezone (${timezone}): ${toError(fallbackError).message}` ) } } } /** * 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, scheduleValues: ReturnType ): string { switch (scheduleType) { case 'minutes': return `*/${scheduleValues.minutesInterval} * * * *` case 'hourly': return `${scheduleValues.hourlyMinute} * * * *` case 'daily': { const [hours, minutes] = scheduleValues.dailyTime return `${minutes} ${hours} * * *` } case 'weekly': { const [hours, minutes] = scheduleValues.weeklyTime return `${minutes} ${hours} * * ${scheduleValues.weeklyDay}` } case 'monthly': { const [hours, minutes] = scheduleValues.monthlyTime return `${minutes} ${hours} ${scheduleValues.monthlyDay} * *` } case 'custom': { if (!scheduleValues.cronExpression?.trim()) { throw new Error('Custom schedule requires a valid cron expression') } return scheduleValues.cronExpression } default: throw new Error(`Unsupported schedule type: ${scheduleType}`) } } /** * 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 * @returns Date object for next execution time */ export function calculateNextRunTime( scheduleType: string, scheduleValues: ReturnType ): Date { // Get timezone (default to UTC) const timezone = scheduleValues.timezone || 'UTC' // Get the current time const baseDate = new Date() // If we have both a start date and time, use them together with timezone awareness if (scheduleValues.scheduleStartAt && scheduleValues.scheduleTime) { try { logger.debug( `Creating date with: startAt=${scheduleValues.scheduleStartAt}, time=${scheduleValues.scheduleTime}, timezone=${timezone}` ) const combinedDate = createDateWithTimezone( scheduleValues.scheduleStartAt, scheduleValues.scheduleTime, timezone ) logger.debug(`Combined date result: ${combinedDate.toISOString()}`) // If the combined date is in the future, use it as our next run time if (combinedDate > baseDate) { return combinedDate } } catch (e) { logger.error('Error combining scheduled date and time:', e) } } // If only scheduleStartAt is set (without scheduleTime), parse it directly else if (scheduleValues.scheduleStartAt) { try { // Check if the date string already includes time information const startAtStr = scheduleValues.scheduleStartAt const hasTimeComponent = startAtStr.includes('T') && (startAtStr.includes(':') || startAtStr.includes('.')) if (hasTimeComponent) { // If the string already has time info, parse it directly but with timezone awareness const startDate = new Date(startAtStr) // If it's a UTC ISO string (ends with Z), use it directly if (startAtStr.endsWith('Z') && timezone === 'UTC') { if (startDate > baseDate) { return startDate } } else { // For non-UTC dates or when timezone isn't UTC, we need to interpret it in the specified timezone // Extract time from the date string (crude but effective for ISO format) const timeMatch = startAtStr.match(/T(\d{2}:\d{2})/) const timeStr = timeMatch ? timeMatch[1] : '00:00' // Use our timezone-aware function with the extracted time const tzAwareDate = createDateWithTimezone( startAtStr.split('T')[0], // Just the date part timeStr, // Time extracted from string timezone ) if (tzAwareDate > baseDate) { return tzAwareDate } } } else { // If no time component in the string, use midnight in the specified timezone const startDate = createDateWithTimezone( scheduleValues.scheduleStartAt, '00:00', // Use midnight in the specified timezone timezone ) if (startDate > baseDate) { return startDate } } } catch (e) { logger.error('Error parsing scheduleStartAt:', e) } } try { const cronExpression = generateCronExpression(scheduleType, scheduleValues) logger.debug(`Using cron expression: ${cronExpression} with timezone: ${timezone}`) const cron = new Cron(cronExpression, { timezone, }) const nextDate = cron.nextRun() if (!nextDate) { throw new Error(`No next run date calculated for cron: ${cronExpression}`) } 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}: ${toError(error).message}` ) } } /** * Converts a cron expression to a human-readable string format * Uses the cronstrue library for accurate parsing of complex cron expressions * * Croner spells the last weekday of the month as `#L` (e.g. `1#L`) while * cronstrue spells the same thing `L`; the expression is normalized to * cronstrue's syntax for display only, so the stored cron keeps croner's syntax * and scheduling stays correct. * * @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 forDisplay = cronExpression.replace(/([0-7])#L\b/g, '$1L') const baseDescription = cronstrue .toString(forDisplay, { use24HourTimeFormat: false, verbose: false, }) .replace(/\b0(\d:\d{2})/g, '$1') if (timezone && timezone !== 'UTC') { const tzAbbr = getTimezoneAbbreviation(timezone) return `${baseDescription} (${tzAbbr})` } return baseDescription } catch (error) { logger.warn('Failed to parse cron expression with cronstrue:', { cronExpression, error: toError(error).message, }) return `Schedule: ${cronExpression}${timezone && timezone !== 'UTC' ? ` (${getTimezoneAbbreviation(timezone)})` : ''}` } } const REVERSE_DAY_MAP: Record = { 0: 'SUN', 1: 'MON', 2: 'TUE', 3: 'WED', 4: 'THU', 5: 'FRI', 6: 'SAT', 7: 'SUN', } export type ScheduleType = 'minutes' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'custom' export interface CronFormState { scheduleType: ScheduleType minutesInterval: string hourlyMinute: string dailyTime: string weeklyDay: string weeklyDayTime: string monthlyDay: string monthlyTime: string cronExpression: string } const CRON_FORM_DEFAULTS: CronFormState = { scheduleType: 'custom', minutesInterval: '15', hourlyMinute: '0', dailyTime: '09:00', weeklyDay: 'MON', weeklyDayTime: '09:00', monthlyDay: '1', monthlyTime: '09:00', cronExpression: '', } /** * Reverse-parses a cron expression into schedule type and form field values. * Used to pre-populate the schedule modal when editing an existing schedule. */ export function parseCronToScheduleType(cronExpression: string | null | undefined): CronFormState { if (!cronExpression?.trim()) { return { ...CRON_FORM_DEFAULTS } } const parts = cronExpression.trim().split(/\s+/) if (parts.length !== 5) { return { ...CRON_FORM_DEFAULTS, cronExpression } } const [minute, hour, dayOfMonth, month, dayOfWeek] = parts const pad = (n: number) => String(n).padStart(2, '0') if ( minute.startsWith('*/') && hour === '*' && dayOfMonth === '*' && month === '*' && dayOfWeek === '*' ) { const interval = Number.parseInt(minute.slice(2), 10) if (!Number.isNaN(interval) && interval > 0) { return { ...CRON_FORM_DEFAULTS, scheduleType: 'minutes', minutesInterval: String(interval) } } } const m = Number.parseInt(minute, 10) const h = Number.parseInt(hour, 10) if ( !Number.isNaN(m) && hour === '*' && dayOfMonth === '*' && month === '*' && dayOfWeek === '*' ) { return { ...CRON_FORM_DEFAULTS, scheduleType: 'hourly', hourlyMinute: String(m) } } if ( !Number.isNaN(m) && !Number.isNaN(h) && dayOfMonth === '*' && month === '*' && dayOfWeek === '*' ) { return { ...CRON_FORM_DEFAULTS, scheduleType: 'daily', dailyTime: `${pad(h)}:${pad(m)}` } } if ( !Number.isNaN(m) && !Number.isNaN(h) && dayOfMonth === '*' && month === '*' && dayOfWeek !== '*' ) { const dow = Number.parseInt(dayOfWeek, 10) const dayName = REVERSE_DAY_MAP[dow] if (dayName) { return { ...CRON_FORM_DEFAULTS, scheduleType: 'weekly', weeklyDay: dayName, weeklyDayTime: `${pad(h)}:${pad(m)}`, } } } if ( !Number.isNaN(m) && !Number.isNaN(h) && dayOfMonth !== '*' && month === '*' && dayOfWeek === '*' ) { const dom = Number.parseInt(dayOfMonth, 10) if (!Number.isNaN(dom) && dom >= 1 && dom <= 31) { return { ...CRON_FORM_DEFAULTS, scheduleType: 'monthly', monthlyDay: String(dom), monthlyTime: `${pad(h)}:${pad(m)}`, } } } return { ...CRON_FORM_DEFAULTS, cronExpression } } /** * Format schedule information for display */ export const getScheduleInfo = ( cronExpression: string | null, nextRunAt: string | null, lastRanAt: string | null, scheduleType?: string | null, timezone?: string | null ): { scheduleTiming: string nextRunFormatted: string | null lastRunFormatted: string | null } => { if (!nextRunAt) { return { scheduleTiming: 'Unknown schedule', nextRunFormatted: null, lastRunFormatted: null, } } let scheduleTiming = 'Unknown schedule' if (cronExpression) { scheduleTiming = parseCronToHumanReadable(cronExpression, timezone || undefined) } else if (scheduleType) { scheduleTiming = `${scheduleType.charAt(0).toUpperCase() + scheduleType.slice(1)}` } return { scheduleTiming, nextRunFormatted: formatDateTime(new Date(nextRunAt)), lastRunFormatted: lastRanAt ? formatDateTime(new Date(lastRanAt)) : null, } }