Skip to content

Commit 992f362

Browse files
improvement(tables): render date cells wall-clock-faithful via offset-preserved storage
1 parent 10060c6 commit 992f362

10 files changed

Lines changed: 290 additions & 188 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,7 @@ function ColumnField({ column, value, onChange }: ColumnFieldProps) {
247247
}
248248

249249
if (column.type === 'date') {
250-
const parts = dateValueToLocalParts(formatValueForInput(value, 'date'), timeZone)
250+
const parts = dateValueToLocalParts(formatValueForInput(value, 'date'))
251251
return (
252252
<ChipModalField type='custom' title={title} required={column.required} hint={hint}>
253253
<div className='flex items-center gap-2'>

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

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import { Badge, Checkbox, cn, Tooltip } from '@sim/emcn'
66
import { parse } from 'tldts'
77
import type { RowExecutionMetadata } from '@/lib/table'
88
import { StatusBadge } from '@/app/workspace/[workspaceId]/logs/utils'
9-
import { useTimezone } from '@/hooks/queries/general-settings'
109
import { storageToDisplay } from '../../../utils'
1110
import type { DisplayColumn } from '../types'
1211
import { SimResourceCell, type SimResourceType } from './sim-resource-cell'
@@ -235,7 +234,6 @@ interface CellRenderProps {
235234
export function CellRender({ kind, isEditing }: CellRenderProps): React.ReactElement | null {
236235
const valueText = kind.kind === 'value' ? kind.text : null
237236
const revealedValueText = useTypewriter(valueText)
238-
const timeZone = useTimezone()
239237

240238
switch (kind.kind) {
241239
case 'value':
@@ -334,7 +332,7 @@ export function CellRender({ kind, isEditing }: CellRenderProps): React.ReactEle
334332
case 'date':
335333
return (
336334
<span className={cn('text-[var(--text-primary)]', isEditing && 'invisible')}>
337-
{storageToDisplay(kind.text, { seconds: true, timeZone })}
335+
{storageToDisplay(kind.text, { seconds: true })}
338336
</span>
339337
)
340338

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/expanded-cell-popover.tsx

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,6 @@ export function ExpandedCellPopover({
4646
const rootRef = useRef<HTMLDivElement>(null)
4747
const textareaRef = useRef<HTMLTextAreaElement>(null)
4848
const [rect, setRect] = useState<{ top: number; left: number; width: number } | null>(null)
49-
const timeZone = useTimezone()
5049

5150
const target = useMemo(() => {
5251
if (!expandedCell) return null
@@ -154,10 +153,7 @@ export function ExpandedCellPopover({
154153
key={`${expandedCell.rowId}:${expandedCell.columnKey ?? expandedCell.columnName}`}
155154
initialValue={
156155
target.column.type === 'date'
157-
? storageToDisplay(formatValueForInput(target.value, 'date'), {
158-
seconds: true,
159-
timeZone,
160-
})
156+
? storageToDisplay(formatValueForInput(target.value, 'date'), { seconds: true })
161157
: formatValueForInput(target.value, target.column.type)
162158
}
163159
column={target.column}
@@ -215,6 +211,12 @@ function ExpandedCellEditor({
215211
const timeZone = useTimezone()
216212

217213
const handleSave = () => {
214+
// Untouched draft → close without writing. For dates this also avoids
215+
// re-stamping the stored offset with this viewer's zone.
216+
if (draftValue === initialValue) {
217+
onClose()
218+
return
219+
}
218220
// Only date columns go through `displayToStorage` — it now parses many
219221
// date shapes, so a number draft like "2024" must not reach it.
220222
const raw =

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

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -56,23 +56,20 @@ function InlineDateEditor({
5656
const timeZone = useTimezone()
5757

5858
const storedValue = formatValueForInput(value, column.type)
59-
const [draft, setDraft] = useState(() =>
59+
const initialDraft =
6060
initialCharacter !== undefined
6161
? initialCharacter
62-
: storageToDisplay(storedValue, { seconds: true, timeZone })
63-
)
62+
: storageToDisplay(storedValue, { seconds: true })
63+
const [draft, setDraft] = useState(initialDraft)
6464
const [invalid, setInvalid] = useState(false)
6565
/** Picker commits mutate the draft from timeouts/child handlers; reading it
6666
* through a ref keeps the scheduled blur-save from saving a stale draft. */
6767
const draftRef = useRef(draft)
6868
draftRef.current = draft
6969

70-
/** The calendar is zone-agnostic (it works on wall times), so feed it the
71-
* wall representation of the draft in the viewer's effective zone. */
72-
const draftParts = dateValueToLocalParts(
73-
displayToStorage(draft, timeZone) ?? storedValue,
74-
timeZone
75-
)
70+
/** The calendar works on wall times; feed it the draft's literal wall
71+
* representation. */
72+
const draftParts = dateValueToLocalParts(displayToStorage(draft, timeZone) ?? storedValue)
7673
const pickerValue = draftParts.day
7774
? draftParts.time
7875
? `${draftParts.day}T${draftParts.time}`
@@ -98,6 +95,14 @@ function InlineDateEditor({
9895
if (doneRef.current) return
9996
clearTimeout(blurTimeoutRef.current)
10097
const current = draftRef.current
98+
// Untouched draft → re-save the stored value byte-identical. Re-parsing
99+
// the display form would re-stamp the offset with THIS viewer's zone,
100+
// silently shifting the instant of a value someone else wrote.
101+
if (storageVal === undefined && initialCharacter === undefined && current === initialDraft) {
102+
doneRef.current = true
103+
onSave(storedValue || null, reason)
104+
return
105+
}
101106
const raw = storageVal ?? displayToStorage(current, timeZone) ?? current
102107
if (raw && Number.isNaN(Date.parse(raw))) {
103108
if (reason === 'blur') {
@@ -114,7 +119,7 @@ function InlineDateEditor({
114119
doneRef.current = true
115120
onSave(raw || null, reason)
116121
},
117-
[invalid, onSave, onCancel, timeZone]
122+
[invalid, onSave, onCancel, timeZone, initialDraft, initialCharacter, storedValue]
118123
)
119124

120125
const handleKeyDown = useCallback(
@@ -169,7 +174,7 @@ function InlineDateEditor({
169174
}
170175
const canonical = displayToStorage(picked, timeZone)
171176
if (!canonical) return
172-
setDraft(storageToDisplay(canonical, { seconds: true, timeZone }))
177+
setDraft(storageToDisplay(canonical, { seconds: true }))
173178
setInvalid(false)
174179
inputRef.current?.focus()
175180
},

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1414,7 +1414,7 @@ export function TableGrid({
14141414
}
14151415
}
14161416
} else if (column.type === 'date') {
1417-
text = storageToDisplay(String(val), { seconds: true, timeZone: timeZoneRef.current })
1417+
text = storageToDisplay(String(val), { seconds: true })
14181418
} else {
14191419
text = String(val)
14201420
}

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.test.ts

Lines changed: 54 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -17,49 +17,37 @@ describe('dateValueToLocalParts / localPartsToDateValue', () => {
1717
expect(localPartsToDateValue('2026-07-06', null)).toBe('2026-07-06')
1818
})
1919

20-
it('splits instants into local day/time and round-trips exactly', () => {
21-
const stored = new Date(2026, 6, 6, 16, 4, 55).toISOString()
22-
const parts = dateValueToLocalParts(stored)
23-
expect(parts).toEqual({ day: '2026-07-06', time: '16:04:55' })
24-
expect(localPartsToDateValue(parts.day as string, parts.time)).toBe(stored)
25-
})
26-
27-
it('keeps the time when only the day changes', () => {
28-
const stored = new Date(2026, 6, 6, 16, 4, 55).toISOString()
29-
const parts = dateValueToLocalParts(stored)
30-
expect(localPartsToDateValue('2026-07-09', parts.time)).toBe(
31-
new Date(2026, 6, 9, 16, 4, 55).toISOString()
20+
it('splits instants into their literal wall day/time — no zone conversion', () => {
21+
expect(dateValueToLocalParts('2026-07-06T16:04:55-07:00')).toEqual({
22+
day: '2026-07-06',
23+
time: '16:04:55',
24+
})
25+
expect(dateValueToLocalParts('2026-07-06T23:04:55Z')).toEqual({
26+
day: '2026-07-06',
27+
time: '23:04:55',
28+
})
29+
expect(dateValueToLocalParts('2026-07-06T23:04:55.000Z')).toEqual({
30+
day: '2026-07-06',
31+
time: '23:04:55',
32+
})
33+
})
34+
35+
it('recombines parts stamping the given zone offset, keeping the wall time', () => {
36+
expect(localPartsToDateValue('2026-07-06', '16:04:55', 'America/New_York')).toBe(
37+
'2026-07-06T16:04:55-04:00'
3238
)
33-
})
34-
35-
it('accepts HH:mm times, defaulting seconds to zero', () => {
36-
expect(localPartsToDateValue('2026-07-06', '16:04')).toBe(
37-
new Date(2026, 6, 6, 16, 4, 0).toISOString()
39+
expect(localPartsToDateValue('2026-07-09', '16:04:55', 'America/New_York')).toBe(
40+
'2026-07-09T16:04:55-04:00'
41+
)
42+
expect(localPartsToDateValue('2026-07-06', '16:04', 'America/New_York')).toBe(
43+
'2026-07-06T16:04:00-04:00'
3844
)
3945
})
4046

4147
it('returns null parts for unparseable values', () => {
4248
expect(dateValueToLocalParts('garbage')).toEqual({ day: null, time: null })
4349
expect(dateValueToLocalParts('')).toEqual({ day: null, time: null })
4450
})
45-
46-
it('reads and recombines parts in an explicit IANA zone', () => {
47-
const stored = '2026-07-06T20:04:55.000Z'
48-
const parts = dateValueToLocalParts(stored, 'America/New_York')
49-
expect(parts).toEqual({ day: '2026-07-06', time: '16:04:55' })
50-
expect(localPartsToDateValue(parts.day as string, parts.time, 'America/New_York')).toBe(stored)
51-
})
52-
})
53-
54-
describe('timezone-aware display round-trip', () => {
55-
it('parses and renders wall times in the effective zone, not the runtime zone', () => {
56-
const zone = 'America/New_York'
57-
const stored = displayToStorage('07/06/2026 4:04:55 PM', zone)
58-
expect(stored).toBe('2026-07-06T20:04:55.000Z')
59-
expect(storageToDisplay(stored as string, { seconds: true, timeZone: zone })).toBe(
60-
'07/06/2026 4:04:55 PM'
61-
)
62-
})
6351
})
6452

6553
describe('displayToStorage', () => {
@@ -70,22 +58,25 @@ describe('displayToStorage', () => {
7058
expect(displayToStorage('7/6')).toBe(`${new Date().getFullYear()}-07-06`)
7159
})
7260

73-
it('parses M/D/YYYY with a time to a local-zone UTC instant', () => {
74-
expect(displayToStorage('07/06/2026 4:04 PM')).toBe(
75-
new Date(2026, 6, 6, 16, 4, 0).toISOString()
61+
it('parses M/D/YYYY with a time to a wall time stamped with the given zone', () => {
62+
expect(displayToStorage('07/06/2026 4:04 PM', 'America/New_York')).toBe(
63+
'2026-07-06T16:04:00-04:00'
64+
)
65+
expect(displayToStorage('07/06/2026 4:04:55 PM', 'America/New_York')).toBe(
66+
'2026-07-06T16:04:55-04:00'
7667
)
77-
expect(displayToStorage('07/06/2026 4:04:55 PM')).toBe(
78-
new Date(2026, 6, 6, 16, 4, 55).toISOString()
68+
expect(displayToStorage('07/06/2026 16:04', 'America/New_York')).toBe(
69+
'2026-07-06T16:04:00-04:00'
7970
)
80-
expect(displayToStorage('07/06/2026 16:04')).toBe(new Date(2026, 6, 6, 16, 4, 0).toISOString())
81-
expect(displayToStorage('07/06/2026 12:00 AM')).toBe(
82-
new Date(2026, 6, 6, 0, 0, 0).toISOString()
71+
expect(displayToStorage('07/06/2026 12:00 AM', 'America/New_York')).toBe(
72+
'2026-07-06T00:00:00-04:00'
8373
)
8474
})
8575

86-
it('passes canonical instants and offset strings through Date.parse exactly', () => {
87-
expect(displayToStorage('2026-07-06T23:04:55.000Z')).toBe('2026-07-06T23:04:55.000Z')
88-
expect(displayToStorage('2026-07-06 16:04:55 PDT')).toBe('2026-07-06T23:04:55.000Z')
76+
it('preserves the wall time and offset of canonical and offset strings', () => {
77+
expect(displayToStorage('2026-07-06T16:04:55-07:00')).toBe('2026-07-06T16:04:55-07:00')
78+
expect(displayToStorage('2026-07-06T23:04:55.000Z')).toBe('2026-07-06T23:04:55Z')
79+
expect(displayToStorage('2026-07-06 16:04:55 PDT')).toBe('2026-07-06T16:04:55-07:00')
8980
})
9081

9182
it('rejects invalid dates and times', () => {
@@ -102,18 +93,28 @@ describe('storageToDisplay', () => {
10293
expect(storageToDisplay('2026-07-06')).toBe('07/06/2026')
10394
})
10495

105-
it('round-trips an instant through the editor draft format', () => {
106-
const stored = new Date(2026, 6, 6, 16, 4, 55).toISOString()
96+
it('renders the literal wall time identically regardless of viewer or offset', () => {
97+
expect(storageToDisplay('2026-07-06T16:04:55-07:00', { seconds: true })).toBe(
98+
'07/06/2026 4:04:55 PM'
99+
)
100+
expect(storageToDisplay('2026-07-06T16:04:55+09:00', { seconds: true })).toBe(
101+
'07/06/2026 4:04:55 PM'
102+
)
103+
})
104+
105+
it('round-trips an instant through the editor draft format without shifting', () => {
106+
const stored = displayToStorage('07/06/2026 4:04:55 PM', 'America/New_York') as string
107107
const draft = storageToDisplay(stored, { seconds: true })
108-
expect(displayToStorage(draft)).toBe(stored)
108+
expect(draft).toBe('07/06/2026 4:04:55 PM')
109+
expect(displayToStorage(draft, 'America/New_York')).toBe(stored)
109110
})
110111
})
111112

112113
describe('cleanCellValue', () => {
113114
it('normalizes date cells to canonical storage', () => {
114115
const column = { name: 'due', type: 'date' } as const
115116
expect(cleanCellValue('07/06/2026', column)).toBe('2026-07-06')
116-
expect(cleanCellValue('2026-07-06T23:04:55.000Z', column)).toBe('2026-07-06T23:04:55.000Z')
117+
expect(cleanCellValue('2026-07-06T16:04:55-07:00', column)).toBe('2026-07-06T16:04:55-07:00')
117118
expect(cleanCellValue('nope', column)).toBeNull()
118119
expect(cleanCellValue('', column)).toBeNull()
119120
})
@@ -127,7 +128,9 @@ describe('cleanCellValue', () => {
127128
describe('formatValueForInput', () => {
128129
it('gives editors the canonical value, surfacing legacy UTC midnights as calendar days', () => {
129130
expect(formatValueForInput('2026-07-06T00:00:00.000Z', 'date')).toBe('2026-07-06')
130-
expect(formatValueForInput('2026-07-06T23:04:55.000Z', 'date')).toBe('2026-07-06T23:04:55.000Z')
131+
expect(formatValueForInput('2026-07-06T16:04:55-07:00', 'date')).toBe(
132+
'2026-07-06T16:04:55-07:00'
133+
)
131134
expect(formatValueForInput('2026-07-06', 'date')).toBe('2026-07-06')
132135
})
133136
})

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.ts

Lines changed: 24 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -93,35 +93,35 @@ export function formatValueForInput(value: unknown, type: string): string {
9393
return String(value)
9494
}
9595

96-
/** A canonical date-cell value split into viewer-local editing parts. */
96+
/** A canonical date-cell value split into its wall-clock editing parts. */
9797
export interface DateCellLocalParts {
98-
/** Local calendar day `YYYY-MM-DD`, or null when the value is unparseable. */
98+
/** Calendar day `YYYY-MM-DD`, or null when the value is unparseable. */
9999
day: string | null
100-
/** Local time-of-day `HH:mm:ss`, or null for calendar-date values. */
100+
/** Time-of-day `HH:mm:ss`, or null for calendar-date values. */
101101
time: string | null
102102
}
103103

104104
/**
105105
* Splits a canonical date-cell value into the day and time the date/time
106-
* pickers edit, read in the given IANA zone (the viewer's effective
107-
* timezone; runtime-local when omitted). Calendar dates have no time part.
106+
* pickers edit — the value's **literal wall time**, no timezone conversion
107+
* (display and editing are wall-clock-faithful for every viewer). Calendar
108+
* dates have no time part; legacy strings normalize first.
108109
*/
109-
export function dateValueToLocalParts(value: string, timeZone?: string): DateCellLocalParts {
110+
export function dateValueToLocalParts(value: string): DateCellLocalParts {
110111
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return { day: value, time: null }
111-
const ms = Date.parse(value)
112-
if (Number.isNaN(ms)) return { day: null, time: null }
113-
const wall = getWallClockParts(new Date(ms), timeZone)
114-
const pad = (n: number) => String(n).padStart(2, '0')
115-
return {
116-
day: `${wall.year}-${pad(wall.month)}-${pad(wall.day)}`,
117-
time: `${pad(wall.hour)}:${pad(wall.minute)}:${pad(wall.second)}`,
118-
}
112+
const wall = value.match(
113+
/^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})(?::(\d{2}))?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?$/
114+
)
115+
if (wall) return { day: wall[1], time: `${wall[2]}:${wall[3] ?? '00'}` }
116+
const canonical = normalizeDateCellValue(value)
117+
if (!canonical || canonical === value) return { day: null, time: null }
118+
return dateValueToLocalParts(canonical)
119119
}
120120

121121
/**
122122
* Recombines picker-edited parts into a canonical date-cell value: a calendar
123-
* date when there is no time, else the UTC instant of that wall time in the
124-
* given zone (runtime-local when omitted).
123+
* date when there is no time, else that wall time stamped with the given
124+
* zone's offset (runtime-local when omitted).
125125
*/
126126
export function localPartsToDateValue(day: string, time: string | null, timeZone?: string): string {
127127
if (!time) return day
@@ -137,23 +137,20 @@ export function todayLocalCalendarDate(timeZone?: string): string {
137137

138138
/**
139139
* Format a stored date-cell value for display: calendar dates as MM/DD/YYYY,
140-
* instants in the viewer's effective timezone as MM/DD/YYYY h:mm AM/PM. Pass
141-
* `seconds: true` for editor drafts so re-saving an untouched cell keeps
142-
* second precision.
140+
* instants as their literal wall time `MM/DD/YYYY h:mm AM/PM` — identical
141+
* for every viewer. Pass `seconds: true` for editor drafts so re-saving an
142+
* untouched cell keeps second precision.
143143
*/
144-
export function storageToDisplay(
145-
stored: string,
146-
options?: { seconds?: boolean; timeZone?: string }
147-
): string {
144+
export function storageToDisplay(stored: string, options?: { seconds?: boolean }): string {
148145
return formatDateCellDisplay(stored, options)
149146
}
150147

151148
/**
152149
* Parse a date-cell input string to its canonical storage form: `YYYY-MM-DD`
153-
* for date-only inputs (MM/DD/YYYY, MM/DD, ISO), a UTC ISO instant for inputs
154-
* carrying a time. Naive times are interpreted in `timeZone` (the viewer's
155-
* effective timezone; the runtime's zone when omitted). Returns null when
156-
* unparseable.
150+
* for date-only inputs (MM/DD/YYYY, MM/DD, ISO), an offset-preserved instant
151+
* for inputs carrying a time. Naive times are stamped with the offset of
152+
* `timeZone` (the writer's effective timezone; the runtime's zone when
153+
* omitted). Returns null when unparseable.
157154
*/
158155
export function displayToStorage(display: string, timeZone?: string): string | null {
159156
const trimmed = display.trim()

0 commit comments

Comments
 (0)