Skip to content

Commit 1bf421a

Browse files
committed
fix(tables): preserve expiration timestamp offsets
1 parent 4ff6872 commit 1bf421a

25 files changed

Lines changed: 501 additions & 173 deletions

File tree

apps/docs/content/docs/tables/index.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ Every column has a type, which decides how its values are stored and validated.
2828
| **JSON** | An object or array | `{ "tier": "pro" }` |
2929
| **Select** | One of a fixed set of options, or several | `Pro` |
3030

31-
Expiration accepts valid ISO timestamps with `Z` or an explicit numeric UTC offset, such as `2026-03-16T14:30:00-07:00`. Seconds are optional; fractional seconds support up to six digits. Values normalize to UTC without losing fractional precision, so `2026-03-16T14:30:00-07:00` and `2026-03-16T21:30:00Z` represent the same deadline and compare equally. Epoch numbers and timezone-free dates are not accepted. The picker edits UTC directly; cells, clipboard values, and exports use the normalized UTC value. Picking a day without a time uses midnight UTC. A table can have one Expiration column. An empty expiration leaves the row unexpired; on an update, omit the field to preserve it or set it to `null` to clear it. Expired rows are removed by periodic cleanup, subject to the table's delete lock.
31+
Expiration accepts valid ISO timestamps with `Z` or an explicit numeric UTC offset, such as `2026-03-16T14:30:00-07:00`. Seconds are optional; fractional seconds support up to six digits. Values preserve their supplied clock time and numeric offset without losing fractional precision; `Z` is stored as `-00:00`. For example, `2026-03-16T14:30:00-07:00` and `2026-03-16T21:30:00Z` represent the same deadline and compare equally. Epoch numbers and timezone-free dates are not accepted. The picker retains the stored offset; cells, clipboard values, and exports use that offset too. Picking a day without a time uses midnight in that offset. New picker values default to `-00:00`. Numeric offsets are fixed: editing a date does not automatically switch between summer and winter offsets. A table can have one Expiration column. An empty expiration leaves the row unexpired; on an update, omit the field to preserve it or set it to `null` to clear it. Expired rows are removed by periodic cleanup, subject to the table's delete lock.
3232

3333
Types are enforced as you enter values, so a Number column only takes numbers.
3434

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.test.tsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ const table: TableInfo = {
100100

101101
const row: TableRow = {
102102
id: 'row-1',
103-
data: { expires_at: '2026-11-01T08:00:00Z' },
103+
data: { expires_at: '2026-11-01T01:00:00-07:00' },
104104
executions: {},
105105
position: 0,
106106
createdAt: '2026-01-01T00:00:00Z',
@@ -119,7 +119,7 @@ describe('RowModal expiration editing', () => {
119119
mockUpdateRow.mockResolvedValue(undefined)
120120
})
121121

122-
it('edits UTC fields while timezone settings load or change', async () => {
122+
it('preserves expiration offsets while timezone settings load or change', async () => {
123123
mockUseTimezoneState.mockReturnValue({ timezone: 'Asia/Tokyo', status: 'loading' })
124124
const container = document.createElement('div')
125125
document.body.appendChild(container)
@@ -136,7 +136,7 @@ describe('RowModal expiration editing', () => {
136136
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
137137
act(() => root.render(createElement(RowModal, props)))
138138

139-
expect(container.querySelector<HTMLInputElement>('[data-testid="time"]')?.value).toBe('08:00')
139+
expect(container.querySelector<HTMLInputElement>('[data-testid="time"]')?.value).toBe('01:00')
140140
expect(container.querySelector<HTMLButtonElement>('[data-testid="submit"]')?.disabled).toBe(
141141
false
142142
)
@@ -154,15 +154,15 @@ describe('RowModal expiration editing', () => {
154154
act(() => root.render(createElement(RowModal, props)))
155155

156156
const timeInput = container.querySelector<HTMLInputElement>('[data-testid="time"]')
157-
expect(timeInput?.value).toBe('08:00')
157+
expect(timeInput?.value).toBe('01:00')
158158
act(() => changeInput(timeInput as HTMLInputElement, '01:30'))
159159

160160
const submit = container.querySelector<HTMLButtonElement>('[data-testid="submit"]')
161161
await act(async () => submit?.click())
162162

163163
expect(mockUpdateRow).toHaveBeenCalledWith({
164164
rowId: 'row-1',
165-
data: { expires_at: '2026-11-01T01:30:00Z' },
165+
data: { expires_at: '2026-11-01T01:30:00-07:00' },
166166
})
167167
expect(props.onSuccess).toHaveBeenCalledTimes(1)
168168

@@ -206,7 +206,7 @@ describe('RowModal expiration editing', () => {
206206
container.remove()
207207
})
208208

209-
it('allows UTC expiration edits even when the saved timezone is invalid', () => {
209+
it('allows expiration edits even when the saved timezone is invalid', () => {
210210
mockUseTimezoneState.mockReturnValue({
211211
timezone: 'America/Los_Angeles',
212212
savedTimezone: 'Mars/Olympus',
@@ -226,7 +226,7 @@ describe('RowModal expiration editing', () => {
226226

227227
act(() => root.render(createElement(RowModal, props)))
228228

229-
expect(container.querySelector<HTMLInputElement>('[data-testid="time"]')?.value).toBe('08:00')
229+
expect(container.querySelector<HTMLInputElement>('[data-testid="time"]')?.value).toBe('01:00')
230230
expect(container.querySelector<HTMLButtonElement>('[data-testid="submit"]')?.disabled).toBe(
231231
false
232232
)

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.tsx

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import { useParams } from 'next/navigation'
2222
import type { ColumnDefinition, TableInfo, TableRow } from '@/lib/table'
2323
import { columnTypeOf } from '@/lib/table/column-types'
2424
import { resolveCurrencyCode } from '@/lib/table/currency'
25-
import { ttlValueFromPicker, ttlValueToPickerParts } from '@/lib/table/ttl-values'
25+
import { todayAtTtlOffset, ttlValueFromPicker, ttlValueToPickerParts } from '@/lib/table/ttl-values'
2626
import { getTimezoneEditBlockedMessage } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/timezone-editing'
2727
import { type TimezoneState, useTimezoneState } from '@/hooks/queries/general-settings'
2828
import { useDeleteTableRow, useDeleteTableRows, useUpdateTableRow } from '@/hooks/queries/tables'
@@ -341,32 +341,37 @@ function ColumnField({ column, value, timeZone, onChange }: ColumnFieldProps) {
341341
)
342342
}
343343

344-
if (definition.editor === 'date' || definition.editor === 'utc-date') {
345-
const isUtc = definition.editor === 'utc-date'
346-
const pickerTimeZone = isUtc ? 'UTC' : timeZone
344+
if (definition.editor === 'date' || definition.editor === 'offset-date') {
347345
const storedValue = formatValueForInput(value, column.type)
348-
const parts = isUtc ? ttlValueToPickerParts(storedValue) : dateValueToLocalParts(storedValue)
346+
const offsetParts =
347+
definition.editor === 'offset-date' ? ttlValueToPickerParts(storedValue) : null
348+
const parts = offsetParts ?? dateValueToLocalParts(storedValue)
349+
const pickerToday = offsetParts
350+
? todayAtTtlOffset(offsetParts.offset)
351+
: todayLocalCalendarDate(timeZone)
349352
const valueFromParts = (day: string, time: string | null) =>
350-
isUtc ? ttlValueFromPicker(day, time) : localPartsToDateValue(day, time, timeZone)
353+
offsetParts
354+
? ttlValueFromPicker(day, time, offsetParts.offset)
355+
: localPartsToDateValue(day, time, timeZone)
351356
return (
352357
<ChipModalField type='custom' title={title} required={column.required} hint={hint}>
353358
<div className='flex items-center gap-2'>
354359
<ChipDatePicker
355360
value={parts.day ?? undefined}
356-
today={todayLocalCalendarDate(pickerTimeZone)}
361+
today={pickerToday}
357362
onChange={(day) => onChange(valueFromParts(day, parts.time))}
358363
placeholder='Select date'
359364
className='flex-1'
360365
/>
361366
<ChipTimePicker
362367
value={parts.time?.slice(0, 5)}
363-
onChange={(time) =>
364-
onChange(valueFromParts(parts.day ?? todayLocalCalendarDate(pickerTimeZone), time))
365-
}
366-
placeholder={isUtc ? 'UTC time' : 'Add time'}
368+
onChange={(time) => onChange(valueFromParts(parts.day ?? pickerToday, time))}
369+
placeholder='Add time'
367370
className='w-[110px]'
368371
/>
369-
{isUtc && <span className='text-[var(--text-tertiary)] text-small'>UTC</span>}
372+
{offsetParts && (
373+
<span className='text-[var(--text-tertiary)] text-small'>{offsetParts.offset}</span>
374+
)}
370375
</div>
371376
</ChipModalField>
372377
)

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors.test.ts

Lines changed: 57 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,14 @@ import { cleanCellValue } from '@/app/workspace/[workspaceId]/tables/[tableId]/u
1414

1515
const { mockToastError, mockUseTimezoneState, mockCalendar } = vi.hoisted(() => ({
1616
mockToastError: vi.fn(),
17-
mockCalendar: vi.fn((_props: { onChange: (value: string) => void }) => null),
17+
mockCalendar: vi.fn(
18+
(_props: {
19+
onChange: (value: string) => void
20+
value?: string
21+
timeLabel?: string
22+
today?: string
23+
}) => null
24+
),
1825
mockUseTimezoneState: vi.fn(),
1926
}))
2027

@@ -61,14 +68,14 @@ describe('dateEditorRawValue', () => {
6168
expect(cleanCellValue(repeatedRaw, ttlColumn, timezone)).toBeNull()
6269

6370
const fractionalRaw = dateEditorRawValue('2023-11-14t22:13:20.001Z', ttlColumn, timezone)
64-
expect(cleanCellValue(fractionalRaw, ttlColumn, timezone)).toBe('2023-11-14T22:13:20.001Z')
71+
expect(cleanCellValue(fractionalRaw, ttlColumn, timezone)).toBe('2023-11-14T22:13:20.001-00:00')
6572
})
6673

6774
it.each([
68-
['2026-11-01T01:30', '2026-11-01T01:30:00Z'],
69-
['2026-03-08T02:30:45', '2026-03-08T02:30:45Z'],
70-
['2026-09-07', '2026-09-07T00:00:00Z'],
71-
])('saves literal UTC picker selection %s', (picked, expected) => {
75+
['2026-11-01T01:30', '2026-11-01T01:30:00-00:00'],
76+
['2026-03-08T02:30:45', '2026-03-08T02:30:45-00:00'],
77+
['2026-09-07', '2026-09-07T00:00:00-00:00'],
78+
])('saves new picker selections with a zero offset %s', (picked, expected) => {
7279
const container = document.createElement('div')
7380
document.body.appendChild(container)
7481
const root = createRoot(container)
@@ -96,13 +103,42 @@ describe('dateEditorRawValue', () => {
96103
container.remove()
97104
})
98105

106+
it.each(['-07:00', '-08:00', '+05:45', '-00:00', '+00:00'])(
107+
'retains %s when changing the date and time in the picker',
108+
(offset) => {
109+
const container = document.createElement('div')
110+
document.body.appendChild(container)
111+
const root = createRoot(container)
112+
const onSave = vi.fn()
113+
act(() =>
114+
root.render(
115+
createElement(InlineEditor, {
116+
column: column('ttl'),
117+
value: `2026-09-07T07:30:00.123456${offset}`,
118+
onSave,
119+
onCancel: vi.fn(),
120+
})
121+
)
122+
)
123+
const picker = mockCalendar.mock.calls.at(-1)![0]
124+
expect(picker.timeLabel).toBe(`Time (${offset})`)
125+
act(() => picker.onChange('2026-11-01T01:30:45'))
126+
const input = container.querySelector('input') as HTMLInputElement
127+
expect(input.value).toBe(`2026-11-01T01:30:45${offset}`)
128+
act(() => input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })))
129+
expect(onSave).toHaveBeenCalledWith(`2026-11-01T01:30:45${offset}`, 'enter')
130+
act(() => root.unmount())
131+
container.remove()
132+
}
133+
)
134+
99135
it('keeps ordinary date drafts on their existing display parser', () => {
100136
expect(dateEditorRawValue('11/01/2026 1:30:00 AM', column('date'), 'America/New_York')).toBe(
101137
'2026-11-01T01:30:00-04:00'
102138
)
103139
})
104140

105-
it('accepts a typed offset timestamp and saves the same instant in UTC', () => {
141+
it('preserves a typed offset timestamp and its microseconds', () => {
106142
const container = document.createElement('div')
107143
document.body.appendChild(container)
108144
const root = createRoot(container)
@@ -120,18 +156,18 @@ describe('dateEditorRawValue', () => {
120156
const input = container.querySelector('input') as HTMLInputElement
121157
act(() => changeInput(input, '2026-09-07T07:30:00.123456-07:00'))
122158
act(() => input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })))
123-
expect(onSave).toHaveBeenCalledWith('2026-09-07T14:30:00.123456Z', 'enter')
159+
expect(onSave).toHaveBeenCalledWith('2026-09-07T07:30:00.123456-07:00', 'enter')
124160
expect(mockToastError).not.toHaveBeenCalled()
125161
act(() => root.unmount())
126162
container.remove()
127163
})
128164

129-
it('keeps an open TTL edit in UTC when the timezone setting changes', () => {
165+
it('keeps an open TTL edit in its supplied offset when the timezone setting changes', () => {
130166
const container = document.createElement('div')
131167
document.body.appendChild(container)
132168
const root = createRoot(container)
133169
const onSave = vi.fn()
134-
const value = '2026-06-15T13:00:30Z'
170+
const value = '2026-06-15T06:00:30-07:00'
135171
const props = {
136172
value,
137173
column: column('ttl'),
@@ -148,18 +184,18 @@ describe('dateEditorRawValue', () => {
148184
act(() => root.render(createElement(InlineEditor, props)))
149185

150186
const input = container.querySelector('input') as HTMLInputElement
151-
expect(input?.value).toBe('2026-06-15T13:00:30Z')
187+
expect(input?.value).toBe('2026-06-15T06:00:30-07:00')
152188
act(() => changeInput(input, '2026-09-01T09:00:00Z'))
153189
act(() => {
154190
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
155191
})
156192

157-
expect(onSave).toHaveBeenCalledWith('2026-09-01T09:00:00Z', 'enter')
193+
expect(onSave).toHaveBeenCalledWith('2026-09-01T09:00:00-00:00', 'enter')
158194
act(() => root.unmount())
159195
container.remove()
160196
})
161197

162-
it('creates a UTC TTL draft while timezone settings are loading', () => {
198+
it('converts a legacy Z value to a zero-offset draft while timezone settings are loading', () => {
163199
mockUseTimezoneState.mockReturnValue({
164200
timezone: 'Asia/Tokyo',
165201
status: 'loading',
@@ -177,7 +213,7 @@ describe('dateEditorRawValue', () => {
177213

178214
act(() => root.render(createElement(InlineEditor, props)))
179215

180-
expect(container.querySelector('input')?.value).toBe('2026-06-15T13:00:30Z')
216+
expect(container.querySelector('input')?.value).toBe('2026-06-15T13:00:30-00:00')
181217
expect(container.querySelector('[role="status"]')).toBeNull()
182218

183219
mockUseTimezoneState.mockReturnValue({
@@ -191,7 +227,7 @@ describe('dateEditorRawValue', () => {
191227
act(() => changeInput(input, '2026-09-01T09:00:00Z'))
192228
act(() => input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })))
193229

194-
expect(onSave).toHaveBeenCalledWith('2026-09-01T09:00:00Z', 'enter')
230+
expect(onSave).toHaveBeenCalledWith('2026-09-01T09:00:00-00:00', 'enter')
195231
act(() => root.unmount())
196232
container.remove()
197233
})
@@ -263,19 +299,19 @@ describe('dateEditorRawValue', () => {
263299
{
264300
caseName: 'a historical sub-minute offset',
265301
timezone: 'Africa/Monrovia',
266-
value: '1970-01-01T00:44:30Z',
302+
value: '1970-01-01T00:44:30-00:00',
267303
},
268304
{
269305
caseName: 'microsecond precision',
270306
timezone: 'America/Los_Angeles',
271-
value: '2026-09-07T14:30:00.123456Z',
307+
value: '2026-09-07T07:30:00.123456-07:00',
272308
},
273309
{
274310
caseName: 'the far-future representable boundary',
275311
timezone: 'Asia/Tokyo',
276-
value: '9999-12-31T23:59:59Z',
312+
value: '9999-12-31T23:59:59+00:00',
277313
},
278-
])('preserves the exact UTC string for $caseName when untouched', ({ timezone, value }) => {
314+
])('preserves the exact offset string for $caseName when untouched', ({ timezone, value }) => {
279315
mockUseTimezoneState.mockReturnValue({ timezone, status: 'ready' })
280316
const container = document.createElement('div')
281317
document.body.appendChild(container)
@@ -314,15 +350,15 @@ describe('dateEditorRawValue', () => {
314350
act(() =>
315351
root.render(
316352
createElement(InlineEditor, {
317-
value: '1970-01-01T00:44:30Z',
353+
value: '1970-01-01T00:44:30-00:00',
318354
column: column('ttl'),
319355
onSave: vi.fn(),
320356
onCancel,
321357
})
322358
)
323359
)
324360

325-
expect(container.querySelector('input')?.value).toBe('1970-01-01T00:44:30Z')
361+
expect(container.querySelector('input')?.value).toBe('1970-01-01T00:44:30-00:00')
326362
expect(onCancel).not.toHaveBeenCalled()
327363
expect(mockToastError).not.toHaveBeenCalled()
328364
act(() => root.unmount())

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors.tsx

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { Check } from '@sim/emcn/icons'
1717
import type { ColumnDefinition } from '@/lib/table'
1818
import { columnTypeOf } from '@/lib/table/column-types'
1919
import { isCalendarDateString } from '@/lib/table/dates'
20-
import { ttlValueFromPicker, ttlValueToPickerParts } from '@/lib/table/ttl-values'
20+
import { todayAtTtlOffset, ttlValueFromPicker, ttlValueToPickerParts } from '@/lib/table/ttl-values'
2121
import { getTimezoneEditBlockedMessage } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/timezone-editing'
2222
import { useTimezoneState } from '@/hooks/queries/general-settings'
2323
import type { SaveReason } from '../../../types'
@@ -121,12 +121,12 @@ function ReadyInlineDateEditor({
121121
const editTimeZoneRef = useRef(initialTimeZone)
122122
const timeZone = editTimeZoneRef.current
123123

124-
const isUtc = columnTypeOf(column).editor === 'utc-date'
124+
const isOffsetDate = columnTypeOf(column).editor === 'offset-date'
125125
const storedValue = formatValueForInput(value, column.type)
126126
const initialDraft =
127127
initialCharacter !== undefined
128128
? initialCharacter
129-
: isUtc
129+
: isOffsetDate
130130
? storedValue
131131
: storageToDisplay(storedValue, { seconds: true })
132132
const [draft, setDraft] = useState(initialDraft)
@@ -136,10 +136,9 @@ function ReadyInlineDateEditor({
136136
const draftRef = useRef(draft)
137137
draftRef.current = draft
138138

139-
/** Expiration pickers use UTC; Date pickers preserve the stored wall time. */
140-
const draftParts = isUtc
141-
? ttlValueToPickerParts(draft)
142-
: dateValueToLocalParts(displayToStorage(draft, timeZone) ?? storedValue)
139+
const offsetParts = isOffsetDate ? ttlValueToPickerParts(draft) : null
140+
const draftParts =
141+
offsetParts ?? dateValueToLocalParts(displayToStorage(draft, timeZone) ?? storedValue)
143142
const pickerValue = draftParts.day
144143
? draftParts.time
145144
? `${draftParts.day}T${draftParts.time}`
@@ -239,12 +238,12 @@ function ReadyInlineDateEditor({
239238
const handlePickerChange = (picked: string) => {
240239
clearTimeout(blurTimeoutRef.current)
241240
if (isCalendarDateString(picked)) {
242-
doSave('enter', isUtc ? ttlValueFromPicker(picked, null) : picked)
241+
doSave('enter', offsetParts ? ttlValueFromPicker(picked, null, offsetParts.offset) : picked)
243242
return
244243
}
245-
if (isUtc) {
244+
if (offsetParts) {
246245
const [day, time] = picked.split('T')
247-
setDraft(ttlValueFromPicker(day, time ?? null))
246+
setDraft(ttlValueFromPicker(day, time ?? null, offsetParts.offset))
248247
setInvalid(false)
249248
inputRef.current?.focus()
250249
return
@@ -275,7 +274,7 @@ function ReadyInlineDateEditor({
275274
}}
276275
onKeyDown={handleKeyDown}
277276
onBlur={scheduleBlurSave}
278-
placeholder={isUtc ? 'YYYY-MM-DDTHH:mm:ss±HH:mm' : 'mm/dd/yyyy'}
277+
placeholder={isOffsetDate ? 'YYYY-MM-DDTHH:mm:ss±HH:mm' : 'mm/dd/yyyy'}
279278
className={cn(
280279
'w-full min-w-0 select-text border-none bg-transparent p-0 text-[var(--text-primary)] text-small outline-hidden',
281280
invalid && 'text-[var(--text-error)]'
@@ -295,8 +294,10 @@ function ReadyInlineDateEditor({
295294
value={pickerValue}
296295
onChange={handlePickerChange}
297296
showTime
298-
timeLabel={isUtc ? 'Time (UTC)' : undefined}
299-
today={todayLocalCalendarDate(timeZone)}
297+
timeLabel={offsetParts ? `Time (${offsetParts.offset})` : undefined}
298+
today={
299+
offsetParts ? todayAtTtlOffset(offsetParts.offset) : todayLocalCalendarDate(timeZone)
300+
}
300301
/>
301302
</PopoverContent>
302303
</Popover>
@@ -495,7 +496,7 @@ export function InlineEditor(props: InlineEditorProps) {
495496
switch (columnTypeOf(props.column).editor) {
496497
case 'date':
497498
return <InlineDateEditor {...props} />
498-
case 'utc-date':
499+
case 'offset-date':
499500
return <ReadyInlineDateEditor {...props} initialTimeZone='UTC' />
500501
case 'select':
501502
return <InlineSelectEditor {...props} />

0 commit comments

Comments
 (0)