Skip to content

Commit 619e912

Browse files
fix(tables): canonicalize date cells, render times in effective timezone (#5465)
* fix(tables): canonicalize date cells, render times in effective timezone * improvement(tables): render date cells wall-clock-faithful via offset-preserved storage * fix(tables): address review — preserve midnight instants, validate calendar days, effective-zone Today
1 parent 60e3509 commit 619e912

24 files changed

Lines changed: 1116 additions & 97 deletions

File tree

apps/sim/app/api/table/[tableId]/import-async/route.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { generateRequestId } from '@/lib/core/utils/request'
1010
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1111
import { runTableImport, type TableImportPayload } from '@/lib/table/import-runner'
1212
import { markTableJobRunning, releaseJobClaim } from '@/lib/table/jobs/service'
13+
import { getUserSettings } from '@/lib/users/queries'
1314
import { accessError, checkAccess } from '@/app/api/table/utils'
1415

1516
const logger = createLogger('TableImportIntoAsync')
@@ -33,7 +34,8 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro
3334
const parsed = await parseRequest(importIntoTableAsyncContract, request, { params })
3435
if (!parsed.success) return parsed.response
3536
const { tableId } = parsed.data.params
36-
const { workspaceId, fileKey, fileName, mode, mapping, createColumns } = parsed.data.body
37+
const { workspaceId, fileKey, fileName, mode, mapping, createColumns, timezone } =
38+
parsed.data.body
3739

3840
const access = await checkAccess(tableId, userId, 'write')
3941
if (!access.ok) return accessError(access, requestId, tableId)
@@ -79,6 +81,7 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro
7981
mode,
8082
mapping,
8183
createColumns,
84+
timezone: timezone ?? (await getUserSettings(userId)).timezone ?? 'UTC',
8285
}
8386
if (isTriggerDevEnabled) {
8487
// Trigger.dev runs the import outside the web container, so it survives app deploys.

apps/sim/app/api/table/[tableId]/import/route.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
csvImportModeSchema,
1212
tableIdParamsSchema,
1313
} from '@/lib/api/contracts/tables'
14+
import { ianaTimezoneSchema } from '@/lib/api/contracts/user'
1415
import { getValidationErrorMessage } from '@/lib/api/server'
1516
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
1617
import { isMultipartError, readMultipart } from '@/lib/core/utils/multipart'
@@ -36,6 +37,7 @@ import {
3637
wouldExceedRowLimit,
3738
} from '@/lib/table'
3839
import { importAppendRows, importReplaceRows } from '@/lib/table/import-data'
40+
import { getUserSettings } from '@/lib/users/queries'
3941
import {
4042
accessError,
4143
checkAccess,
@@ -162,6 +164,18 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro
162164
createColumns = createColumnsValidation.data
163165
}
164166

167+
let timezone = (await getUserSettings(authResult.userId)).timezone ?? 'UTC'
168+
if (fields.timezone) {
169+
const timezoneValidation = ianaTimezoneSchema.safeParse(fields.timezone)
170+
if (!timezoneValidation.success) {
171+
return NextResponse.json(
172+
{ error: getValidationErrorMessage(timezoneValidation.error) },
173+
{ status: 400 }
174+
)
175+
}
176+
timezone = timezoneValidation.data
177+
}
178+
165179
const delimiter = extensionValidation.data === 'tsv' ? '\t' : ','
166180
const parser = createCsvParser(delimiter)
167181
// `.pipe` doesn't forward source errors; forward them so the iterator throws.
@@ -250,7 +264,9 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro
250264
)
251265
}
252266

253-
const coerced = coerceRowsForTable(rows, prospectiveTable.schema, validation.effectiveMap)
267+
const coerced = coerceRowsForTable(rows, prospectiveTable.schema, validation.effectiveMap, {
268+
timezone,
269+
})
254270

255271
// Atomically claim the table before writing. The pre-check above reads a checkAccess snapshot
256272
// taken before the parse/validation; a background import could claim the table in that window.

apps/sim/app/api/table/import-async/route.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
TableConflictError,
2121
} from '@/lib/table'
2222
import { runTableImport, type TableImportPayload } from '@/lib/table/import-runner'
23+
import { getUserSettings } from '@/lib/users/queries'
2324
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
2425

2526
const logger = createLogger('TableImportAsync')
@@ -38,7 +39,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
3839

3940
const parsed = await parseRequest(importTableAsyncContract, request, {})
4041
if (!parsed.success) return parsed.response
41-
const { workspaceId, fileKey, fileName, deleteSourceFile } = parsed.data.body
42+
const { workspaceId, fileKey, fileName, deleteSourceFile, timezone } = parsed.data.body
4243

4344
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
4445
if (permission !== 'write' && permission !== 'admin') {
@@ -111,6 +112,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
111112
delimiter,
112113
mode: 'create',
113114
deleteSourceFile,
115+
timezone: timezone ?? (await getUserSettings(userId)).timezone ?? 'UTC',
114116
}
115117
if (isTriggerDevEnabled) {
116118
// Trigger.dev runs the import outside the web container, so it survives app deploys.

apps/sim/app/api/table/import-csv/route.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { toError } from '@sim/utils/errors'
44
import { generateId } from '@sim/utils/id'
55
import { type NextRequest, NextResponse } from 'next/server'
66
import { csvExtensionSchema, csvImportFormSchema } from '@/lib/api/contracts/tables'
7+
import { ianaTimezoneSchema } from '@/lib/api/contracts/user'
78
import { getValidationErrorMessage } from '@/lib/api/server'
89
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
910
import { isMultipartError, readMultipart } from '@/lib/core/utils/multipart'
@@ -25,6 +26,7 @@ import {
2526
type TableDefinition,
2627
type TableSchema,
2728
} from '@/lib/table'
29+
import { getUserSettings } from '@/lib/users/queries'
2830
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
2931
import {
3032
csvProxyBodyCapResponse,
@@ -85,6 +87,18 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
8587
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
8688
}
8789

90+
let timezone = (await getUserSettings(userId)).timezone ?? 'UTC'
91+
if (fields.timezone) {
92+
const timezoneResult = ianaTimezoneSchema.safeParse(fields.timezone)
93+
if (!timezoneResult.success) {
94+
return NextResponse.json(
95+
{ error: getValidationErrorMessage(timezoneResult.error) },
96+
{ status: 400 }
97+
)
98+
}
99+
timezone = timezoneResult.data
100+
}
101+
88102
const ext = file.filename.split('.').pop()?.toLowerCase()
89103
const extensionResult = csvExtensionSchema.safeParse(ext)
90104
if (!extensionResult.success) {
@@ -112,7 +126,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
112126
currentRowCount: number
113127
) => {
114128
if (rows.length === 0) return 0
115-
const coerced = coerceRowsForTable(rows, state.schema, state.headerToColumn)
129+
const coerced = coerceRowsForTable(rows, state.schema, state.headerToColumn, { timezone })
116130
const result = await batchInsertRows(
117131
{ tableId: state.table.id, rows: coerced, workspaceId, userId },
118132
// The created table's rowCount is frozen at 0; pass the running total so the

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

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,22 @@ import {
1111
ChipModalField,
1212
ChipModalFooter,
1313
ChipModalHeader,
14+
ChipTimePicker,
1415
Label,
1516
} from '@sim/emcn'
1617
import { createLogger } from '@sim/logger'
1718
import { getErrorMessage } from '@sim/utils/errors'
1819
import { useParams } from 'next/navigation'
1920
import type { ColumnDefinition, TableInfo, TableRow } from '@/lib/table'
21+
import { useTimezone } from '@/hooks/queries/general-settings'
2022
import { useDeleteTableRow, useDeleteTableRows, useUpdateTableRow } from '@/hooks/queries/tables'
21-
import { cleanCellValue, formatValueForInput } from '../../utils'
23+
import {
24+
cleanCellValue,
25+
dateValueToLocalParts,
26+
formatValueForInput,
27+
localPartsToDateValue,
28+
todayLocalCalendarDate,
29+
} from '../../utils'
2230

2331
const logger = createLogger('RowModal')
2432

@@ -34,14 +42,15 @@ export interface RowModalProps {
3442

3543
function cleanRowData(
3644
columns: ColumnDefinition[],
37-
rowData: Record<string, unknown>
45+
rowData: Record<string, unknown>,
46+
timeZone: string
3847
): Record<string, unknown> {
3948
const cleanData: Record<string, unknown> = {}
4049

4150
columns.forEach((col) => {
4251
const value = rowData[col.name]
4352
try {
44-
cleanData[col.name] = cleanCellValue(value, col)
53+
cleanData[col.name] = cleanCellValue(value, col, timeZone)
4554
} catch {
4655
throw new Error(`Invalid JSON for field: ${col.name}`)
4756
}
@@ -66,6 +75,7 @@ export function RowModal({ mode, isOpen, onClose, table, row, rowIds, onSuccess
6675
const schema = table?.schema
6776
const columns = schema?.columns || []
6877

78+
const timeZone = useTimezone()
6979
const [rowData, setRowData] = useState<Record<string, unknown>>(() =>
7080
mode === 'edit' && row ? row.data : {}
7181
)
@@ -81,7 +91,7 @@ export function RowModal({ mode, isOpen, onClose, table, row, rowIds, onSuccess
8191
setError(null)
8292

8393
try {
84-
const cleanData = cleanRowData(columns, rowData)
94+
const cleanData = cleanRowData(columns, rowData, timeZone)
8595

8696
if (row) {
8797
await updateRowMutation.mutateAsync({ rowId: row.id, data: cleanData })
@@ -189,6 +199,7 @@ interface ColumnFieldProps {
189199

190200
function ColumnField({ column, value, onChange }: ColumnFieldProps) {
191201
const checkboxId = useId()
202+
const timeZone = useTimezone()
192203
const title = (
193204
<>
194205
{column.name}
@@ -236,14 +247,30 @@ function ColumnField({ column, value, onChange }: ColumnFieldProps) {
236247
}
237248

238249
if (column.type === 'date') {
250+
const parts = dateValueToLocalParts(formatValueForInput(value, 'date'))
239251
return (
240252
<ChipModalField type='custom' title={title} required={column.required} hint={hint}>
241-
<ChipDatePicker
242-
value={formatValueForInput(value, column.type) || undefined}
243-
onChange={onChange}
244-
placeholder='Select date'
245-
fullWidth
246-
/>
253+
<div className='flex items-center gap-2'>
254+
<ChipDatePicker
255+
value={parts.day ?? undefined}
256+
today={todayLocalCalendarDate(timeZone)}
257+
onChange={(day) => onChange(localPartsToDateValue(day, parts.time, timeZone))}
258+
placeholder='Select date'
259+
flush
260+
className='flex-1'
261+
/>
262+
<ChipTimePicker
263+
value={parts.time?.slice(0, 5)}
264+
onChange={(time) =>
265+
onChange(
266+
localPartsToDateValue(parts.day ?? todayLocalCalendarDate(timeZone), time, timeZone)
267+
)
268+
}
269+
placeholder='Add time'
270+
flush
271+
className='w-[110px]'
272+
/>
273+
</div>
247274
</ChipModalField>
248275
)
249276
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -354,7 +354,7 @@ export function CellRender({ kind, isEditing }: CellRenderProps): React.ReactEle
354354
case 'date':
355355
return (
356356
<span className={cn('text-[var(--text-primary)]', isEditing && 'invisible')}>
357-
{storageToDisplay(kind.text)}
357+
{storageToDisplay(kind.text, { seconds: true })}
358358
</span>
359359
)
360360

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

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,14 @@ import type React from 'react'
44
import { useEffect, useEffectEvent, useLayoutEffect, useMemo, useRef, useState } from 'react'
55
import { Button } from '@sim/emcn'
66
import type { TableRow as TableRowType } from '@/lib/table'
7+
import { useTimezone } from '@/hooks/queries/general-settings'
78
import type { EditingCell, SaveReason } from '../../../types'
8-
import { cleanCellValue, displayToStorage, formatValueForInput } from '../../../utils'
9+
import {
10+
cleanCellValue,
11+
displayToStorage,
12+
formatValueForInput,
13+
storageToDisplay,
14+
} from '../../../utils'
915
import type { DisplayColumn } from '../types'
1016

1117
interface ExpandedCellPopoverProps {
@@ -145,7 +151,11 @@ export function ExpandedCellPopover({
145151
{isEditable ? (
146152
<ExpandedCellEditor
147153
key={`${expandedCell.rowId}:${expandedCell.columnKey ?? expandedCell.columnName}`}
148-
initialValue={formatValueForInput(target.value, target.column.type)}
154+
initialValue={
155+
target.column.type === 'date'
156+
? storageToDisplay(formatValueForInput(target.value, 'date'), { seconds: true })
157+
: formatValueForInput(target.value, target.column.type)
158+
}
149159
column={target.column}
150160
rowId={target.row.id}
151161
onSave={onSave}
@@ -198,14 +208,22 @@ function ExpandedCellEditor({
198208
}: ExpandedCellEditorProps) {
199209
const [draftValue, setDraftValue] = useState(initialValue)
200210
const [parseError, setParseError] = useState<string | null>(null)
211+
const timeZone = useTimezone()
201212

202213
const handleSave = () => {
203-
// `displayToStorage` only normalizes dates — it returns null for anything else.
204-
// Fall back to the raw draft for non-date columns, matching the inline editor.
205-
const raw = displayToStorage(draftValue) ?? draftValue
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+
}
220+
// Only date columns go through `displayToStorage` — it now parses many
221+
// date shapes, so a number draft like "2024" must not reach it.
222+
const raw =
223+
column.type === 'date' ? (displayToStorage(draftValue, timeZone) ?? draftValue) : draftValue
206224
let cleaned: unknown
207225
try {
208-
cleaned = cleanCellValue(raw, column)
226+
cleaned = cleanCellValue(raw, column, timeZone)
209227
} catch {
210228
setParseError('Invalid JSON')
211229
return

0 commit comments

Comments
 (0)