mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-08 22:48:14 -05:00
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:
2
LICENSE
2
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.
|
||||
|
||||
2
NOTICE
2
NOTICE
@@ -1,4 +1,4 @@
|
||||
Sim Studio
|
||||
Copyright 2025 Sim Studio
|
||||
Copyright 2026 Sim Studio
|
||||
|
||||
This product includes software developed for the Sim project.
|
||||
@@ -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
|
||||
|
||||
@@ -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...'
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -461,6 +461,7 @@ function SubBlockComponent({
|
||||
multiSelect={config.multiSelect}
|
||||
fetchOptions={config.fetchOptions}
|
||||
dependsOn={config.dependsOn}
|
||||
searchable={config.searchable}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
|
||||
309
apps/sim/components/emcn/components/time-picker/time-picker.tsx
Normal file
309
apps/sim/components/emcn/components/time-picker/time-picker.tsx
Normal 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 }
|
||||
@@ -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')
|
||||
|
||||
@@ -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)}`
|
||||
|
||||
Reference in New Issue
Block a user