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..28c971bda9f 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,30 @@ function ColumnField({ column, value, onChange }: ColumnFieldProps) { } if (column.type === 'date') { + const parts = dateValueToLocalParts(formatValueForInput(value, 'date')) 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..ec6a185e16b 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 @@ -332,7 +332,7 @@ export function CellRender({ kind, isEditing }: CellRenderProps): React.ReactEle case 'date': return ( - {storageToDisplay(kind.text)} + {storageToDisplay(kind.text, { seconds: true })} ) 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..ce99edc0894 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 { @@ -145,7 +151,11 @@ 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 + // Untouched draft → close without writing. For dates this also avoids + // re-stamping the stored offset with this viewer's zone. + if (draftValue === initialValue) { + onClose() + return + } + // 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..20ac51847a2 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,12 +3,16 @@ 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, + todayLocalCalendarDate, } from '../../../utils' interface InlineEditorProps { @@ -28,7 +32,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 +47,35 @@ 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) - ) + const initialDraft = + initialCharacter !== undefined + ? initialCharacter + : storageToDisplay(storedValue, { seconds: true }) + const [draft, setDraft] = useState(initialDraft) 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 works on wall times; feed it the draft's literal wall + * representation. */ + const draftParts = dateValueToLocalParts(displayToStorage(draft, timeZone) ?? storedValue) + const pickerValue = draftParts.day + ? draftParts.time + ? `${draftParts.day}T${draftParts.time}` + : draftParts.day + : undefined useEffect(() => { const input = inputRef.current @@ -66,7 +95,16 @@ function InlineDateEditor({ (reason: SaveReason, storageVal?: string) => { if (doneRef.current) return clearTimeout(blurTimeoutRef.current) - const raw = storageVal ?? displayToStorage(draft) ?? draft + const current = draftRef.current + // Untouched draft → re-save the stored value byte-identical. Re-parsing + // the display form would re-stamp the offset with THIS viewer's zone, + // silently shifting the instant of a value someone else wrote. + if (storageVal === undefined && initialCharacter === undefined && current === initialDraft) { + doneRef.current = true + onSave(storedValue || null, reason) + return + } + 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 +120,7 @@ function InlineDateEditor({ doneRef.current = true onSave(raw || null, reason) }, - [draft, invalid, onSave, onCancel] + [invalid, onSave, onCancel, timeZone, initialDraft, initialCharacter, storedValue] ) const handleKeyDown = useCallback( @@ -103,16 +141,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 })) + setInvalid(false) + inputRef.current?.focus() }, - [doSave] + [doSave, timeZone] ) const handlePickerOpenChange = useCallback((open: boolean) => { @@ -133,7 +200,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 +209,20 @@ 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..50579bab7cd 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 }) } 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..eace4541f1f --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.test.ts @@ -0,0 +1,138 @@ +/** + * @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 their literal wall day/time — no zone conversion', () => { + expect(dateValueToLocalParts('2026-07-06T16:04:55-07:00')).toEqual({ + day: '2026-07-06', + time: '16:04:55', + }) + expect(dateValueToLocalParts('2026-07-06T23:04:55Z')).toEqual({ + day: '2026-07-06', + time: '23:04:55', + }) + expect(dateValueToLocalParts('2026-07-06T23:04:55.000Z')).toEqual({ + day: '2026-07-06', + time: '23:04:55', + }) + }) + + it('recombines parts stamping the given zone offset, keeping the wall time', () => { + expect(localPartsToDateValue('2026-07-06', '16:04:55', 'America/New_York')).toBe( + '2026-07-06T16:04:55-04:00' + ) + expect(localPartsToDateValue('2026-07-09', '16:04:55', 'America/New_York')).toBe( + '2026-07-09T16:04:55-04:00' + ) + expect(localPartsToDateValue('2026-07-06', '16:04', 'America/New_York')).toBe( + '2026-07-06T16:04:00-04:00' + ) + }) + + it('returns null parts for unparseable values', () => { + expect(dateValueToLocalParts('garbage')).toEqual({ day: null, time: null }) + expect(dateValueToLocalParts('')).toEqual({ day: null, time: null }) + }) +}) + +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 wall time stamped with the given zone', () => { + expect(displayToStorage('07/06/2026 4:04 PM', 'America/New_York')).toBe( + '2026-07-06T16:04:00-04:00' + ) + expect(displayToStorage('07/06/2026 4:04:55 PM', 'America/New_York')).toBe( + '2026-07-06T16:04:55-04:00' + ) + expect(displayToStorage('07/06/2026 16:04', 'America/New_York')).toBe( + '2026-07-06T16:04:00-04:00' + ) + expect(displayToStorage('07/06/2026 12:00 AM', 'America/New_York')).toBe( + '2026-07-06T00:00:00-04:00' + ) + }) + + it('preserves the wall time and offset of canonical and offset strings', () => { + expect(displayToStorage('2026-07-06T16:04:55-07:00')).toBe('2026-07-06T16:04:55-07:00') + expect(displayToStorage('2026-07-06T23:04:55.000Z')).toBe('2026-07-06T23:04:55Z') + expect(displayToStorage('2026-07-06 16:04:55 PDT')).toBe('2026-07-06T16:04:55-07:00') + }) + + 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('02/30/2026')).toBeNull() + expect(displayToStorage('2/30')).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('renders the literal wall time identically regardless of viewer or offset', () => { + expect(storageToDisplay('2026-07-06T16:04:55-07:00', { seconds: true })).toBe( + '07/06/2026 4:04:55 PM' + ) + expect(storageToDisplay('2026-07-06T16:04:55+09:00', { seconds: true })).toBe( + '07/06/2026 4:04:55 PM' + ) + }) + + it('round-trips an instant through the editor draft format without shifting', () => { + const stored = displayToStorage('07/06/2026 4:04:55 PM', 'America/New_York') as string + const draft = storageToDisplay(stored, { seconds: true }) + expect(draft).toBe('07/06/2026 4:04:55 PM') + expect(displayToStorage(draft, 'America/New_York')).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-06T16:04:55-07:00', column)).toBe('2026-07-06T16:04:55-07:00') + 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-06T16:04:55-07:00', 'date')).toBe( + '2026-07-06T16:04:55-07:00' + ) + 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..84e03eefaf0 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,113 @@ 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 its wall-clock editing parts. */ +export interface DateCellLocalParts { + /** Calendar day `YYYY-MM-DD`, or null when the value is unparseable. */ + day: string | null + /** Time-of-day `HH:mm:ss`, or null for calendar-date values. */ + time: string | null +} + +/** + * Splits a canonical date-cell value into the day and time the date/time + * pickers edit — the value's **literal wall time**, no timezone conversion + * (display and editing are wall-clock-faithful for every viewer). Calendar + * dates have no time part; legacy strings normalize first. + */ +export function dateValueToLocalParts(value: string): DateCellLocalParts { + if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return { day: value, time: null } + const wall = value.match( + /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})(?::(\d{2}))?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?$/ + ) + if (wall) return { day: wall[1], time: `${wall[2]}:${wall[3] ?? '00'}` } + const canonical = normalizeDateCellValue(value) + if (!canonical || canonical === value) return { day: null, time: null } + return dateValueToLocalParts(canonical) +} + +/** + * Recombines picker-edited parts into a canonical date-cell value: a calendar + * date when there is no time, else that wall time stamped with the given + * zone's offset (runtime-local when omitted). + */ +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)}` +} + /** - * Convert a stored YYYY-MM-DD date string to MM/DD/YYYY display format. + * Format a stored date-cell value for display: calendar dates as MM/DD/YYYY, + * instants as their literal wall time `MM/DD/YYYY h:mm AM/PM` — identical + * for every viewer. Pass `seconds: true` for editor drafts so re-saving an + * untouched cell keeps second precision. */ -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 storageToDisplay(stored: string, options?: { seconds?: boolean }): string { + return formatDateCellDisplay(stored, options) } /** - * Convert a MM/DD/YYYY (or MM/DD) display string back to YYYY-MM-DD storage format. + * Parse a date-cell input string to its canonical storage form: `YYYY-MM-DD` + * for date-only inputs (MM/DD/YYYY, MM/DD, ISO), an offset-preserved instant + * for inputs carrying a time. Naive times are stamped with the offset of + * `timeZone` (the writer's effective timezone; the runtime's zone when + * omitted). Returns null when unparseable. */ -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 displayToStorage(display: string, timeZone?: string): string | null { + const trimmed = display.trim() + const withTime = trimmed.match( + /^(\d{1,2})\/(\d{1,2})\/(\d{4})[ ,]+(\d{1,2}):(\d{2})(?::(\d{2}))?(?:\s*(AM|PM))?$/i + ) + if (withTime) { + const [, m, d, y, h, min, sec, meridiem] = withTime + let hours = Number(h) + if (meridiem) { + if (hours < 1 || hours > 12) return null + hours = (hours % 12) + (meridiem.toUpperCase() === 'PM' ? 12 : 0) + } else if (hours > 23) { + return null + } + if (Number(min) > 59 || Number(sec ?? 0) > 59) return null + if (!isValidCalendarDay(Number(y), Number(m), Number(d))) return null + const pad = (n: string) => n.padStart(2, '0') + // Route through the shared normalizer so the wall time resolves in the + // effective zone. + return normalizeDateCellValue( + `${y}-${pad(m)}-${pad(d)}T${String(hours).padStart(2, '0')}:${min}:${sec ?? '00'}`, + { timezone: timeZone } + ) } - const full = 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 + if (!isValidCalendarDay(Number(full[3]), Number(full[1]), Number(full[2]))) return null return `${full[3]}-${full[1].padStart(2, '0')}-${full[2].padStart(2, '0')}` } - const partial = 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 = Number(todayLocalCalendarDate(timeZone).slice(0, 4)) + if (!isValidCalendarDay(year, Number(partial[1]), Number(partial[2]))) return null + return `${year}-${partial[1].padStart(2, '0')}-${partial[2].padStart(2, '0')}` } - return null + return normalizeDateCellValue(trimmed, { timezone: timeZone }) +} + +/** True when Y/M/D is a real calendar day — `Date` rolls impossible days over + * (02/30 → 03/02) instead of rejecting them, so compare the round-trip. */ +function isValidCalendarDay(year: number, month: number, day: number): boolean { + if (month < 1 || month > 12 || day < 1 || day > 31) return false + const check = new Date(year, month - 1, day) + return check.getMonth() === month - 1 && check.getDate() === day } diff --git a/apps/sim/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..3ff51410e15 --- /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' + +/** The runtime zone's offset suffix at a given local wall time, e.g. `-07:00`. */ +function localOffsetSuffix(local: Date): string { + const minutes = -local.getTimezoneOffset() + if (minutes === 0) return 'Z' + const sign = minutes > 0 ? '+' : '-' + const abs = Math.abs(minutes) + const pad = (n: number) => String(n).padStart(2, '0') + return `${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}` +} + +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('preserves the wall time and offset of explicit-offset inputs', () => { + expect(normalizeDateCellValue('2026-07-06T16:04:55-07:00')).toBe('2026-07-06T16:04:55-07:00') + expect(normalizeDateCellValue('2026-07-06 16:04:55 PDT')).toBe('2026-07-06T16:04:55-07:00') + expect(normalizeDateCellValue('2026-07-06T23:04:55.000Z')).toBe('2026-07-06T23:04:55Z') + expect(normalizeDateCellValue('2026-07-06 16:04:55+00')).toBe('2026-07-06T16:04:55Z') + expect(normalizeDateCellValue('2026-07-06 16:04:55 EST')).toBe('2026-07-06T16:04:55-05:00') + }) + + it('is idempotent on canonical instants', () => { + const canonical = '2026-07-06T16:04:55-07:00' + expect(normalizeDateCellValue(canonical)).toBe(canonical) + expect(normalizeDateCellValue(canonical, { timezone: 'Asia/Tokyo' })).toBe(canonical) + }) + + it('stamps naive datetimes with the runtime zone offset by default', () => { + const local = new Date(2026, 6, 6, 16, 4, 55) + expect(normalizeDateCellValue('2026-07-06 16:04:55')).toBe( + `2026-07-06T16:04:55${localOffsetSuffix(local)}` + ) + }) + + it('stamps naive datetimes with the provided IANA zone offset', () => { + // July → America/New_York is EDT (UTC-4) + expect(normalizeDateCellValue('2026-07-06 16:04:55', { timezone: 'America/New_York' })).toBe( + '2026-07-06T16:04:55-04:00' + ) + // 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-15T12:00:00-05:00' + ) + expect(normalizeDateCellValue('7/6/2026 4:04 PM', { timezone: 'America/Los_Angeles' })).toBe( + '2026-07-06T16:04:00-07:00' + ) + }) + + it('ignores the zone option when the input carries an explicit offset', () => { + expect( + normalizeDateCellValue('2026-07-06T23:04:55.000Z', { timezone: 'America/New_York' }) + ).toBe('2026-07-06T23:04:55Z') + expect( + normalizeDateCellValue('2026-07-06 16:04:55 PDT', { timezone: 'America/New_York' }) + ).toBe('2026-07-06T16:04:55-07:00') + }) + + 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 the literal wall time — identical for every viewer', () => { + expect(formatDateCellDisplay('2026-07-06T16:04:55-07:00')).toBe('07/06/2026 4:04 PM') + expect(formatDateCellDisplay('2026-07-06T16:04:55-07:00', { seconds: true })).toBe( + '07/06/2026 4:04:55 PM' + ) + // The offset never shifts the displayed wall time + expect(formatDateCellDisplay('2026-07-06T16:04:55+09:00')).toBe('07/06/2026 4:04 PM') + expect(formatDateCellDisplay('2026-07-06T23:04:55Z')).toBe('07/06/2026 11:04 PM') + expect(formatDateCellDisplay('2026-07-06T00:30:00-07:00')).toBe('07/06/2026 12:30 AM') + }) + + it('omits the seconds suffix when seconds are zero', () => { + expect(formatDateCellDisplay('2026-07-06T23:04:00Z', { seconds: true })).toBe( + '07/06/2026 11:04 PM' + ) + }) + + it('returns unparseable legacy strings as-is', () => { + expect(formatDateCellDisplay('garbage')).toBe('garbage') + }) +}) + +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 canonicalizes instants', () => { + expect(storedDateToEditable('2026-07-06')).toBe('2026-07-06') + expect(storedDateToEditable('2026-07-06T16:04:55-07:00')).toBe('2026-07-06T16:04:55-07:00') + expect(storedDateToEditable('2026-07-06T23:04:55.000Z')).toBe('2026-07-06T23:04:55Z') + }) + + 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..a38eca7f21e --- /dev/null +++ b/apps/sim/lib/table/dates.ts @@ -0,0 +1,315 @@ +/** + * 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. + * - **Instant with preserved offset** — RFC 3339 `YYYY-MM-DDTHH:mm:ss±HH:MM` + * (or `Z`). The wall-time part is what was written and is what every viewer + * sees — display never converts across timezones. The offset suffix carries + * the true instant for machine consumers (SQL `::timestamptz` casts, + * workflows, agents, exports). + * + * The interpretation of an input is determined once, at write time: explicit + * offsets (`Z`, `-07:00`, `PDT`) are preserved as written; naive datetime + * strings are stamped with the offset of the writer's effective timezone + * (via {@link NormalizeDateCellOptions.timezone}), else the runtime's local + * zone — the browser for UI writes, the server (UTC in production) for raw + * API writes. After that the stored value is final: reads render its wall + * time verbatim, identically for everyone. + * + * 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}$/ + +/** + * Canonical (or canonical-enough legacy) instant: a literal wall time with an + * optional fractional-seconds part and an optional offset suffix. The capture + * groups are the wall-time fields display renders verbatim. + */ +const WALL_INSTANT_PATTERN = + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?$/ + +/** + * Legacy shape: old CSV imports stored date-only columns as UTC-midnight + * instants. Treated as calendar dates so historical rows render as pure days + * rather than a spurious "12:00 AM". + */ +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})?$/ + +/** + * Fixed offsets (minutes east of UTC) for the RFC 2822 US timezone + * abbreviations — the only abbreviations `Date.parse` accepts, applied as + * literal offsets exactly as the engine does. + */ +const US_ABBREVIATION_OFFSET_MINUTES: Record = { + EST: -300, + EDT: -240, + CST: -360, + CDT: -300, + MST: -420, + MDT: -360, + PST: -480, + PDT: -420, +} + +/** 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())}` +} + +/** `Z` for zero, else `±HH:MM`. */ +function formatOffsetSuffix(offsetMinutes: number): string { + if (offsetMinutes === 0) return 'Z' + const sign = offsetMinutes > 0 ? '+' : '-' + const abs = Math.abs(offsetMinutes) + return `${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}` +} + +/** + * Trailing offset (minutes east of UTC) of a datetime string, or null when + * naive. Recognizes exactly what `Date.parse` recognizes: numeric offsets, + * `Z`/`UT`/`UTC`/`GMT`, and the RFC 2822 US abbreviations. Deliberately does + * not match a trailing `AM`/`PM`. + */ +function extractExplicitOffsetMinutes(value: string): number | null { + const numeric = value.match(/([+-])(\d{1,2}):?(\d{2})?\s*$/) + if (numeric) { + const sign = numeric[1] === '-' ? -1 : 1 + return sign * (Number(numeric[2]) * 60 + Number(numeric[3] ?? 0)) + } + if (/(?:Z|UTC?|GMT)$/i.test(value)) return 0 + const abbreviation = value.match(/([ECMP][SD]T)$/i) + if (abbreviation) return US_ABBREVIATION_OFFSET_MINUTES[abbreviation[1].toUpperCase()] + return null +} + +/** Serializes UTC-read fields of `shifted` as a wall time with `offset`. */ +function formatUtcFieldsAsWall(shifted: Date, offsetMinutes: number): string { + return `${toUtcCalendarDate(shifted)}T${pad(shifted.getUTCHours())}:${pad( + shifted.getUTCMinutes() + )}:${pad(shifted.getUTCSeconds())}${formatOffsetSuffix(offsetMinutes)}` +} + +/** Serializes local-read fields of `parsed` as a wall time with `offset`. */ +function formatLocalFieldsAsWall(parsed: Date, offsetMinutes: number): string { + return `${toLocalCalendarDate(parsed)}T${pad(parsed.getHours())}:${pad( + parsed.getMinutes() + )}:${pad(parsed.getSeconds())}${formatOffsetSuffix(offsetMinutes)}` +} + +export interface NormalizeDateCellOptions { + /** + * IANA zone whose offset stamps 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 offset-preserved instants: the wall time survives verbatim + * (explicit offsets kept as written, naive readings stamped 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) + } + const explicitOffset = extractExplicitOffsetMinutes(trimmed) + if (explicitOffset !== null) { + // The input's own wall time = the instant shifted east by its offset, + // read as UTC fields. + return formatUtcFieldsAsWall(new Date(ms + explicitOffset * 60_000), explicitOffset) + } + if (options?.timezone) { + // `parsed`'s local getters recover the wall-clock fields V8 read from the + // naive string; stamp them with the requested zone's offset at that time. + const instant = wallTimeInZoneToUtc(parsed, options.timezone) + const offsetMinutes = Math.round(zoneOffsetMs(options.timezone, instant) / 60_000) + return formatLocalFieldsAsWall(parsed, offsetMinutes) + } + return formatLocalFieldsAsWall(parsed, -parsed.getTimezoneOffset()) +} + +/** + * Canonical form a stored date cell should be edited (and re-saved) as. + * Legacy UTC-midnight instants surface as their UTC calendar day (old CSV + * imports stored date-only columns that way). 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 +} + +function formatWallForDisplay( + month: string, + day: string, + year: string, + hour: number, + minute: string, + second: number, + withSeconds: boolean | undefined +): string { + const hours12 = hour % 12 === 0 ? 12 : hour % 12 + const meridiem = hour < 12 ? 'AM' : 'PM' + const secondsPart = withSeconds && second !== 0 ? `:${pad(second)}` : '' + return `${month}/${day}/${year} ${hours12}:${minute}${secondsPart} ${meridiem}` +} + +/** + * Formats a stored date-cell value for display. Calendar dates (and legacy + * UTC-midnight instants) render as `MM/DD/YYYY`; instants render their + * **literal wall time** as `MM/DD/YYYY h:mm AM/PM` — identical for every + * viewer, no timezone conversion. Legacy strings that predate + * canonicalization render via a runtime-local normalization; unparseable + * ones 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 wall = stored.match(WALL_INSTANT_PATTERN) + if (wall) { + const [, year, month, day, hour, minute, second] = wall + return formatWallForDisplay( + month, + day, + year, + Number(hour), + minute, + Number(second ?? 0), + options?.seconds + ) + } + const canonical = normalizeDateCellValue(stored) + if (!canonical) return stored + return formatDateCellDisplay(canonical, options) +} 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..d4997a123fe 100644 --- a/apps/sim/lib/table/import.test.ts +++ b/apps/sim/lib/table/import.test.ts @@ -137,8 +137,12 @@ 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, preserves datetime wall times with their offset, 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-01T12:30:00-07:00') + expect(coerceValue('2024-01-01 12:30', 'date', { timezone: 'America/New_York' })).toBe( + '2024-01-01T12:30:00-05:00' + ) 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.test.ts b/packages/emcn/src/components/calendar/calendar.test.ts index f0e3360a21d..e3056173e45 100644 --- a/packages/emcn/src/components/calendar/calendar.test.ts +++ b/packages/emcn/src/components/calendar/calendar.test.ts @@ -2,7 +2,33 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { buildRangeBounds, formatDateRangeLabel, parseDateValue } from './calendar' +import { + buildRangeBounds, + formatDateRangeLabel, + parseDateTimeValue, + parseDateValue, +} from './calendar' + +describe('parseDateTimeValue', () => { + it('reads bare dates as pure days with no time', () => { + expect(parseDateTimeValue('2026-07-06').time).toBeNull() + }) + + it('keeps the time of T datetime strings, even at exactly midnight', () => { + expect(parseDateTimeValue('2026-07-07T00:00:00').time).toBe('00:00') + expect(parseDateTimeValue('2026-07-06T16:04:55').time).toBe('16:04:55') + expect(parseDateTimeValue('2026-07-06T16:04').time).toBe('16:04') + }) + + it('treats a coincidental local midnight as no time for Date instances', () => { + expect(parseDateTimeValue(new Date(2026, 6, 6)).time).toBeNull() + expect(parseDateTimeValue(new Date(2026, 6, 6, 16, 4, 55)).time).toBe('16:04:55') + }) + + it('returns nulls for unparseable input', () => { + expect(parseDateTimeValue('garbage')).toEqual({ date: null, time: null }) + }) +}) describe('parseDateValue', () => { it('parses a YYYY-MM-DD string as a local day', () => { diff --git a/packages/emcn/src/components/calendar/calendar.tsx b/packages/emcn/src/components/calendar/calendar.tsx index cb1af8e0fe7..e47fbd59b51 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,44 @@ 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 `T` datetime string keeps its time even at exactly midnight — it was + * deliberately supplied with a time component, and dropping it would let a + * day-pick silently convert a midnight instant into a bare calendar date. + * The midnight-means-no-time reading applies only to `Date` instances and + * non-`T` strings, where a coincidental local midnight usually denotes a + * pure day. + */ +export 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 } + if (typeof value === 'string' && value.includes('T')) { + return { date: parsed, time: timeOfDayFrom(parsed) } + } + 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 +202,26 @@ 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 + /** + * Today's calendar day (`YYYY-MM-DD`) in the caller's effective timezone; + * drives the Today button and today ring. Defaults to the runtime's local + * day — pass this when the effective zone can differ from the browser's. + */ + today?: string } interface CalendarRangeProps extends CalendarBaseProps { @@ -205,6 +259,9 @@ export type CalendarProps = CalendarSingleProps | CalendarRangeProps * * * @example + * + * + * @example * */ export function Calendar(props: CalendarProps) { @@ -289,19 +346,52 @@ function WeekdayRow() { ) } -function SingleCalendarView({ value, onChange, className }: CalendarSingleProps) { - const selected = useMemo(() => parseDateValue(value), [value]) - const { today, view, setView, goToPrevMonth, goToNextMonth, cells } = useCalendarView(selected) +function SingleCalendarView({ + value, + onChange, + showTime = false, + today: todayValue, + className, +}: CalendarSingleProps) { + const parsed = useMemo(() => parseDateTimeValue(value), [value]) + const selected = parsed.date + const { + today: runtimeToday, + view, + setView, + goToPrevMonth, + goToNextMonth, + cells, + } = useCalendarView(selected) + const today = useMemo( + () => (todayValue ? (parseDateValue(todayValue) ?? runtimeToday) : runtimeToday), + [todayValue, runtimeToday] + ) - 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 selectDay = (day: number) => onChange?.(toDateString(view.year, view.month, day)) + 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) => 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 +422,18 @@ function SingleCalendarView({ value, onChange, className }: CalendarSingleProps) })} + {showTime && ( +
+ Time + +
+ )} +