From 10060c676cc899bf25ed8c76919971bacbbe90bc Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 6 Jul 2026 20:11:35 -0700 Subject: [PATCH] fix(tables): canonicalize date cells, render times in effective timezone --- .../api/table/[tableId]/import-async/route.ts | 5 +- .../app/api/table/[tableId]/import/route.ts | 18 +- apps/sim/app/api/table/import-async/route.ts | 4 +- apps/sim/app/api/table/import-csv/route.ts | 16 +- .../components/row-modal/row-modal.tsx | 46 +++- .../table-grid/cells/cell-render.tsx | 4 +- .../cells/expanded-cell-popover.tsx | 28 ++- .../table-grid/cells/inline-editors.tsx | 94 ++++++-- .../components/table-grid/table-grid.tsx | 10 +- .../tables/[tableId]/utils.test.ts | 133 +++++++++++ .../[workspaceId]/tables/[tableId]/utils.ts | 129 +++++++--- apps/sim/hooks/queries/general-settings.ts | 2 +- apps/sim/hooks/queries/tables.ts | 14 +- apps/sim/lib/api/contracts/tables.ts | 13 ++ apps/sim/lib/table/dates.test.ts | 158 +++++++++++++ apps/sim/lib/table/dates.ts | 221 ++++++++++++++++++ apps/sim/lib/table/import-runner.ts | 10 +- apps/sim/lib/table/import.test.ts | 5 +- apps/sim/lib/table/import.ts | 12 +- apps/sim/lib/table/index.ts | 1 + apps/sim/lib/table/validation.ts | 6 +- .../emcn/src/components/calendar/calendar.tsx | 88 ++++++- 22 files changed, 930 insertions(+), 87 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.test.ts create mode 100644 apps/sim/lib/table/dates.test.ts create mode 100644 apps/sim/lib/table/dates.ts diff --git a/apps/sim/app/api/table/[tableId]/import-async/route.ts b/apps/sim/app/api/table/[tableId]/import-async/route.ts index 22f51bfaa93..b440dfaf680 100644 --- a/apps/sim/app/api/table/[tableId]/import-async/route.ts +++ b/apps/sim/app/api/table/[tableId]/import-async/route.ts @@ -10,6 +10,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { runTableImport, type TableImportPayload } from '@/lib/table/import-runner' import { markTableJobRunning, releaseJobClaim } from '@/lib/table/jobs/service' +import { getUserSettings } from '@/lib/users/queries' import { accessError, checkAccess } from '@/app/api/table/utils' const logger = createLogger('TableImportIntoAsync') @@ -33,7 +34,8 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro const parsed = await parseRequest(importIntoTableAsyncContract, request, { params }) if (!parsed.success) return parsed.response const { tableId } = parsed.data.params - const { workspaceId, fileKey, fileName, mode, mapping, createColumns } = parsed.data.body + const { workspaceId, fileKey, fileName, mode, mapping, createColumns, timezone } = + parsed.data.body const access = await checkAccess(tableId, userId, 'write') if (!access.ok) return accessError(access, requestId, tableId) @@ -79,6 +81,7 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro mode, mapping, createColumns, + timezone: timezone ?? (await getUserSettings(userId)).timezone ?? 'UTC', } if (isTriggerDevEnabled) { // Trigger.dev runs the import outside the web container, so it survives app deploys. diff --git a/apps/sim/app/api/table/[tableId]/import/route.ts b/apps/sim/app/api/table/[tableId]/import/route.ts index 79bb7238ab9..63ed87220fb 100644 --- a/apps/sim/app/api/table/[tableId]/import/route.ts +++ b/apps/sim/app/api/table/[tableId]/import/route.ts @@ -11,6 +11,7 @@ import { csvImportModeSchema, tableIdParamsSchema, } from '@/lib/api/contracts/tables' +import { ianaTimezoneSchema } from '@/lib/api/contracts/user' import { getValidationErrorMessage } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { isMultipartError, readMultipart } from '@/lib/core/utils/multipart' @@ -36,6 +37,7 @@ import { wouldExceedRowLimit, } from '@/lib/table' import { importAppendRows, importReplaceRows } from '@/lib/table/import-data' +import { getUserSettings } from '@/lib/users/queries' import { accessError, checkAccess, @@ -162,6 +164,18 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro createColumns = createColumnsValidation.data } + let timezone = (await getUserSettings(authResult.userId)).timezone ?? 'UTC' + if (fields.timezone) { + const timezoneValidation = ianaTimezoneSchema.safeParse(fields.timezone) + if (!timezoneValidation.success) { + return NextResponse.json( + { error: getValidationErrorMessage(timezoneValidation.error) }, + { status: 400 } + ) + } + timezone = timezoneValidation.data + } + const delimiter = extensionValidation.data === 'tsv' ? '\t' : ',' const parser = createCsvParser(delimiter) // `.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 ) } - const coerced = coerceRowsForTable(rows, prospectiveTable.schema, validation.effectiveMap) + const coerced = coerceRowsForTable(rows, prospectiveTable.schema, validation.effectiveMap, { + timezone, + }) // Atomically claim the table before writing. The pre-check above reads a checkAccess snapshot // taken before the parse/validation; a background import could claim the table in that window. diff --git a/apps/sim/app/api/table/import-async/route.ts b/apps/sim/app/api/table/import-async/route.ts index bb0d83d168a..500429075df 100644 --- a/apps/sim/app/api/table/import-async/route.ts +++ b/apps/sim/app/api/table/import-async/route.ts @@ -20,6 +20,7 @@ import { TableConflictError, } from '@/lib/table' import { runTableImport, type TableImportPayload } from '@/lib/table/import-runner' +import { getUserSettings } from '@/lib/users/queries' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('TableImportAsync') @@ -38,7 +39,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const parsed = await parseRequest(importTableAsyncContract, request, {}) if (!parsed.success) return parsed.response - const { workspaceId, fileKey, fileName, deleteSourceFile } = parsed.data.body + const { workspaceId, fileKey, fileName, deleteSourceFile, timezone } = parsed.data.body const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) if (permission !== 'write' && permission !== 'admin') { @@ -111,6 +112,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { delimiter, mode: 'create', deleteSourceFile, + timezone: timezone ?? (await getUserSettings(userId)).timezone ?? 'UTC', } if (isTriggerDevEnabled) { // Trigger.dev runs the import outside the web container, so it survives app deploys. diff --git a/apps/sim/app/api/table/import-csv/route.ts b/apps/sim/app/api/table/import-csv/route.ts index 91ee680706d..fb29cffab8f 100644 --- a/apps/sim/app/api/table/import-csv/route.ts +++ b/apps/sim/app/api/table/import-csv/route.ts @@ -4,6 +4,7 @@ import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' import { csvExtensionSchema, csvImportFormSchema } from '@/lib/api/contracts/tables' +import { ianaTimezoneSchema } from '@/lib/api/contracts/user' import { getValidationErrorMessage } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { isMultipartError, readMultipart } from '@/lib/core/utils/multipart' @@ -25,6 +26,7 @@ import { type TableDefinition, type TableSchema, } from '@/lib/table' +import { getUserSettings } from '@/lib/users/queries' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { csvProxyBodyCapResponse, @@ -85,6 +87,18 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } + let timezone = (await getUserSettings(userId)).timezone ?? 'UTC' + if (fields.timezone) { + const timezoneResult = ianaTimezoneSchema.safeParse(fields.timezone) + if (!timezoneResult.success) { + return NextResponse.json( + { error: getValidationErrorMessage(timezoneResult.error) }, + { status: 400 } + ) + } + timezone = timezoneResult.data + } + const ext = file.filename.split('.').pop()?.toLowerCase() const extensionResult = csvExtensionSchema.safeParse(ext) if (!extensionResult.success) { @@ -112,7 +126,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { currentRowCount: number ) => { if (rows.length === 0) return 0 - const coerced = coerceRowsForTable(rows, state.schema, state.headerToColumn) + const coerced = coerceRowsForTable(rows, state.schema, state.headerToColumn, { timezone }) const result = await batchInsertRows( { tableId: state.table.id, rows: coerced, workspaceId, userId }, // The created table's rowCount is frozen at 0; pass the running total so the 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 d0045f4f40a..4527e850777 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 @@ -11,14 +11,22 @@ import { ChipModalField, ChipModalFooter, ChipModalHeader, + ChipTimePicker, Label, } from '@sim/emcn' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { useParams } from 'next/navigation' import type { ColumnDefinition, TableInfo, TableRow } from '@/lib/table' +import { useTimezone } from '@/hooks/queries/general-settings' import { useDeleteTableRow, useDeleteTableRows, useUpdateTableRow } from '@/hooks/queries/tables' -import { cleanCellValue, formatValueForInput } from '../../utils' +import { + cleanCellValue, + dateValueToLocalParts, + formatValueForInput, + localPartsToDateValue, + todayLocalCalendarDate, +} from '../../utils' const logger = createLogger('RowModal') @@ -34,14 +42,15 @@ export interface RowModalProps { function cleanRowData( columns: ColumnDefinition[], - rowData: Record + rowData: Record, + timeZone: string ): Record { const cleanData: Record = {} columns.forEach((col) => { const value = rowData[col.name] try { - cleanData[col.name] = cleanCellValue(value, col) + cleanData[col.name] = cleanCellValue(value, col, timeZone) } catch { throw new Error(`Invalid JSON for field: ${col.name}`) } @@ -66,6 +75,7 @@ export function RowModal({ mode, isOpen, onClose, table, row, rowIds, onSuccess const schema = table?.schema const columns = schema?.columns || [] + const timeZone = useTimezone() const [rowData, setRowData] = useState>(() => mode === 'edit' && row ? row.data : {} ) @@ -81,7 +91,7 @@ export function RowModal({ mode, isOpen, onClose, table, row, rowIds, onSuccess setError(null) try { - const cleanData = cleanRowData(columns, rowData) + const cleanData = cleanRowData(columns, rowData, timeZone) if (row) { await updateRowMutation.mutateAsync({ rowId: row.id, data: cleanData }) @@ -189,6 +199,7 @@ interface ColumnFieldProps { function ColumnField({ column, value, onChange }: ColumnFieldProps) { const checkboxId = useId() + const timeZone = useTimezone() const title = ( <> {column.name} @@ -236,14 +247,29 @@ function ColumnField({ column, value, onChange }: ColumnFieldProps) { } if (column.type === 'date') { + const parts = dateValueToLocalParts(formatValueForInput(value, 'date'), timeZone) return ( - +
+ onChange(localPartsToDateValue(day, parts.time, timeZone))} + placeholder='Select date' + flush + className='flex-1' + /> + + onChange( + localPartsToDateValue(parts.day ?? todayLocalCalendarDate(timeZone), time, timeZone) + ) + } + placeholder='Add time' + flush + className='w-[110px]' + /> +
) } diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx index b5bdccd21db..23787ff5efb 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx @@ -6,6 +6,7 @@ import { Badge, Checkbox, cn, Tooltip } from '@sim/emcn' import { parse } from 'tldts' import type { RowExecutionMetadata } from '@/lib/table' import { StatusBadge } from '@/app/workspace/[workspaceId]/logs/utils' +import { useTimezone } from '@/hooks/queries/general-settings' import { storageToDisplay } from '../../../utils' import type { DisplayColumn } from '../types' import { SimResourceCell, type SimResourceType } from './sim-resource-cell' @@ -234,6 +235,7 @@ interface CellRenderProps { export function CellRender({ kind, isEditing }: CellRenderProps): React.ReactElement | null { const valueText = kind.kind === 'value' ? kind.text : null const revealedValueText = useTypewriter(valueText) + const timeZone = useTimezone() switch (kind.kind) { case 'value': @@ -332,7 +334,7 @@ export function CellRender({ kind, isEditing }: CellRenderProps): React.ReactEle case 'date': return ( - {storageToDisplay(kind.text)} + {storageToDisplay(kind.text, { seconds: true, timeZone })} ) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/expanded-cell-popover.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/expanded-cell-popover.tsx index fc1c7daeb07..c020f62a90f 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/expanded-cell-popover.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/expanded-cell-popover.tsx @@ -4,8 +4,14 @@ import type React from 'react' import { useEffect, useEffectEvent, useLayoutEffect, useMemo, useRef, useState } from 'react' import { Button } from '@sim/emcn' import type { TableRow as TableRowType } from '@/lib/table' +import { useTimezone } from '@/hooks/queries/general-settings' import type { EditingCell, SaveReason } from '../../../types' -import { cleanCellValue, displayToStorage, formatValueForInput } from '../../../utils' +import { + cleanCellValue, + displayToStorage, + formatValueForInput, + storageToDisplay, +} from '../../../utils' import type { DisplayColumn } from '../types' interface ExpandedCellPopoverProps { @@ -40,6 +46,7 @@ export function ExpandedCellPopover({ const rootRef = useRef(null) const textareaRef = useRef(null) const [rect, setRect] = useState<{ top: number; left: number; width: number } | null>(null) + const timeZone = useTimezone() const target = useMemo(() => { if (!expandedCell) return null @@ -145,7 +152,14 @@ export function ExpandedCellPopover({ {isEditable ? ( (null) + const timeZone = useTimezone() const handleSave = () => { - // `displayToStorage` only normalizes dates — it returns null for anything else. - // Fall back to the raw draft for non-date columns, matching the inline editor. - const raw = displayToStorage(draftValue) ?? draftValue + // Only date columns go through `displayToStorage` — it now parses many + // date shapes, so a number draft like "2024" must not reach it. + const raw = + column.type === 'date' ? (displayToStorage(draftValue, timeZone) ?? draftValue) : draftValue let cleaned: unknown try { - cleaned = cleanCellValue(raw, column) + cleaned = cleanCellValue(raw, column, timeZone) } catch { setParseError('Invalid JSON') return 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 bbea94ef95d..60cbde7ef89 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 @@ -3,9 +3,12 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { Calendar, cn, Popover, PopoverAnchor, PopoverContent, toast } from '@sim/emcn' import type { ColumnDefinition } from '@/lib/table' +import { isCalendarDateString } from '@/lib/table/dates' +import { useTimezone } from '@/hooks/queries/general-settings' import type { SaveReason } from '../../../types' import { cleanCellValue, + dateValueToLocalParts, displayToStorage, formatValueForInput, storageToDisplay, @@ -28,7 +31,13 @@ function handleEditorWheel(e: React.WheelEvent) { } } -/** Inline editor for `date` columns — text input + popover calendar. */ +/** + * Inline editor for `date` columns — text input + popover with a calendar and + * a time field. Picking a day on a date-only value commits immediately (the + * pick fully determines the value); when the value carries a time, picker + * 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({ value, column, @@ -37,16 +46,38 @@ function InlineDateEditor({ onCancel, }: InlineEditorProps) { const inputRef = useRef(null) + const popoverRef = useRef(null) const doneRef = useRef(false) const blurTimeoutRef = useRef | undefined>(undefined) + /** Timestamp of the last pointerdown inside the popover — blur-save skips + * 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() const storedValue = formatValueForInput(value, column.type) const [draft, setDraft] = useState(() => - initialCharacter !== undefined ? initialCharacter : storageToDisplay(storedValue) + initialCharacter !== undefined + ? initialCharacter + : storageToDisplay(storedValue, { seconds: true, timeZone }) ) const [invalid, setInvalid] = useState(false) + /** Picker commits mutate the draft from timeouts/child handlers; reading it + * through a ref keeps the scheduled blur-save from saving a stale draft. */ + const draftRef = useRef(draft) + draftRef.current = draft - const pickerValue = displayToStorage(draft) || storedValue || undefined + /** The calendar is zone-agnostic (it works on wall times), so feed it the + * wall representation of the draft in the viewer's effective zone. */ + const draftParts = dateValueToLocalParts( + displayToStorage(draft, timeZone) ?? storedValue, + timeZone + ) + const pickerValue = draftParts.day + ? draftParts.time + ? `${draftParts.day}T${draftParts.time}` + : draftParts.day + : undefined useEffect(() => { const input = inputRef.current @@ -66,7 +97,8 @@ function InlineDateEditor({ (reason: SaveReason, storageVal?: string) => { if (doneRef.current) return clearTimeout(blurTimeoutRef.current) - const raw = storageVal ?? displayToStorage(draft) ?? draft + const current = draftRef.current + const raw = storageVal ?? displayToStorage(current, timeZone) ?? current if (raw && Number.isNaN(Date.parse(raw))) { if (reason === 'blur') { if (!invalid) toast.error('Invalid date') @@ -82,7 +114,7 @@ function InlineDateEditor({ doneRef.current = true onSave(raw || null, reason) }, - [draft, invalid, onSave, onCancel] + [invalid, onSave, onCancel, timeZone] ) const handleKeyDown = useCallback( @@ -103,16 +135,45 @@ function InlineDateEditor({ [doSave, onCancel] ) - const handleBlur = useCallback(() => { - blurTimeoutRef.current = setTimeout(() => doSave('blur'), 200) + const handlePopoverPointerDown = useCallback(() => { + popoverPointerAtRef.current = Date.now() + }, []) + + /** Saves on blur unless focus (or an in-flight pointer interaction) is still + * inside the editor's input/popover system. */ + const scheduleBlurSave = useCallback(() => { + clearTimeout(blurTimeoutRef.current) + blurTimeoutRef.current = setTimeout(() => { + const active = document.activeElement + if (active && (active === inputRef.current || popoverRef.current?.contains(active))) return + if (Date.now() - popoverPointerAtRef.current < 300) { + inputRef.current?.focus() + return + } + doSave('blur') + }, 200) }, [doSave]) + /** + * The calendar (with `showTime`) owns the day/time merge and emits either a + * bare `YYYY-MM-DD` (no time — the pick fully determines the value, commit + * immediately) or a local `YYYY-MM-DDTHH:mm[:ss]` wall time (update the + * draft and keep editing). + */ const handlePickerChange = useCallback( - (dateStr: string) => { + (picked: string) => { clearTimeout(blurTimeoutRef.current) - doSave('enter', dateStr) + if (isCalendarDateString(picked)) { + doSave('enter', picked) + return + } + const canonical = displayToStorage(picked, timeZone) + if (!canonical) return + setDraft(storageToDisplay(canonical, { seconds: true, timeZone })) + setInvalid(false) + inputRef.current?.focus() }, - [doSave] + [doSave, timeZone] ) const handlePickerOpenChange = useCallback((open: boolean) => { @@ -133,7 +194,7 @@ function InlineDateEditor({ setInvalid(false) }} onKeyDown={handleKeyDown} - onBlur={handleBlur} + onBlur={scheduleBlurSave} placeholder='mm/dd/yyyy' className={cn( 'w-full min-w-0 select-text border-none bg-transparent p-0 text-[var(--text-primary)] text-small outline-none', @@ -142,8 +203,15 @@ function InlineDateEditor({ /> - - + + diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx index 7521df83ecb..15cf9b1670d 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx @@ -14,6 +14,7 @@ import type { ColumnDefinition, Filter, TableRow as TableRowType, WorkflowGroup import { getColumnId } from '@/lib/table/column-keys' import { TABLE_LIMITS } from '@/lib/table/constants' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' +import { useTimezone } from '@/hooks/queries/general-settings' import { useAddTableColumn, useBatchCreateTableRows, @@ -486,6 +487,10 @@ export function TableGrid({ const workflowsRef = useRef(workflows) workflowsRef.current = workflows + const timeZone = useTimezone() + const timeZoneRef = useRef(timeZone) + timeZoneRef.current = timeZone + const updateRowMutation = useUpdateTableRow({ workspaceId, tableId }) const createRowMutation = useCreateTableRow({ workspaceId, tableId }) const batchCreateRowsMutation = useBatchCreateTableRows({ workspaceId, tableId }) @@ -1409,7 +1414,7 @@ export function TableGrid({ } } } else if (column.type === 'date') { - text = storageToDisplay(String(val)) + text = storageToDisplay(String(val), { seconds: true, timeZone: timeZoneRef.current }) } else { text = String(val) } @@ -2717,7 +2722,8 @@ export function TableGrid({ try { rowData[currentCols[targetCol].key] = cleanCellValue( pasteRows[r][c], - currentCols[targetCol] + currentCols[targetCol], + timeZoneRef.current ) } catch { /* skip invalid values */ diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.test.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.test.ts new file mode 100644 index 00000000000..0cd76b50038 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.test.ts @@ -0,0 +1,133 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + cleanCellValue, + dateValueToLocalParts, + displayToStorage, + formatValueForInput, + localPartsToDateValue, + storageToDisplay, +} from '@/app/workspace/[workspaceId]/tables/[tableId]/utils' + +describe('dateValueToLocalParts / localPartsToDateValue', () => { + it('splits calendar dates without a time part and round-trips', () => { + expect(dateValueToLocalParts('2026-07-06')).toEqual({ day: '2026-07-06', time: null }) + expect(localPartsToDateValue('2026-07-06', null)).toBe('2026-07-06') + }) + + it('splits instants into local day/time and round-trips exactly', () => { + const stored = new Date(2026, 6, 6, 16, 4, 55).toISOString() + const parts = dateValueToLocalParts(stored) + expect(parts).toEqual({ day: '2026-07-06', time: '16:04:55' }) + expect(localPartsToDateValue(parts.day as string, parts.time)).toBe(stored) + }) + + it('keeps the time when only the day changes', () => { + const stored = new Date(2026, 6, 6, 16, 4, 55).toISOString() + const parts = dateValueToLocalParts(stored) + expect(localPartsToDateValue('2026-07-09', parts.time)).toBe( + new Date(2026, 6, 9, 16, 4, 55).toISOString() + ) + }) + + it('accepts HH:mm times, defaulting seconds to zero', () => { + expect(localPartsToDateValue('2026-07-06', '16:04')).toBe( + new Date(2026, 6, 6, 16, 4, 0).toISOString() + ) + }) + + it('returns null parts for unparseable values', () => { + expect(dateValueToLocalParts('garbage')).toEqual({ day: null, time: null }) + expect(dateValueToLocalParts('')).toEqual({ day: null, time: null }) + }) + + it('reads and recombines parts in an explicit IANA zone', () => { + const stored = '2026-07-06T20:04:55.000Z' + const parts = dateValueToLocalParts(stored, 'America/New_York') + expect(parts).toEqual({ day: '2026-07-06', time: '16:04:55' }) + expect(localPartsToDateValue(parts.day as string, parts.time, 'America/New_York')).toBe(stored) + }) +}) + +describe('timezone-aware display round-trip', () => { + it('parses and renders wall times in the effective zone, not the runtime zone', () => { + const zone = 'America/New_York' + const stored = displayToStorage('07/06/2026 4:04:55 PM', zone) + expect(stored).toBe('2026-07-06T20:04:55.000Z') + expect(storageToDisplay(stored as string, { seconds: true, timeZone: zone })).toBe( + '07/06/2026 4:04:55 PM' + ) + }) +}) + +describe('displayToStorage', () => { + it('parses date-only display formats to calendar dates', () => { + expect(displayToStorage('07/06/2026')).toBe('2026-07-06') + expect(displayToStorage('7/6/2026')).toBe('2026-07-06') + expect(displayToStorage('2026-07-06')).toBe('2026-07-06') + expect(displayToStorage('7/6')).toBe(`${new Date().getFullYear()}-07-06`) + }) + + it('parses M/D/YYYY with a time to a local-zone UTC instant', () => { + expect(displayToStorage('07/06/2026 4:04 PM')).toBe( + new Date(2026, 6, 6, 16, 4, 0).toISOString() + ) + expect(displayToStorage('07/06/2026 4:04:55 PM')).toBe( + new Date(2026, 6, 6, 16, 4, 55).toISOString() + ) + expect(displayToStorage('07/06/2026 16:04')).toBe(new Date(2026, 6, 6, 16, 4, 0).toISOString()) + expect(displayToStorage('07/06/2026 12:00 AM')).toBe( + new Date(2026, 6, 6, 0, 0, 0).toISOString() + ) + }) + + it('passes canonical instants and offset strings through Date.parse exactly', () => { + expect(displayToStorage('2026-07-06T23:04:55.000Z')).toBe('2026-07-06T23:04:55.000Z') + expect(displayToStorage('2026-07-06 16:04:55 PDT')).toBe('2026-07-06T23:04:55.000Z') + }) + + it('rejects invalid dates and times', () => { + expect(displayToStorage('13/06/2026')).toBeNull() + expect(displayToStorage('07/06/2026 25:00')).toBeNull() + expect(displayToStorage('07/06/2026 13:00 PM')).toBeNull() + expect(displayToStorage('02/30/2026 5:00 PM')).toBeNull() + expect(displayToStorage('garbage')).toBeNull() + }) +}) + +describe('storageToDisplay', () => { + it('renders calendar dates as MM/DD/YYYY', () => { + expect(storageToDisplay('2026-07-06')).toBe('07/06/2026') + }) + + it('round-trips an instant through the editor draft format', () => { + const stored = new Date(2026, 6, 6, 16, 4, 55).toISOString() + const draft = storageToDisplay(stored, { seconds: true }) + expect(displayToStorage(draft)).toBe(stored) + }) +}) + +describe('cleanCellValue', () => { + it('normalizes date cells to canonical storage', () => { + const column = { name: 'due', type: 'date' } as const + expect(cleanCellValue('07/06/2026', column)).toBe('2026-07-06') + expect(cleanCellValue('2026-07-06T23:04:55.000Z', column)).toBe('2026-07-06T23:04:55.000Z') + expect(cleanCellValue('nope', column)).toBeNull() + expect(cleanCellValue('', column)).toBeNull() + }) + + it('leaves non-date types on their existing contracts', () => { + expect(cleanCellValue('2024', { name: 'n', type: 'number' } as const)).toBe(2024) + expect(cleanCellValue('true', { name: 'b', type: 'boolean' } as const)).toBe(true) + }) +}) + +describe('formatValueForInput', () => { + it('gives editors the canonical value, surfacing legacy UTC midnights as calendar days', () => { + expect(formatValueForInput('2026-07-06T00:00:00.000Z', 'date')).toBe('2026-07-06') + expect(formatValueForInput('2026-07-06T23:04:55.000Z', 'date')).toBe('2026-07-06T23:04:55.000Z') + expect(formatValueForInput('2026-07-06', 'date')).toBe('2026-07-06') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.ts index 55e310c3630..49d1fb2a871 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.ts @@ -1,4 +1,10 @@ import type { ColumnDefinition } from '@/lib/table' +import { + formatDateCellDisplay, + getWallClockParts, + normalizeDateCellValue, + storedDateToEditable, +} from '@/lib/table/dates' type BadgeVariant = 'green' | 'blue' | 'purple' | 'orange' | 'teal' | 'gray' @@ -41,7 +47,11 @@ export function getTypeBadgeVariant(type: string): BadgeVariant { * Coerce a raw input value to the appropriate type for a column. * Throws on invalid JSON. */ -export function cleanCellValue(value: unknown, column: ColumnDefinition): unknown { +export function cleanCellValue( + value: unknown, + column: ColumnDefinition, + timeZone?: string +): unknown { if (column.type === 'number') { if (value === '') return null const num = Number(value) @@ -59,8 +69,7 @@ export function cleanCellValue(value: unknown, column: ColumnDefinition): unknow } if (column.type === 'date') { if (value === '' || value === null || value === undefined) return null - const str = String(value) - return Number.isNaN(Date.parse(str)) ? null : str + return displayToStorage(String(value), timeZone) } return value || null } @@ -78,53 +87,115 @@ export function formatValueForInput(value: unknown, type: string): string { return typeof value === 'string' ? value : JSON.stringify(value) } if (type === 'date' && value) { - const str = String(value) - const match = str.match(/^(\d{4})-(\d{2})-(\d{2})/) - if (match) return match[0] - try { - const date = new Date(str) - return date.toISOString().split('T')[0] - } catch { - return str - } + return storedDateToEditable(String(value)) } if (typeof value === 'object') return JSON.stringify(value) return String(value) } +/** A canonical date-cell value split into viewer-local editing parts. */ +export interface DateCellLocalParts { + /** Local calendar day `YYYY-MM-DD`, or null when the value is unparseable. */ + day: string | null + /** Local time-of-day `HH:mm:ss`, or null for calendar-date values. */ + time: string | null +} + /** - * Convert a stored YYYY-MM-DD date string to MM/DD/YYYY display format. + * Splits a canonical date-cell value into the day and time the date/time + * pickers edit, read in the given IANA zone (the viewer's effective + * timezone; runtime-local when omitted). Calendar dates have no time part. */ -export function storageToDisplay(stored: string): string { - const match = stored.match(/^(\d{4})-(\d{2})-(\d{2})/) - if (match) return `${match[2]}/${match[3]}/${match[1]}` - return stored +export function dateValueToLocalParts(value: string, timeZone?: string): DateCellLocalParts { + if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return { day: value, time: null } + const ms = Date.parse(value) + if (Number.isNaN(ms)) return { day: null, time: null } + const wall = getWallClockParts(new Date(ms), timeZone) + const pad = (n: number) => String(n).padStart(2, '0') + return { + day: `${wall.year}-${pad(wall.month)}-${pad(wall.day)}`, + time: `${pad(wall.hour)}:${pad(wall.minute)}:${pad(wall.second)}`, + } } /** - * Convert a MM/DD/YYYY (or MM/DD) display string back to YYYY-MM-DD storage format. + * Recombines picker-edited parts into a canonical date-cell value: a calendar + * date when there is no time, else the UTC instant of that wall time in the + * given zone (runtime-local when omitted). */ -export function displayToStorage(display: string): string | null { - const iso = display.match(/^(\d{4})-(\d{2})-(\d{2})$/) - if (iso) { - const month = Number(iso[2]) - const day = Number(iso[3]) - if (month < 1 || month > 12 || day < 1 || day > 31) return null - return display +export function localPartsToDateValue(day: string, time: string | null, timeZone?: string): string { + if (!time) return day + return normalizeDateCellValue(`${day}T${time}`, { timezone: timeZone }) ?? day +} + +/** Today's calendar day as `YYYY-MM-DD` in the given zone (runtime-local when omitted). */ +export function todayLocalCalendarDate(timeZone?: string): string { + const wall = getWallClockParts(new Date(), timeZone) + const pad = (n: number) => String(n).padStart(2, '0') + return `${wall.year}-${pad(wall.month)}-${pad(wall.day)}` +} + +/** + * Format a stored date-cell value for display: calendar dates as MM/DD/YYYY, + * instants in the viewer's effective timezone as MM/DD/YYYY h:mm AM/PM. Pass + * `seconds: true` for editor drafts so re-saving an untouched cell keeps + * second precision. + */ +export function storageToDisplay( + stored: string, + options?: { seconds?: boolean; timeZone?: string } +): string { + return formatDateCellDisplay(stored, options) +} + +/** + * Parse a date-cell input string to its canonical storage form: `YYYY-MM-DD` + * for date-only inputs (MM/DD/YYYY, MM/DD, ISO), a UTC ISO instant for inputs + * carrying a time. Naive times are interpreted in `timeZone` (the viewer's + * effective timezone; the runtime's zone when omitted). Returns null when + * unparseable. + */ +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 + // Date.parse rolls impossible days over (02/30 → 03/02) instead of + // rejecting them, so validate the calendar day explicitly. + const dayCheck = new Date(Number(y), Number(m) - 1, Number(d)) + if (dayCheck.getMonth() !== Number(m) - 1 || dayCheck.getDate() !== 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 = display.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/) + const full = trimmed.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/) if (full) { const month = Number(full[1]) const day = Number(full[2]) if (month < 1 || month > 12 || day < 1 || day > 31) return null return `${full[3]}-${full[1].padStart(2, '0')}-${full[2].padStart(2, '0')}` } - const partial = display.match(/^(\d{1,2})\/(\d{1,2})$/) + const partial = trimmed.match(/^(\d{1,2})\/(\d{1,2})$/) if (partial) { const month = Number(partial[1]) const day = Number(partial[2]) if (month < 1 || month > 12 || day < 1 || day > 31) return null - return `${new Date().getFullYear()}-${partial[1].padStart(2, '0')}-${partial[2].padStart(2, '0')}` + const year = todayLocalCalendarDate(timeZone).slice(0, 4) + return `${year}-${partial[1].padStart(2, '0')}-${partial[2].padStart(2, '0')}` } - return null + return normalizeDateCellValue(trimmed, { timezone: timeZone }) } diff --git a/apps/sim/hooks/queries/general-settings.ts b/apps/sim/hooks/queries/general-settings.ts index 1c110f49d68..26faf6ebfb7 100644 --- a/apps/sim/hooks/queries/general-settings.ts +++ b/apps/sim/hooks/queries/general-settings.ts @@ -7,7 +7,7 @@ import { type MothershipEnvironment, type UserSettingsApi, updateUserSettingsContract, -} from '@/lib/api/contracts' +} from '@/lib/api/contracts/user' import { syncThemeToNextThemes } from '@/lib/core/utils/theme' import { getBrowserTimezone } from '@/lib/core/utils/timezone' diff --git a/apps/sim/hooks/queries/tables.ts b/apps/sim/hooks/queries/tables.ts index e9e10f908dd..51dc4308d22 100644 --- a/apps/sim/hooks/queries/tables.ts +++ b/apps/sim/hooks/queries/tables.ts @@ -95,6 +95,7 @@ import { optimisticallyScheduleNewlyEligibleGroups, } from '@/lib/table/deps' import { runUploadStrategy } from '@/lib/uploads/client/direct-upload' +import { useTimezone } from '@/hooks/queries/general-settings' import { TABLE_LIST_STALE_TIME, type TableQueryScope, @@ -1410,6 +1411,7 @@ interface UploadCsvParams { */ export function useUploadCsvToTable() { const queryClient = useQueryClient() + const timezone = useTimezone() return useMutation({ mutationFn: async ({ workspaceId, file }: UploadCsvParams) => { @@ -1417,6 +1419,7 @@ export function useUploadCsvToTable() { // stream and needs workspaceId before it reaches the (large) file. const formData = new FormData() formData.append('workspaceId', workspaceId) + formData.append('timezone', timezone) formData.append('file', file) // boundary-raw-fetch: multipart/form-data CSV upload, requestJson only supports JSON bodies @@ -1474,11 +1477,12 @@ async function uploadCsvToWorkspaceStorage( */ export function useImportCsvAsync() { const queryClient = useQueryClient() + const timezone = useTimezone() return useMutation({ mutationFn: async ({ workspaceId, file, onProgress }: ImportCsvAsyncParams) => { const fileKey = await uploadCsvToWorkspaceStorage(file, workspaceId, onProgress) const response = await requestJson(importTableAsyncContract, { - body: { workspaceId, fileKey, fileName: file.name }, + body: { workspaceId, fileKey, fileName: file.name, timezone }, }) return response.data }, @@ -1507,10 +1511,11 @@ interface ImportFileAsTableParams { */ export function useImportFileAsTable() { const queryClient = useQueryClient() + const timezone = useTimezone() return useMutation({ mutationFn: async ({ workspaceId, fileKey, fileName }: ImportFileAsTableParams) => { const response = await requestJson(importTableAsyncContract, { - body: { workspaceId, fileKey, fileName, deleteSourceFile: false }, + body: { workspaceId, fileKey, fileName, deleteSourceFile: false, timezone }, }) return response.data }, @@ -1543,6 +1548,7 @@ interface ImportCsvIntoTableAsyncParams { */ export function useImportCsvIntoTableAsync() { const queryClient = useQueryClient() + const timezone = useTimezone() return useMutation({ mutationFn: async ({ workspaceId, @@ -1556,7 +1562,7 @@ export function useImportCsvIntoTableAsync() { const fileKey = await uploadCsvToWorkspaceStorage(file, workspaceId, onProgress) const response = await requestJson(importIntoTableAsyncContract, { params: { tableId }, - body: { workspaceId, fileKey, fileName: file.name, mode, mapping, createColumns }, + body: { workspaceId, fileKey, fileName: file.name, mode, mapping, createColumns, timezone }, }) return response.data }, @@ -1601,6 +1607,7 @@ interface ImportCsvIntoTableResponse { */ export function useImportCsvIntoTable() { const queryClient = useQueryClient() + const timezone = useTimezone() return useMutation({ mutationFn: async ({ @@ -1616,6 +1623,7 @@ export function useImportCsvIntoTable() { const formData = new FormData() formData.append('workspaceId', workspaceId) formData.append('mode', mode) + formData.append('timezone', timezone) if (mapping) { formData.append('mapping', JSON.stringify(mapping)) } diff --git a/apps/sim/lib/api/contracts/tables.ts b/apps/sim/lib/api/contracts/tables.ts index b9d0512b0c0..c0fd003fa5a 100644 --- a/apps/sim/lib/api/contracts/tables.ts +++ b/apps/sim/lib/api/contracts/tables.ts @@ -1,6 +1,7 @@ import { isRecordLike } from '@sim/utils/object' import { z } from 'zod' import { type ContractJsonResponse, defineRouteContract } from '@/lib/api/contracts/types' +import { ianaTimezoneSchema } from '@/lib/api/contracts/user' import type { CsvHeaderMapping, EnrichmentRunDetail, @@ -404,6 +405,12 @@ export const importTableAsyncBodySchema = z.object({ * (e.g. the file viewer's "Import as a table") that must survive the import. */ deleteSourceFile: z.boolean().optional(), + /** + * IANA zone used to interpret naive datetime strings in the file (Excel and + * Sheets exports carry no offset). Defaults to the importing user's saved + * timezone, else UTC. + */ + timezone: ianaTimezoneSchema.optional(), }) export type ImportTableAsyncBody = z.input @@ -686,6 +693,12 @@ export const importIntoTableAsyncBodySchema = z.object({ mode: csvImportModeSchema, mapping: z.record(z.string(), z.string().nullable()).optional(), createColumns: z.array(z.string()).optional(), + /** + * IANA zone used to interpret naive datetime strings in the file (Excel and + * Sheets exports carry no offset). Defaults to the importing user's saved + * timezone, else UTC. + */ + timezone: ianaTimezoneSchema.optional(), }) export type ImportIntoTableAsyncBody = z.input diff --git a/apps/sim/lib/table/dates.test.ts b/apps/sim/lib/table/dates.test.ts new file mode 100644 index 00000000000..a1940b5f185 --- /dev/null +++ b/apps/sim/lib/table/dates.test.ts @@ -0,0 +1,158 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + formatDateCellDisplay, + isCalendarDateString, + normalizeDateCellValue, + storedDateToEditable, +} from '@/lib/table/dates' + +describe('isCalendarDateString', () => { + it('accepts YYYY-MM-DD and rejects everything else', () => { + expect(isCalendarDateString('2026-07-06')).toBe(true) + expect(isCalendarDateString('2026-13-45')).toBe(false) + expect(isCalendarDateString('2026-07-06T00:00:00Z')).toBe(false) + expect(isCalendarDateString('07/06/2026')).toBe(false) + }) +}) + +describe('normalizeDateCellValue', () => { + it('keeps calendar dates timezone-free', () => { + expect(normalizeDateCellValue('2026-07-06')).toBe('2026-07-06') + expect(normalizeDateCellValue(' 2026-07-06 ')).toBe('2026-07-06') + }) + + it('normalizes date-only inputs in other formats to calendar dates', () => { + expect(normalizeDateCellValue('07/06/2026')).toBe('2026-07-06') + expect(normalizeDateCellValue('7/6/2026')).toBe('2026-07-06') + expect(normalizeDateCellValue('July 6, 2026')).toBe('2026-07-06') + }) + + it('normalizes reduced-precision ISO forms via their UTC day', () => { + expect(normalizeDateCellValue('2026-07')).toBe('2026-07-01') + expect(normalizeDateCellValue('2026')).toBe('2026-01-01') + }) + + it('converts inputs with an explicit offset to exact UTC instants', () => { + expect(normalizeDateCellValue('2026-07-06T16:04:55-07:00')).toBe('2026-07-06T23:04:55.000Z') + expect(normalizeDateCellValue('2026-07-06 16:04:55 PDT')).toBe('2026-07-06T23:04:55.000Z') + expect(normalizeDateCellValue('2026-07-06T23:04:55.000Z')).toBe('2026-07-06T23:04:55.000Z') + expect(normalizeDateCellValue('2026-07-06 16:04:55+00')).toBe('2026-07-06T16:04:55.000Z') + }) + + it('interprets naive datetimes in the runtime local zone by default', () => { + expect(normalizeDateCellValue('2026-07-06 16:04:55')).toBe( + new Date(2026, 6, 6, 16, 4, 55).toISOString() + ) + }) + + it('interprets naive datetimes in the provided IANA zone', () => { + // July → America/New_York is EDT (UTC-4) + expect(normalizeDateCellValue('2026-07-06 16:04:55', { timezone: 'America/New_York' })).toBe( + '2026-07-06T20:04:55.000Z' + ) + // January → EST (UTC-5); DST resolved per wall date, not per import date + expect(normalizeDateCellValue('2026-01-15 12:00', { timezone: 'America/New_York' })).toBe( + '2026-01-15T17:00:00.000Z' + ) + expect(normalizeDateCellValue('7/6/2026 4:04 PM', { timezone: 'America/Los_Angeles' })).toBe( + '2026-07-06T23:04:00.000Z' + ) + }) + + it('ignores the zone option when the input carries an explicit offset', () => { + expect( + normalizeDateCellValue('2026-07-06T23:04:55.000Z', { timezone: 'America/New_York' }) + ).toBe('2026-07-06T23:04:55.000Z') + expect( + normalizeDateCellValue('2026-07-06 16:04:55 PDT', { timezone: 'America/New_York' }) + ).toBe('2026-07-06T23:04:55.000Z') + }) + + it('leaves calendar dates untouched by the zone option', () => { + expect(normalizeDateCellValue('2026-07-06', { timezone: 'America/New_York' })).toBe( + '2026-07-06' + ) + }) + + it('throws on an invalid IANA zone', () => { + expect(() => normalizeDateCellValue('2026-07-06 12:00', { timezone: 'Not/AZone' })).toThrow( + RangeError + ) + }) + + it('returns null for unparseable input', () => { + expect(normalizeDateCellValue('not-a-date')).toBeNull() + expect(normalizeDateCellValue('')).toBeNull() + expect(normalizeDateCellValue('2026-13-45')).toBeNull() + expect(normalizeDateCellValue('13/06/2026')).toBeNull() + }) +}) + +describe('formatDateCellDisplay', () => { + it('renders calendar dates as MM/DD/YYYY', () => { + expect(formatDateCellDisplay('2026-07-06')).toBe('07/06/2026') + }) + + it('renders legacy UTC-midnight instants as their UTC calendar day', () => { + expect(formatDateCellDisplay('2026-07-06T00:00:00.000Z')).toBe('07/06/2026') + expect(formatDateCellDisplay('2026-07-06T00:00:00Z')).toBe('07/06/2026') + }) + + it('renders instants in the viewer local zone with a 12-hour time', () => { + const stored = '2026-07-06T23:04:55.000Z' + const local = new Date(stored) + const hours24 = local.getHours() + const hours12 = hours24 % 12 === 0 ? 12 : hours24 % 12 + const expectedDay = `${String(local.getMonth() + 1).padStart(2, '0')}/${String( + local.getDate() + ).padStart(2, '0')}/${local.getFullYear()}` + const meridiem = hours24 < 12 ? 'AM' : 'PM' + expect(formatDateCellDisplay(stored)).toBe(`${expectedDay} ${hours12}:04 ${meridiem}`) + expect(formatDateCellDisplay(stored, { seconds: true })).toBe( + `${expectedDay} ${hours12}:04:55 ${meridiem}` + ) + }) + + it('omits the seconds suffix when seconds are zero', () => { + const stored = '2026-07-06T23:04:00.000Z' + expect(formatDateCellDisplay(stored, { seconds: true })).not.toContain(':04:') + }) + + it('returns unparseable legacy strings as-is', () => { + expect(formatDateCellDisplay('garbage')).toBe('garbage') + }) + + it('renders instants in an explicit IANA zone', () => { + const stored = '2026-07-06T23:04:55.000Z' + expect(formatDateCellDisplay(stored, { timeZone: 'America/New_York' })).toBe( + '07/06/2026 7:04 PM' + ) + expect(formatDateCellDisplay(stored, { timeZone: 'America/New_York', seconds: true })).toBe( + '07/06/2026 7:04:55 PM' + ) + // Day rolls forward east of the instant's UTC day + expect(formatDateCellDisplay(stored, { timeZone: 'Asia/Tokyo' })).toBe('07/07/2026 8:04 AM') + }) + + it('keeps calendar dates zone-independent', () => { + expect(formatDateCellDisplay('2026-07-06', { timeZone: 'Asia/Tokyo' })).toBe('07/06/2026') + }) +}) + +describe('storedDateToEditable', () => { + it('surfaces legacy UTC-midnight instants as their UTC calendar day', () => { + expect(storedDateToEditable('2026-07-06T00:00:00.000Z')).toBe('2026-07-06') + }) + + it('keeps calendar dates and real instants canonical', () => { + expect(storedDateToEditable('2026-07-06')).toBe('2026-07-06') + expect(storedDateToEditable('2026-07-06T23:04:55.000Z')).toBe('2026-07-06T23:04:55.000Z') + }) + + it('passes unparseable legacy strings through', () => { + expect(storedDateToEditable('garbage')).toBe('garbage') + }) +}) diff --git a/apps/sim/lib/table/dates.ts b/apps/sim/lib/table/dates.ts new file mode 100644 index 00000000000..189d3a1c2bb --- /dev/null +++ b/apps/sim/lib/table/dates.ts @@ -0,0 +1,221 @@ +/** + * Canonical date-cell semantics for user tables. + * + * A `date` cell stores exactly one of two shapes: + * + * - **Calendar date** `YYYY-MM-DD` — a timezone-free day. Never converted; + * renders identically for every viewer. + * - **Instant** — a full UTC ISO-8601 string (`Date.prototype.toISOString` + * output). Rendered in the viewer's local timezone. + * + * Inputs with an explicit offset (`Z`, `-07:00`, `PDT`) convert exactly. + * Naive datetime strings are interpreted in the runtime's local timezone: + * the browser's when written through the UI (the author's wall clock), the + * server's (UTC in production) for CSV imports and raw API writes. + * + * This module is pure and shared by server coercion and client rendering. + * Client code must import it via this concrete path, never the `@/lib/table` + * barrel (the barrel is server-tainted). + */ + +const CALENDAR_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/ + +/** + * Legacy shape: old CSV imports stored date-only columns as UTC-midnight + * instants. Treated as calendar dates (UTC day) so historical rows don't + * shift a day for viewers west of Greenwich. + */ +const UTC_MIDNIGHT_PATTERN = /^\d{4}-\d{2}-\d{2}T00:00:00(\.000)?Z$/ + +/** A time-of-day component anywhere in the string (e.g. `16:04`). */ +const TIME_COMPONENT_PATTERN = /\d{1,2}:\d{2}/ + +/** + * ISO reduced-precision date forms (`2026`, `2026-07`) parse as UTC per spec, + * unlike other date-only forms which V8 parses as local time. + */ +const ISO_REDUCED_DATE_PATTERN = /^\d{4}(-\d{2})?$/ + +/** + * Trailing timezone information V8's parser recognizes: `Z`, `UT`/`UTC`/`GMT`, + * US abbreviations (`PST`, `EDT`, …), and numeric offsets (`+05`, `-0700`, + * `+00:00`). Deliberately does not match a trailing `AM`/`PM`. + */ +const EXPLICIT_OFFSET_PATTERN = /(?:Z|UTC?|GMT|[ECMP][SD]T)$|[+-]\d{1,2}(?::?\d{2})?$/i + +/** 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 adjusted = guess - zoneOffsetMs(timeZone, new Date(guess)) + return new Date(guess - zoneOffsetMs(timeZone, new Date(adjusted))) +} + +function pad(n: number): string { + return String(n).padStart(2, '0') +} + +function toLocalCalendarDate(date: Date): string { + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +} + +function toUtcCalendarDate(date: Date): string { + return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}` +} + +export interface NormalizeDateCellOptions { + /** + * IANA zone used to interpret naive datetime strings (no explicit offset), + * e.g. a CSV import applying the importing user's timezone. Defaults to the + * runtime's local zone — the author's wall clock in the browser, UTC on + * production servers. Throws a RangeError on an invalid zone. + */ + timezone?: string +} + +/** + * Normalizes a raw string to a canonical date-cell value, or `null` when it + * cannot be parsed. Date-only inputs become calendar dates; inputs carrying a + * time become UTC instants (naive ones interpreted per + * {@link NormalizeDateCellOptions.timezone} — see module doc). + */ +export function normalizeDateCellValue( + raw: string, + options?: NormalizeDateCellOptions +): 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 ms = Date.parse(trimmed) + if (Number.isNaN(ms)) return null + const parsed = new Date(ms) + if (!TIME_COMPONENT_PATTERN.test(trimmed)) { + return ISO_REDUCED_DATE_PATTERN.test(trimmed) + ? toUtcCalendarDate(parsed) + : toLocalCalendarDate(parsed) + } + if (options?.timezone && !EXPLICIT_OFFSET_PATTERN.test(trimmed)) { + // `parsed`'s local getters recover the wall-clock fields V8 read from the + // naive string; reinterpret that reading in the requested zone. + return wallTimeInZoneToUtc(parsed, options.timezone).toISOString() + } + return parsed.toISOString() +} + +/** + * Canonical form a stored date cell should be edited (and re-saved) as. + * Legacy UTC-midnight instants surface as their UTC calendar day — feeding + * them to `new Date()`-based editors as instants would shift the day for + * viewers west of Greenwich. Unparseable legacy strings pass through so the + * editor shows what is actually stored. + */ +export function storedDateToEditable(stored: string): string { + if (UTC_MIDNIGHT_PATTERN.test(stored)) return toUtcCalendarDate(new Date(stored)) + return normalizeDateCellValue(stored) ?? stored +} + +interface FormatDateCellDisplayOptions { + /** Include seconds on instants when non-zero (editor drafts round-trip precision). */ + seconds?: boolean + /** IANA zone instants render in. Defaults to the runtime's local zone. */ + timeZone?: string +} + +/** + * Formats a stored date-cell value for display. Calendar dates (and legacy + * UTC-midnight instants) render as `MM/DD/YYYY`; instants render in the + * viewer's effective timezone as `MM/DD/YYYY h:mm AM/PM`. Unparseable + * strings (pre-canonicalization rows) are returned as-is. + */ +export function formatDateCellDisplay( + stored: string, + options?: FormatDateCellDisplayOptions +): string { + const calendar = stored.match(/^(\d{4})-(\d{2})-(\d{2})$/) + if (calendar) return `${calendar[2]}/${calendar[3]}/${calendar[1]}` + if (UTC_MIDNIGHT_PATTERN.test(stored)) { + const date = new Date(stored) + return `${pad(date.getUTCMonth() + 1)}/${pad(date.getUTCDate())}/${date.getUTCFullYear()}` + } + const ms = Date.parse(stored) + if (Number.isNaN(ms)) return stored + const wall = getWallClockParts(new Date(ms), options?.timeZone) + const day = `${pad(wall.month)}/${pad(wall.day)}/${wall.year}` + const hours12 = wall.hour % 12 === 0 ? 12 : wall.hour % 12 + const meridiem = wall.hour < 12 ? 'AM' : 'PM' + const secondsPart = options?.seconds && wall.second !== 0 ? `:${pad(wall.second)}` : '' + return `${day} ${hours12}:${pad(wall.minute)}${secondsPart} ${meridiem}` +} diff --git a/apps/sim/lib/table/import-runner.ts b/apps/sim/lib/table/import-runner.ts index 13887dd1c10..31086f34743 100644 --- a/apps/sim/lib/table/import-runner.ts +++ b/apps/sim/lib/table/import-runner.ts @@ -68,6 +68,12 @@ export interface TableImportPayload { * survive the import. */ deleteSourceFile?: boolean + /** + * IANA zone used to interpret naive datetime strings in the file. The + * kickoff routes resolve it (request → user setting → UTC) so the detached + * worker never needs a settings lookup. + */ + timezone?: string } /** @@ -199,7 +205,9 @@ export async function runTableImport(payload: TableImportPayload): Promise // may own. Runs per batch (not just at the emit cadence) so we stop within one batch. const owns = await updateJobProgress(tableId, inserted, importId) if (!owns) throw new ImportSupersededError() - const coerced = coerceRowsForTable(rows, schema, headerToColumn) + const coerced = coerceRowsForTable(rows, schema, headerToColumn, { + timezone: payload.timezone, + }) const rowLimit = await assertRowCapacity({ workspaceId, currentRowCount: existingRowCount + inserted, diff --git a/apps/sim/lib/table/import.test.ts b/apps/sim/lib/table/import.test.ts index d25ee031e0e..226aa899054 100644 --- a/apps/sim/lib/table/import.test.ts +++ b/apps/sim/lib/table/import.test.ts @@ -137,8 +137,9 @@ describe('import', () => { expect(coerceValue('yes', 'boolean')).toBeNull() }) - it('coerces dates to ISO strings and falls back to the original string', () => { - expect(coerceValue('2024-01-01', 'date')).toBe(new Date('2024-01-01').toISOString()) + it('keeps date-only values as calendar dates, converts datetimes to UTC instants, and falls back to the original string', () => { + expect(coerceValue('2024-01-01', 'date')).toBe('2024-01-01') + expect(coerceValue('2024-01-01T12:30:00-07:00', 'date')).toBe('2024-01-01T19:30:00.000Z') expect(coerceValue('not-a-date', 'date')).toBe('not-a-date') }) }) diff --git a/apps/sim/lib/table/import.ts b/apps/sim/lib/table/import.ts index a132ba96dd7..104186e32e1 100644 --- a/apps/sim/lib/table/import.ts +++ b/apps/sim/lib/table/import.ts @@ -13,6 +13,7 @@ import { type Options as CsvParseOptions, type Parser, parse as parseCsvStream } from 'csv-parse' import { getColumnId } from '@/lib/table/column-keys' +import { type NormalizeDateCellOptions, normalizeDateCellValue } from '@/lib/table/dates' import type { ColumnDefinition, RowData, TableSchema } from '@/lib/table/types' /** @@ -218,7 +219,8 @@ export function inferSchemaFromCsv( */ export function coerceValue( value: unknown, - colType: CsvColumnType + colType: CsvColumnType, + options?: NormalizeDateCellOptions ): string | number | boolean | null | Record | unknown[] { if (value === null || value === undefined || value === '') return null switch (colType) { @@ -233,8 +235,7 @@ export function coerceValue( return null } case 'date': { - const d = new Date(String(value)) - return Number.isNaN(d.getTime()) ? String(value) : d.toISOString() + return normalizeDateCellValue(String(value), options) ?? String(value) } case 'json': { if (typeof value === 'object') return value as Record | unknown[] @@ -407,7 +408,8 @@ export function buildAutoMapping(csvHeaders: string[], tableSchema: TableSchema) export function coerceRowsForTable( rows: Record[], tableSchema: TableSchema, - headerToColumn: Map + headerToColumn: Map, + options?: NormalizeDateCellOptions ): RowData[] { const colByName = new Map(tableSchema.columns.map((c) => [c.name, c])) @@ -419,7 +421,7 @@ export function coerceRowsForTable( const col = colByName.get(colName) if (!col) continue const colType = (col.type as CsvColumnType) ?? 'string' - coerced[getColumnId(col)] = coerceValue(value, colType) as RowData[string] + coerced[getColumnId(col)] = coerceValue(value, colType, options) as RowData[string] } return coerced }) diff --git a/apps/sim/lib/table/index.ts b/apps/sim/lib/table/index.ts index c79a13f182c..58a73f2313f 100644 --- a/apps/sim/lib/table/index.ts +++ b/apps/sim/lib/table/index.ts @@ -9,6 +9,7 @@ export * from '@/lib/table/billing' export * from '@/lib/table/column-keys' export * from '@/lib/table/columns/service' export * from '@/lib/table/constants' +export * from '@/lib/table/dates' export * from '@/lib/table/import' export * from '@/lib/table/import-data' export * from '@/lib/table/jobs/service' diff --git a/apps/sim/lib/table/validation.ts b/apps/sim/lib/table/validation.ts index 0029f576845..df773160513 100644 --- a/apps/sim/lib/table/validation.ts +++ b/apps/sim/lib/table/validation.ts @@ -8,6 +8,7 @@ import { and, eq, or, type SQL, sql } from 'drizzle-orm' import { NextResponse } from 'next/server' import { getColumnId } from '@/lib/table/column-keys' import { COLUMN_TYPES, getMaxRowSizeBytes, NAME_PATTERN, TABLE_LIMITS } from '@/lib/table/constants' +import { normalizeDateCellValue } from '@/lib/table/dates' import { withSeqscanOff } from '@/lib/table/planner' import type { ColumnDefinition, @@ -296,7 +297,10 @@ function coerceValueToColumnType( } return { ok: false } case 'date': { - if (typeof value === 'string' && !Number.isNaN(Date.parse(value))) return { ok: true, value } + if (typeof value === 'string') { + const normalized = normalizeDateCellValue(value) + return normalized === null ? { ok: false } : { ok: true, value: normalized } + } // Date instances and epoch numbers may still be out of the representable // range (>±8.64e15ms) — guard `toISOString()`, which throws RangeError on // an Invalid Date, so an over-range value degrades to `{ ok: false }` diff --git a/packages/emcn/src/components/calendar/calendar.tsx b/packages/emcn/src/components/calendar/calendar.tsx index cb1af8e0fe7..1ab1ec0c075 100644 --- a/packages/emcn/src/components/calendar/calendar.tsx +++ b/packages/emcn/src/components/calendar/calendar.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useMemo, useState } from 'react' +import { useMemo, useState } from 'react' import { ChevronLeft, ChevronRight } from 'lucide-react' import { cn } from '../../lib/cn' import { Chip, chipVariants } from '../chip/chip' @@ -123,6 +123,35 @@ function extractTime(value: string | Date | undefined, fallback: string): string return typeof value === 'string' && value.includes('T') ? value.slice(11, 16) : fallback } +/** Local `HH:mm` (plus `:ss` when non-zero) time-of-day of a `Date`. */ +function timeOfDayFrom(date: Date): string { + const base = `${pad2(date.getHours())}:${pad2(date.getMinutes())}` + return date.getSeconds() > 0 ? `${base}:${pad2(date.getSeconds())}` : base +} + +/** + * Parses a date value into its local day plus an optional time-of-day. Bare + * `YYYY-MM-DD` strings are pure days (no time). Datetime strings parse through + * `Date` so an explicit offset (`Z`, `-07:00`) resolves to the **local** day — + * unlike {@link parseDateValue}'s date-slice fast path, which would read the + * UTC day. A midnight-sharp time reads as "no time" so date-only values that + * arrive as `Date`s or `T00:00` strings stay pure days. + */ +function parseDateTimeValue(value: string | Date | undefined): { + date: Date | null + time: string | null +} { + if (!value) return { date: null, time: null } + if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)) { + return { date: parseDateValue(value), time: null } + } + const parsed = value instanceof Date ? value : new Date(value) + if (Number.isNaN(parsed.getTime())) return { date: null, time: null } + const isMidnight = + parsed.getHours() === 0 && parsed.getMinutes() === 0 && parsed.getSeconds() === 0 + return { date: parsed, time: isMidnight ? null : timeOfDayFrom(parsed) } +} + /** * Orders a start/end pair and serializes the range bounds to the wire format. * Without `showTime` the bounds are bare `YYYY-MM-DD` days; with it, the start @@ -164,10 +193,20 @@ interface CalendarBaseProps { interface CalendarSingleProps extends CalendarBaseProps { mode?: 'single' - /** Selected date as a `YYYY-MM-DD` string or `Date`. */ + /** Selected date as a `YYYY-MM-DD` (or datetime) string or `Date`. */ value?: string | Date - /** Called with the picked date in `YYYY-MM-DD` format. */ + /** + * Called with the picked date in `YYYY-MM-DD` format — or, with `showTime` + * and a set time, the local wall time `YYYY-MM-DDTHH:mm[:ss]`. + */ onChange?: (value: string) => void + /** + * Adds a time-of-day input under the grid. Day picks keep the current time + * (seconds included when the seeded value had them); time edits re-emit on + * the selected (or today's) day. Without a time set, day picks emit bare + * `YYYY-MM-DD` days. + */ + showTime?: boolean } interface CalendarRangeProps extends CalendarBaseProps { @@ -205,6 +244,9 @@ export type CalendarProps = CalendarSingleProps | CalendarRangeProps * * * @example + * + * + * @example * */ export function Calendar(props: CalendarProps) { @@ -289,19 +331,35 @@ function WeekdayRow() { ) } -function SingleCalendarView({ value, onChange, className }: CalendarSingleProps) { - const selected = useMemo(() => parseDateValue(value), [value]) +function SingleCalendarView({ value, onChange, showTime = false, className }: CalendarSingleProps) { + const parsed = useMemo(() => parseDateTimeValue(value), [value]) + const selected = parsed.date const { today, view, setView, goToPrevMonth, goToNextMonth, cells } = useCalendarView(selected) - useEffect(() => { + const [timeOfDay, setTimeOfDay] = useState(parsed.time) + const [prevValue, setPrevValue] = useState(value) + if (value !== prevValue) { + setPrevValue(value) + setTimeOfDay(parsed.time) if (selected) setView({ month: selected.getMonth(), year: selected.getFullYear() }) - }, [selected, setView]) + } + + const emit = (year: number, month: number, day: number, time: string | null) => { + const dateStr = toDateString(year, month, day) + onChange?.(showTime && time ? `${dateStr}T${time}` : dateStr) + } - const selectDay = (day: number) => onChange?.(toDateString(view.year, view.month, day)) + const selectDay = (day: number) => emit(view.year, view.month, day, timeOfDay) const goToToday = () => { setView({ month: today.getMonth(), year: today.getFullYear() }) - onChange?.(toDateString(today.getFullYear(), today.getMonth(), today.getDate())) + emit(today.getFullYear(), today.getMonth(), today.getDate(), timeOfDay) + } + + const handleTimeChange = (time: string) => { + setTimeOfDay(time) + const base = selected ?? today + emit(base.getFullYear(), base.getMonth(), base.getDate(), time) } return ( @@ -332,6 +390,18 @@ function SingleCalendarView({ value, onChange, className }: CalendarSingleProps) })} + {showTime && ( +
+ Time + +
+ )} +