diff --git a/apps/sim/app/api/cron/cleanup-table-row-ttl/route.test.ts b/apps/sim/app/api/cron/cleanup-table-row-ttl/route.test.ts
index 8b31afd352e..9e62e4eb1ce 100644
--- a/apps/sim/app/api/cron/cleanup-table-row-ttl/route.test.ts
+++ b/apps/sim/app/api/cron/cleanup-table-row-ttl/route.test.ts
@@ -77,6 +77,23 @@ describe('table row TTL cleanup route', () => {
expect(mockEnqueue.mock.calls[0]?.[2]?.jobId).toBe(mockEnqueue.mock.calls[1]?.[2]?.jobId)
})
+ it('uses a new id immediately after the next fifteen-minute window begins', async () => {
+ const request = () =>
+ createMockRequest(
+ 'GET',
+ undefined,
+ {},
+ 'http://localhost:3000/api/cron/cleanup-table-row-ttl'
+ )
+
+ vi.setSystemTime(new Date('2026-08-22T17:14:59.999Z'))
+ await GET(request())
+ vi.setSystemTime(new Date('2026-08-22T17:15:00.000Z'))
+ await GET(request())
+
+ expect(mockEnqueue.mock.calls[0]?.[2]?.jobId).not.toBe(mockEnqueue.mock.calls[1]?.[2]?.jobId)
+ })
+
it('returns the cron auth refusal without touching the queue', async () => {
mockVerifyCronAuth.mockReturnValue(new Response(null, { status: 401 }))
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.test.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.test.tsx
new file mode 100644
index 00000000000..851a755b22a
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.test.tsx
@@ -0,0 +1,148 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { act, createElement, type ReactNode } from 'react'
+import { createRoot } from 'react-dom/client'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type { TableInfo, TableRow } from '@/lib/table'
+import { RowModal } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal'
+
+const { mockUseTimezoneState, mockUpdateRow, mockDeleteRow, mockDeleteRows } = vi.hoisted(() => ({
+ mockUseTimezoneState: vi.fn(),
+ mockUpdateRow: vi.fn(),
+ mockDeleteRow: vi.fn(),
+ mockDeleteRows: vi.fn(),
+}))
+
+vi.mock('next/navigation', () => ({
+ useParams: () => ({ workspaceId: 'workspace-1' }),
+}))
+vi.mock('@/hooks/queries/general-settings', () => ({
+ useTimezoneState: mockUseTimezoneState,
+}))
+vi.mock('@/hooks/queries/tables', () => ({
+ useUpdateTableRow: () => ({ mutateAsync: mockUpdateRow, isPending: false }),
+ useDeleteTableRow: () => ({ mutateAsync: mockDeleteRow, isPending: false }),
+ useDeleteTableRows: () => ({ mutateAsync: mockDeleteRows, isPending: false }),
+}))
+vi.mock('@sim/emcn', () => {
+ const passthrough = ({ children }: { children?: ReactNode }) => children ?? null
+ return {
+ Checkbox: () => null,
+ ChipConfirmModal: passthrough,
+ ChipDatePicker: ({ value, onChange }: { value?: string; onChange: (value: string) => void }) =>
+ createElement(
+ 'button',
+ { type: 'button', 'data-testid': 'date', onClick: () => onChange(value ?? '2026-11-01') },
+ value
+ ),
+ ChipModal: passthrough,
+ ChipModalBody: passthrough,
+ ChipModalError: passthrough,
+ ChipModalField: passthrough,
+ ChipModalFooter: ({
+ primaryAction,
+ }: {
+ primaryAction: { disabled?: boolean; onClick?: () => void }
+ }) =>
+ createElement(
+ 'button',
+ {
+ type: 'button',
+ 'data-testid': 'submit',
+ disabled: primaryAction.disabled,
+ onClick: primaryAction.onClick,
+ },
+ 'Update Row'
+ ),
+ ChipModalHeader: passthrough,
+ ChipTimePicker: ({ value, onChange }: { value?: string; onChange: (value: string) => void }) =>
+ createElement('input', {
+ 'data-testid': 'time',
+ value: value ?? '',
+ onChange: (event: { currentTarget: { value: string } }) =>
+ onChange(event.currentTarget.value),
+ }),
+ Label: passthrough,
+ }
+})
+
+const table: TableInfo = {
+ id: 'table-1',
+ name: 'Expiring rows',
+ schema: { columns: [{ name: 'expires_at', type: 'ttl' }] },
+}
+
+const row: TableRow = {
+ id: 'row-1',
+ data: { expires_at: Date.parse('2026-11-01T08:00:00Z') / 1000 },
+ executions: {},
+ position: 0,
+ createdAt: '2026-01-01T00:00:00Z',
+ updatedAt: '2026-01-01T00:00:00Z',
+}
+
+function changeInput(input: HTMLInputElement, value: string) {
+ const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set
+ setter?.call(input, value)
+ input.dispatchEvent(new Event('input', { bubbles: true }))
+}
+
+describe('RowModal expiration editing', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockUpdateRow.mockResolvedValue(undefined)
+ })
+
+ it('waits for the saved timezone, freezes it, and chooses the later repeated hour', async () => {
+ mockUseTimezoneState.mockReturnValue({ timezone: 'Asia/Tokyo', status: 'loading' })
+ const container = document.createElement('div')
+ document.body.appendChild(container)
+ const root = createRoot(container)
+ const props = {
+ mode: 'edit' as const,
+ isOpen: true,
+ onClose: vi.fn(),
+ table,
+ row,
+ onSuccess: vi.fn(),
+ }
+
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+ act(() => root.render(createElement(RowModal, props)))
+
+ expect(container.querySelector('[role="status"]')?.textContent).toBe('Loading timezone…')
+ expect(container.querySelector('[data-testid="time"]')).toBeNull()
+ expect(container.querySelector('[data-testid="submit"]')?.disabled).toBe(
+ true
+ )
+
+ mockUseTimezoneState.mockReturnValue({
+ timezone: 'America/Los_Angeles',
+ status: 'ready',
+ })
+ act(() => root.render(createElement(RowModal, props)))
+
+ mockUseTimezoneState.mockReturnValue({
+ timezone: 'America/New_York',
+ status: 'ready',
+ })
+ act(() => root.render(createElement(RowModal, props)))
+
+ const timeInput = container.querySelector('[data-testid="time"]')
+ expect(timeInput?.value).toBe('01:00')
+ act(() => changeInput(timeInput as HTMLInputElement, '01:30'))
+
+ const submit = container.querySelector('[data-testid="submit"]')
+ await act(async () => submit?.click())
+
+ expect(mockUpdateRow).toHaveBeenCalledWith({
+ rowId: 'row-1',
+ data: { expires_at: Date.parse('2026-11-01T09:30:00Z') / 1000 },
+ })
+ expect(props.onSuccess).toHaveBeenCalledTimes(1)
+
+ act(() => root.unmount())
+ container.remove()
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.tsx
index e9734bafba0..e139b38b849 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.tsx
@@ -1,6 +1,6 @@
'use client'
-import { useId, useState } from 'react'
+import { useId, useRef, useState } from 'react'
import {
Checkbox,
ChipConfirmModal,
@@ -20,7 +20,7 @@ import { useParams } from 'next/navigation'
import type { ColumnDefinition, TableInfo, TableRow } from '@/lib/table'
import { columnTypeOf } from '@/lib/table/column-types'
import { resolveCurrencyCode } from '@/lib/table/currency'
-import { useTimezone } from '@/hooks/queries/general-settings'
+import { useTimezoneState } from '@/hooks/queries/general-settings'
import { useDeleteTableRow, useDeleteTableRows, useUpdateTableRow } from '@/hooks/queries/tables'
import {
cleanCellValue,
@@ -78,7 +78,14 @@ export function RowModal({ mode, isOpen, onClose, table, row, rowIds, onSuccess
const schema = table?.schema
const columns = schema?.columns || []
- const timeZone = useTimezone()
+ const timezoneState = useTimezoneState()
+ const editTimeZoneRef = useRef(null)
+ if (timezoneState.status === 'ready' && editTimeZoneRef.current === null) {
+ editTimeZoneRef.current = timezoneState.timezone
+ }
+ const hasTtlColumn = mode === 'edit' && columns.some((column) => column.type === 'ttl')
+ const ttlTimezoneUnavailable = hasTtlColumn && editTimeZoneRef.current === null
+ const timeZone = editTimeZoneRef.current ?? timezoneState.timezone
const [rowData, setRowData] = useState>(() =>
mode === 'edit' && row ? row.data : {}
)
@@ -92,6 +99,7 @@ export function RowModal({ mode, isOpen, onClose, table, row, rowIds, onSuccess
const handleFormSubmit = async (e?: React.FormEvent) => {
e?.preventDefault()
setError(null)
+ if (ttlTimezoneUnavailable) return
try {
const cleanData = cleanRowData(columns, rowData, timeZone)
@@ -169,15 +177,22 @@ export function RowModal({ mode, isOpen, onClose, table, row, rowIds, onSuccess
Update values for {table?.name ?? 'table'}
onChange(localPartsToDateValue(day, parts.time, timeZone))}
+ onChange={(day) => onChange(valueFromParts(day, parts.time))}
placeholder='Select date'
className='flex-1'
/>
- onChange(
- localPartsToDateValue(parts.day ?? todayLocalCalendarDate(timeZone), time, timeZone)
- )
+ onChange(valueFromParts(parts.day ?? todayLocalCalendarDate(timeZone), time))
}
placeholder='Add time'
className='w-[110px]'
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors.test.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors.test.ts
new file mode 100644
index 00000000000..e5b3e8c4264
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors.test.ts
@@ -0,0 +1,229 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { act, createElement, type ReactNode } from 'react'
+import { createRoot } from 'react-dom/client'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type { ColumnDefinition } from '@/lib/table'
+import {
+ dateEditorRawValue,
+ InlineEditor,
+} from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors'
+import { cleanCellValue } from '@/app/workspace/[workspaceId]/tables/[tableId]/utils'
+
+const { mockToastError, mockUseTimezoneState } = vi.hoisted(() => ({
+ mockToastError: vi.fn(),
+ mockUseTimezoneState: vi.fn(),
+}))
+
+vi.mock('@/hooks/queries/general-settings', () => ({ useTimezoneState: mockUseTimezoneState }))
+vi.mock('@sim/emcn', () => {
+ const passthrough = ({ children }: { children?: ReactNode }) => children ?? null
+ return {
+ Calendar: () => null,
+ cn: (...classes: unknown[]) => classes.filter(Boolean).join(' '),
+ DropdownMenu: passthrough,
+ DropdownMenuContent: passthrough,
+ DropdownMenuItem: passthrough,
+ DropdownMenuTrigger: passthrough,
+ Popover: passthrough,
+ PopoverAnchor: () => null,
+ PopoverContent: passthrough,
+ toast: { error: mockToastError },
+ }
+})
+const column = (type: ColumnDefinition['type']): ColumnDefinition => ({ name: 'expires_at', type })
+
+function changeInput(input: HTMLInputElement, value: string) {
+ const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set
+ setter?.call(input, value)
+ input.dispatchEvent(new Event('input', { bubbles: true }))
+}
+
+describe('dateEditorRawValue', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockUseTimezoneState.mockReturnValue({
+ timezone: 'America/Los_Angeles',
+ status: 'ready',
+ })
+ })
+
+ it('leaves TTL drafts for TTL coercion to resolve safely', () => {
+ const ttlColumn = column('ttl')
+ const timezone = 'America/New_York'
+ const repeatedWallClock = '11/01/2026 1:30:00 AM'
+
+ const repeatedRaw = dateEditorRawValue(repeatedWallClock, ttlColumn, timezone)
+ expect(repeatedRaw).toBe(repeatedWallClock)
+ expect(cleanCellValue(repeatedRaw, ttlColumn, timezone)).toBe(
+ Date.parse('2026-11-01T06:30:00Z') / 1000
+ )
+
+ const fractionalRaw = dateEditorRawValue('2023-11-14t22:13:20.001Z', ttlColumn, timezone)
+ expect(cleanCellValue(fractionalRaw, ttlColumn, timezone)).toBe(1_700_000_001)
+ })
+
+ it('keeps ordinary date drafts on their existing display parser', () => {
+ expect(dateEditorRawValue('11/01/2026 1:30:00 AM', column('date'), 'America/New_York')).toBe(
+ '2026-11-01T01:30:00-04:00'
+ )
+ })
+
+ it('keeps an open TTL edit in its starting timezone when the setting changes', () => {
+ const container = document.createElement('div')
+ document.body.appendChild(container)
+ const root = createRoot(container)
+ const onSave = vi.fn()
+ const value = Date.parse('2026-06-15T13:00:30Z') / 1000
+ const props = {
+ value,
+ column: column('ttl'),
+ onSave,
+ onCancel: vi.fn(),
+ }
+
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+ act(() => root.render(createElement(InlineEditor, props)))
+ mockUseTimezoneState.mockReturnValue({
+ timezone: 'America/New_York',
+ status: 'ready',
+ })
+ act(() => root.render(createElement(InlineEditor, props)))
+
+ const input = container.querySelector('input') as HTMLInputElement
+ expect(input?.value).toBe('06/15/2026 6:00:30 AM')
+ act(() => changeInput(input, '09/01/2026 9:00 AM'))
+ act(() => {
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
+ })
+
+ expect(onSave).toHaveBeenCalledWith(Date.parse('2026-09-01T16:00:00Z') / 1000, 'enter')
+ act(() => root.unmount())
+ container.remove()
+ })
+
+ it('waits for the saved timezone before creating a TTL draft', () => {
+ mockUseTimezoneState.mockReturnValue({
+ timezone: 'Asia/Tokyo',
+ status: 'loading',
+ })
+ const container = document.createElement('div')
+ document.body.appendChild(container)
+ const root = createRoot(container)
+ const onSave = vi.fn()
+ const props = {
+ value: Date.parse('2026-06-15T13:00:30Z') / 1000,
+ column: column('ttl'),
+ onSave,
+ onCancel: vi.fn(),
+ }
+
+ act(() => root.render(createElement(InlineEditor, props)))
+
+ expect(container.querySelector('input')).toBeNull()
+ expect(container.querySelector('[role="status"]')?.textContent).toBe('Loading timezone…')
+
+ mockUseTimezoneState.mockReturnValue({
+ timezone: 'America/Los_Angeles',
+ status: 'ready',
+ })
+ act(() => root.render(createElement(InlineEditor, props)))
+
+ const input = container.querySelector('input') as HTMLInputElement
+ expect(input.disabled).toBe(false)
+ act(() => changeInput(input, '09/01/2026 9:00 AM'))
+ act(() => input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })))
+
+ expect(onSave).toHaveBeenCalledWith(Date.parse('2026-09-01T16:00:00Z') / 1000, 'enter')
+ act(() => root.unmount())
+ container.remove()
+ })
+
+ it('rejects an impossible TTL draft without clearing the cell', () => {
+ const container = document.createElement('div')
+ document.body.appendChild(container)
+ const root = createRoot(container)
+ const onSave = vi.fn()
+
+ act(() =>
+ root.render(
+ createElement(InlineEditor, {
+ value: Date.parse('2026-06-15T13:00:30Z') / 1000,
+ column: column('ttl'),
+ onSave,
+ onCancel: vi.fn(),
+ })
+ )
+ )
+
+ const input = container.querySelector('input') as HTMLInputElement
+ act(() => changeInput(input, '02/30/2026 1:30 AM'))
+ act(() => input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })))
+
+ expect(onSave).not.toHaveBeenCalled()
+ expect(mockToastError).toHaveBeenCalledWith('Invalid expiration date')
+ act(() => root.unmount())
+ container.remove()
+ })
+
+ it.each([
+ { caseName: 'a historical sub-minute offset', timezone: 'Africa/Monrovia', value: 2670 },
+ {
+ caseName: 'the far-future representable boundary',
+ timezone: 'Asia/Tokyo',
+ value: 253_402_300_799,
+ },
+ ])('preserves the exact epoch for $caseName when untouched', ({ timezone, value }) => {
+ mockUseTimezoneState.mockReturnValue({ timezone, status: 'ready' })
+ const container = document.createElement('div')
+ document.body.appendChild(container)
+ const root = createRoot(container)
+ const onSave = vi.fn()
+
+ act(() =>
+ root.render(
+ createElement(InlineEditor, {
+ value,
+ column: column('ttl'),
+ onSave,
+ onCancel: vi.fn(),
+ })
+ )
+ )
+
+ const input = container.querySelector('input') as HTMLInputElement
+ act(() => input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })))
+
+ expect(onSave).toHaveBeenCalledWith(value, 'enter')
+ act(() => root.unmount())
+ container.remove()
+ })
+
+ it('cancels TTL editing when the saved timezone cannot be loaded', () => {
+ mockUseTimezoneState.mockReturnValue({
+ timezone: 'America/Los_Angeles',
+ status: 'error',
+ })
+ const container = document.createElement('div')
+ document.body.appendChild(container)
+ const root = createRoot(container)
+ const onCancel = vi.fn()
+
+ act(() =>
+ root.render(
+ createElement(InlineEditor, {
+ value: 2670,
+ column: column('ttl'),
+ onSave: vi.fn(),
+ onCancel,
+ })
+ )
+ )
+
+ expect(onCancel).toHaveBeenCalledOnce()
+ expect(mockToastError).toHaveBeenCalledWith('Could not load timezone')
+ act(() => root.unmount())
+ container.remove()
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors.tsx
index f5c8526173b..b03c3aeb69e 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors.tsx
@@ -17,7 +17,7 @@ import { Check } from '@sim/emcn/icons'
import type { ColumnDefinition } from '@/lib/table'
import { columnTypeOf } from '@/lib/table/column-types'
import { isCalendarDateString } from '@/lib/table/dates'
-import { useTimezone } from '@/hooks/queries/general-settings'
+import { useTimezoneState } from '@/hooks/queries/general-settings'
import type { SaveReason } from '../../../types'
import {
cleanCellValue,
@@ -37,6 +37,21 @@ interface InlineEditorProps {
onCancel: () => void
}
+/**
+ * Produces the raw draft that the column type will coerce on save. Ordinary
+ * date columns keep their display parser for partial dates; other date-editor
+ * types receive the untouched draft so their own safety rules are not erased.
+ */
+export function dateEditorRawValue(
+ draft: string,
+ column: ColumnDefinition,
+ timeZone: string,
+ storageValue?: string
+): string {
+ if (storageValue !== undefined) return storageValue
+ return column.type === 'date' ? (displayToStorage(draft, timeZone) ?? draft) : draft
+}
+
/** Redirect wheel gestures over an inline editor to the surrounding table scroll container. */
function handleEditorWheel(e: React.WheelEvent) {
e.preventDefault()
@@ -53,13 +68,40 @@ function handleEditorWheel(e: React.WheelEvent) {
* edits update the draft in place — the day pick keeps the time-of-day
* (including seconds), the time field keeps the day — and Enter/blur commits.
*/
-function InlineDateEditor({
+function InlineDateEditor(props: InlineEditorProps) {
+ const { column, onCancel } = props
+ const timezoneState = useTimezoneState()
+ const ttlTimezoneUnavailable = column.type === 'ttl' && timezoneState.status !== 'ready'
+
+ useEffect(() => {
+ if (column.type !== 'ttl' || timezoneState.status !== 'error') return
+ toast.error('Could not load timezone')
+ onCancel()
+ }, [column.type, onCancel, timezoneState.status])
+
+ if (ttlTimezoneUnavailable) {
+ return (
+
+ {timezoneState.status === 'error' ? 'Timezone unavailable' : 'Loading timezone…'}
+
+ )
+ }
+
+ return
+}
+
+interface ReadyInlineDateEditorProps extends InlineEditorProps {
+ initialTimeZone: string
+}
+
+function ReadyInlineDateEditor({
value,
column,
initialCharacter,
onSave,
onCancel,
-}: InlineEditorProps) {
+ initialTimeZone,
+}: ReadyInlineDateEditorProps) {
const inputRef = useRef(null)
const popoverRef = useRef(null)
const doneRef = useRef(false)
@@ -68,7 +110,9 @@ function InlineDateEditor({
* and refocuses while a popover interaction is in flight (covers browsers
* where buttons don't take focus on click). */
const popoverPointerAtRef = useRef(0)
- const timeZone = useTimezone()
+ /** Keep one wall-clock interpretation for the lifetime of this edit. */
+ const editTimeZoneRef = useRef(initialTimeZone)
+ const timeZone = editTimeZoneRef.current
const storedValue = formatValueForInput(value, column.type, timeZone)
const initialDraft =
@@ -115,26 +159,45 @@ function InlineDateEditor({
// silently shifting the instant of a value someone else wrote.
if (storageVal === undefined && initialCharacter === undefined && current === initialDraft) {
doneRef.current = true
- onSave(storedValue ? cleanCellValue(storedValue, column, timeZone) : null, reason)
+ onSave(
+ column.type === 'ttl'
+ ? (value ?? null)
+ : storedValue
+ ? cleanCellValue(storedValue, column, timeZone)
+ : null,
+ reason
+ )
return
}
- const raw = storageVal ?? displayToStorage(current, timeZone) ?? current
- if (raw && Number.isNaN(Date.parse(raw))) {
+ const raw = dateEditorRawValue(current, column, timeZone, storageVal)
+ const cleaned = raw ? cleanCellValue(raw, column, timeZone) : null
+ const parseError = columnTypeOf(column).parseErrorMessage
+ if (raw && cleaned === null && parseError) {
if (reason === 'blur') {
- if (!invalid) toast.error('Invalid date')
+ if (!invalid) toast.error(parseError)
doneRef.current = true
onCancel()
} else {
- toast.error('Invalid date')
+ toast.error(parseError)
setInvalid(true)
inputRef.current?.focus()
}
return
}
doneRef.current = true
- onSave(raw ? cleanCellValue(raw, column, timeZone) : null, reason)
+ onSave(cleaned, reason)
},
- [invalid, onSave, onCancel, timeZone, initialDraft, initialCharacter, storedValue, column]
+ [
+ invalid,
+ onSave,
+ onCancel,
+ timeZone,
+ initialDraft,
+ initialCharacter,
+ storedValue,
+ column,
+ value,
+ ]
)
const handleKeyDown = useCallback(
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.test.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.test.ts
index a4e051aed2e..c77ce7256e3 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.test.ts
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.test.ts
@@ -206,4 +206,19 @@ describe('formatValueForInput', () => {
cleanCellValue('2023-11-14', { name: 'expires_at', type: 'ttl' }, 'America/New_York')
).toBe(1_699_938_000)
})
+
+ it('uses the latest effective timezone for each TTL edit', () => {
+ const column = { name: 'expires_at', type: 'ttl' } as const
+ const input = '2026-06-15 09:00:30'
+
+ expect(cleanCellValue(input, column, 'America/New_York')).toBe(
+ Date.parse('2026-06-15T13:00:30Z') / 1000
+ )
+ expect(cleanCellValue(input, column, 'Asia/Kathmandu')).toBe(
+ Date.parse('2026-06-15T03:15:30Z') / 1000
+ )
+ expect(cleanCellValue(input, column, 'America/New_York')).toBe(
+ Date.parse('2026-06-15T13:00:30Z') / 1000
+ )
+ })
})
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.ts
index b31b5f1ea48..c9892f8466e 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.ts
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.ts
@@ -1,7 +1,8 @@
+import { getWallClockParts } from '@/lib/core/utils/timezone'
import type { ColumnDefinition, JsonValue } from '@/lib/table'
import type { ColumnType } from '@/lib/table/column-types'
import { columnTypeById, columnTypeOf } from '@/lib/table/column-types'
-import { formatDateCellDisplay, getWallClockParts, normalizeDateCellValue } from '@/lib/table/dates'
+import { formatDateCellDisplay, normalizeDateCellValue } from '@/lib/table/dates'
/**
* Pick a fresh "untitled[_N]" name not already taken by `columns`. Used by
@@ -146,46 +147,12 @@ export function storageToDisplay(stored: string, options?: { seconds?: boolean }
*/
export function displayToStorage(display: string, timeZone?: string): string | null {
const trimmed = display.trim()
- const withTime = trimmed.match(
- /^(\d{1,2})\/(\d{1,2})\/(\d{4})[ ,]+(\d{1,2}):(\d{2})(?::(\d{2}))?(?:\s*(AM|PM))?$/i
- )
- if (withTime) {
- const [, m, d, y, h, min, sec, meridiem] = withTime
- let hours = Number(h)
- if (meridiem) {
- if (hours < 1 || hours > 12) return null
- hours = (hours % 12) + (meridiem.toUpperCase() === 'PM' ? 12 : 0)
- } else if (hours > 23) {
- return null
- }
- if (Number(min) > 59 || Number(sec ?? 0) > 59) return null
- if (!isValidCalendarDay(Number(y), Number(m), Number(d))) return null
- const pad = (n: string) => n.padStart(2, '0')
- // Route through the shared normalizer so the wall time resolves in the
- // effective zone.
- return normalizeDateCellValue(
- `${y}-${pad(m)}-${pad(d)}T${String(hours).padStart(2, '0')}:${min}:${sec ?? '00'}`,
- { timezone: timeZone }
- )
- }
- const full = trimmed.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/)
- if (full) {
- if (!isValidCalendarDay(Number(full[3]), Number(full[1]), Number(full[2]))) return null
- return `${full[3]}-${full[1].padStart(2, '0')}-${full[2].padStart(2, '0')}`
- }
const partial = trimmed.match(/^(\d{1,2})\/(\d{1,2})$/)
if (partial) {
const year = Number(todayLocalCalendarDate(timeZone).slice(0, 4))
- if (!isValidCalendarDay(year, Number(partial[1]), Number(partial[2]))) return null
- return `${year}-${partial[1].padStart(2, '0')}-${partial[2].padStart(2, '0')}`
+ return normalizeDateCellValue(
+ `${year}-${partial[1].padStart(2, '0')}-${partial[2].padStart(2, '0')}`
+ )
}
return normalizeDateCellValue(trimmed, { timezone: timeZone })
}
-
-/** True when Y/M/D is a real calendar day — `Date` rolls impossible days over
- * (02/30 → 03/02) instead of rejecting them, so compare the round-trip. */
-function isValidCalendarDay(year: number, month: number, day: number): boolean {
- if (month < 1 || month > 12 || day < 1 || day > 31) return false
- const check = new Date(year, month - 1, day)
- return check.getMonth() === month - 1 && check.getDate() === day
-}
diff --git a/apps/sim/background/cleanup-table-row-ttl.test.ts b/apps/sim/background/cleanup-table-row-ttl.test.ts
index ba69f1bf5ee..9a4db2c92cc 100644
--- a/apps/sim/background/cleanup-table-row-ttl.test.ts
+++ b/apps/sim/background/cleanup-table-row-ttl.test.ts
@@ -89,7 +89,7 @@ describe('table row TTL cleanup', () => {
})
it('compares TTL values with whole Date.now epoch seconds', async () => {
- const nowEpochMilliseconds = 1_700_000_000_123
+ const nowEpochMilliseconds = 1_700_000_000_999
const nowEpochSeconds = 1_700_000_000
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(nowEpochMilliseconds)
mockDeleteExecute.mockResolvedValue([{ count: 0, createdAt: null, lastId: null }])
diff --git a/apps/sim/hooks/queries/general-settings.test.ts b/apps/sim/hooks/queries/general-settings.test.ts
new file mode 100644
index 00000000000..528bff952c7
--- /dev/null
+++ b/apps/sim/hooks/queries/general-settings.test.ts
@@ -0,0 +1,71 @@
+/**
+ * @vitest-environment node
+ */
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockGetBrowserTimezone, mockUseQuery } = vi.hoisted(() => ({
+ mockGetBrowserTimezone: vi.fn(),
+ mockUseQuery: vi.fn(),
+}))
+
+vi.mock('@tanstack/react-query', () => ({
+ useMutation: vi.fn(),
+ useQuery: mockUseQuery,
+ useQueryClient: vi.fn(),
+}))
+vi.mock('@/lib/core/utils/timezone', () => ({ getBrowserTimezone: mockGetBrowserTimezone }))
+
+import { useTimezone, useTimezoneState } from '@/hooks/queries/general-settings'
+
+describe('useTimezone', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockGetBrowserTimezone.mockReturnValue('America/Los_Angeles')
+ })
+
+ it('uses the browser timezone while no preference is saved', () => {
+ mockUseQuery.mockReturnValue({ data: { timezone: null } })
+
+ expect(useTimezone()).toBe('America/Los_Angeles')
+ expect(useTimezoneState()).toEqual({
+ timezone: 'America/Los_Angeles',
+ status: 'ready',
+ })
+ })
+
+ it('uses a saved timezone instead of the browser fallback', () => {
+ mockUseQuery.mockReturnValue({ data: { timezone: 'Asia/Kathmandu' } })
+
+ expect(useTimezone()).toBe('Asia/Kathmandu')
+ expect(mockGetBrowserTimezone).not.toHaveBeenCalled()
+ })
+
+ it('reads the current setting again after it changes', () => {
+ let timezone: string | null = 'America/New_York'
+ mockUseQuery.mockImplementation(() => ({ data: { timezone } }))
+
+ expect(useTimezone()).toBe('America/New_York')
+ timezone = 'Asia/Tokyo'
+ expect(useTimezone()).toBe('Asia/Tokyo')
+ timezone = null
+ expect(useTimezone()).toBe('America/Los_Angeles')
+ })
+
+ it('distinguishes an unresolved preference from an explicit browser fallback', () => {
+ mockUseQuery.mockReturnValue({ data: undefined, isError: false })
+
+ expect(useTimezoneState()).toEqual({
+ timezone: 'America/Los_Angeles',
+ status: 'loading',
+ })
+ })
+
+ it('reports an unavailable preference instead of treating it as resolved', () => {
+ mockUseQuery.mockReturnValue({ data: undefined, isError: true })
+
+ expect(useTimezoneState()).toEqual({
+ timezone: 'America/Los_Angeles',
+ status: 'error',
+ })
+ })
+})
diff --git a/apps/sim/hooks/queries/general-settings.ts b/apps/sim/hooks/queries/general-settings.ts
index 2c3efa310ad..b23585307db 100644
--- a/apps/sim/hooks/queries/general-settings.ts
+++ b/apps/sim/hooks/queries/general-settings.ts
@@ -149,8 +149,25 @@ export function useBillingUsageNotifications(): boolean {
* captured so scheduling honors the account preference rather than the device.
*/
export function useTimezone(): string {
- const { data } = useGeneralSettings()
- return data?.timezone ?? getBrowserTimezone()
+ return useTimezoneState().timezone
+}
+
+export interface TimezoneState {
+ timezone: string
+ status: 'loading' | 'ready' | 'error'
+}
+
+/**
+ * The effective timezone together with whether the saved preference is known.
+ * Destructive time-based editors use the status to avoid capturing the browser
+ * fallback while the preference request is still in flight.
+ */
+export function useTimezoneState(): TimezoneState {
+ const { data, isError } = useGeneralSettings()
+ return {
+ timezone: data?.timezone ?? getBrowserTimezone(),
+ status: data ? 'ready' : isError ? 'error' : 'loading',
+ }
}
/**
diff --git a/apps/sim/lib/core/utils/timezone.test.ts b/apps/sim/lib/core/utils/timezone.test.ts
index 935a9061cee..5ff9f6cef4d 100644
--- a/apps/sim/lib/core/utils/timezone.test.ts
+++ b/apps/sim/lib/core/utils/timezone.test.ts
@@ -1,11 +1,62 @@
import { describe, expect, it } from 'vitest'
import {
+ formatInstantInTimeZone,
getSupportedTimezones,
getTimezoneOptions,
+ getWallClockParts,
wallClockNow,
zonedClockDate,
zonedWallClockToUtc,
-} from './timezone'
+ zonedWallClockWithOffset,
+} from '@/lib/core/utils/timezone'
+
+describe('formatInstantInTimeZone', () => {
+ it.each([
+ ['UTC', '2026-06-15T00:15:30Z', '2026-06-15T00:15:30Z'],
+ ['America/Los_Angeles', '2026-06-15T00:15:30Z', '2026-06-14T17:15:30-07:00'],
+ ['Asia/Tokyo', '2026-06-15T00:15:30Z', '2026-06-15T09:15:30+09:00'],
+ ['Asia/Kathmandu', '2026-06-15T00:15:30Z', '2026-06-15T06:00:30+05:45'],
+ ['Australia/Lord_Howe', '2026-06-15T00:15:30Z', '2026-06-15T10:45:30+10:30'],
+ ])('formats an instant in %s with its exact offset', (timeZone, iso, expected) => {
+ expect(formatInstantInTimeZone(new Date(iso), timeZone)).toBe(expected)
+ })
+
+ it('distinguishes both copies of an autumn daylight-saving hour', () => {
+ expect(formatInstantInTimeZone(new Date('2026-11-01T05:30:00Z'), 'America/New_York')).toBe(
+ '2026-11-01T01:30:00-04:00'
+ )
+ expect(formatInstantInTimeZone(new Date('2026-11-01T06:30:00Z'), 'America/New_York')).toBe(
+ '2026-11-01T01:30:00-05:00'
+ )
+ })
+
+ it('round-trips the same instant after changing display timezones', () => {
+ const instant = new Date('2026-11-01T06:30:00Z')
+ for (const timeZone of [
+ 'UTC',
+ 'America/Los_Angeles',
+ 'America/New_York',
+ 'Asia/Kathmandu',
+ 'Australia/Lord_Howe',
+ ]) {
+ const editable = formatInstantInTimeZone(instant, timeZone)
+ expect(new Date(editable).getTime()).toBe(instant.getTime())
+ }
+ })
+})
+
+describe('getWallClockParts', () => {
+ it('returns the calendar fields of an instant in the requested timezone', () => {
+ expect(getWallClockParts(new Date('2026-06-15T00:15:30Z'), 'America/Los_Angeles')).toEqual({
+ year: 2026,
+ month: 6,
+ day: 14,
+ hour: 17,
+ minute: 15,
+ second: 30,
+ })
+ })
+})
describe('zonedWallClockToUtc', () => {
it('treats a UTC wall-clock as the same instant', () => {
@@ -48,10 +99,103 @@ describe('zonedWallClockToUtc', () => {
})
it('resolves a spring-forward gap wall-clock forward by the DST shift', () => {
- // 2026-03-08 02:00–02:59 does not exist in America/New_York (EST→EDT).
- expect(zonedWallClockToUtc('2026-03-08T02:30', 'America/New_York').toISOString()).toBe(
- '2026-03-08T07:30:00.000Z'
+ const instant = zonedWallClockToUtc('2026-03-08T02:30', 'America/New_York')
+ const stampedWallClock = zonedWallClockWithOffset('2026-03-08T02:30', 'America/New_York')
+
+ expect(instant.toISOString()).toBe('2026-03-08T07:30:00.000Z')
+ expect(stampedWallClock).toBe('2026-03-08T02:30-05:00')
+ expect(new Date(stampedWallClock).toISOString()).toBe(instant.toISOString())
+ })
+
+ it.each([
+ [
+ 'Europe/Berlin',
+ '2026-03-29T02:30',
+ '2026-03-29T01:30:00.000Z',
+ '2026-03-29T03:30:00+02:00',
+ '2026-03-29T02:30+01:00',
+ ],
+ [
+ 'Australia/Lord_Howe',
+ '2026-10-04T02:15',
+ '2026-10-03T15:45:00.000Z',
+ '2026-10-04T02:45:00+11:00',
+ '2026-10-04T02:15+10:30',
+ ],
+ ])(
+ 'resolves an east-of-UTC spring-forward gap in %s to the first compatible wall-clock',
+ (timeZone, wallClock, expectedInstant, expectedRenderedWallClock, expectedStampedWallClock) => {
+ const instant = zonedWallClockToUtc(wallClock, timeZone)
+ const stampedWallClock = zonedWallClockWithOffset(wallClock, timeZone)
+
+ expect(instant.toISOString()).toBe(expectedInstant)
+ expect(formatInstantInTimeZone(instant, timeZone)).toBe(expectedRenderedWallClock)
+ expect(stampedWallClock).toBe(expectedStampedWallClock)
+ expect(new Date(stampedWallClock).toISOString()).toBe(expectedInstant)
+ }
+ )
+
+ it.each([
+ ['America/New_York', '2026-11-01T01:30', '2026-11-01T06:30:00.000Z', '-05:00'],
+ ['Europe/Berlin', '2026-10-25T02:30', '2026-10-25T01:30:00.000Z', '+01:00'],
+ ['Australia/Lord_Howe', '2026-04-05T01:45', '2026-04-04T15:15:00.000Z', '+10:30'],
+ ])(
+ 'chooses the later post-transition instant for an ambiguous fall-back wall-clock in %s',
+ (timeZone, wallClock, expectedInstant, expectedOffset) => {
+ const instant = zonedWallClockToUtc(wallClock, timeZone)
+ const stampedWallClock = zonedWallClockWithOffset(wallClock, timeZone)
+
+ expect(instant.toISOString()).toBe(expectedInstant)
+ expect(stampedWallClock).toBe(`${wallClock}${expectedOffset}`)
+ expect(new Date(stampedWallClock).toISOString()).toBe(expectedInstant)
+ }
+ )
+
+ it.each([
+ ['America/New_York', '2026-11-01T01:30', '2026-11-01T05:30:00.000Z', '-04:00'],
+ ['Europe/Berlin', '2026-10-25T02:30', '2026-10-25T00:30:00.000Z', '+02:00'],
+ ['Australia/Lord_Howe', '2026-04-05T01:45', '2026-04-04T14:45:00.000Z', '+11:00'],
+ ])(
+ 'can choose the earlier instant for an ambiguous fall-back wall-clock in %s',
+ (timeZone, wallClock, expectedInstant, expectedOffset) => {
+ const options = { ambiguousTime: 'earlier' as const }
+ const instant = zonedWallClockToUtc(wallClock, timeZone, options)
+ const stampedWallClock = zonedWallClockWithOffset(wallClock, timeZone, options)
+
+ expect(instant.toISOString()).toBe(expectedInstant)
+ expect(stampedWallClock).toBe(`${wallClock}${expectedOffset}`)
+ expect(new Date(stampedWallClock).toISOString()).toBe(expectedInstant)
+ }
+ )
+
+ it('does not retain timezone state between consecutive resolutions', () => {
+ const wallClock = '2026-06-15T09:00:30'
+
+ expect(zonedWallClockToUtc(wallClock, 'America/New_York').toISOString()).toBe(
+ '2026-06-15T13:00:30.000Z'
+ )
+ expect(zonedWallClockToUtc(wallClock, 'Asia/Kathmandu').toISOString()).toBe(
+ '2026-06-15T03:15:30.000Z'
+ )
+ expect(zonedWallClockToUtc(wallClock, 'America/New_York').toISOString()).toBe(
+ '2026-06-15T13:00:30.000Z'
+ )
+ })
+
+ it('can serialize historical sub-minute offsets toward a later instant', () => {
+ const wallClock = '1970-01-01T00:00:00'
+ const timezone = 'Africa/Monrovia'
+ const exactInstant = zonedWallClockToUtc(wallClock, timezone)
+ const options = { offsetMinuteRounding: 'floor' as const }
+
+ expect(exactInstant.toISOString()).toBe('1970-01-01T00:44:30.000Z')
+ expect(zonedWallClockWithOffset(wallClock, timezone, options)).toBe('1970-01-01T00:00:00-00:45')
+ expect(formatInstantInTimeZone(exactInstant, timezone, options)).toBe(
+ '1970-01-01T00:00:00-00:45'
)
+ expect(
+ Date.parse(zonedWallClockWithOffset(wallClock, timezone, options))
+ ).toBeGreaterThanOrEqual(exactInstant.getTime())
})
})
diff --git a/apps/sim/lib/core/utils/timezone.ts b/apps/sim/lib/core/utils/timezone.ts
index 297c0a65ec8..36cbcb98951 100644
--- a/apps/sim/lib/core/utils/timezone.ts
+++ b/apps/sim/lib/core/utils/timezone.ts
@@ -23,6 +23,41 @@ const COMMON_TIMEZONES = [
'Australia/Sydney',
]
+/** A wall-clock reading of an instant in some timezone. */
+export interface WallClockParts {
+ year: number
+ /** 1-based month. */
+ month: number
+ day: number
+ hour: number
+ minute: number
+ second: number
+}
+
+function pad(value: number): string {
+ return String(value).padStart(2, '0')
+}
+
+/** RFC 3339 offset suffix: `Z` for zero, else `±HH:MM`. */
+export function formatUtcOffsetSuffix(offsetMinutes: number): string {
+ if (offsetMinutes === 0) return 'Z'
+ const sign = offsetMinutes > 0 ? '+' : '-'
+ const absoluteMinutes = Math.abs(offsetMinutes)
+ return `${sign}${pad(Math.floor(absoluteMinutes / 60))}:${pad(absoluteMinutes % 60)}`
+}
+
+function offsetMsFromWallClock(instant: Date, wall: WallClockParts): number {
+ const wallAsUtc = Date.UTC(
+ wall.year,
+ wall.month - 1,
+ wall.day,
+ wall.hour,
+ wall.minute,
+ wall.second
+ )
+ return wallAsUtc - instant.getTime()
+}
+
/** The IANA timezone the current runtime resolves to (e.g. `America/New_York`). */
export function getBrowserTimezone(): string {
return Intl.DateTimeFormat().resolvedOptions().timeZone
@@ -116,22 +151,63 @@ export function getTimezoneOptions(): TimezoneOption[] {
}
/**
- * An instant's wall-clock time in `timeZone` as a naive `yyyy-MM-ddTHH:mm`
- * string. Lets callers reason about a user's local date/time without UTC — e.g.
- * to recover the local date/time a stored task instant represents in its zone.
+ * The wall-clock fields of `instant` in `timeZone`, or in the runtime's local
+ * timezone when omitted.
*/
-export function zonedWallClock(instant: Date, timeZone: string): string {
- const parts = new Intl.DateTimeFormat('en-CA', {
+export function getWallClockParts(instant: Date, timeZone?: string): WallClockParts {
+ if (!timeZone) {
+ return {
+ year: instant.getFullYear(),
+ month: instant.getMonth() + 1,
+ day: instant.getDate(),
+ hour: instant.getHours(),
+ minute: instant.getMinutes(),
+ second: instant.getSeconds(),
+ }
+ }
+
+ const parts = new Intl.DateTimeFormat('en-US', {
timeZone,
+ hourCycle: 'h23',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
- hourCycle: 'h23',
+ second: '2-digit',
}).formatToParts(instant)
- const get = (type: string) => parts.find((p) => p.type === type)?.value ?? '00'
- return `${get('year')}-${get('month')}-${get('day')}T${get('hour')}:${get('minute')}`
+ const get = (type: string) => Number(parts.find((part) => part.type === type)?.value)
+ return {
+ year: get('year'),
+ month: get('month'),
+ day: get('day'),
+ hour: get('hour'),
+ minute: get('minute'),
+ second: get('second'),
+ }
+}
+
+/** Formats an instant as an RFC 3339 wall time in an IANA timezone. */
+export function formatInstantInTimeZone(
+ instant: Date,
+ timeZone: string,
+ options?: ZonedWallClockOptions
+): string {
+ const wall = getWallClockParts(instant, timeZone)
+ const wholeSecondInstant = new Date(Math.floor(instant.getTime() / 1000) * 1000)
+ const exactOffsetMinutes = offsetMsFromWallClock(wholeSecondInstant, wall) / 60_000
+ const offsetMinutes = roundOffsetMinutes(exactOffsetMinutes, options)
+ return `${wall.year}-${pad(wall.month)}-${pad(wall.day)}T${pad(wall.hour)}:${pad(wall.minute)}:${pad(wall.second)}${formatUtcOffsetSuffix(offsetMinutes)}`
+}
+
+/**
+ * An instant's wall-clock time in `timeZone` as a naive `yyyy-MM-ddTHH:mm`
+ * string. Lets callers reason about a user's local date/time without UTC — e.g.
+ * to recover the local date/time a stored task instant represents in its zone.
+ */
+export function zonedWallClock(instant: Date, timeZone: string): string {
+ const wall = getWallClockParts(instant, timeZone)
+ return `${wall.year}-${pad(wall.month)}-${pad(wall.day)}T${pad(wall.hour)}:${pad(wall.minute)}`
}
/** The current wall-clock time in `timeZone` as a naive `yyyy-MM-ddTHH:mm` string. */
@@ -156,26 +232,59 @@ export function zonedClockDate(instant: Date, timeZone: string): Date {
/** The UTC offset (ms, east-positive) of `timeZone` at a given instant. */
function timezoneOffsetMs(instant: Date, timeZone: string): number {
- const parts = new Intl.DateTimeFormat('en-US', {
- timeZone,
- hourCycle: 'h23',
- year: 'numeric',
- month: '2-digit',
- day: '2-digit',
- hour: '2-digit',
- minute: '2-digit',
- second: '2-digit',
- }).formatToParts(instant)
- const get = (type: string) => Number(parts.find((p) => p.type === type)?.value)
- const asUtc = Date.UTC(
- get('year'),
- get('month') - 1,
- get('day'),
- get('hour'),
- get('minute'),
- get('second')
+ return offsetMsFromWallClock(instant, getWallClockParts(instant, timeZone))
+}
+
+interface ZonedWallClockResolution {
+ instant: Date
+ offsetMinutes: number
+}
+
+export interface ZonedWallClockOptions {
+ /** Which real instant to use when the wall clock occurs twice during a DST fall-back. */
+ ambiguousTime?: 'earlier' | 'later'
+ /** How to serialize rare historical offsets containing seconds into RFC 3339 minutes. */
+ offsetMinuteRounding?: 'nearest' | 'floor'
+}
+
+function roundOffsetMinutes(exactOffsetMinutes: number, options?: ZonedWallClockOptions): number {
+ return options?.offsetMinuteRounding === 'floor'
+ ? Math.floor(exactOffsetMinutes)
+ : Math.round(exactOffsetMinutes)
+}
+
+function resolveZonedWallClock(
+ wallClock: string,
+ timeZone: string,
+ options?: ZonedWallClockOptions
+): ZonedWallClockResolution {
+ const [datePart, timePart] = wallClock.split('T')
+ const [year, month, day] = datePart.split('-').map(Number)
+ const [hour, minute, second = 0] = timePart.split(':').map(Number)
+ const utcGuess = Date.UTC(year, month - 1, day, hour, minute, second)
+ const dayMs = 24 * 60 * 60 * 1000
+ const offsets = new Set(
+ [-dayMs, 0, dayMs].map((distance) => timezoneOffsetMs(new Date(utcGuess + distance), timeZone))
)
- return asUtc - instant.getTime()
+ const candidates = [...offsets].map((offset) => {
+ const instantMs = utcGuess - offset
+ const actualOffset = timezoneOffsetMs(new Date(instantMs), timeZone)
+ return { instantMs, wallClockMs: instantMs + actualOffset }
+ })
+ const exactCandidate = candidates
+ .filter(({ wallClockMs }) => wallClockMs === utcGuess)
+ .sort((a, b) =>
+ options?.ambiguousTime === 'earlier' ? a.instantMs - b.instantMs : b.instantMs - a.instantMs
+ )[0]
+ const compatibleCandidate = candidates
+ .filter(({ wallClockMs }) => wallClockMs > utcGuess)
+ .sort((a, b) => a.wallClockMs - b.wallClockMs || a.instantMs - b.instantMs)[0]
+ const chosenCandidate = exactCandidate ?? compatibleCandidate ?? candidates[0]
+ const instantMs = chosenCandidate.instantMs
+ return {
+ instant: new Date(instantMs),
+ offsetMinutes: (utcGuess - instantMs) / 60_000,
+ }
}
/**
@@ -184,23 +293,28 @@ function timezoneOffsetMs(instant: Date, timeZone: string): number {
* whose own offset reproduces the requested wall-clock, which is correct for any
* date (including future ones whose offset differs from today's) and across DST:
* a naive single pass reads the offset on the wrong side of a same-day boundary
- * — notably the autumn fall-back hour — and lands an hour off. For an ambiguous
- * fall-back wall-clock the later (post-transition) instant is chosen; a
+ * — notably the autumn fall-back hour — and lands an hour off. An ambiguous
+ * fall-back wall-clock defaults to the later, post-transition instant, but
+ * callers preserving earlier semantics may request the earlier instant. A
* wall-clock in the spring-forward gap (a nonexistent local hour) has no
* self-consistent instant and resolves forward by the DST shift, matching how
* calendar apps treat that once-a-year hour.
*/
-export function zonedWallClockToUtc(wallClock: string, timeZone: string): Date {
- const [datePart, timePart] = wallClock.split('T')
- const [year, month, day] = datePart.split('-').map(Number)
- const [hour, minute, second = 0] = timePart.split(':').map(Number)
- const utcGuess = Date.UTC(year, month - 1, day, hour, minute, second)
- const guessOffset = timezoneOffsetMs(new Date(utcGuess), timeZone)
- const candidate = utcGuess - guessOffset
- const candidateOffset = timezoneOffsetMs(new Date(candidate), timeZone)
- if (candidateOffset === guessOffset) return new Date(candidate)
- const adjusted = utcGuess - candidateOffset
- return timezoneOffsetMs(new Date(adjusted), timeZone) === candidateOffset
- ? new Date(adjusted)
- : new Date(candidate)
+export function zonedWallClockToUtc(
+ wallClock: string,
+ timeZone: string,
+ options?: ZonedWallClockOptions
+): Date {
+ return resolveZonedWallClock(wallClock, timeZone, options).instant
+}
+
+/** Stamps a naive wall-clock with the offset selected by the shared timezone resolver. */
+export function zonedWallClockWithOffset(
+ wallClock: string,
+ timeZone: string,
+ options?: ZonedWallClockOptions
+): string {
+ const resolution = resolveZonedWallClock(wallClock, timeZone, options)
+ const offsetMinutes = roundOffsetMinutes(resolution.offsetMinutes, options)
+ return `${wallClock}${formatUtcOffsetSuffix(offsetMinutes)}`
}
diff --git a/apps/sim/lib/table/__tests__/column-type-registry.test.ts b/apps/sim/lib/table/__tests__/column-type-registry.test.ts
index 7fcdceeb148..a63886a4a5c 100644
--- a/apps/sim/lib/table/__tests__/column-type-registry.test.ts
+++ b/apps/sim/lib/table/__tests__/column-type-registry.test.ts
@@ -10,6 +10,7 @@
* here.
*/
import { describe, expect, it } from 'vitest'
+import { zonedWallClockToUtc } from '@/lib/core/utils/timezone'
import type { ColumnType } from '@/lib/table/column-types'
import {
ALL_COLUMN_TYPES,
@@ -139,7 +140,7 @@ describe('ttl columns', () => {
})
expect(COLUMN_TYPE_REGISTRY.ttl.coerce('2023-11-14T22:13:20.123Z', column)).toEqual({
ok: true,
- value: 1_700_000_000,
+ value: 1_700_000_001,
})
expect(COLUMN_TYPE_REGISTRY.ttl.coerce('not-a-date', column)).toEqual({ ok: false })
expect(COLUMN_TYPE_REGISTRY.ttl.coerce(1_700_000_000.5, column)).toEqual({ ok: false })
@@ -192,6 +193,67 @@ describe('ttl columns', () => {
).toBe('2023-11-05T01:30:00-05:00')
})
+ it('matches the shared wall-clock resolver in every effective timezone', () => {
+ const wallClock = '2026-06-15T09:00:30'
+ for (const timezone of [
+ 'UTC',
+ 'America/Los_Angeles',
+ 'America/New_York',
+ 'Asia/Kathmandu',
+ 'Australia/Lord_Howe',
+ ]) {
+ const expected = Math.floor(zonedWallClockToUtc(wallClock, timezone).getTime() / 1000)
+ expect(COLUMN_TYPE_REGISTRY.ttl.coerce(wallClock, column, { timezone })).toEqual({
+ ok: true,
+ value: expected,
+ })
+ }
+ })
+
+ it.each([
+ ['Europe/Berlin', '2026-03-29T02:30'],
+ ['Australia/Lord_Howe', '2026-10-04T02:15'],
+ ])('coerces a %s spring-forward gap wall clock to the compatible epoch', (timezone, input) => {
+ const expected = Math.floor(zonedWallClockToUtc(input, timezone).getTime() / 1000)
+ expect(COLUMN_TYPE_REGISTRY.ttl.coerce(input, column, { timezone })).toEqual({
+ ok: true,
+ value: expected,
+ })
+ })
+
+ it('coerces a localized month-name gap input in the explicit workspace timezone', () => {
+ const timezone = 'America/New_York'
+ const expected = Math.floor(zonedWallClockToUtc('2026-03-08T02:30', timezone).getTime() / 1000)
+ expect(COLUMN_TYPE_REGISTRY.ttl.coerce('March 8, 2026 2:30 AM', column, { timezone })).toEqual({
+ ok: true,
+ value: expected,
+ })
+ })
+
+ it('rejects an impossible ISO expiration date', () => {
+ expect(
+ COLUMN_TYPE_REGISTRY.ttl.coerce('2026-02-30T12:00:00', column, { timezone: 'UTC' })
+ ).toEqual({ ok: false })
+ })
+
+ it('round-trips epoch seconds after the editor timezone changes', () => {
+ for (const seconds of [1_700_000_000, 1_699_162_200, 1_699_165_800]) {
+ for (const timezone of [
+ 'UTC',
+ 'America/Los_Angeles',
+ 'America/New_York',
+ 'Asia/Kathmandu',
+ 'Australia/Lord_Howe',
+ ]) {
+ const editable = COLUMN_TYPE_REGISTRY.ttl.formatForInput(seconds, column, { timezone })
+ expect(COLUMN_TYPE_REGISTRY.ttl.coerce(editable, column, { timezone })).toEqual({
+ ok: true,
+ value: seconds,
+ })
+ }
+ }
+ })
+
it('limits a table to one ttl column', () => {
expect(COLUMN_TYPE_REGISTRY.ttl.maxPerTable).toBe(1)
})
diff --git a/apps/sim/lib/table/column-types/ttl.test.ts b/apps/sim/lib/table/column-types/ttl.test.ts
index db0984509db..f0f9c8e91e5 100644
--- a/apps/sim/lib/table/column-types/ttl.test.ts
+++ b/apps/sim/lib/table/column-types/ttl.test.ts
@@ -3,6 +3,12 @@
*/
import { describe, expect, it } from 'vitest'
+import {
+ formatInstantInTimeZone,
+ getSupportedTimezones,
+ zonedWallClockToUtc,
+} from '@/lib/core/utils/timezone'
+import { parseTtlEpochSeconds, ttlColumnType } from '@/lib/table/column-types/ttl'
import { retypeCellRewrite } from '@/lib/table/columns/service'
import type { ColumnDefinition } from '@/lib/table/types'
@@ -15,4 +21,133 @@ describe('TTL column type', () => {
retypeCellRewrite(1_700_000_000, column({ type: 'date' }), column({ type: 'ttl' }))
).toEqual({ value: '2023-11-14T22:13:20Z' })
})
+
+ it.each([
+ ['UTC', '2026-06-15T09:00:30', '2026-06-15T09:00:30.000Z'],
+ ['America/New_York', '2026-06-15T09:00:30', '2026-06-15T13:00:30.000Z'],
+ ['America/New_York', '2026-01-15T09:00:30', '2026-01-15T14:00:30.000Z'],
+ ['Asia/Kathmandu', '2026-06-15T09:00:30', '2026-06-15T03:15:30.000Z'],
+ ['Australia/Lord_Howe', '2026-06-15T09:00:30', '2026-06-14T22:30:30.000Z'],
+ ])('stores %s wall-clock input as the expected epoch second', (timezone, input, iso) => {
+ expect(parseTtlEpochSeconds(input, { timezone })).toBe(Date.parse(iso) / 1000)
+ })
+
+ it.each([
+ ['America/New_York', '2026-11-01T01:30', '2026-11-01T06:30:00.000Z'],
+ ['Europe/Berlin', '2026-10-25T02:30', '2026-10-25T01:30:00.000Z'],
+ ['Australia/Lord_Howe', '2026-04-05T01:45', '2026-04-04T15:15:00.000Z'],
+ ])(
+ 'chooses the later expiration when %s repeats a wall-clock time',
+ (timezone, input, laterInstant) => {
+ expect(parseTtlEpochSeconds(input, { timezone })).toBe(Date.parse(laterInstant) / 1000)
+ }
+ )
+
+ it.each([
+ ['America/New_York', '2026-03-08T02:30', '2026-03-08T07:30:00.000Z'],
+ ['Europe/Berlin', '2026-03-29T02:30', '2026-03-29T01:30:00.000Z'],
+ ['Australia/Lord_Howe', '2026-10-04T02:15', '2026-10-03T15:45:00.000Z'],
+ ])(
+ 'moves a nonexistent %s wall-clock expiration forward across the gap',
+ (timezone, input, compatibleInstant) => {
+ expect(parseTtlEpochSeconds(input, { timezone })).toBe(Date.parse(compatibleInstant) / 1000)
+ }
+ )
+
+ it('rounds fractional instants up so expiration is never stored early', () => {
+ expect(parseTtlEpochSeconds('2023-11-14T22:13:20.001Z')).toBe(1_700_000_001)
+ expect(parseTtlEpochSeconds('2023-11-14T22:13:20.999Z')).toBe(1_700_000_001)
+ expect(parseTtlEpochSeconds('2023-11-14T22:13:20.0001Z')).toBe(1_700_000_001)
+ expect(parseTtlEpochSeconds('2023-11-14t22:13:20.001Z')).toBe(1_700_000_001)
+ expect(parseTtlEpochSeconds('2023-11-14t17:13:20.001', { timezone: 'America/New_York' })).toBe(
+ 1_700_000_001
+ )
+ expect(parseTtlEpochSeconds(new Date('2023-11-14T22:13:20.001Z'))).toBe(1_700_000_001)
+ expect(parseTtlEpochSeconds('2023-11-14T22:13:20.000Z')).toBe(1_700_000_000)
+ })
+
+ it('rounds historical sub-minute timezone offsets toward a later expiration', () => {
+ const timezone = 'Africa/Monrovia'
+ const exactInstant = Date.parse('1970-01-01T00:44:30Z') / 1000
+
+ expect(parseTtlEpochSeconds('1970-01-01T00:00:00', { timezone })).toBeGreaterThanOrEqual(
+ exactInstant
+ )
+
+ const editable = ttlColumnType.formatForInput(exactInstant, column({ type: 'ttl' }), {
+ timezone,
+ })
+ expect(editable).toBe('1970-01-01T00:00:00-00:45')
+ expect(parseTtlEpochSeconds(editable, { timezone })).toBeGreaterThanOrEqual(exactInstant)
+ })
+
+ it('never resolves representative wall clocks early in any supported timezone', () => {
+ for (const timezone of getSupportedTimezones()) {
+ for (const wallClock of ['1970-01-01T00:00:00', '2026-06-15T09:00:30']) {
+ const exactSecond = Math.ceil(
+ zonedWallClockToUtc(wallClock, timezone, { ambiguousTime: 'later' }).getTime() / 1000
+ )
+ expect(
+ parseTtlEpochSeconds(wallClock, { timezone }),
+ `${timezone} ${wallClock}`
+ ).toBeGreaterThanOrEqual(exactSecond)
+ }
+ }
+ })
+
+ it('never moves stored epoch seconds earlier when formatted in any supported timezone', () => {
+ for (const timezone of getSupportedTimezones()) {
+ for (const seconds of [0, Date.parse('2026-11-01T06:30:00Z') / 1000]) {
+ const editable = ttlColumnType.formatForInput(seconds, column({ type: 'ttl' }), {
+ timezone,
+ })
+ expect(
+ parseTtlEpochSeconds(editable, { timezone }),
+ `${timezone} ${editable}`
+ ).toBeGreaterThanOrEqual(seconds)
+ }
+ }
+ })
+
+ it('uses the timezone supplied for each call rather than a previous setting', () => {
+ const input = '2026-06-15T09:00:30'
+
+ expect(parseTtlEpochSeconds(input, { timezone: 'America/New_York' })).toBe(
+ Date.parse('2026-06-15T13:00:30Z') / 1000
+ )
+ expect(parseTtlEpochSeconds(input, { timezone: 'Asia/Kathmandu' })).toBe(
+ Date.parse('2026-06-15T03:15:30Z') / 1000
+ )
+ expect(parseTtlEpochSeconds(input, { timezone: 'America/New_York' })).toBe(
+ Date.parse('2026-06-15T13:00:30Z') / 1000
+ )
+ })
+
+ it('round-trips the same epoch after the editor timezone changes', () => {
+ const seconds = Date.parse('2026-11-01T06:30:00Z') / 1000
+
+ for (const timezone of [
+ 'UTC',
+ 'America/Los_Angeles',
+ 'America/New_York',
+ 'Asia/Kathmandu',
+ 'Australia/Lord_Howe',
+ ]) {
+ const editable = ttlColumnType.formatForInput(seconds, column({ type: 'ttl' }), { timezone })
+ expect(editable).toBe(formatInstantInTimeZone(new Date(seconds * 1000), timezone))
+ expect(parseTtlEpochSeconds(editable, { timezone })).toBe(seconds)
+ }
+ })
+
+ it('keeps the TTL repeated-hour policy separate from ordinary date behavior', () => {
+ const input = '2026-11-01T01:30'
+ const timezone = 'America/New_York'
+
+ expect(zonedWallClockToUtc(input, timezone, { ambiguousTime: 'earlier' }).toISOString()).toBe(
+ '2026-11-01T05:30:00.000Z'
+ )
+ expect(parseTtlEpochSeconds(input, { timezone })).toBe(
+ Date.parse('2026-11-01T06:30:00Z') / 1000
+ )
+ })
})
diff --git a/apps/sim/lib/table/column-types/ttl.ts b/apps/sim/lib/table/column-types/ttl.ts
index f0e5b37cdec..1ea0773d116 100644
--- a/apps/sim/lib/table/column-types/ttl.ts
+++ b/apps/sim/lib/table/column-types/ttl.ts
@@ -1,8 +1,8 @@
import { TypeTtl } from '@sim/emcn/icons'
+import { formatInstantInTimeZone } from '@/lib/core/utils/timezone'
import type { ColumnTypeDefinition } from '@/lib/table/column-types/types'
import {
formatDateCellDisplay,
- formatInstantInTimeZone,
type NormalizeDateCellOptions,
normalizeDateCellValue,
} from '@/lib/table/dates'
@@ -10,11 +10,23 @@ import type { ColumnDefinition } from '@/lib/table/types'
const NUMERIC_VALUE_PATTERN = /^-?\d+(?:\.\d+)?$/
const ISO_DATE_PREFIX_PATTERN = /^(\d{4}-\d{2}-\d{2})(?:$|[T ])/i
+const FRACTIONAL_SECONDS_PATTERN = /[T ]\d{1,2}:\d{2}:\d{2}\.(\d+)/i
function isRepresentableEpochSeconds(value: number): boolean {
return Number.isSafeInteger(value) && !Number.isNaN(new Date(value * 1000).getTime())
}
+/** Rounds toward the future so integer-second storage can never expire an instant early. */
+function epochSecondAtOrAfter(milliseconds: number): number {
+ return Math.ceil(milliseconds / 1000)
+}
+
+/** Whether an ISO-shaped input names any instant after its whole second. */
+function hasFractionalSecond(value: string): boolean {
+ const digits = value.match(FRACTIONAL_SECONDS_PATTERN)?.[1]
+ return digits ? /[1-9]/.test(digits) : false
+}
+
/** Converts a TTL cell input to integer Unix epoch seconds. */
export function parseTtlEpochSeconds(
value: unknown,
@@ -24,7 +36,7 @@ export function parseTtlEpochSeconds(
if (value instanceof Date) {
const milliseconds = value.getTime()
- return Number.isNaN(milliseconds) ? null : Math.floor(milliseconds / 1000)
+ return Number.isNaN(milliseconds) ? null : epochSecondAtOrAfter(milliseconds)
}
if (typeof value !== 'string') return null
@@ -36,17 +48,22 @@ export function parseTtlEpochSeconds(
return isRepresentableEpochSeconds(numeric) ? numeric : null
}
- const normalized = normalizeDateCellValue(trimmed, options)
+ const ttlOptions: NormalizeDateCellOptions = {
+ ...options,
+ ambiguousTime: 'later',
+ offsetMinuteRounding: 'floor',
+ }
+ const normalized = normalizeDateCellValue(trimmed, ttlOptions)
if (normalized === null) return null
const instant = /^\d{4}-\d{2}-\d{2}$/.test(normalized)
- ? normalizeDateCellValue(`${normalized}T00:00:00`, options)
+ ? normalizeDateCellValue(`${normalized}T00:00:00`, ttlOptions)
: normalized
if (instant === null) return null
const inputIsoDate = trimmed.match(ISO_DATE_PREFIX_PATTERN)?.[1]
if (inputIsoDate && instant.slice(0, 10) !== inputIsoDate) return null
- const milliseconds = Date.parse(instant)
+ const milliseconds = Date.parse(instant) + (hasFractionalSecond(trimmed) ? 1 : 0)
if (Number.isNaN(milliseconds)) return null
- const seconds = Math.floor(milliseconds / 1000)
+ const seconds = epochSecondAtOrAfter(milliseconds)
return isRepresentableEpochSeconds(seconds) ? seconds : null
}
@@ -59,7 +76,7 @@ function epochSecondsToIso(value: unknown): string | null {
function epochSecondsToEditable(value: unknown, timeZone?: string): string | null {
const iso = epochSecondsToIso(value)
if (!iso || !timeZone) return iso
- return formatInstantInTimeZone(new Date(iso), timeZone)
+ return formatInstantInTimeZone(new Date(iso), timeZone, { offsetMinuteRounding: 'floor' })
}
export const ttlColumnType: ColumnTypeDefinition = {
diff --git a/apps/sim/lib/table/dates.test.ts b/apps/sim/lib/table/dates.test.ts
index 3ff51410e15..5126c5d7724 100644
--- a/apps/sim/lib/table/dates.test.ts
+++ b/apps/sim/lib/table/dates.test.ts
@@ -22,6 +22,8 @@ function localOffsetSuffix(local: Date): string {
describe('isCalendarDateString', () => {
it('accepts YYYY-MM-DD and rejects everything else', () => {
expect(isCalendarDateString('2026-07-06')).toBe(true)
+ expect(isCalendarDateString('2024-02-29')).toBe(true)
+ expect(isCalendarDateString('2026-02-30')).toBe(false)
expect(isCalendarDateString('2026-13-45')).toBe(false)
expect(isCalendarDateString('2026-07-06T00:00:00Z')).toBe(false)
expect(isCalendarDateString('07/06/2026')).toBe(false)
@@ -80,6 +82,62 @@ describe('normalizeDateCellValue', () => {
)
})
+ it('reads localized numeric wall clocks before applying the provided IANA zone', () => {
+ expect(normalizeDateCellValue('3/8/2026 2:30 AM', { timezone: 'America/New_York' })).toBe(
+ '2026-03-08T02:30:00-05:00'
+ )
+ expect(normalizeDateCellValue('7/6/2026, 16:04:55', { timezone: 'Asia/Tokyo' })).toBe(
+ '2026-07-06T16:04:55+09:00'
+ )
+ })
+
+ it('reads month-name wall clocks independently of the runtime timezone', () => {
+ expect(normalizeDateCellValue('March 8, 2026 2:30 AM', { timezone: 'America/New_York' })).toBe(
+ '2026-03-08T02:30:00-05:00'
+ )
+ })
+
+ it('rejects impossible month-name calendar dates', () => {
+ expect(
+ normalizeDateCellValue('February 29, 2025 2:30 AM', { timezone: 'America/New_York' })
+ ).toBeNull()
+ expect(
+ normalizeDateCellValue('April 31, 2026 4:04 PM', { timezone: 'America/New_York' })
+ ).toBeNull()
+ })
+
+ it('accepts valid leap-day month-name wall clocks in either date order', () => {
+ expect(
+ normalizeDateCellValue('February 29, 2024 4:04 PM', { timezone: 'America/New_York' })
+ ).toBe('2024-02-29T16:04:00-05:00')
+ expect(normalizeDateCellValue('29 Feb 2024 4:04 PM', { timezone: 'America/New_York' })).toBe(
+ '2024-02-29T16:04:00-05:00'
+ )
+ })
+
+ it.each([
+ ['America/New_York', '2026-11-01 01:30:00', '2026-11-01T01:30:00-04:00'],
+ ['America/New_York', '2026-03-08 02:30:00', '2026-03-08T02:30:00-05:00'],
+ ['Asia/Kathmandu', '2026-06-15 09:00:00', '2026-06-15T09:00:00+05:45'],
+ ['Australia/Lord_Howe', '2026-06-15 09:00:00', '2026-06-15T09:00:00+10:30'],
+ ])('uses the shared timezone rules for %s', (timezone, input, expected) => {
+ expect(normalizeDateCellValue(input, { timezone })).toBe(expected)
+ })
+
+ it('uses each provided timezone independently when the setting changes', () => {
+ const input = '2026-06-15 09:00:30'
+
+ expect(normalizeDateCellValue(input, { timezone: 'America/New_York' })).toBe(
+ '2026-06-15T09:00:30-04:00'
+ )
+ expect(normalizeDateCellValue(input, { timezone: 'Asia/Kathmandu' })).toBe(
+ '2026-06-15T09:00:30+05:45'
+ )
+ expect(normalizeDateCellValue(input, { timezone: 'America/New_York' })).toBe(
+ '2026-06-15T09:00:30-04:00'
+ )
+ })
+
it('ignores the zone option when the input carries an explicit offset', () => {
expect(
normalizeDateCellValue('2026-07-06T23:04:55.000Z', { timezone: 'America/New_York' })
@@ -107,6 +165,28 @@ describe('normalizeDateCellValue', () => {
expect(normalizeDateCellValue('2026-13-45')).toBeNull()
expect(normalizeDateCellValue('13/06/2026')).toBeNull()
})
+
+ it('rejects impossible ISO calendar and time fields', () => {
+ expect(normalizeDateCellValue('2026-02-30')).toBeNull()
+ expect(normalizeDateCellValue('2025-02-29T12:00:00Z')).toBeNull()
+ expect(normalizeDateCellValue('2026-02-30 12:00', { timezone: 'UTC' })).toBeNull()
+ expect(normalizeDateCellValue('2026-02-30 12:00 PDT')).toBeNull()
+ expect(normalizeDateCellValue('2026-07-06T24:00', { timezone: 'UTC' })).toBeNull()
+ expect(normalizeDateCellValue('2026-07-06 24:00+00')).toBeNull()
+ expect(normalizeDateCellValue('2026-07-06T12:60:00-04:00')).toBeNull()
+ expect(normalizeDateCellValue('02/30/2026')).toBeNull()
+ expect(normalizeDateCellValue('February 29, 2025')).toBeNull()
+ expect(normalizeDateCellValue('February 29, 2025 12:00')).toBeNull()
+ expect(normalizeDateCellValue('February 29, 2025 12:00', { timezone: 'UTC' })).toBeNull()
+ })
+
+ it('accepts leap days and valid daylight-saving gap wall clocks', () => {
+ expect(normalizeDateCellValue('2024-02-29')).toBe('2024-02-29')
+ expect(normalizeDateCellValue('2024-02-29T12:00:00Z')).toBe('2024-02-29T12:00:00Z')
+ expect(normalizeDateCellValue('2026-03-08T02:30:00', { timezone: 'America/New_York' })).toBe(
+ '2026-03-08T02:30:00-05:00'
+ )
+ })
})
describe('formatDateCellDisplay', () => {
diff --git a/apps/sim/lib/table/dates.ts b/apps/sim/lib/table/dates.ts
index 0c6360f63fb..88bfa6c28c0 100644
--- a/apps/sim/lib/table/dates.ts
+++ b/apps/sim/lib/table/dates.ts
@@ -23,7 +23,14 @@
* barrel (the barrel is server-tainted).
*/
-const CALENDAR_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/
+import {
+ formatUtcOffsetSuffix,
+ type ZonedWallClockOptions,
+ zonedWallClockWithOffset,
+} from '@/lib/core/utils/timezone'
+
+const CALENDAR_DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/
+const LOCALIZED_CALENDAR_DATE_PATTERN = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/
/**
* Canonical (or canonical-enough legacy) instant: a literal wall time with an
@@ -31,7 +38,35 @@ const CALENDAR_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/
* groups are the wall-time fields display renders verbatim.
*/
const WALL_INSTANT_PATTERN =
- /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?$/
+ /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?(?:\.\d+)?(?:\s*(?:Z|UTC?|GMT|[ECMP][SD]T)|[+-]\d{1,2}(?::?\d{2})?)?$/i
+
+const LOCALIZED_WALL_CLOCK_PATTERN =
+ /^(\d{1,2})\/(\d{1,2})\/(\d{4})[ ,]+(\d{1,2}):(\d{2})(?::(\d{2}))?(?:\s*(AM|PM))?$/i
+
+const MONTH_NAME_PATTERN =
+ 'Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:t(?:ember)?)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?'
+const MONTH_FIRST_DATE_PATTERN = new RegExp(
+ `\\b(${MONTH_NAME_PATTERN})\\s+(\\d{1,2})(?:,)?\\s+(\\d{4})\\b`,
+ 'i'
+)
+const DAY_FIRST_DATE_PATTERN = new RegExp(
+ `\\b(\\d{1,2})\\s+(${MONTH_NAME_PATTERN})(?:,)?\\s+(\\d{4})\\b`,
+ 'i'
+)
+const MONTH_BY_ABBREVIATION: Record = {
+ JAN: 1,
+ FEB: 2,
+ MAR: 3,
+ APR: 4,
+ MAY: 5,
+ JUN: 6,
+ JUL: 7,
+ AUG: 8,
+ SEP: 9,
+ OCT: 10,
+ NOV: 11,
+ DEC: 12,
+}
/**
* Legacy shape: old CSV imports stored date-only columns as UTC-midnight
@@ -67,81 +102,10 @@ const US_ABBREVIATION_OFFSET_MINUTES: Record = {
/** True when `value` is a canonical timezone-free calendar date. */
export function isCalendarDateString(value: string): boolean {
- return CALENDAR_DATE_PATTERN.test(value) && !Number.isNaN(Date.parse(value))
-}
-
-/** A wall-clock reading of an instant in some timezone. */
-export interface WallClockParts {
- year: number
- /** 1-based month. */
- month: number
- day: number
- hour: number
- minute: number
- second: number
-}
-
-/**
- * The wall-clock reading of `date` in `timeZone` — or in the runtime's local
- * zone when omitted. Throws a RangeError on an invalid IANA zone — callers
- * validate at the boundary.
- */
-export function getWallClockParts(date: Date, timeZone?: string): WallClockParts {
- if (!timeZone) {
- return {
- year: date.getFullYear(),
- month: date.getMonth() + 1,
- day: date.getDate(),
- hour: date.getHours(),
- minute: date.getMinutes(),
- second: date.getSeconds(),
- }
- }
- const parts = new Intl.DateTimeFormat('en-US', {
- timeZone,
- hourCycle: 'h23',
- year: 'numeric',
- month: '2-digit',
- day: '2-digit',
- hour: '2-digit',
- minute: '2-digit',
- second: '2-digit',
- }).formatToParts(date)
- const get = (type: string) => Number(parts.find((p) => p.type === type)?.value)
- return {
- year: get('year'),
- month: get('month'),
- day: get('day'),
- hour: get('hour'),
- minute: get('minute'),
- second: get('second'),
- }
-}
-
-/** Offset of `timeZone` from UTC (ms east) at the moment `at`. */
-function zoneOffsetMs(timeZone: string, at: Date): number {
- const wall = getWallClockParts(at, timeZone)
- const asUtc = Date.UTC(wall.year, wall.month - 1, wall.day, wall.hour, wall.minute, wall.second)
- return asUtc - at.getTime()
-}
-
-/**
- * Converts a wall-clock reading in `timeZone` to the UTC instant it denotes.
- * Two-pass so readings near a DST transition resolve with the offset in
- * force at that wall time.
- */
-function wallTimeInZoneToUtc(wall: Date, timeZone: string): Date {
- const guess = Date.UTC(
- wall.getFullYear(),
- wall.getMonth(),
- wall.getDate(),
- wall.getHours(),
- wall.getMinutes(),
- wall.getSeconds(),
- wall.getMilliseconds()
+ const calendar = value.match(CALENDAR_DATE_PATTERN)
+ return Boolean(
+ calendar && isValidCalendarDay(Number(calendar[1]), Number(calendar[2]), Number(calendar[3]))
)
- const adjusted = guess - zoneOffsetMs(timeZone, new Date(guess))
- return new Date(guess - zoneOffsetMs(timeZone, new Date(adjusted)))
}
function pad(n: number): string {
@@ -156,29 +120,6 @@ function toUtcCalendarDate(date: Date): string {
return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}`
}
-/** `Z` for zero, else `±HH:MM`. */
-function formatOffsetSuffix(offsetMinutes: number): string {
- if (offsetMinutes === 0) return 'Z'
- const sign = offsetMinutes > 0 ? '+' : '-'
- const abs = Math.abs(offsetMinutes)
- return `${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`
-}
-
-/** Formats an instant as canonical wall time in an IANA timezone. */
-export function formatInstantInTimeZone(date: Date, timeZone: string): string {
- const wall = getWallClockParts(date, timeZone)
- const wallAsUtc = Date.UTC(
- wall.year,
- wall.month - 1,
- wall.day,
- wall.hour,
- wall.minute,
- wall.second
- )
- const offsetMinutes = Math.round((wallAsUtc - date.getTime()) / 60_000)
- return `${wall.year}-${pad(wall.month)}-${pad(wall.day)}T${pad(wall.hour)}:${pad(wall.minute)}:${pad(wall.second)}${formatOffsetSuffix(offsetMinutes)}`
-}
-
/**
* Trailing offset (minutes east of UTC) of a datetime string, or null when
* naive. Recognizes exactly what `Date.parse` recognizes: numeric offsets,
@@ -201,14 +142,118 @@ function extractExplicitOffsetMinutes(value: string): number | null {
function formatUtcFieldsAsWall(shifted: Date, offsetMinutes: number): string {
return `${toUtcCalendarDate(shifted)}T${pad(shifted.getUTCHours())}:${pad(
shifted.getUTCMinutes()
- )}:${pad(shifted.getUTCSeconds())}${formatOffsetSuffix(offsetMinutes)}`
+ )}:${pad(shifted.getUTCSeconds())}${formatUtcOffsetSuffix(offsetMinutes)}`
}
/** Serializes local-read fields of `parsed` as a wall time with `offset`. */
function formatLocalFieldsAsWall(parsed: Date, offsetMinutes: number): string {
return `${toLocalCalendarDate(parsed)}T${pad(parsed.getHours())}:${pad(
parsed.getMinutes()
- )}:${pad(parsed.getSeconds())}${formatOffsetSuffix(offsetMinutes)}`
+ )}:${pad(parsed.getSeconds())}${formatUtcOffsetSuffix(offsetMinutes)}`
+}
+
+/** True when numeric year, month, and day fields describe a real calendar day. */
+function isValidCalendarDay(year: number, month: number, day: number): boolean {
+ if (month < 1 || month > 12 || day < 1) return false
+ const leapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0)
+ const daysInMonth = [31, leapYear ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
+ return day <= daysInMonth[month - 1]
+}
+
+/** Validates and formats numeric wall-clock fields as naive ISO. */
+function formatValidatedWallClock(
+ year: number,
+ month: number,
+ day: number,
+ hour: number,
+ minute: number,
+ second: number
+): string | null {
+ if (
+ !isValidCalendarDay(year, month, day) ||
+ hour < 0 ||
+ hour > 23 ||
+ minute < 0 ||
+ minute > 59 ||
+ second < 0 ||
+ second > 59
+ ) {
+ return null
+ }
+ return `${String(year).padStart(4, '0')}-${pad(month)}-${pad(day)}T${pad(hour)}:${pad(minute)}:${pad(second)}`
+}
+
+/** Reads an ISO-shaped wall clock literally, before runtime timezone normalization. */
+function parseIsoWallClock(match: RegExpMatchArray): string | null {
+ return formatValidatedWallClock(
+ Number(match[1]),
+ Number(match[2]),
+ Number(match[3]),
+ Number(match[4]),
+ Number(match[5]),
+ Number(match[6] ?? 0)
+ )
+}
+
+/** Reads a supported US numeric wall clock literally, including 12-hour input. */
+function parseLocalizedWallClock(match: RegExpMatchArray): string | null {
+ const meridiem = match[7]?.toUpperCase()
+ let hour = Number(match[4])
+ if (meridiem) {
+ if (hour < 1 || hour > 12) return null
+ hour = (hour % 12) + (meridiem === 'PM' ? 12 : 0)
+ }
+ return formatValidatedWallClock(
+ Number(match[3]),
+ Number(match[1]),
+ Number(match[2]),
+ hour,
+ Number(match[5]),
+ Number(match[6] ?? 0)
+ )
+}
+
+interface CalendarFields {
+ year: number
+ month: number
+ day: number
+}
+
+/** Extracts literal calendar fields from supported month-name date forms. */
+function extractMonthNameCalendar(value: string): CalendarFields | null {
+ const monthFirst = value.match(MONTH_FIRST_DATE_PATTERN)
+ if (monthFirst) {
+ return {
+ year: Number(monthFirst[3]),
+ month: MONTH_BY_ABBREVIATION[monthFirst[1].slice(0, 3).toUpperCase()],
+ day: Number(monthFirst[2]),
+ }
+ }
+ const dayFirst = value.match(DAY_FIRST_DATE_PATTERN)
+ if (!dayFirst) return null
+ return {
+ year: Number(dayFirst[3]),
+ month: MONTH_BY_ABBREVIATION[dayFirst[2].slice(0, 3).toUpperCase()],
+ day: Number(dayFirst[1]),
+ }
+}
+
+/** Recovers broader naive `Date.parse` inputs without consulting the runtime timezone. */
+function parseNaiveWallClockAsUtc(value: string): string | null {
+ const calendar = extractMonthNameCalendar(value)
+ if (calendar && !isValidCalendarDay(calendar.year, calendar.month, calendar.day)) return null
+ const ms = Date.parse(`${value} UTC`)
+ if (Number.isNaN(ms)) return null
+ const parsed = new Date(ms)
+ if (
+ calendar &&
+ (parsed.getUTCFullYear() !== calendar.year ||
+ parsed.getUTCMonth() + 1 !== calendar.month ||
+ parsed.getUTCDate() !== calendar.day)
+ ) {
+ return null
+ }
+ return `${toUtcCalendarDate(parsed)}T${pad(parsed.getUTCHours())}:${pad(parsed.getUTCMinutes())}:${pad(parsed.getUTCSeconds())}`
}
export interface NormalizeDateCellOptions {
@@ -220,6 +265,14 @@ export interface NormalizeDateCellOptions {
* zone.
*/
timezone?: string
+ /**
+ * Which instant to use when a naive wall time occurs twice during a DST
+ * fall-back. Ordinary date cells preserve their historical earlier-instant
+ * behavior; instant-like callers may explicitly choose `later`.
+ */
+ ambiguousTime?: ZonedWallClockOptions['ambiguousTime']
+ /** How sub-minute historical offsets are serialized to RFC 3339 minutes. */
+ offsetMinuteRounding?: ZonedWallClockOptions['offsetMinuteRounding']
}
/**
@@ -235,12 +288,37 @@ export function normalizeDateCellValue(
): string | null {
const trimmed = raw.trim()
if (!trimmed) return null
- if (CALENDAR_DATE_PATTERN.test(trimmed)) {
- return Number.isNaN(Date.parse(trimmed)) ? null : trimmed
+ const calendar = trimmed.match(CALENDAR_DATE_PATTERN)
+ if (calendar) {
+ return isValidCalendarDay(Number(calendar[1]), Number(calendar[2]), Number(calendar[3]))
+ ? trimmed
+ : null
}
+ const localizedCalendar = trimmed.match(LOCALIZED_CALENDAR_DATE_PATTERN)
+ if (localizedCalendar) {
+ const month = Number(localizedCalendar[1])
+ const day = Number(localizedCalendar[2])
+ const year = Number(localizedCalendar[3])
+ return isValidCalendarDay(year, month, day)
+ ? `${String(year).padStart(4, '0')}-${pad(month)}-${pad(day)}`
+ : null
+ }
+ const isoMatch = trimmed.match(WALL_INSTANT_PATTERN)
+ const isoWallClock = isoMatch ? parseIsoWallClock(isoMatch) : undefined
+ if (isoWallClock === null) return null
+ const localizedMatch = trimmed.match(LOCALIZED_WALL_CLOCK_PATTERN)
+ const localizedWallClock = localizedMatch ? parseLocalizedWallClock(localizedMatch) : undefined
+ if (localizedWallClock === null) return null
const ms = Date.parse(trimmed)
if (Number.isNaN(ms)) return null
const parsed = new Date(ms)
+ const monthNameCalendar = extractMonthNameCalendar(trimmed)
+ if (
+ monthNameCalendar &&
+ !isValidCalendarDay(monthNameCalendar.year, monthNameCalendar.month, monthNameCalendar.day)
+ ) {
+ return null
+ }
if (!TIME_COMPONENT_PATTERN.test(trimmed)) {
return ISO_REDUCED_DATE_PATTERN.test(trimmed)
? toUtcCalendarDate(parsed)
@@ -253,11 +331,12 @@ export function normalizeDateCellValue(
return formatUtcFieldsAsWall(new Date(ms + explicitOffset * 60_000), explicitOffset)
}
if (options?.timezone) {
- // `parsed`'s local getters recover the wall-clock fields V8 read from the
- // naive string; stamp them with the requested zone's offset at that time.
- const instant = wallTimeInZoneToUtc(parsed, options.timezone)
- const offsetMinutes = Math.round(zoneOffsetMs(options.timezone, instant) / 60_000)
- return formatLocalFieldsAsWall(parsed, offsetMinutes)
+ const wallClock = isoWallClock ?? localizedWallClock ?? parseNaiveWallClockAsUtc(trimmed)
+ if (!wallClock) return null
+ return zonedWallClockWithOffset(wallClock, options.timezone, {
+ ambiguousTime: options.ambiguousTime ?? 'earlier',
+ offsetMinuteRounding: options.offsetMinuteRounding,
+ })
}
return formatLocalFieldsAsWall(parsed, -parsed.getTimezoneOffset())
}
diff --git a/apps/sim/lib/table/import.test.ts b/apps/sim/lib/table/import.test.ts
index 45463296049..463c18d42de 100644
--- a/apps/sim/lib/table/import.test.ts
+++ b/apps/sim/lib/table/import.test.ts
@@ -181,6 +181,18 @@ describe('import', () => {
)
expect(coerceValue('not-a-date', 'ttl')).toBe('not-a-date')
})
+
+ it('applies the timezone supplied to each TTL import independently', () => {
+ const input = '2026-06-15 09:00:30'
+
+ expect(coerceValue(input, 'ttl', { timezone: 'America/New_York' })).toBe(
+ Date.parse('2026-06-15T13:00:30Z') / 1000
+ )
+ expect(coerceValue(input, 'ttl', { timezone: 'Asia/Kathmandu' })).toBe(
+ Date.parse('2026-06-15T03:15:30Z') / 1000
+ )
+ expect(coerceValue('2023-11-14T22:13:20.001Z', 'ttl')).toBe(1_700_000_001)
+ })
})
describe('buildAutoMapping', () => {