d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
683 lines
22 KiB
TypeScript
683 lines
22 KiB
TypeScript
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<string, SubBlockValue | any>
|
|
[key: string]: any
|
|
}
|
|
|
|
export const DAY_MAP: Record<string, number> = {
|
|
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<typeof getScheduleTimeValues>
|
|
): 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<typeof getScheduleTimeValues>
|
|
): 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 `<weekday>#L` (e.g. `1#L`) while
|
|
* cronstrue spells the same thing `<weekday>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<number, string> = {
|
|
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,
|
|
}
|
|
}
|