diff --git a/LICENSE b/LICENSE index 61a6813c4b..f4e76aaaac 100644 --- a/LICENSE +++ b/LICENSE @@ -187,7 +187,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2025 Sim Studio, Inc. + Copyright 2026 Sim Studio, Inc. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/NOTICE b/NOTICE index 5a60dc6863..11d32f13cd 100644 --- a/NOTICE +++ b/NOTICE @@ -1,4 +1,4 @@ Sim Studio -Copyright 2025 Sim Studio +Copyright 2026 Sim Studio This product includes software developed for the Sim project. \ No newline at end of file diff --git a/apps/sim/app/playground/page.tsx b/apps/sim/app/playground/page.tsx index c6487636a6..844007d81a 100644 --- a/apps/sim/app/playground/page.tsx +++ b/apps/sim/app/playground/page.tsx @@ -74,6 +74,7 @@ import { TableHeader, TableRow, Textarea, + TimePicker, Tooltip, Trash, Trash2, @@ -125,6 +126,7 @@ export default function PlaygroundPage() { const [switchValue, setSwitchValue] = useState(false) const [checkboxValue, setCheckboxValue] = useState(false) const [sliderValue, setSliderValue] = useState([50]) + const [timeValue, setTimeValue] = useState('09:30') const [activeTab, setActiveTab] = useState('profile') const [isDarkMode, setIsDarkMode] = useState(false) @@ -491,6 +493,31 @@ export default function PlaygroundPage() { + {/* TimePicker */} +
+ +
+ +
+ {timeValue} +
+ +
+ {}} placeholder='Small size' size='sm' /> +
+
+ +
+ {}} /> +
+
+ +
+ +
+
+
+ {/* Breadcrumb */}
Promise> /** Field dependencies that trigger option refetch when changed */ dependsOn?: SubBlockConfig['dependsOn'] + /** Enable search input in dropdown */ + searchable?: boolean } /** @@ -70,6 +72,7 @@ export function Dropdown({ multiSelect = false, fetchOptions, dependsOn, + searchable = false, }: DropdownProps) { const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId) as [ string | string[] | null | undefined, @@ -369,7 +372,7 @@ export function Dropdown({ ) }, [multiSelect, multiValues, optionMap]) - const isSearchable = subBlockId === 'operation' + const isSearchable = searchable || (subBlockId === 'operation' && comboboxOptions.length > 5) return ( ) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/time-input/time-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/time-input/time-input.tsx index ef018e6632..d583e79720 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/time-input/time-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/time-input/time-input.tsx @@ -1,8 +1,6 @@ 'use client' -import * as React from 'react' -import { Button, Input, Popover, PopoverContent, PopoverTrigger } from '@/components/emcn' -import { cn } from '@/lib/core/utils/cn' +import { TimePicker } from '@/components/emcn' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' interface TimeInputProps { @@ -15,6 +13,10 @@ interface TimeInputProps { disabled?: boolean } +/** + * Time input wrapper for sub-block editor. + * Connects the EMCN TimePicker to the sub-block store. + */ export function TimeInput({ blockId, subBlockId, @@ -26,143 +28,20 @@ export function TimeInput({ }: TimeInputProps) { const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId) - // Use preview value when in preview mode, otherwise use store value const value = isPreview ? previewValue : storeValue - const [isOpen, setIsOpen] = React.useState(false) - // Convert 24h time string to display format (12h with AM/PM) - const formatDisplayTime = (time: string) => { - if (!time) return '' - const [hours, minutes] = time.split(':') - const hour = Number.parseInt(hours, 10) - const ampm = hour >= 12 ? 'PM' : 'AM' - const displayHour = hour % 12 || 12 - return `${displayHour}:${minutes} ${ampm}` - } - - // Convert display time to 24h format for storage - const formatStorageTime = (hour: number, minute: number, ampm: string) => { - const hours24 = ampm === 'PM' ? (hour === 12 ? 12 : hour + 12) : hour === 12 ? 0 : hour - return `${hours24.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}` - } - - const [hour, setHour] = React.useState('12') - const [minute, setMinute] = React.useState('00') - const [ampm, setAmpm] = React.useState<'AM' | 'PM'>('AM') - - // Update the time when any component changes - const updateTime = (newHour?: string, newMinute?: string, newAmpm?: 'AM' | 'PM') => { + const handleChange = (newValue: string) => { if (isPreview || disabled) return - const h = Number.parseInt(newHour ?? hour) || 12 - const m = Number.parseInt(newMinute ?? minute) || 0 - const p = newAmpm ?? ampm - setStoreValue(formatStorageTime(h, m, p)) - } - - // Initialize from existing value - React.useEffect(() => { - if (value) { - const [hours, minutes] = value.split(':') - const hour24 = Number.parseInt(hours, 10) - const _minute = Number.parseInt(minutes, 10) - const isAM = hour24 < 12 - setHour((hour24 % 12 || 12).toString()) - setMinute(minutes) - setAmpm(isAM ? 'AM' : 'PM') - } - }, [value]) - - const handleBlur = () => { - updateTime() - setIsOpen(false) + setStoreValue(newValue) } return ( - { - setIsOpen(open) - if (!open) { - handleBlur() - } - }} - > - -
- -
-
- -
- { - const val = e.target.value.replace(/[^0-9]/g, '') - if (val === '') { - setHour('') - return - } - const numVal = Number.parseInt(val) - if (!Number.isNaN(numVal)) { - const newHour = Math.min(12, Math.max(1, numVal)).toString() - setHour(newHour) - updateTime(newHour) - } - }} - onBlur={() => { - const numVal = Number.parseInt(hour) || 12 - setHour(numVal.toString()) - updateTime(numVal.toString()) - }} - type='text' - autoComplete='off' - /> - : - { - const val = e.target.value.replace(/[^0-9]/g, '') - if (val === '') { - setMinute('') - return - } - const numVal = Number.parseInt(val) - if (!Number.isNaN(numVal)) { - const newMinute = Math.min(59, Math.max(0, numVal)).toString().padStart(2, '0') - setMinute(newMinute) - updateTime(undefined, newMinute) - } - }} - onBlur={() => { - const numVal = Number.parseInt(minute) || 0 - setMinute(numVal.toString().padStart(2, '0')) - updateTime(undefined, numVal.toString()) - }} - type='text' - autoComplete='off' - /> - -
-
-
+ ) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx index 310c3df0dd..bde404a0bc 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx @@ -461,6 +461,7 @@ function SubBlockComponent({ multiSelect={config.multiSelect} fetchOptions={config.fetchOptions} dependsOn={config.dependsOn} + searchable={config.searchable} /> ) diff --git a/apps/sim/background/schedule-execution.ts b/apps/sim/background/schedule-execution.ts index 85eeaabfd9..cdb5819dbe 100644 --- a/apps/sim/background/schedule-execution.ts +++ b/apps/sim/background/schedule-execution.ts @@ -372,8 +372,7 @@ function calculateNextRunTime( return nextDate } - const lastRanAt = schedule.lastRanAt ? new Date(schedule.lastRanAt) : null - return calculateNextTime(scheduleType, scheduleValues, lastRanAt) + return calculateNextTime(scheduleType, scheduleValues) } export async function executeScheduleJob(payload: ScheduleExecutionPayload) { diff --git a/apps/sim/blocks/blocks/schedule.ts b/apps/sim/blocks/blocks/schedule.ts index 1e384f2282..a4490a9cf8 100644 --- a/apps/sim/blocks/blocks/schedule.ts +++ b/apps/sim/blocks/blocks/schedule.ts @@ -128,24 +128,48 @@ export const ScheduleBlock: BlockConfig = { id: 'timezone', type: 'dropdown', title: 'Timezone', + searchable: true, options: [ + // UTC { label: 'UTC', id: 'UTC' }, - { label: 'US Eastern (UTC-5)', id: 'America/New_York' }, - { label: 'US Central (UTC-6)', id: 'America/Chicago' }, - { label: 'US Mountain (UTC-7)', id: 'America/Denver' }, + // Americas { label: 'US Pacific (UTC-8)', id: 'America/Los_Angeles' }, + { label: 'US Mountain (UTC-7)', id: 'America/Denver' }, + { label: 'US Central (UTC-6)', id: 'America/Chicago' }, + { label: 'US Eastern (UTC-5)', id: 'America/New_York' }, + { label: 'US Alaska (UTC-9)', id: 'America/Anchorage' }, + { label: 'US Hawaii (UTC-10)', id: 'Pacific/Honolulu' }, + { label: 'Canada Toronto (UTC-5)', id: 'America/Toronto' }, + { label: 'Canada Vancouver (UTC-8)', id: 'America/Vancouver' }, { label: 'Mexico City (UTC-6)', id: 'America/Mexico_City' }, { label: 'São Paulo (UTC-3)', id: 'America/Sao_Paulo' }, + { label: 'Buenos Aires (UTC-3)', id: 'America/Argentina/Buenos_Aires' }, + // Europe { label: 'London (UTC+0)', id: 'Europe/London' }, { label: 'Paris (UTC+1)', id: 'Europe/Paris' }, { label: 'Berlin (UTC+1)', id: 'Europe/Berlin' }, + { label: 'Amsterdam (UTC+1)', id: 'Europe/Amsterdam' }, + { label: 'Madrid (UTC+1)', id: 'Europe/Madrid' }, + { label: 'Rome (UTC+1)', id: 'Europe/Rome' }, + { label: 'Moscow (UTC+3)', id: 'Europe/Moscow' }, + // Middle East / Africa { label: 'Dubai (UTC+4)', id: 'Asia/Dubai' }, + { label: 'Tel Aviv (UTC+2)', id: 'Asia/Tel_Aviv' }, + { label: 'Cairo (UTC+2)', id: 'Africa/Cairo' }, + { label: 'Johannesburg (UTC+2)', id: 'Africa/Johannesburg' }, + // Asia { label: 'India (UTC+5:30)', id: 'Asia/Kolkata' }, + { label: 'Bangkok (UTC+7)', id: 'Asia/Bangkok' }, + { label: 'Jakarta (UTC+7)', id: 'Asia/Jakarta' }, { label: 'Singapore (UTC+8)', id: 'Asia/Singapore' }, { label: 'China (UTC+8)', id: 'Asia/Shanghai' }, { label: 'Hong Kong (UTC+8)', id: 'Asia/Hong_Kong' }, + { label: 'Seoul (UTC+9)', id: 'Asia/Seoul' }, { label: 'Tokyo (UTC+9)', id: 'Asia/Tokyo' }, + // Australia / Pacific + { label: 'Perth (UTC+8)', id: 'Australia/Perth' }, { label: 'Sydney (UTC+10)', id: 'Australia/Sydney' }, + { label: 'Melbourne (UTC+10)', id: 'Australia/Melbourne' }, { label: 'Auckland (UTC+12)', id: 'Pacific/Auckland' }, ], value: () => 'UTC', diff --git a/apps/sim/components/emcn/components/index.ts b/apps/sim/components/emcn/components/index.ts index 7dc1acbb38..9e9ca0414e 100644 --- a/apps/sim/components/emcn/components/index.ts +++ b/apps/sim/components/emcn/components/index.ts @@ -98,4 +98,5 @@ export { TableRow, } from './table/table' export { Textarea } from './textarea/textarea' +export { TimePicker, type TimePickerProps, timePickerVariants } from './time-picker/time-picker' export { Tooltip } from './tooltip/tooltip' diff --git a/apps/sim/components/emcn/components/time-picker/time-picker.tsx b/apps/sim/components/emcn/components/time-picker/time-picker.tsx new file mode 100644 index 0000000000..1bd45418b1 --- /dev/null +++ b/apps/sim/components/emcn/components/time-picker/time-picker.tsx @@ -0,0 +1,309 @@ +/** + * TimePicker component with popover dropdown for time selection. + * Uses Radix UI Popover primitives for positioning and accessibility. + * + * @example + * ```tsx + * // Basic time picker + * setTime(timeString)} + * placeholder="Select time" + * /> + * + * // Small size variant + * + * + * // Disabled state + * + * ``` + */ + +'use client' + +import * as React from 'react' +import { cva, type VariantProps } from 'class-variance-authority' +import { ChevronDown } from 'lucide-react' +import { + Popover, + PopoverAnchor, + PopoverContent, +} from '@/components/emcn/components/popover/popover' +import { cn } from '@/lib/core/utils/cn' + +/** + * Variant styles for the time picker trigger. + * Matches the input and combobox styling patterns. + */ +const timePickerVariants = cva( + 'flex w-full rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[8px] font-sans font-medium text-[var(--text-primary)] placeholder:text-[var(--text-muted)] outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50 hover:border-[var(--surface-7)] hover:bg-[var(--surface-5)] dark:hover:border-[var(--surface-7)] dark:hover:bg-[var(--border-1)] transition-colors', + { + variants: { + variant: { + default: '', + }, + size: { + default: 'py-[6px] text-sm', + sm: 'py-[5px] text-[12px]', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + } +) + +/** + * Props for the TimePicker component. + */ +export interface TimePickerProps + extends Omit, 'onChange'>, + VariantProps { + /** Current time value in 24h format (HH:mm) */ + value?: string + /** Callback when time changes, returns HH:mm format */ + onChange?: (value: string) => void + /** Placeholder text when no value is selected */ + placeholder?: string + /** Whether the picker is disabled */ + disabled?: boolean + /** Size variant */ + size?: 'default' | 'sm' +} + +/** + * Converts a 24h time string to 12h display format with AM/PM. + */ +function formatDisplayTime(time: string): string { + if (!time) return '' + const [hours, minutes] = time.split(':') + const hour = Number.parseInt(hours, 10) + const ampm = hour >= 12 ? 'PM' : 'AM' + const displayHour = hour % 12 || 12 + return `${displayHour}:${minutes} ${ampm}` +} + +/** + * Converts 12h time components to 24h format string. + */ +function formatStorageTime(hour: number, minute: number, ampm: 'AM' | 'PM'): string { + const hours24 = ampm === 'PM' ? (hour === 12 ? 12 : hour + 12) : hour === 12 ? 0 : hour + return `${hours24.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}` +} + +/** + * Parses a 24h time string into 12h components. + */ +function parseTime(time: string): { hour: string; minute: string; ampm: 'AM' | 'PM' } { + if (!time) return { hour: '12', minute: '00', ampm: 'AM' } + const [hours, minutes] = time.split(':') + const hour24 = Number.parseInt(hours, 10) + const isAM = hour24 < 12 + return { + hour: (hour24 % 12 || 12).toString(), + minute: minutes || '00', + ampm: isAM ? 'AM' : 'PM', + } +} + +/** + * TimePicker component matching emcn design patterns. + * Provides a popover dropdown for time selection. + */ +const TimePicker = React.forwardRef( + ( + { + className, + variant, + size, + value, + onChange, + placeholder = 'Select time', + disabled = false, + ...props + }, + ref + ) => { + const [open, setOpen] = React.useState(false) + const hourInputRef = React.useRef(null) + const parsed = React.useMemo(() => parseTime(value || ''), [value]) + const [hour, setHour] = React.useState(parsed.hour) + const [minute, setMinute] = React.useState(parsed.minute) + const [ampm, setAmpm] = React.useState<'AM' | 'PM'>(parsed.ampm) + + React.useEffect(() => { + const newParsed = parseTime(value || '') + setHour(newParsed.hour) + setMinute(newParsed.minute) + setAmpm(newParsed.ampm) + }, [value]) + + React.useEffect(() => { + if (open) { + setTimeout(() => { + hourInputRef.current?.focus() + hourInputRef.current?.select() + }, 0) + } + }, [open]) + + const updateTime = React.useCallback( + (newHour?: string, newMinute?: string, newAmpm?: 'AM' | 'PM') => { + if (disabled) return + const h = Number.parseInt(newHour ?? hour) || 12 + const m = Number.parseInt(newMinute ?? minute) || 0 + const p = newAmpm ?? ampm + onChange?.(formatStorageTime(h, m, p)) + }, + [disabled, hour, minute, ampm, onChange] + ) + + const handleHourChange = React.useCallback((e: React.ChangeEvent) => { + const val = e.target.value.replace(/[^0-9]/g, '').slice(0, 2) + setHour(val) + }, []) + + const handleHourBlur = React.useCallback(() => { + const numVal = Number.parseInt(hour) || 12 + const clamped = Math.min(12, Math.max(1, numVal)) + setHour(clamped.toString()) + updateTime(clamped.toString()) + }, [hour, updateTime]) + + const handleMinuteChange = React.useCallback((e: React.ChangeEvent) => { + const val = e.target.value.replace(/[^0-9]/g, '').slice(0, 2) + setMinute(val) + }, []) + + const handleMinuteBlur = React.useCallback(() => { + const numVal = Number.parseInt(minute) || 0 + const clamped = Math.min(59, Math.max(0, numVal)) + setMinute(clamped.toString().padStart(2, '0')) + updateTime(undefined, clamped.toString()) + }, [minute, updateTime]) + + const handleKeyDown = React.useCallback( + (e: React.KeyboardEvent) => { + if (!disabled && (e.key === 'Enter' || e.key === ' ')) { + e.preventDefault() + setOpen(!open) + } + }, + [disabled, open] + ) + + /** + * Handles Enter key in inputs to close picker. + */ + const handleInputKeyDown = React.useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault() + e.currentTarget.blur() + setOpen(false) + } + }, []) + + const handleTriggerClick = React.useCallback(() => { + if (!disabled) { + setOpen(!open) + } + }, [disabled, open]) + + const displayValue = value ? formatDisplayTime(value) : '' + + return ( + +
+ +
+ + {displayValue || placeholder} + + +
+
+ + +
+ + : + +
+ {(['AM', 'PM'] as const).map((period) => ( + + ))} +
+
+
+
+
+ ) + } +) + +TimePicker.displayName = 'TimePicker' + +export { TimePicker, timePickerVariants } diff --git a/apps/sim/lib/workflows/schedules/utils.test.ts b/apps/sim/lib/workflows/schedules/utils.test.ts index 834f189b7f..e8bc13fda6 100644 --- a/apps/sim/lib/workflows/schedules/utils.test.ts +++ b/apps/sim/lib/workflows/schedules/utils.test.ts @@ -390,13 +390,9 @@ describe('Schedule Utilities', () => { cronExpression: null, } - // Last ran 10 minutes ago - const lastRanAt = new Date() - lastRanAt.setMinutes(lastRanAt.getMinutes() - 10) - - const nextRun = calculateNextRunTime('minutes', scheduleValues, lastRanAt) + const nextRun = calculateNextRunTime('minutes', scheduleValues) - // With Croner, it calculates based on cron expression, not lastRanAt + // Croner calculates based on cron expression // Just verify we get a future date expect(nextRun instanceof Date).toBe(true) expect(nextRun > new Date()).toBe(true) @@ -523,11 +519,13 @@ describe('Schedule Utilities', () => { it.concurrent('should include timezone information when provided', () => { const resultPT = parseCronToHumanReadable('0 9 * * *', 'America/Los_Angeles') - expect(resultPT).toContain('(PT)') + // Intl.DateTimeFormat returns PST or PDT depending on DST + expect(resultPT).toMatch(/\(P[SD]T\)/) expect(resultPT).toContain('09:00 AM') const resultET = parseCronToHumanReadable('30 14 * * *', 'America/New_York') - expect(resultET).toContain('(ET)') + // Intl.DateTimeFormat returns EST or EDT depending on DST + expect(resultET).toMatch(/\(E[SD]T\)/) expect(resultET).toContain('02:30 PM') const resultUTC = parseCronToHumanReadable('0 12 * * *', 'UTC') diff --git a/apps/sim/lib/workflows/schedules/utils.ts b/apps/sim/lib/workflows/schedules/utils.ts index c15376958b..cf2ebb7ae6 100644 --- a/apps/sim/lib/workflows/schedules/utils.ts +++ b/apps/sim/lib/workflows/schedules/utils.ts @@ -27,7 +27,6 @@ export function validateCronExpression( } try { - // Validate with timezone if provided for accurate next run calculation const cron = new Cron(cronExpression, timezone ? { timezone } : undefined) const nextRun = cron.nextRun() @@ -324,13 +323,11 @@ export function generateCronExpression( * 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 (currently unused, Croner calculates from current time) * @returns Date object for next execution time */ export function calculateNextRunTime( scheduleType: string, - scheduleValues: ReturnType, - lastRanAt?: Date | null + scheduleValues: ReturnType ): Date { // Get timezone (default to UTC) const timezone = scheduleValues.timezone || 'UTC' @@ -341,7 +338,7 @@ export function calculateNextRunTime( // If we have both a start date and time, use them together with timezone awareness if (scheduleValues.scheduleStartAt && scheduleValues.scheduleTime) { try { - logger.info( + logger.debug( `Creating date with: startAt=${scheduleValues.scheduleStartAt}, time=${scheduleValues.scheduleTime}, timezone=${timezone}` ) @@ -351,7 +348,7 @@ export function calculateNextRunTime( timezone ) - logger.info(`Combined date result: ${combinedDate.toISOString()}`) + 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) { @@ -412,13 +409,10 @@ export function calculateNextRunTime( } } - // 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 Croner instance with timezone support const cron = new Cron(cronExpression, { timezone, }) @@ -440,23 +434,24 @@ export function calculateNextRunTime( } /** - * Helper function to get a friendly timezone abbreviation + * Helper function to get a friendly timezone abbreviation. + * Uses Intl.DateTimeFormat to get the correct abbreviation for the current time, + * automatically handling DST transitions. */ 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', - } + if (timezone === 'UTC') return 'UTC' - return timezoneMap[timezone] || timezone + try { + const formatter = new Intl.DateTimeFormat('en-US', { + timeZone: timezone, + timeZoneName: 'short', + }) + const parts = formatter.formatToParts(new Date()) + const tzPart = parts.find((p) => p.type === 'timeZoneName') + return tzPart?.value || timezone + } catch { + return timezone + } } /** @@ -469,13 +464,11 @@ function getTimezoneAbbreviation(timezone: string): string { */ export const parseCronToHumanReadable = (cronExpression: string, timezone?: string): string => { try { - // 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 }) - // Add timezone information if provided and not UTC if (timezone && timezone !== 'UTC') { const tzAbbr = getTimezoneAbbreviation(timezone) return `${baseDescription} (${tzAbbr})` @@ -487,7 +480,6 @@ export const parseCronToHumanReadable = (cronExpression: string, timezone?: stri cronExpression, error: error instanceof Error ? error.message : String(error), }) - // Fallback to displaying the raw cron expression return `Schedule: ${cronExpression}${timezone && timezone !== 'UTC' ? ` (${getTimezoneAbbreviation(timezone)})` : ''}` } } @@ -517,7 +509,6 @@ export const getScheduleInfo = ( let scheduleTiming = 'Unknown schedule' if (cronExpression) { - // Pass timezone to parseCronToHumanReadable for accurate display scheduleTiming = parseCronToHumanReadable(cronExpression, timezone || undefined) } else if (scheduleType) { scheduleTiming = `${scheduleType.charAt(0).toUpperCase() + scheduleType.slice(1)}`