feat(time-picker): added timepicker emcn component, added to playground, added searchable prop for dropdown, added more timezones for schedule, updated license and notice date (#2668)

* feat(time-picker): added timepicker emcn component, added to playground, added searchable prop for dropdown, added more timezones for schedule, updated license and notice date

* removed unused params, cleaned up redundant utils
This commit is contained in:
Waleed
2026-01-02 18:46:39 -08:00
committed by GitHub
parent 4df5d56ac5
commit c3adcf315b
12 changed files with 411 additions and 179 deletions

View File

@@ -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.

2
NOTICE
View File

@@ -1,4 +1,4 @@
Sim Studio
Copyright 2025 Sim Studio
Copyright 2026 Sim Studio
This product includes software developed for the Sim project.

View File

@@ -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() {
</VariantRow>
</Section>
{/* TimePicker */}
<Section title='TimePicker'>
<VariantRow label='default'>
<div className='w-48'>
<TimePicker value={timeValue} onChange={setTimeValue} placeholder='Select time' />
</div>
<span className='text-[var(--text-secondary)] text-sm'>{timeValue}</span>
</VariantRow>
<VariantRow label='size sm'>
<div className='w-48'>
<TimePicker value='14:00' onChange={() => {}} placeholder='Small size' size='sm' />
</div>
</VariantRow>
<VariantRow label='no value'>
<div className='w-48'>
<TimePicker placeholder='Select time...' onChange={() => {}} />
</div>
</VariantRow>
<VariantRow label='disabled'>
<div className='w-48'>
<TimePicker value='09:00' disabled />
</div>
</VariantRow>
</Section>
{/* Breadcrumb */}
<Section title='Breadcrumb'>
<Breadcrumb

View File

@@ -46,6 +46,8 @@ interface DropdownProps {
) => Promise<Array<{ label: string; id: string }>>
/** 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<string | string[]>(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 (
<Combobox
@@ -391,7 +394,7 @@ export function Dropdown({
isLoading={isLoadingOptions}
error={fetchError}
searchable={isSearchable}
searchPlaceholder='Search operations...'
searchPlaceholder='Search...'
/>
)
}

View File

@@ -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<string>(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<string>('12')
const [minute, setMinute] = React.useState<string>('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 (
<Popover
open={isOpen}
onOpenChange={(open) => {
setIsOpen(open)
if (!open) {
handleBlur()
}
}}
>
<PopoverTrigger asChild>
<div className='relative w-full cursor-pointer'>
<Input
readOnly
disabled={isPreview || disabled}
value={value ? formatDisplayTime(value) : ''}
placeholder={placeholder || 'Select time'}
autoComplete='off'
className={cn('cursor-pointer', !value && 'text-muted-foreground', className)}
/>
</div>
</PopoverTrigger>
<PopoverContent className='w-auto p-4'>
<div className='flex items-center space-x-2'>
<Input
className='w-[4rem]'
value={hour}
onChange={(e) => {
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'
/>
<span className='text-[var(--text-primary)]'>:</span>
<Input
className='w-[4rem]'
value={minute}
onChange={(e) => {
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'
/>
<Button
variant='outline'
className='w-[4rem]'
onClick={() => {
const newAmpm = ampm === 'AM' ? 'PM' : 'AM'
setAmpm(newAmpm)
updateTime(undefined, undefined, newAmpm)
}}
>
{ampm}
</Button>
</div>
</PopoverContent>
</Popover>
<TimePicker
value={value || undefined}
onChange={handleChange}
placeholder={placeholder || 'Select time'}
disabled={isPreview || disabled}
className={className}
/>
)
}

View File

@@ -461,6 +461,7 @@ function SubBlockComponent({
multiSelect={config.multiSelect}
fetchOptions={config.fetchOptions}
dependsOn={config.dependsOn}
searchable={config.searchable}
/>
</div>
)

View File

@@ -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) {

View File

@@ -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',

View File

@@ -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'

View File

@@ -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
* <TimePicker
* value={time}
* onChange={(timeString) => setTime(timeString)}
* placeholder="Select time"
* />
*
* // Small size variant
* <TimePicker
* value={time}
* onChange={setTime}
* size="sm"
* />
*
* // Disabled state
* <TimePicker value="09:00" disabled />
* ```
*/
'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<React.HTMLAttributes<HTMLDivElement>, 'onChange'>,
VariantProps<typeof timePickerVariants> {
/** 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<HTMLDivElement, TimePickerProps>(
(
{
className,
variant,
size,
value,
onChange,
placeholder = 'Select time',
disabled = false,
...props
},
ref
) => {
const [open, setOpen] = React.useState(false)
const hourInputRef = React.useRef<HTMLInputElement>(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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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 (
<Popover open={open} onOpenChange={setOpen}>
<div ref={ref} className='relative w-full' {...props}>
<PopoverAnchor asChild>
<div
role='button'
tabIndex={disabled ? -1 : 0}
aria-disabled={disabled}
className={cn(
timePickerVariants({ variant, size }),
'relative cursor-pointer items-center justify-between',
disabled && 'cursor-not-allowed opacity-50',
className
)}
onClick={handleTriggerClick}
onKeyDown={handleKeyDown}
>
<span className={cn('flex-1 truncate', !displayValue && 'text-[var(--text-muted)]')}>
{displayValue || placeholder}
</span>
<ChevronDown
className={cn(
'ml-[8px] h-4 w-4 flex-shrink-0 opacity-50 transition-transform',
open && 'rotate-180'
)}
/>
</div>
</PopoverAnchor>
<PopoverContent
side='bottom'
align='start'
sideOffset={4}
className='w-auto rounded-[6px] border border-[var(--border-1)] p-[8px]'
>
<div className='flex items-center gap-[6px]'>
<input
ref={hourInputRef}
className='w-[40px] rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[6px] py-[5px] text-center font-medium font-sans text-[13px] text-[var(--text-primary)] outline-none transition-colors placeholder:text-[var(--text-muted)] focus:outline-none focus-visible:outline-none focus-visible:ring-0'
value={hour}
onChange={handleHourChange}
onBlur={handleHourBlur}
onKeyDown={handleInputKeyDown}
type='text'
inputMode='numeric'
maxLength={2}
autoComplete='off'
/>
<span className='font-medium text-[13px] text-[var(--text-muted)]'>:</span>
<input
className='w-[40px] rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[6px] py-[5px] text-center font-medium font-sans text-[13px] text-[var(--text-primary)] outline-none transition-colors placeholder:text-[var(--text-muted)] focus:outline-none focus-visible:outline-none focus-visible:ring-0'
value={minute}
onChange={handleMinuteChange}
onBlur={handleMinuteBlur}
onKeyDown={handleInputKeyDown}
type='text'
inputMode='numeric'
maxLength={2}
autoComplete='off'
/>
<div className='ml-[2px] flex overflow-hidden rounded-[4px] border border-[var(--border-1)]'>
{(['AM', 'PM'] as const).map((period) => (
<button
key={period}
type='button'
onClick={() => {
setAmpm(period)
updateTime(undefined, undefined, period)
}}
className={cn(
'px-[8px] py-[5px] font-medium font-sans text-[12px] transition-colors',
ampm === period
? 'bg-[var(--brand-secondary)] text-[var(--bg)]'
: 'bg-[var(--surface-5)] text-[var(--text-secondary)] hover:bg-[var(--surface-7)] hover:text-[var(--text-primary)] dark:hover:bg-[var(--surface-5)]'
)}
>
{period}
</button>
))}
</div>
</div>
</PopoverContent>
</div>
</Popover>
)
}
)
TimePicker.displayName = 'TimePicker'
export { TimePicker, timePickerVariants }

View File

@@ -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)
const nextRun = calculateNextRunTime('minutes', scheduleValues, lastRanAt)
// 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')

View File

@@ -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<typeof getScheduleTimeValues>,
lastRanAt?: Date | null
scheduleValues: ReturnType<typeof getScheduleTimeValues>
): 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<string, string> = {
'America/Los_Angeles': 'PT',
'America/Denver': 'MT',
'America/Chicago': 'CT',
'America/New_York': 'ET',
'Europe/London': 'GMT/BST',
'Europe/Paris': 'CET/CEST',
'Asia/Tokyo': 'JST',
'Asia/Singapore': 'SGT',
'Australia/Sydney': 'AEDT/AEST',
UTC: 'UTC',
}
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)}`