From 53ea8f29d40928ddb5d5e917c7b6c0fabff3679d Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Sun, 30 Aug 2026 14:13:19 -0700 Subject: [PATCH 01/11] fix(timezone): preserve low-year wall clocks Avoid Date.UTC's 1900 remapping and retain four-digit years through date and TTL editing. --- apps/sim/lib/core/utils/timezone.test.ts | 26 +++++++++++++++ apps/sim/lib/core/utils/timezone.ts | 37 ++++++++++++++------- apps/sim/lib/table/column-types/ttl.test.ts | 12 +++++++ apps/sim/lib/table/dates.test.ts | 9 +++++ apps/sim/lib/table/dates.ts | 5 +-- 5 files changed, 75 insertions(+), 14 deletions(-) diff --git a/apps/sim/lib/core/utils/timezone.test.ts b/apps/sim/lib/core/utils/timezone.test.ts index 5ff9f6cef4d..66a438e4c65 100644 --- a/apps/sim/lib/core/utils/timezone.test.ts +++ b/apps/sim/lib/core/utils/timezone.test.ts @@ -6,12 +6,14 @@ import { getWallClockParts, wallClockNow, zonedClockDate, + zonedWallClock, zonedWallClockToUtc, zonedWallClockWithOffset, } from '@/lib/core/utils/timezone' describe('formatInstantInTimeZone', () => { it.each([ + ['UTC', '0050-01-15T12:00:00Z', '0050-01-15T12:00:00Z'], ['UTC', '2026-06-15T00:15:30Z', '2026-06-15T00:15:30Z'], ['America/Los_Angeles', '2026-06-15T00:15:30Z', '2026-06-14T17:15:30-07:00'], ['Asia/Tokyo', '2026-06-15T00:15:30Z', '2026-06-15T09:15:30+09:00'], @@ -43,6 +45,10 @@ describe('formatInstantInTimeZone', () => { expect(new Date(editable).getTime()).toBe(instant.getTime()) } }) + + it('preserves a four-digit low year in naive wall-clock output', () => { + expect(zonedWallClock(new Date('0050-01-15T12:00:00Z'), 'UTC')).toBe('0050-01-15T12:00') + }) }) describe('getWallClockParts', () => { @@ -65,6 +71,26 @@ describe('zonedWallClockToUtc', () => { ) }) + it.each(['0000', '0001', '0050', '0099'])( + 'preserves the full year %s when resolving a wall-clock', + (year) => { + expect(zonedWallClockToUtc(`${year}-01-15T12:00`, 'UTC').toISOString()).toBe( + `${year}-01-15T12:00:00.000Z` + ) + } + ) + + it('uses the requested low year when resolving historical timezone rules', () => { + const wallClock = '0050-01-15T12:00:00' + + expect(zonedWallClockToUtc(wallClock, 'America/New_York').toISOString()).toBe( + '0050-01-15T16:56:02.000Z' + ) + expect(zonedWallClockWithOffset(wallClock, 'America/New_York')).toBe( + '0050-01-15T12:00:00-04:56' + ) + }) + it('applies a positive (east-of-UTC) offset (Asia/Kolkata, UTC+5:30)', () => { expect(zonedWallClockToUtc('2026-06-15T09:00', 'Asia/Kolkata').toISOString()).toBe( '2026-06-15T03:30:00.000Z' diff --git a/apps/sim/lib/core/utils/timezone.ts b/apps/sim/lib/core/utils/timezone.ts index 36cbcb98951..7f416d852f7 100644 --- a/apps/sim/lib/core/utils/timezone.ts +++ b/apps/sim/lib/core/utils/timezone.ts @@ -38,6 +38,23 @@ function pad(value: number): string { return String(value).padStart(2, '0') } +/** Formats years 0–9999 using ISO's four-digit representation. */ +export function formatIsoYear(year: number): string { + const serialized = String(year) + return year >= 0 && year <= 9999 ? serialized.padStart(4, '0') : serialized +} + +/** Builds a UTC timestamp without `Date.UTC` remapping years 0–99 to 1900–1999. */ +function utcTimestamp(wall: WallClockParts): number { + if (wall.year < 0 || wall.year > 99) { + return Date.UTC(wall.year, wall.month - 1, wall.day, wall.hour, wall.minute, wall.second) + } + const date = new Date(0) + date.setUTCFullYear(wall.year, wall.month - 1, wall.day) + date.setUTCHours(wall.hour, wall.minute, wall.second, 0) + return date.getTime() +} + /** RFC 3339 offset suffix: `Z` for zero, else `±HH:MM`. */ export function formatUtcOffsetSuffix(offsetMinutes: number): string { if (offsetMinutes === 0) return 'Z' @@ -47,14 +64,7 @@ export function formatUtcOffsetSuffix(offsetMinutes: number): string { } function offsetMsFromWallClock(instant: Date, wall: WallClockParts): number { - const wallAsUtc = Date.UTC( - wall.year, - wall.month - 1, - wall.day, - wall.hour, - wall.minute, - wall.second - ) + const wallAsUtc = utcTimestamp(wall) return wallAsUtc - instant.getTime() } @@ -169,6 +179,7 @@ export function getWallClockParts(instant: Date, timeZone?: string): WallClockPa const parts = new Intl.DateTimeFormat('en-US', { timeZone, hourCycle: 'h23', + era: 'short', year: 'numeric', month: '2-digit', day: '2-digit', @@ -177,8 +188,10 @@ export function getWallClockParts(instant: Date, timeZone?: string): WallClockPa second: '2-digit', }).formatToParts(instant) const get = (type: string) => Number(parts.find((part) => part.type === type)?.value) + const year = get('year') + const era = parts.find((part) => part.type === 'era')?.value return { - year: get('year'), + year: era === 'BC' ? 1 - year : year, month: get('month'), day: get('day'), hour: get('hour'), @@ -197,7 +210,7 @@ export function formatInstantInTimeZone( const wholeSecondInstant = new Date(Math.floor(instant.getTime() / 1000) * 1000) const exactOffsetMinutes = offsetMsFromWallClock(wholeSecondInstant, wall) / 60_000 const offsetMinutes = roundOffsetMinutes(exactOffsetMinutes, options) - return `${wall.year}-${pad(wall.month)}-${pad(wall.day)}T${pad(wall.hour)}:${pad(wall.minute)}:${pad(wall.second)}${formatUtcOffsetSuffix(offsetMinutes)}` + return `${formatIsoYear(wall.year)}-${pad(wall.month)}-${pad(wall.day)}T${pad(wall.hour)}:${pad(wall.minute)}:${pad(wall.second)}${formatUtcOffsetSuffix(offsetMinutes)}` } /** @@ -207,7 +220,7 @@ export function formatInstantInTimeZone( */ export function zonedWallClock(instant: Date, timeZone: string): string { const wall = getWallClockParts(instant, timeZone) - return `${wall.year}-${pad(wall.month)}-${pad(wall.day)}T${pad(wall.hour)}:${pad(wall.minute)}` + return `${formatIsoYear(wall.year)}-${pad(wall.month)}-${pad(wall.day)}T${pad(wall.hour)}:${pad(wall.minute)}` } /** The current wall-clock time in `timeZone` as a naive `yyyy-MM-ddTHH:mm` string. */ @@ -261,7 +274,7 @@ function resolveZonedWallClock( const [datePart, timePart] = wallClock.split('T') const [year, month, day] = datePart.split('-').map(Number) const [hour, minute, second = 0] = timePart.split(':').map(Number) - const utcGuess = Date.UTC(year, month - 1, day, hour, minute, second) + const utcGuess = utcTimestamp({ year, month, day, hour, minute, second }) const dayMs = 24 * 60 * 60 * 1000 const offsets = new Set( [-dayMs, 0, dayMs].map((distance) => timezoneOffsetMs(new Date(utcGuess + distance), timeZone)) diff --git a/apps/sim/lib/table/column-types/ttl.test.ts b/apps/sim/lib/table/column-types/ttl.test.ts index f0f9c8e91e5..c42d7bf8bc2 100644 --- a/apps/sim/lib/table/column-types/ttl.test.ts +++ b/apps/sim/lib/table/column-types/ttl.test.ts @@ -139,6 +139,18 @@ describe('TTL column type', () => { } }) + it('round-trips a low-year expiration through the editor', () => { + const input = '0050-01-15T12:00:00' + const seconds = parseTtlEpochSeconds(input, { timezone: 'UTC' }) + + expect(seconds).toBe(Date.parse(`${input}Z`) / 1000) + const editable = ttlColumnType.formatForInput(seconds, column({ type: 'ttl' }), { + timezone: 'UTC', + }) + expect(editable).toBe(`${input}Z`) + expect(parseTtlEpochSeconds(editable, { timezone: 'UTC' })).toBe(seconds) + }) + it('keeps the TTL repeated-hour policy separate from ordinary date behavior', () => { const input = '2026-11-01T01:30' const timezone = 'America/New_York' diff --git a/apps/sim/lib/table/dates.test.ts b/apps/sim/lib/table/dates.test.ts index 5126c5d7724..36df539d5c3 100644 --- a/apps/sim/lib/table/dates.test.ts +++ b/apps/sim/lib/table/dates.test.ts @@ -82,6 +82,15 @@ describe('normalizeDateCellValue', () => { ) }) + it('uses the requested low year when applying IANA timezone rules', () => { + const normalized = normalizeDateCellValue('0050-01-15T12:00:00', { + timezone: 'America/New_York', + }) + + expect(normalized).toBe('0050-01-15T12:00:00-04:56') + expect(storedDateToEditable(normalized ?? '')).toBe('0050-01-15T12:00:00-04:56') + }) + it('reads localized numeric wall clocks before applying the provided IANA zone', () => { expect(normalizeDateCellValue('3/8/2026 2:30 AM', { timezone: 'America/New_York' })).toBe( '2026-03-08T02:30:00-05:00' diff --git a/apps/sim/lib/table/dates.ts b/apps/sim/lib/table/dates.ts index 88bfa6c28c0..f112ad2f242 100644 --- a/apps/sim/lib/table/dates.ts +++ b/apps/sim/lib/table/dates.ts @@ -24,6 +24,7 @@ */ import { + formatIsoYear, formatUtcOffsetSuffix, type ZonedWallClockOptions, zonedWallClockWithOffset, @@ -113,11 +114,11 @@ function pad(n: number): string { } function toLocalCalendarDate(date: Date): string { - return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` + return `${formatIsoYear(date.getFullYear())}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` } function toUtcCalendarDate(date: Date): string { - return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}` + return `${formatIsoYear(date.getUTCFullYear())}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}` } /** From feabe4db6b6280bf3463e9eb0343d0d0db927400 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Sun, 30 Aug 2026 14:18:35 -0700 Subject: [PATCH 02/11] fix(timezone): fall back from invalid saved zones --- .../hooks/queries/general-settings.test.ts | 19 +++++++++++++++++-- apps/sim/hooks/queries/general-settings.ts | 6 ++++-- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/apps/sim/hooks/queries/general-settings.test.ts b/apps/sim/hooks/queries/general-settings.test.ts index 528bff952c7..88bb2c81095 100644 --- a/apps/sim/hooks/queries/general-settings.test.ts +++ b/apps/sim/hooks/queries/general-settings.test.ts @@ -3,8 +3,9 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGetBrowserTimezone, mockUseQuery } = vi.hoisted(() => ({ +const { mockGetBrowserTimezone, mockIsValidTimezone, mockUseQuery } = vi.hoisted(() => ({ mockGetBrowserTimezone: vi.fn(), + mockIsValidTimezone: vi.fn(), mockUseQuery: vi.fn(), })) @@ -13,7 +14,10 @@ vi.mock('@tanstack/react-query', () => ({ useQuery: mockUseQuery, useQueryClient: vi.fn(), })) -vi.mock('@/lib/core/utils/timezone', () => ({ getBrowserTimezone: mockGetBrowserTimezone })) +vi.mock('@/lib/core/utils/timezone', () => ({ + getBrowserTimezone: mockGetBrowserTimezone, + isValidTimezone: mockIsValidTimezone, +})) import { useTimezone, useTimezoneState } from '@/hooks/queries/general-settings' @@ -21,6 +25,7 @@ describe('useTimezone', () => { beforeEach(() => { vi.clearAllMocks() mockGetBrowserTimezone.mockReturnValue('America/Los_Angeles') + mockIsValidTimezone.mockReturnValue(true) }) it('uses the browser timezone while no preference is saved', () => { @@ -40,6 +45,16 @@ describe('useTimezone', () => { expect(mockGetBrowserTimezone).not.toHaveBeenCalled() }) + it('uses the browser timezone when the saved preference is invalid', () => { + mockUseQuery.mockReturnValue({ data: { timezone: 'Not/AZone' } }) + mockIsValidTimezone.mockReturnValue(false) + + expect(useTimezoneState()).toEqual({ + timezone: 'America/Los_Angeles', + status: 'ready', + }) + }) + it('reads the current setting again after it changes', () => { let timezone: string | null = 'America/New_York' mockUseQuery.mockImplementation(() => ({ data: { timezone } })) diff --git a/apps/sim/hooks/queries/general-settings.ts b/apps/sim/hooks/queries/general-settings.ts index b23585307db..d256143e5b8 100644 --- a/apps/sim/hooks/queries/general-settings.ts +++ b/apps/sim/hooks/queries/general-settings.ts @@ -9,7 +9,7 @@ import { updateUserSettingsContract, } from '@/lib/api/contracts/user' import { syncThemeToNextThemes } from '@/lib/core/utils/theme' -import { getBrowserTimezone } from '@/lib/core/utils/timezone' +import { getBrowserTimezone, isValidTimezone } from '@/lib/core/utils/timezone' const logger = createLogger('GeneralSettingsQuery') @@ -164,8 +164,10 @@ export interface TimezoneState { */ export function useTimezoneState(): TimezoneState { const { data, isError } = useGeneralSettings() + const savedTimezone = data?.timezone return { - timezone: data?.timezone ?? getBrowserTimezone(), + timezone: + savedTimezone && isValidTimezone(savedTimezone) ? savedTimezone : getBrowserTimezone(), status: data ? 'ready' : isError ? 'error' : 'loading', } } From 50ea9de3f42756467b410e53d704ad3dcf6bbb96 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Sun, 30 Aug 2026 14:22:03 -0700 Subject: [PATCH 03/11] fix(tables): report rejected TTL imports --- .../lib/table/column-types/import-coercion.ts | 2 +- apps/sim/lib/table/import.test.ts | 4 +-- .../lib/table/orchestration/import.test.ts | 33 +++++++++++++++++++ 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/apps/sim/lib/table/column-types/import-coercion.ts b/apps/sim/lib/table/column-types/import-coercion.ts index 99a823efbf2..9bc0768c61e 100644 --- a/apps/sim/lib/table/column-types/import-coercion.ts +++ b/apps/sim/lib/table/column-types/import-coercion.ts @@ -7,7 +7,7 @@ type ImportValue = Exclude type ImportCoercer = (value: unknown, options?: NormalizeDateCellOptions) => ImportValue const IMPORT_COERCERS: Partial> = { - ttl: (value, options) => parseTtlEpochSeconds(value, options) ?? String(value), + ttl: (value, options) => parseTtlEpochSeconds(value, options), } /** Applies lightweight type-specific CSV coercion without loading the full column registry. */ diff --git a/apps/sim/lib/table/import.test.ts b/apps/sim/lib/table/import.test.ts index 463c18d42de..f8259213a45 100644 --- a/apps/sim/lib/table/import.test.ts +++ b/apps/sim/lib/table/import.test.ts @@ -173,13 +173,13 @@ describe('import', () => { expect(coerceValue('not-a-date', 'date')).toBe('not-a-date') }) - it('coerces TTL imports to epoch seconds and preserves invalid input for row validation', () => { + it('coerces TTL imports to epoch seconds and rejects invalid input', () => { expect(coerceValue('2023-11-14T22:13:20Z', 'ttl')).toBe(1_700_000_000) expect(coerceValue('1700000000', 'ttl')).toBe(1_700_000_000) expect(coerceValue('2023-11-14 17:13:20', 'ttl', { timezone: 'America/New_York' })).toBe( 1_700_000_000 ) - expect(coerceValue('not-a-date', 'ttl')).toBe('not-a-date') + expect(coerceValue('not-a-date', 'ttl')).toBeNull() }) it('applies the timezone supplied to each TTL import independently', () => { diff --git a/apps/sim/lib/table/orchestration/import.test.ts b/apps/sim/lib/table/orchestration/import.test.ts index 405613b0de1..dfac5d90e8d 100644 --- a/apps/sim/lib/table/orchestration/import.test.ts +++ b/apps/sim/lib/table/orchestration/import.test.ts @@ -271,6 +271,39 @@ describe('performTableCsvImport', () => { }) }) + it('counts invalid TTL cells that the import blanks', async () => { + const result = await performTableCsvImport( + importParams({ + table: { + ...TABLE, + schema: { + columns: [ + { + id: 'col_expires_at', + name: 'expires_at', + type: 'ttl', + required: false, + unique: false, + }, + ], + }, + }, + fileStream: csvStream('expires_at\n2023-11-14T22:13:20Z\nnot-a-date\n'), + }) + ) + + expect(result.success).toBe(true) + expect(result.data?.rejections).toEqual({ + rowsRejected: 0, + cellsRejected: 1, + rejectedSamples: [], + }) + expect(mockImportAppendRows.mock.calls[0][2]).toEqual([ + { col_expires_at: 1_700_000_000 }, + { col_expires_at: null }, + ]) + }) + it('omits the accounting entirely from a clean import', async () => { const result = await performTableCsvImport(importParams()) From f524db4c0756213deaba11989b8e5ee625a12996 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Sun, 30 Aug 2026 14:24:12 -0700 Subject: [PATCH 04/11] fix(timezone): reject empty zone identifiers --- apps/sim/lib/core/utils/timezone.test.ts | 4 ++++ apps/sim/lib/core/utils/timezone.ts | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/sim/lib/core/utils/timezone.test.ts b/apps/sim/lib/core/utils/timezone.test.ts index 66a438e4c65..64ce3f793d4 100644 --- a/apps/sim/lib/core/utils/timezone.test.ts +++ b/apps/sim/lib/core/utils/timezone.test.ts @@ -62,6 +62,10 @@ describe('getWallClockParts', () => { second: 30, }) }) + + it('rejects an empty timezone instead of using the runtime local timezone', () => { + expect(() => getWallClockParts(new Date('2026-06-15T00:15:30Z'), '')).toThrow(RangeError) + }) }) describe('zonedWallClockToUtc', () => { diff --git a/apps/sim/lib/core/utils/timezone.ts b/apps/sim/lib/core/utils/timezone.ts index 7f416d852f7..e6319cd59dd 100644 --- a/apps/sim/lib/core/utils/timezone.ts +++ b/apps/sim/lib/core/utils/timezone.ts @@ -165,7 +165,7 @@ export function getTimezoneOptions(): TimezoneOption[] { * timezone when omitted. */ export function getWallClockParts(instant: Date, timeZone?: string): WallClockParts { - if (!timeZone) { + if (timeZone === undefined) { return { year: instant.getFullYear(), month: instant.getMonth() + 1, From b7e563580f2d4bac9f728eeaa5818c635a8b2881 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Sun, 30 Aug 2026 14:28:25 -0700 Subject: [PATCH 05/11] fix(tables): dispatch row delete triggers asynchronously --- .../service-filter-threading.test.ts | 22 +++++++++++++++++++ apps/sim/lib/table/rows/service.ts | 2 +- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/apps/sim/lib/table/__tests__/service-filter-threading.test.ts b/apps/sim/lib/table/__tests__/service-filter-threading.test.ts index e5b89dc7239..a7089a000f4 100644 --- a/apps/sim/lib/table/__tests__/service-filter-threading.test.ts +++ b/apps/sim/lib/table/__tests__/service-filter-threading.test.ts @@ -210,6 +210,28 @@ describe('delete trigger dispatch', () => { ) }) + it('returns after deleting one row without waiting for trigger dispatch', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'row-1', data: { name: 'Ada' } }]) + let releaseTrigger: (() => void) | undefined + const triggerPending = new Promise((resolve) => { + releaseTrigger = resolve + }) + mockFireTableTrigger.mockReturnValueOnce(triggerPending) + const deletion = deleteRow(TABLE, 'row-1', 'req-delete-one') + const onDeleteSettled = vi.fn() + void deletion.then(onDeleteSettled) + + await vi.waitFor(() => expect(mockFireTableTrigger).toHaveBeenCalledTimes(1)) + await Promise.resolve() + + try { + expect(onDeleteSettled).toHaveBeenCalledTimes(1) + } finally { + releaseTrigger?.() + await deletion + } + }) + it('fires once with every committed snapshot in an ID batch', async () => { dbChainMockFns.returning.mockResolvedValueOnce([ { id: 'row-1', data: { name: 'Ada' } }, diff --git a/apps/sim/lib/table/rows/service.ts b/apps/sim/lib/table/rows/service.ts index d322382ea9c..f036f1efce0 100644 --- a/apps/sim/lib/table/rows/service.ts +++ b/apps/sim/lib/table/rows/service.ts @@ -1918,7 +1918,7 @@ export async function deleteRow( if (!deleted) throw new OrchestrationError('not_found', 'Row not found') logger.info(`[${requestId}] Deleted row ${rowId} from table ${table.id}`) - await dispatchDeleteTriggers(table, [deleted], requestId) + void dispatchDeleteTriggers(table, [deleted], requestId) } type BulkUpdateMatch = { id: string; data: RowData } From 0d1686e31fe472f69b343bf66ea819141c747650 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Sun, 30 Aug 2026 15:24:34 -0700 Subject: [PATCH 06/11] fix(tables): cap rows to delete snapshot budget --- apps/sim/lib/table/constants.test.ts | 13 +++++++++++++ apps/sim/lib/table/constants.ts | 14 +++++++++----- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/apps/sim/lib/table/constants.test.ts b/apps/sim/lib/table/constants.test.ts index 68d0b1c723b..050246dddb2 100644 --- a/apps/sim/lib/table/constants.test.ts +++ b/apps/sim/lib/table/constants.test.ts @@ -41,6 +41,7 @@ import { getBillingDisabledTableLimits, getDeleteSnapshotBatchSize, getMaxPageBytes, + getMaxRowSizeBytes, TABLE_LIMITS, } from '@/lib/table/constants?constants-test' @@ -88,6 +89,18 @@ describe('getMaxPageBytes', () => { }) }) +describe('getMaxRowSizeBytes', () => { + beforeEach(() => { + for (const key of Object.keys(mockEnv)) delete mockEnv[key] + }) + + it('caps overrides at the delete snapshot byte budget', () => { + mockEnv.TABLE_MAX_ROW_SIZE_BYTES = String(TABLE_LIMITS.DELETE_SNAPSHOT_BATCH_MAX_BYTES * 2) + + expect(getMaxRowSizeBytes()).toBe(TABLE_LIMITS.DELETE_SNAPSHOT_BATCH_MAX_BYTES) + }) +}) + describe('getDeleteSnapshotBatchSize', () => { beforeEach(() => { for (const key of Object.keys(mockEnv)) delete mockEnv[key] diff --git a/apps/sim/lib/table/constants.ts b/apps/sim/lib/table/constants.ts index e5c514acd8e..c622cbbb9f4 100644 --- a/apps/sim/lib/table/constants.ts +++ b/apps/sim/lib/table/constants.ts @@ -146,13 +146,17 @@ export function getMaxPageBytes(): number { /** * Maximum serialized size in bytes of a single row. Defaults to * `TABLE_LIMITS.MAX_ROW_SIZE_BYTES`; overridable via the - * `TABLE_MAX_ROW_SIZE_BYTES` env var (server-only, read at call time). + * `TABLE_MAX_ROW_SIZE_BYTES` env var (server-only, read at call time), capped + * at the delete snapshot budget so every accepted row fits in one batch. */ export function getMaxRowSizeBytes(): number { - return envNumber(env.TABLE_MAX_ROW_SIZE_BYTES, TABLE_LIMITS.MAX_ROW_SIZE_BYTES, { - min: 1, - integer: true, - }) + return Math.min( + envNumber(env.TABLE_MAX_ROW_SIZE_BYTES, TABLE_LIMITS.MAX_ROW_SIZE_BYTES, { + min: 1, + integer: true, + }), + TABLE_LIMITS.DELETE_SNAPSHOT_BATCH_MAX_BYTES + ) } /** From 2300445d28fc0a9965a52a1378f606fe4e4e7849 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Sun, 30 Aug 2026 15:33:01 -0700 Subject: [PATCH 07/11] fix(tables): signal partial TTL cleanup changes --- .../background/cleanup-table-row-ttl.test.ts | 19 +++++++ apps/sim/background/cleanup-table-row-ttl.ts | 56 ++++++++++--------- 2 files changed, 48 insertions(+), 27 deletions(-) diff --git a/apps/sim/background/cleanup-table-row-ttl.test.ts b/apps/sim/background/cleanup-table-row-ttl.test.ts index d49cf34eae5..c26a5b664c3 100644 --- a/apps/sim/background/cleanup-table-row-ttl.test.ts +++ b/apps/sim/background/cleanup-table-row-ttl.test.ts @@ -256,6 +256,25 @@ describe('table row TTL cleanup', () => { expect(mockSignalTableRowsChanged).toHaveBeenCalledWith(secondTable.id) }) + it('signals tables changed before a later table cleanup failure propagates', async () => { + const secondTable = { + ...table, + id: 'table-2', + } + mockListExecute.mockResolvedValue([ + { id: table.id, workspaceId: table.workspaceId }, + { id: secondTable.id, workspaceId: secondTable.workspaceId }, + ]) + mockWithLockedTable.mockImplementation(async (tableId, mutate) => { + if (tableId === secondTable.id) throw new Error('second table cleanup failed') + return mutate(table, { execute: vi.fn().mockResolvedValue(returnedRows(1)) }) + }) + + await expect(runCleanupTableRowTtl()).rejects.toThrow('second table cleanup failed') + expect(mockSignalTableRowsChanged).toHaveBeenCalledTimes(1) + expect(mockSignalTableRowsChanged).toHaveBeenCalledWith(table.id) + }) + it('registers one serialized Trigger.dev task', () => { expect(cleanupTableRowTtlTask).toEqual( expect.objectContaining({ diff --git a/apps/sim/background/cleanup-table-row-ttl.ts b/apps/sim/background/cleanup-table-row-ttl.ts index bd343a71970..c0b201cfc5e 100644 --- a/apps/sim/background/cleanup-table-row-ttl.ts +++ b/apps/sim/background/cleanup-table-row-ttl.ts @@ -246,36 +246,38 @@ export async function runCleanupTableRowTtl( let deleted = 0 let batches = 0 - while ( - batches < TTL_CLEANUP_MAX_BATCHES && - !signal?.aborted && - tableStates.some((state) => !state.complete) - ) { - for (const state of tableStates) { - if (state.complete) continue - if (batches === TTL_CLEANUP_MAX_BATCHES || signal?.aborted) break + try { + while ( + batches < TTL_CLEANUP_MAX_BATCHES && + !signal?.aborted && + tableStates.some((state) => !state.complete) + ) { + for (const state of tableStates) { + if (state.complete) continue + if (batches === TTL_CLEANUP_MAX_BATCHES || signal?.aborted) break - const batch = await deleteExpiredRowsForTable( - state.ref, - nowEpochSeconds, - batchSize, - state.after - ) - if (!batch.attempted) { - state.complete = true - continue - } + const batch = await deleteExpiredRowsForTable( + state.ref, + nowEpochSeconds, + batchSize, + state.after + ) + if (!batch.attempted) { + state.complete = true + continue + } - batches++ - deleted += batch.deleted - state.deleted += batch.deleted - state.after = batch.cursor ?? undefined - if (batch.deleted < batchSize) state.complete = true + batches++ + deleted += batch.deleted + state.deleted += batch.deleted + state.after = batch.cursor ?? undefined + if (batch.deleted < batchSize) state.complete = true + } + } + } finally { + for (const state of tableStates) { + if (state.deleted > 0) signalTableRowsChanged(state.ref.id) } - } - - for (const state of tableStates) { - if (state.deleted > 0) signalTableRowsChanged(state.ref.id) } const limitReached = From d1bae6943d048bf29b92e35b8f67fe0c75330b49 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Sun, 30 Aug 2026 15:37:45 -0700 Subject: [PATCH 08/11] fix(tables): wait for timezone before date edits --- .../table-grid/cells/inline-editors.test.ts | 36 +++++++++++++++++++ .../table-grid/cells/inline-editors.tsx | 10 +++--- 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors.test.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors.test.ts index e5b3e8c4264..7ca9f9d1d65 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors.test.ts @@ -140,6 +140,42 @@ describe('dateEditorRawValue', () => { container.remove() }) + it('waits for the saved timezone before creating an ordinary date draft', () => { + mockUseTimezoneState.mockReturnValue({ + timezone: 'Asia/Tokyo', + status: 'loading', + }) + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + const onSave = vi.fn() + const props = { + value: '2026-06-15T06:00:30-07:00', + column: column('date'), + onSave, + onCancel: vi.fn(), + } + + act(() => root.render(createElement(InlineEditor, props))) + + expect(container.querySelector('input')).toBeNull() + expect(container.querySelector('[role="status"]')?.textContent).toBe('Loading timezone…') + + mockUseTimezoneState.mockReturnValue({ + timezone: 'America/Los_Angeles', + status: 'ready', + }) + act(() => root.render(createElement(InlineEditor, props))) + + const input = container.querySelector('input') as HTMLInputElement + act(() => changeInput(input, '09/01/2026 9:00 AM')) + act(() => input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))) + + expect(onSave).toHaveBeenCalledWith('2026-09-01T09:00:00-07:00', 'enter') + act(() => root.unmount()) + container.remove() + }) + it('rejects an impossible TTL draft without clearing the cell', () => { const container = document.createElement('div') document.body.appendChild(container) 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 b03c3aeb69e..2eff16abb18 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 @@ -69,17 +69,17 @@ function handleEditorWheel(e: React.WheelEvent) { * (including seconds), the time field keeps the day — and Enter/blur commits. */ function InlineDateEditor(props: InlineEditorProps) { - const { column, onCancel } = props + const { onCancel } = props const timezoneState = useTimezoneState() - const ttlTimezoneUnavailable = column.type === 'ttl' && timezoneState.status !== 'ready' + const timezoneUnavailable = timezoneState.status !== 'ready' useEffect(() => { - if (column.type !== 'ttl' || timezoneState.status !== 'error') return + if (timezoneState.status !== 'error') return toast.error('Could not load timezone') onCancel() - }, [column.type, onCancel, timezoneState.status]) + }, [onCancel, timezoneState.status]) - if (ttlTimezoneUnavailable) { + if (timezoneUnavailable) { return ( {timezoneState.status === 'error' ? 'Timezone unavailable' : 'Loading timezone…'} From 66766bc66de58a8f139cd56817c37722dacb1589 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Sun, 30 Aug 2026 15:43:58 -0700 Subject: [PATCH 09/11] fix(tables): preserve blank TTL values --- apps/sim/lib/table/column-types/ttl.test.ts | 26 ++++++++++++++++++++- apps/sim/lib/table/column-types/ttl.ts | 10 ++++++-- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/apps/sim/lib/table/column-types/ttl.test.ts b/apps/sim/lib/table/column-types/ttl.test.ts index c42d7bf8bc2..717e96ebb8a 100644 --- a/apps/sim/lib/table/column-types/ttl.test.ts +++ b/apps/sim/lib/table/column-types/ttl.test.ts @@ -10,7 +10,7 @@ import { } from '@/lib/core/utils/timezone' import { parseTtlEpochSeconds, ttlColumnType } from '@/lib/table/column-types/ttl' import { retypeCellRewrite } from '@/lib/table/columns/service' -import type { ColumnDefinition } from '@/lib/table/types' +import type { ColumnDefinition, JsonValue } from '@/lib/table/types' const column = (over: Partial): ColumnDefinition => ({ name: 'col', type: 'string', ...over }) as ColumnDefinition @@ -22,6 +22,30 @@ describe('TTL column type', () => { ).toEqual({ value: '2023-11-14T22:13:20Z' }) }) + it('keeps blank and malformed TTL values out of the epoch-zero formatter', () => { + const cases: Array<[unknown, string]> = [ + [null, ''], + [undefined, ''], + ['', ''], + [' ', ' '], + [false, 'false'], + [[], ''], + ] + for (const [value, fallback] of cases) { + expect(ttlColumnType.formatForDisplay(value, column({ type: 'ttl' }))).toBe(fallback) + expect(ttlColumnType.formatForInput(value, column({ type: 'ttl' }))).toBe(fallback) + } + }) + + it('preserves blank and malformed TTL values when converting to a date', () => { + const target = column({ type: 'date' }) + const values: JsonValue[] = [null, '', ' ', false, []] + + for (const value of values) { + expect(ttlColumnType.valueForConversion?.(value, target)).toEqual(value) + } + }) + it.each([ ['UTC', '2026-06-15T09:00:30', '2026-06-15T09:00:30.000Z'], ['America/New_York', '2026-06-15T09:00:30', '2026-06-15T13:00:30.000Z'], diff --git a/apps/sim/lib/table/column-types/ttl.ts b/apps/sim/lib/table/column-types/ttl.ts index 00088fa1771..5b04023e05c 100644 --- a/apps/sim/lib/table/column-types/ttl.ts +++ b/apps/sim/lib/table/column-types/ttl.ts @@ -68,6 +68,12 @@ export function parseTtlEpochSeconds( } function epochSecondsToIso(value: unknown): string | null { + if ( + typeof value !== 'number' && + (typeof value !== 'string' || !NUMERIC_VALUE_PATTERN.test(value.trim())) + ) { + return null + } const seconds = typeof value === 'number' ? value : Number(value) if (!isRepresentableEpochSeconds(seconds)) return null return new Date(seconds * 1000).toISOString().replace('.000Z', 'Z') @@ -113,10 +119,10 @@ export const ttlColumnType: ColumnTypeDefinition = { formatForDisplay(value) { const iso = epochSecondsToIso(value) - return iso === null ? String(value) : formatDateCellDisplay(iso, { seconds: true }) + return iso === null ? String(value ?? '') : formatDateCellDisplay(iso, { seconds: true }) }, formatForInput(value, _column, context) { - return epochSecondsToEditable(value, context?.timezone) ?? String(value) + return epochSecondsToEditable(value, context?.timezone) ?? String(value ?? '') }, } From 5551be8fa315207eea5ae6c9879a208d9c5b5757 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Sun, 30 Aug 2026 19:29:15 -0700 Subject: [PATCH 10/11] fix(tables): guard date edits against invalid timezones --- .../settings/components/general/general.tsx | 23 +++++- .../general/timezone-picker.test.ts | 52 ++++++++++++ .../components/general/timezone-picker.ts | 45 +++++++++++ .../components/row-modal/row-modal.test.tsx | 80 +++++++++++++++++-- .../components/row-modal/row-modal.tsx | 36 +++++++-- .../table-grid/cells/cell-content.tsx | 4 + .../table-grid/cells/cell-render.test.ts | 36 ++++++++- .../table-grid/cells/cell-render.tsx | 11 ++- .../table-grid/cells/inline-editors.test.ts | 35 +++++++- .../table-grid/cells/inline-editors.tsx | 14 +++- .../components/table-grid/data-row.tsx | 6 ++ .../components/table-grid/table-grid.tsx | 32 +++++++- .../[tableId]/components/timezone-editing.ts | 14 ++++ .../hooks/queries/general-settings.test.ts | 14 +++- apps/sim/hooks/queries/general-settings.ts | 43 +++++++--- apps/sim/lib/core/utils/timezone.ts | 7 +- 16 files changed, 411 insertions(+), 41 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/general/timezone-picker.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/general/timezone-picker.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/timezone-editing.ts diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx index bb6758aec4d..faa87c11096 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx @@ -33,6 +33,10 @@ import { generalViewParam, generalViewUrlKeys, } from '@/app/workspace/[workspaceId]/settings/components/general/search-params' +import { + getTimezonePickerPresentation, + timezonePreferenceFromPickerValue, +} from '@/app/workspace/[workspaceId]/settings/components/general/timezone-picker' import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' @@ -221,7 +225,12 @@ export function General() { } const handleTimezoneChange = async (value: string) => { - await updateSetting.mutateAsync({ key: 'timezone', value }) + const timezone = timezonePreferenceFromPickerValue(value) + if (timezone === undefined) return + await updateSetting.mutateAsync({ + key: 'timezone', + value: timezone, + }) } const handleAutoConnectChange = async (checked: boolean) => { @@ -288,6 +297,14 @@ export function General() { return } + const browserTimezone = getBrowserTimezone() + const savedTimezone = settings?.timezone ?? null + const timezonePicker = getTimezonePickerPresentation( + savedTimezone, + browserTimezone, + TIMEZONE_OPTIONS + ) + return ( <> @@ -433,10 +450,10 @@ export function General() { dropdownWidth={240} searchable searchPlaceholder='Search timezones' - value={settings?.timezone ?? getBrowserTimezone()} + value={timezonePicker.value} onChange={handleTimezoneChange} placeholder='Select timezone' - options={TIMEZONE_OPTIONS} + options={timezonePicker.options} /> diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/general/timezone-picker.test.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/general/timezone-picker.test.ts new file mode 100644 index 00000000000..d64658e74f8 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/general/timezone-picker.test.ts @@ -0,0 +1,52 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + AUTO_TIMEZONE_OPTION_VALUE, + getTimezonePickerPresentation, + INVALID_TIMEZONE_OPTION_VALUE, + timezonePreferenceFromPickerValue, +} from '@/app/workspace/[workspaceId]/settings/components/general/timezone-picker' + +const timezoneOptions = [{ label: 'Los Angeles', value: 'America/Los_Angeles' }] + +describe('getTimezonePickerPresentation', () => { + it('shows an unset preference as an explicit browser-managed option', () => { + expect(getTimezonePickerPresentation(null, 'America/Los_Angeles', timezoneOptions)).toEqual({ + value: AUTO_TIMEZONE_OPTION_VALUE, + options: [ + { label: 'Auto: America/Los_Angeles', value: AUTO_TIMEZONE_OPTION_VALUE }, + ...timezoneOptions, + ], + }) + }) + + it('keeps a valid saved timezone selected independently of Auto', () => { + expect( + getTimezonePickerPresentation('America/Los_Angeles', 'America/Los_Angeles', timezoneOptions) + .value + ).toBe('America/Los_Angeles') + }) + + it('surfaces an invalid saved timezone without making it selectable', () => { + expect(getTimezonePickerPresentation('Mars/Olympus', 'UTC', timezoneOptions)).toEqual({ + value: INVALID_TIMEZONE_OPTION_VALUE, + options: [ + { label: 'Auto: UTC', value: AUTO_TIMEZONE_OPTION_VALUE }, + { + label: 'Invalid: Mars/Olympus', + value: INVALID_TIMEZONE_OPTION_VALUE, + disabled: true, + }, + ...timezoneOptions, + ], + }) + }) + + it('persists Auto as an unset preference', () => { + expect(timezonePreferenceFromPickerValue(AUTO_TIMEZONE_OPTION_VALUE)).toBeNull() + expect(timezonePreferenceFromPickerValue('Asia/Tokyo')).toBe('Asia/Tokyo') + expect(timezonePreferenceFromPickerValue(INVALID_TIMEZONE_OPTION_VALUE)).toBeUndefined() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/general/timezone-picker.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/general/timezone-picker.ts new file mode 100644 index 00000000000..ef0dbbb761d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/general/timezone-picker.ts @@ -0,0 +1,45 @@ +import type { ComboboxOption } from '@sim/emcn' +import { isValidTimezone, sanitizeTimezoneForDisplay } from '@/lib/core/utils/timezone' + +export const AUTO_TIMEZONE_OPTION_VALUE = '__auto_timezone__' +export const INVALID_TIMEZONE_OPTION_VALUE = '__invalid_timezone__' + +interface TimezonePickerPresentation { + value: string + options: ComboboxOption[] +} + +/** Builds the picker state without making an unset browser fallback look persisted. */ +export function getTimezonePickerPresentation( + savedTimezone: string | null, + browserTimezone: string, + timezoneOptions: readonly ComboboxOption[] +): TimezonePickerPresentation { + const hasInvalidTimezone = savedTimezone !== null && !isValidTimezone(savedTimezone) + const safeInvalidTimezone = + savedTimezone === null ? '' : sanitizeTimezoneForDisplay(savedTimezone) + + return { + value: hasInvalidTimezone + ? INVALID_TIMEZONE_OPTION_VALUE + : (savedTimezone ?? AUTO_TIMEZONE_OPTION_VALUE), + options: [ + { label: `Auto: ${browserTimezone}`, value: AUTO_TIMEZONE_OPTION_VALUE }, + ...(hasInvalidTimezone + ? [ + { + label: `Invalid: ${safeInvalidTimezone || '(empty)'}`, + value: INVALID_TIMEZONE_OPTION_VALUE, + disabled: true, + }, + ] + : []), + ...timezoneOptions, + ], + } +} + +export function timezonePreferenceFromPickerValue(value: string): string | null | undefined { + if (value === INVALID_TIMEZONE_OPTION_VALUE) return undefined + return value === AUTO_TIMEZONE_OPTION_VALUE ? null : value +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.test.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.test.tsx index 851a755b22a..773a6600639 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.test.tsx @@ -7,12 +7,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { TableInfo, TableRow } from '@/lib/table' import { RowModal } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal' -const { mockUseTimezoneState, mockUpdateRow, mockDeleteRow, mockDeleteRows } = vi.hoisted(() => ({ - mockUseTimezoneState: vi.fn(), - mockUpdateRow: vi.fn(), - mockDeleteRow: vi.fn(), - mockDeleteRows: vi.fn(), -})) +const { mockToastError, mockUseTimezoneState, mockUpdateRow, mockDeleteRow, mockDeleteRows } = + vi.hoisted(() => ({ + mockToastError: vi.fn(), + mockUseTimezoneState: vi.fn(), + mockUpdateRow: vi.fn(), + mockDeleteRow: vi.fn(), + mockDeleteRows: vi.fn(), + })) vi.mock('next/navigation', () => ({ useParams: () => ({ workspaceId: 'workspace-1' }), @@ -64,6 +66,7 @@ vi.mock('@sim/emcn', () => { onChange(event.currentTarget.value), }), Label: passthrough, + toast: { error: mockToastError }, } }) @@ -145,4 +148,69 @@ describe('RowModal expiration editing', () => { act(() => root.unmount()) container.remove() }) + + it('also waits for timezone settings on an ordinary Date column', () => { + mockUseTimezoneState.mockReturnValue({ timezone: 'Asia/Tokyo', status: 'loading' }) + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + const props = { + mode: 'edit' as const, + isOpen: true, + onClose: vi.fn(), + table: { + id: 'table-2', + name: 'Dates', + schema: { columns: [{ name: 'starts_at', type: 'date' as const }] }, + }, + row: { ...row, data: { starts_at: '2026-06-15T09:00:00+09:00' } }, + onSuccess: vi.fn(), + } + + act(() => root.render(createElement(RowModal, props))) + + expect(container.querySelector('[role="status"]')?.textContent).toBe('Loading timezone…') + expect(container.querySelector('[data-testid="time"]')).toBeNull() + + mockUseTimezoneState.mockReturnValue({ + timezone: 'America/Los_Angeles', + status: 'ready', + }) + act(() => root.render(createElement(RowModal, props))) + + expect(container.querySelector('[data-testid="time"]')).not.toBeNull() + act(() => root.unmount()) + container.remove() + }) + + it('blocks an invalid saved timezone with the plain-text guidance', () => { + mockUseTimezoneState.mockReturnValue({ + timezone: 'America/Los_Angeles', + savedTimezone: 'Mars/Olympus', + status: 'invalid', + }) + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + const props = { + mode: 'edit' as const, + isOpen: true, + onClose: vi.fn(), + table, + row, + onSuccess: vi.fn(), + } + + act(() => root.render(createElement(RowModal, props))) + + expect(container.querySelector('[role="status"]')?.textContent).toBe('Invalid timezone') + expect(container.querySelector('[data-testid="submit"]')?.disabled).toBe( + true + ) + expect(mockToastError).toHaveBeenCalledWith( + 'Your saved timezone “Mars/Olympus” is invalid. Update it in Settings → General before editing Date or Expiration cells.' + ) + act(() => root.unmount()) + container.remove() + }) }) 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 e139b38b849..74229d5c9f2 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 @@ -1,6 +1,6 @@ 'use client' -import { useId, useRef, useState } from 'react' +import { useEffect, useId, useRef, useState } from 'react' import { Checkbox, ChipConfirmModal, @@ -13,6 +13,7 @@ import { ChipModalHeader, ChipTimePicker, Label, + toast, } from '@sim/emcn' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' @@ -20,6 +21,7 @@ import { useParams } from 'next/navigation' import type { ColumnDefinition, TableInfo, TableRow } from '@/lib/table' import { columnTypeOf } from '@/lib/table/column-types' import { resolveCurrencyCode } from '@/lib/table/currency' +import { getTimezoneEditBlockedMessage } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/timezone-editing' import { useTimezoneState } from '@/hooks/queries/general-settings' import { useDeleteTableRow, useDeleteTableRows, useUpdateTableRow } from '@/hooks/queries/tables' import { @@ -83,8 +85,9 @@ export function RowModal({ mode, isOpen, onClose, table, row, rowIds, onSuccess if (timezoneState.status === 'ready' && editTimeZoneRef.current === null) { editTimeZoneRef.current = timezoneState.timezone } - const hasTtlColumn = mode === 'edit' && columns.some((column) => column.type === 'ttl') - const ttlTimezoneUnavailable = hasTtlColumn && editTimeZoneRef.current === null + const hasDateEditorColumn = + mode === 'edit' && columns.some((column) => columnTypeOf(column).editor === 'date') + const timezoneUnavailable = hasDateEditorColumn && editTimeZoneRef.current === null const timeZone = editTimeZoneRef.current ?? timezoneState.timezone const [rowData, setRowData] = useState>(() => mode === 'edit' && row ? row.data : {} @@ -96,10 +99,23 @@ export function RowModal({ mode, isOpen, onClose, table, row, rowIds, onSuccess const isSubmitting = updateRowMutation.isPending || deleteRowMutation.isPending || deleteRowsMutation.isPending + const timezoneBlockedMessage = getTimezoneEditBlockedMessage(timezoneState) + useEffect(() => { + if ( + !isOpen || + !timezoneUnavailable || + (timezoneState.status !== 'invalid' && timezoneState.status !== 'error') || + !timezoneBlockedMessage + ) { + return + } + toast.error(timezoneBlockedMessage) + }, [isOpen, timezoneBlockedMessage, timezoneState.status, timezoneUnavailable]) + const handleFormSubmit = async (e?: React.FormEvent) => { e?.preventDefault() setError(null) - if (ttlTimezoneUnavailable) return + if (timezoneUnavailable) return try { const cleanData = cleanRowData(columns, rowData, timeZone) @@ -177,10 +193,14 @@ export function RowModal({ mode, isOpen, onClose, table, row, rowIds, onSuccess Update values for {table?.name ?? 'table'}

-