From 11db20d95cde0e5b1ea910413db53cdc8c0328a2 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Tue, 25 Aug 2026 12:05:42 -0700 Subject: [PATCH 1/2] fix(timezone): consolidate table wall-clock conversion --- .../[workspaceId]/tables/[tableId]/utils.ts | 3 +- apps/sim/lib/core/utils/timezone.test.ts | 105 ++++++++- apps/sim/lib/core/utils/timezone.ts | 159 ++++++++++---- .../__tests__/column-type-registry.test.ts | 62 ++++++ apps/sim/lib/table/column-types/ttl.ts | 2 +- apps/sim/lib/table/dates.test.ts | 44 ++++ apps/sim/lib/table/dates.ts | 203 ++++++++---------- 7 files changed, 424 insertions(+), 154 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.ts index b31b5f1ea48..1844dc3a07b 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.ts @@ -1,7 +1,8 @@ +import { getWallClockParts } from '@/lib/core/utils/timezone' import type { ColumnDefinition, JsonValue } from '@/lib/table' import type { ColumnType } from '@/lib/table/column-types' import { columnTypeById, columnTypeOf } from '@/lib/table/column-types' -import { formatDateCellDisplay, getWallClockParts, normalizeDateCellValue } from '@/lib/table/dates' +import { formatDateCellDisplay, normalizeDateCellValue } from '@/lib/table/dates' /** * Pick a fresh "untitled[_N]" name not already taken by `columns`. Used by diff --git a/apps/sim/lib/core/utils/timezone.test.ts b/apps/sim/lib/core/utils/timezone.test.ts index 935a9061cee..50085886c28 100644 --- a/apps/sim/lib/core/utils/timezone.test.ts +++ b/apps/sim/lib/core/utils/timezone.test.ts @@ -1,12 +1,63 @@ import { describe, expect, it } from 'vitest' import { + formatInstantInTimeZone, getSupportedTimezones, getTimezoneOptions, + getWallClockParts, wallClockNow, zonedClockDate, zonedWallClockToUtc, + zonedWallClockWithOffset, } from './timezone' +describe('formatInstantInTimeZone', () => { + it.each([ + ['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'], + ['Asia/Kathmandu', '2026-06-15T00:15:30Z', '2026-06-15T06:00:30+05:45'], + ['Australia/Lord_Howe', '2026-06-15T00:15:30Z', '2026-06-15T10:45:30+10:30'], + ])('formats an instant in %s with its exact offset', (timeZone, iso, expected) => { + expect(formatInstantInTimeZone(new Date(iso), timeZone)).toBe(expected) + }) + + it('distinguishes both copies of an autumn daylight-saving hour', () => { + expect(formatInstantInTimeZone(new Date('2026-11-01T05:30:00Z'), 'America/New_York')).toBe( + '2026-11-01T01:30:00-04:00' + ) + expect(formatInstantInTimeZone(new Date('2026-11-01T06:30:00Z'), 'America/New_York')).toBe( + '2026-11-01T01:30:00-05:00' + ) + }) + + it('round-trips the same instant after changing display timezones', () => { + const instant = new Date('2026-11-01T06:30:00Z') + for (const timeZone of [ + 'UTC', + 'America/Los_Angeles', + 'America/New_York', + 'Asia/Kathmandu', + 'Australia/Lord_Howe', + ]) { + const editable = formatInstantInTimeZone(instant, timeZone) + expect(new Date(editable).getTime()).toBe(instant.getTime()) + } + }) +}) + +describe('getWallClockParts', () => { + it('returns the calendar fields of an instant in the requested timezone', () => { + expect(getWallClockParts(new Date('2026-06-15T00:15:30Z'), 'America/Los_Angeles')).toEqual({ + year: 2026, + month: 6, + day: 14, + hour: 17, + minute: 15, + second: 30, + }) + }) +}) + describe('zonedWallClockToUtc', () => { it('treats a UTC wall-clock as the same instant', () => { expect(zonedWallClockToUtc('2026-06-15T09:00', 'UTC').toISOString()).toBe( @@ -48,11 +99,57 @@ describe('zonedWallClockToUtc', () => { }) it('resolves a spring-forward gap wall-clock forward by the DST shift', () => { - // 2026-03-08 02:00–02:59 does not exist in America/New_York (EST→EDT). - expect(zonedWallClockToUtc('2026-03-08T02:30', 'America/New_York').toISOString()).toBe( - '2026-03-08T07:30:00.000Z' - ) + const instant = zonedWallClockToUtc('2026-03-08T02:30', 'America/New_York') + const stampedWallClock = zonedWallClockWithOffset('2026-03-08T02:30', 'America/New_York') + + expect(instant.toISOString()).toBe('2026-03-08T07:30:00.000Z') + expect(stampedWallClock).toBe('2026-03-08T02:30-05:00') + expect(new Date(stampedWallClock).toISOString()).toBe(instant.toISOString()) }) + + it.each([ + [ + 'Europe/Berlin', + '2026-03-29T02:30', + '2026-03-29T01:30:00.000Z', + '2026-03-29T03:30:00+02:00', + '2026-03-29T02:30+01:00', + ], + [ + 'Australia/Lord_Howe', + '2026-10-04T02:15', + '2026-10-03T15:45:00.000Z', + '2026-10-04T02:45:00+11:00', + '2026-10-04T02:15+10:30', + ], + ])( + 'resolves an east-of-UTC spring-forward gap in %s to the first compatible wall-clock', + (timeZone, wallClock, expectedInstant, expectedRenderedWallClock, expectedStampedWallClock) => { + const instant = zonedWallClockToUtc(wallClock, timeZone) + const stampedWallClock = zonedWallClockWithOffset(wallClock, timeZone) + + expect(instant.toISOString()).toBe(expectedInstant) + expect(formatInstantInTimeZone(instant, timeZone)).toBe(expectedRenderedWallClock) + expect(stampedWallClock).toBe(expectedStampedWallClock) + expect(new Date(stampedWallClock).toISOString()).toBe(expectedInstant) + } + ) + + it.each([ + ['America/New_York', '2026-11-01T01:30', '2026-11-01T05:30:00.000Z', '-04:00'], + ['Europe/Berlin', '2026-10-25T02:30', '2026-10-25T00:30:00.000Z', '+02:00'], + ['Australia/Lord_Howe', '2026-04-05T01:45', '2026-04-04T14:45:00.000Z', '+11:00'], + ])( + 'keeps the earlier instant for an ambiguous fall-back wall-clock in %s', + (timeZone, wallClock, expectedInstant, expectedOffset) => { + const instant = zonedWallClockToUtc(wallClock, timeZone) + const stampedWallClock = zonedWallClockWithOffset(wallClock, timeZone) + + expect(instant.toISOString()).toBe(expectedInstant) + expect(stampedWallClock).toBe(`${wallClock}${expectedOffset}`) + expect(new Date(stampedWallClock).toISOString()).toBe(expectedInstant) + } + ) }) describe('wallClockNow', () => { diff --git a/apps/sim/lib/core/utils/timezone.ts b/apps/sim/lib/core/utils/timezone.ts index 5118061bb4d..f3802350bb8 100644 --- a/apps/sim/lib/core/utils/timezone.ts +++ b/apps/sim/lib/core/utils/timezone.ts @@ -21,6 +21,41 @@ const COMMON_TIMEZONES = [ 'Australia/Sydney', ] +/** 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 +} + +function pad(value: number): string { + return String(value).padStart(2, '0') +} + +/** RFC 3339 offset suffix: `Z` for zero, else `±HH:MM`. */ +export function formatUtcOffsetSuffix(offsetMinutes: number): string { + if (offsetMinutes === 0) return 'Z' + const sign = offsetMinutes > 0 ? '+' : '-' + const absoluteMinutes = Math.abs(offsetMinutes) + return `${sign}${pad(Math.floor(absoluteMinutes / 60))}:${pad(absoluteMinutes % 60)}` +} + +function offsetMsFromWallClock(instant: Date, wall: WallClockParts): number { + const wallAsUtc = Date.UTC( + wall.year, + wall.month - 1, + wall.day, + wall.hour, + wall.minute, + wall.second + ) + return wallAsUtc - instant.getTime() +} + /** The IANA timezone the current runtime resolves to (e.g. `America/New_York`). */ export function getBrowserTimezone(): string { return Intl.DateTimeFormat().resolvedOptions().timeZone @@ -83,22 +118,57 @@ export function getTimezoneOptions(): TimezoneOption[] { } /** - * An instant's wall-clock time in `timeZone` as a naive `yyyy-MM-ddTHH:mm` - * string. Lets callers reason about a user's local date/time without UTC — e.g. - * to recover the local date/time a stored task instant represents in its zone. + * The wall-clock fields of `instant` in `timeZone`, or in the runtime's local + * timezone when omitted. */ -export function zonedWallClock(instant: Date, timeZone: string): string { - const parts = new Intl.DateTimeFormat('en-CA', { +export function getWallClockParts(instant: Date, timeZone?: string): WallClockParts { + if (!timeZone) { + return { + year: instant.getFullYear(), + month: instant.getMonth() + 1, + day: instant.getDate(), + hour: instant.getHours(), + minute: instant.getMinutes(), + second: instant.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', - hourCycle: 'h23', + second: '2-digit', }).formatToParts(instant) - const get = (type: string) => parts.find((p) => p.type === type)?.value ?? '00' - return `${get('year')}-${get('month')}-${get('day')}T${get('hour')}:${get('minute')}` + const get = (type: string) => Number(parts.find((part) => part.type === type)?.value) + return { + year: get('year'), + month: get('month'), + day: get('day'), + hour: get('hour'), + minute: get('minute'), + second: get('second'), + } +} + +/** Formats an instant as an RFC 3339 wall time in an IANA timezone. */ +export function formatInstantInTimeZone(instant: Date, timeZone: string): string { + const wall = getWallClockParts(instant, timeZone) + const offsetMinutes = Math.round(offsetMsFromWallClock(instant, wall) / 60_000) + return `${wall.year}-${pad(wall.month)}-${pad(wall.day)}T${pad(wall.hour)}:${pad(wall.minute)}:${pad(wall.second)}${formatUtcOffsetSuffix(offsetMinutes)}` +} + +/** + * An instant's wall-clock time in `timeZone` as a naive `yyyy-MM-ddTHH:mm` + * string. Lets callers reason about a user's local date/time without UTC — e.g. + * to recover the local date/time a stored task instant represents in its zone. + */ +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)}` } /** The current wall-clock time in `timeZone` as a naive `yyyy-MM-ddTHH:mm` string. */ @@ -123,26 +193,40 @@ export function zonedClockDate(instant: Date, timeZone: string): Date { /** The UTC offset (ms, east-positive) of `timeZone` at a given instant. */ function timezoneOffsetMs(instant: Date, timeZone: string): number { - 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(instant) - const get = (type: string) => Number(parts.find((p) => p.type === type)?.value) - const asUtc = Date.UTC( - get('year'), - get('month') - 1, - get('day'), - get('hour'), - get('minute'), - get('second') + return offsetMsFromWallClock(instant, getWallClockParts(instant, timeZone)) +} + +interface ZonedWallClockResolution { + instant: Date + offsetMinutes: number +} + +function resolveZonedWallClock(wallClock: string, timeZone: string): ZonedWallClockResolution { + 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 dayMs = 24 * 60 * 60 * 1000 + const offsets = new Set( + [-dayMs, 0, dayMs].map((distance) => timezoneOffsetMs(new Date(utcGuess + distance), timeZone)) ) - return asUtc - instant.getTime() + const candidates = [...offsets].map((offset) => { + const instantMs = utcGuess - offset + const actualOffset = timezoneOffsetMs(new Date(instantMs), timeZone) + return { instantMs, wallClockMs: instantMs + actualOffset } + }) + const exactCandidate = candidates + .filter(({ wallClockMs }) => wallClockMs === utcGuess) + .sort((a, b) => a.instantMs - b.instantMs)[0] + const compatibleCandidate = candidates + .filter(({ wallClockMs }) => wallClockMs > utcGuess) + .sort((a, b) => a.wallClockMs - b.wallClockMs || a.instantMs - b.instantMs)[0] + const chosenCandidate = exactCandidate ?? compatibleCandidate ?? candidates[0] + const instantMs = chosenCandidate.instantMs + return { + instant: new Date(instantMs), + offsetMinutes: Math.round((utcGuess - instantMs) / 60_000), + } } /** @@ -152,22 +236,17 @@ function timezoneOffsetMs(instant: Date, timeZone: string): number { * date (including future ones whose offset differs from today's) and across DST: * a naive single pass reads the offset on the wrong side of a same-day boundary * — notably the autumn fall-back hour — and lands an hour off. For an ambiguous - * fall-back wall-clock the later (post-transition) instant is chosen; a + * fall-back wall-clock the earlier instant is chosen; a * wall-clock in the spring-forward gap (a nonexistent local hour) has no * self-consistent instant and resolves forward by the DST shift, matching how * calendar apps treat that once-a-year hour. */ export function zonedWallClockToUtc(wallClock: string, timeZone: string): Date { - 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 guessOffset = timezoneOffsetMs(new Date(utcGuess), timeZone) - const candidate = utcGuess - guessOffset - const candidateOffset = timezoneOffsetMs(new Date(candidate), timeZone) - if (candidateOffset === guessOffset) return new Date(candidate) - const adjusted = utcGuess - candidateOffset - return timezoneOffsetMs(new Date(adjusted), timeZone) === candidateOffset - ? new Date(adjusted) - : new Date(candidate) + return resolveZonedWallClock(wallClock, timeZone).instant +} + +/** Stamps a naive wall-clock with the offset selected by the shared timezone resolver. */ +export function zonedWallClockWithOffset(wallClock: string, timeZone: string): string { + const { offsetMinutes } = resolveZonedWallClock(wallClock, timeZone) + return `${wallClock}${formatUtcOffsetSuffix(offsetMinutes)}` } diff --git a/apps/sim/lib/table/__tests__/column-type-registry.test.ts b/apps/sim/lib/table/__tests__/column-type-registry.test.ts index 7fcdceeb148..900575c2e50 100644 --- a/apps/sim/lib/table/__tests__/column-type-registry.test.ts +++ b/apps/sim/lib/table/__tests__/column-type-registry.test.ts @@ -10,6 +10,7 @@ * here. */ import { describe, expect, it } from 'vitest' +import { zonedWallClockToUtc } from '@/lib/core/utils/timezone' import type { ColumnType } from '@/lib/table/column-types' import { ALL_COLUMN_TYPES, @@ -192,6 +193,67 @@ describe('ttl columns', () => { ).toBe('2023-11-05T01:30:00-05:00') }) + it('matches the shared wall-clock resolver in every effective timezone', () => { + const wallClock = '2026-06-15T09:00:30' + for (const timezone of [ + 'UTC', + 'America/Los_Angeles', + 'America/New_York', + 'Asia/Kathmandu', + 'Australia/Lord_Howe', + ]) { + const expected = Math.floor(zonedWallClockToUtc(wallClock, timezone).getTime() / 1000) + expect(COLUMN_TYPE_REGISTRY.ttl.coerce(wallClock, column, { timezone })).toEqual({ + ok: true, + value: expected, + }) + } + }) + + it.each([ + ['Europe/Berlin', '2026-03-29T02:30'], + ['Australia/Lord_Howe', '2026-10-04T02:15'], + ])('coerces a %s spring-forward gap wall clock to the compatible epoch', (timezone, input) => { + const expected = Math.floor(zonedWallClockToUtc(input, timezone).getTime() / 1000) + expect(COLUMN_TYPE_REGISTRY.ttl.coerce(input, column, { timezone })).toEqual({ + ok: true, + value: expected, + }) + }) + + it('coerces a localized month-name gap input in the explicit workspace timezone', () => { + const timezone = 'America/New_York' + const expected = Math.floor(zonedWallClockToUtc('2026-03-08T02:30', timezone).getTime() / 1000) + expect(COLUMN_TYPE_REGISTRY.ttl.coerce('March 8, 2026 2:30 AM', column, { timezone })).toEqual({ + ok: true, + value: expected, + }) + }) + + it('rejects an impossible ISO expiration date', () => { + expect( + COLUMN_TYPE_REGISTRY.ttl.coerce('2026-02-30T12:00:00', column, { timezone: 'UTC' }) + ).toEqual({ ok: false }) + }) + + it('round-trips epoch seconds after the editor timezone changes', () => { + for (const seconds of [1_700_000_000, 1_699_162_200, 1_699_165_800]) { + for (const timezone of [ + 'UTC', + 'America/Los_Angeles', + 'America/New_York', + 'Asia/Kathmandu', + 'Australia/Lord_Howe', + ]) { + const editable = COLUMN_TYPE_REGISTRY.ttl.formatForInput(seconds, column, { timezone }) + expect(COLUMN_TYPE_REGISTRY.ttl.coerce(editable, column, { timezone })).toEqual({ + ok: true, + value: seconds, + }) + } + } + }) + it('limits a table to one ttl column', () => { expect(COLUMN_TYPE_REGISTRY.ttl.maxPerTable).toBe(1) }) diff --git a/apps/sim/lib/table/column-types/ttl.ts b/apps/sim/lib/table/column-types/ttl.ts index 854a04b2c23..82ff2769016 100644 --- a/apps/sim/lib/table/column-types/ttl.ts +++ b/apps/sim/lib/table/column-types/ttl.ts @@ -1,8 +1,8 @@ import { TypeTtl } from '@sim/emcn/icons' +import { formatInstantInTimeZone } from '@/lib/core/utils/timezone' import type { ColumnTypeDefinition } from '@/lib/table/column-types/types' import { formatDateCellDisplay, - formatInstantInTimeZone, type NormalizeDateCellOptions, normalizeDateCellValue, } from '@/lib/table/dates' diff --git a/apps/sim/lib/table/dates.test.ts b/apps/sim/lib/table/dates.test.ts index 3ff51410e15..639d0544120 100644 --- a/apps/sim/lib/table/dates.test.ts +++ b/apps/sim/lib/table/dates.test.ts @@ -22,6 +22,8 @@ function localOffsetSuffix(local: Date): string { describe('isCalendarDateString', () => { it('accepts YYYY-MM-DD and rejects everything else', () => { expect(isCalendarDateString('2026-07-06')).toBe(true) + expect(isCalendarDateString('2024-02-29')).toBe(true) + expect(isCalendarDateString('2026-02-30')).toBe(false) expect(isCalendarDateString('2026-13-45')).toBe(false) expect(isCalendarDateString('2026-07-06T00:00:00Z')).toBe(false) expect(isCalendarDateString('07/06/2026')).toBe(false) @@ -80,6 +82,30 @@ describe('normalizeDateCellValue', () => { ) }) + 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' + ) + expect(normalizeDateCellValue('7/6/2026, 16:04:55', { timezone: 'Asia/Tokyo' })).toBe( + '2026-07-06T16:04:55+09:00' + ) + }) + + it('reads month-name wall clocks independently of the runtime timezone', () => { + expect(normalizeDateCellValue('March 8, 2026 2:30 AM', { timezone: 'America/New_York' })).toBe( + '2026-03-08T02:30:00-05:00' + ) + }) + + it.each([ + ['America/New_York', '2026-11-01 01:30:00', '2026-11-01T01:30:00-04:00'], + ['America/New_York', '2026-03-08 02:30:00', '2026-03-08T02:30:00-05:00'], + ['Asia/Kathmandu', '2026-06-15 09:00:00', '2026-06-15T09:00:00+05:45'], + ['Australia/Lord_Howe', '2026-06-15 09:00:00', '2026-06-15T09:00:00+10:30'], + ])('uses the shared timezone rules for %s', (timezone, input, expected) => { + expect(normalizeDateCellValue(input, { timezone })).toBe(expected) + }) + it('ignores the zone option when the input carries an explicit offset', () => { expect( normalizeDateCellValue('2026-07-06T23:04:55.000Z', { timezone: 'America/New_York' }) @@ -107,6 +133,24 @@ describe('normalizeDateCellValue', () => { expect(normalizeDateCellValue('2026-13-45')).toBeNull() expect(normalizeDateCellValue('13/06/2026')).toBeNull() }) + + it('rejects impossible ISO calendar and time fields', () => { + expect(normalizeDateCellValue('2026-02-30')).toBeNull() + expect(normalizeDateCellValue('2025-02-29T12:00:00Z')).toBeNull() + expect(normalizeDateCellValue('2026-02-30 12:00', { timezone: 'UTC' })).toBeNull() + expect(normalizeDateCellValue('2026-02-30 12:00 PDT')).toBeNull() + expect(normalizeDateCellValue('2026-07-06T24:00', { timezone: 'UTC' })).toBeNull() + expect(normalizeDateCellValue('2026-07-06 24:00+00')).toBeNull() + expect(normalizeDateCellValue('2026-07-06T12:60:00-04:00')).toBeNull() + }) + + it('accepts leap days and valid daylight-saving gap wall clocks', () => { + expect(normalizeDateCellValue('2024-02-29')).toBe('2024-02-29') + expect(normalizeDateCellValue('2024-02-29T12:00:00Z')).toBe('2024-02-29T12:00:00Z') + expect(normalizeDateCellValue('2026-03-08T02:30:00', { timezone: 'America/New_York' })).toBe( + '2026-03-08T02:30:00-05:00' + ) + }) }) describe('formatDateCellDisplay', () => { diff --git a/apps/sim/lib/table/dates.ts b/apps/sim/lib/table/dates.ts index 0c6360f63fb..f6c53ac30de 100644 --- a/apps/sim/lib/table/dates.ts +++ b/apps/sim/lib/table/dates.ts @@ -23,7 +23,9 @@ * barrel (the barrel is server-tainted). */ -const CALENDAR_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/ +import { formatUtcOffsetSuffix, zonedWallClockWithOffset } from '@/lib/core/utils/timezone' + +const CALENDAR_DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/ /** * Canonical (or canonical-enough legacy) instant: a literal wall time with an @@ -31,7 +33,10 @@ const CALENDAR_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/ * 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})?$/ + /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?(?:\.\d+)?(?:\s*(?:Z|UTC?|GMT|[ECMP][SD]T)|[+-]\d{1,2}(?::?\d{2})?)?$/i + +const LOCALIZED_WALL_CLOCK_PATTERN = + /^(\d{1,2})\/(\d{1,2})\/(\d{4})[ ,]+(\d{1,2}):(\d{2})(?::(\d{2}))?(?:\s*(AM|PM))?$/i /** * Legacy shape: old CSV imports stored date-only columns as UTC-midnight @@ -67,81 +72,10 @@ const US_ABBREVIATION_OFFSET_MINUTES: Record = { /** 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 calendar = value.match(CALENDAR_DATE_PATTERN) + return Boolean( + calendar && isValidCalendarDay(Number(calendar[1]), Number(calendar[2]), Number(calendar[3])) ) - const adjusted = guess - zoneOffsetMs(timeZone, new Date(guess)) - return new Date(guess - zoneOffsetMs(timeZone, new Date(adjusted))) } function pad(n: number): string { @@ -156,29 +90,6 @@ 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)}` -} - -/** Formats an instant as canonical wall time in an IANA timezone. */ -export function formatInstantInTimeZone(date: Date, timeZone: string): string { - const wall = getWallClockParts(date, timeZone) - const wallAsUtc = Date.UTC( - wall.year, - wall.month - 1, - wall.day, - wall.hour, - wall.minute, - wall.second - ) - const offsetMinutes = Math.round((wallAsUtc - date.getTime()) / 60_000) - return `${wall.year}-${pad(wall.month)}-${pad(wall.day)}T${pad(wall.hour)}:${pad(wall.minute)}:${pad(wall.second)}${formatOffsetSuffix(offsetMinutes)}` -} - /** * Trailing offset (minutes east of UTC) of a datetime string, or null when * naive. Recognizes exactly what `Date.parse` recognizes: numeric offsets, @@ -201,14 +112,83 @@ function extractExplicitOffsetMinutes(value: string): number | null { function formatUtcFieldsAsWall(shifted: Date, offsetMinutes: number): string { return `${toUtcCalendarDate(shifted)}T${pad(shifted.getUTCHours())}:${pad( shifted.getUTCMinutes() - )}:${pad(shifted.getUTCSeconds())}${formatOffsetSuffix(offsetMinutes)}` + )}:${pad(shifted.getUTCSeconds())}${formatUtcOffsetSuffix(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)}` + )}:${pad(parsed.getSeconds())}${formatUtcOffsetSuffix(offsetMinutes)}` +} + +/** True when numeric year, month, and day fields describe a real calendar day. */ +function isValidCalendarDay(year: number, month: number, day: number): boolean { + if (month < 1 || month > 12 || day < 1) return false + const leapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) + const daysInMonth = [31, leapYear ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] + return day <= daysInMonth[month - 1] +} + +/** Validates and formats numeric wall-clock fields as naive ISO. */ +function formatValidatedWallClock( + year: number, + month: number, + day: number, + hour: number, + minute: number, + second: number +): string | null { + if ( + !isValidCalendarDay(year, month, day) || + hour < 0 || + hour > 23 || + minute < 0 || + minute > 59 || + second < 0 || + second > 59 + ) { + return null + } + return `${String(year).padStart(4, '0')}-${pad(month)}-${pad(day)}T${pad(hour)}:${pad(minute)}:${pad(second)}` +} + +/** Reads an ISO-shaped wall clock literally, before runtime timezone normalization. */ +function parseIsoWallClock(match: RegExpMatchArray): string | null { + return formatValidatedWallClock( + Number(match[1]), + Number(match[2]), + Number(match[3]), + Number(match[4]), + Number(match[5]), + Number(match[6] ?? 0) + ) +} + +/** Reads a supported US numeric wall clock literally, including 12-hour input. */ +function parseLocalizedWallClock(match: RegExpMatchArray): string | null { + const meridiem = match[7]?.toUpperCase() + let hour = Number(match[4]) + if (meridiem) { + if (hour < 1 || hour > 12) return null + hour = (hour % 12) + (meridiem === 'PM' ? 12 : 0) + } + return formatValidatedWallClock( + Number(match[3]), + Number(match[1]), + Number(match[2]), + hour, + Number(match[5]), + Number(match[6] ?? 0) + ) +} + +/** Recovers broader naive `Date.parse` inputs without consulting the runtime timezone. */ +function parseNaiveWallClockAsUtc(value: string): string | null { + const ms = Date.parse(`${value} UTC`) + if (Number.isNaN(ms)) return null + const parsed = new Date(ms) + return `${toUtcCalendarDate(parsed)}T${pad(parsed.getUTCHours())}:${pad(parsed.getUTCMinutes())}:${pad(parsed.getUTCSeconds())}` } export interface NormalizeDateCellOptions { @@ -235,9 +215,18 @@ export function normalizeDateCellValue( ): 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 calendar = trimmed.match(CALENDAR_DATE_PATTERN) + if (calendar) { + return isValidCalendarDay(Number(calendar[1]), Number(calendar[2]), Number(calendar[3])) + ? trimmed + : null } + const isoMatch = trimmed.match(WALL_INSTANT_PATTERN) + const isoWallClock = isoMatch ? parseIsoWallClock(isoMatch) : undefined + if (isoWallClock === null) return null + const localizedMatch = trimmed.match(LOCALIZED_WALL_CLOCK_PATTERN) + const localizedWallClock = localizedMatch ? parseLocalizedWallClock(localizedMatch) : undefined + if (localizedWallClock === null) return null const ms = Date.parse(trimmed) if (Number.isNaN(ms)) return null const parsed = new Date(ms) @@ -253,11 +242,9 @@ export function normalizeDateCellValue( 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) + const wallClock = isoWallClock ?? localizedWallClock ?? parseNaiveWallClockAsUtc(trimmed) + if (!wallClock) return null + return zonedWallClockWithOffset(wallClock, options.timezone) } return formatLocalFieldsAsWall(parsed, -parsed.getTimezoneOffset()) } From d546727f34e3e48435f084cd6c228e5120ac2718 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Tue, 25 Aug 2026 12:43:09 -0700 Subject: [PATCH 2/2] fix(timezone): preserve ambiguous date semantics --- apps/sim/lib/core/utils/timezone.test.ts | 8 ++-- apps/sim/lib/core/utils/timezone.ts | 4 +- apps/sim/lib/table/dates.test.ts | 20 +++++++- apps/sim/lib/table/dates.ts | 60 ++++++++++++++++++++++++ 4 files changed, 85 insertions(+), 7 deletions(-) diff --git a/apps/sim/lib/core/utils/timezone.test.ts b/apps/sim/lib/core/utils/timezone.test.ts index 50085886c28..cc42da77aac 100644 --- a/apps/sim/lib/core/utils/timezone.test.ts +++ b/apps/sim/lib/core/utils/timezone.test.ts @@ -136,11 +136,11 @@ describe('zonedWallClockToUtc', () => { ) it.each([ - ['America/New_York', '2026-11-01T01:30', '2026-11-01T05:30:00.000Z', '-04:00'], - ['Europe/Berlin', '2026-10-25T02:30', '2026-10-25T00:30:00.000Z', '+02:00'], - ['Australia/Lord_Howe', '2026-04-05T01:45', '2026-04-04T14:45:00.000Z', '+11:00'], + ['America/New_York', '2026-11-01T01:30', '2026-11-01T06:30:00.000Z', '-05:00'], + ['Europe/Berlin', '2026-10-25T02:30', '2026-10-25T01:30:00.000Z', '+01:00'], + ['Australia/Lord_Howe', '2026-04-05T01:45', '2026-04-04T15:15:00.000Z', '+10:30'], ])( - 'keeps the earlier instant for an ambiguous fall-back wall-clock in %s', + 'chooses the later post-transition instant for an ambiguous fall-back wall-clock in %s', (timeZone, wallClock, expectedInstant, expectedOffset) => { const instant = zonedWallClockToUtc(wallClock, timeZone) const stampedWallClock = zonedWallClockWithOffset(wallClock, timeZone) diff --git a/apps/sim/lib/core/utils/timezone.ts b/apps/sim/lib/core/utils/timezone.ts index f3802350bb8..4d1de490419 100644 --- a/apps/sim/lib/core/utils/timezone.ts +++ b/apps/sim/lib/core/utils/timezone.ts @@ -217,7 +217,7 @@ function resolveZonedWallClock(wallClock: string, timeZone: string): ZonedWallCl }) const exactCandidate = candidates .filter(({ wallClockMs }) => wallClockMs === utcGuess) - .sort((a, b) => a.instantMs - b.instantMs)[0] + .sort((a, b) => b.instantMs - a.instantMs)[0] const compatibleCandidate = candidates .filter(({ wallClockMs }) => wallClockMs > utcGuess) .sort((a, b) => a.wallClockMs - b.wallClockMs || a.instantMs - b.instantMs)[0] @@ -236,7 +236,7 @@ function resolveZonedWallClock(wallClock: string, timeZone: string): ZonedWallCl * date (including future ones whose offset differs from today's) and across DST: * a naive single pass reads the offset on the wrong side of a same-day boundary * — notably the autumn fall-back hour — and lands an hour off. For an ambiguous - * fall-back wall-clock the earlier instant is chosen; a + * fall-back wall-clock the later, post-transition instant is chosen; a * wall-clock in the spring-forward gap (a nonexistent local hour) has no * self-consistent instant and resolves forward by the DST shift, matching how * calendar apps treat that once-a-year hour. diff --git a/apps/sim/lib/table/dates.test.ts b/apps/sim/lib/table/dates.test.ts index 639d0544120..3ab6a905a1a 100644 --- a/apps/sim/lib/table/dates.test.ts +++ b/apps/sim/lib/table/dates.test.ts @@ -97,8 +97,26 @@ describe('normalizeDateCellValue', () => { ) }) + it('rejects impossible month-name calendar dates', () => { + expect( + normalizeDateCellValue('February 29, 2025 2:30 AM', { timezone: 'America/New_York' }) + ).toBeNull() + expect( + normalizeDateCellValue('April 31, 2026 4:04 PM', { timezone: 'America/New_York' }) + ).toBeNull() + }) + + it('accepts valid leap-day month-name wall clocks in either date order', () => { + expect( + normalizeDateCellValue('February 29, 2024 4:04 PM', { timezone: 'America/New_York' }) + ).toBe('2024-02-29T16:04:00-05:00') + expect(normalizeDateCellValue('29 Feb 2024 4:04 PM', { timezone: 'America/New_York' })).toBe( + '2024-02-29T16:04:00-05:00' + ) + }) + it.each([ - ['America/New_York', '2026-11-01 01:30:00', '2026-11-01T01:30:00-04:00'], + ['America/New_York', '2026-11-01 01:30:00', '2026-11-01T01:30:00-05:00'], ['America/New_York', '2026-03-08 02:30:00', '2026-03-08T02:30:00-05:00'], ['Asia/Kathmandu', '2026-06-15 09:00:00', '2026-06-15T09:00:00+05:45'], ['Australia/Lord_Howe', '2026-06-15 09:00:00', '2026-06-15T09:00:00+10:30'], diff --git a/apps/sim/lib/table/dates.ts b/apps/sim/lib/table/dates.ts index f6c53ac30de..041b984bfaa 100644 --- a/apps/sim/lib/table/dates.ts +++ b/apps/sim/lib/table/dates.ts @@ -38,6 +38,31 @@ const WALL_INSTANT_PATTERN = const LOCALIZED_WALL_CLOCK_PATTERN = /^(\d{1,2})\/(\d{1,2})\/(\d{4})[ ,]+(\d{1,2}):(\d{2})(?::(\d{2}))?(?:\s*(AM|PM))?$/i +const MONTH_NAME_PATTERN = + 'Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:t(?:ember)?)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?' +const MONTH_FIRST_DATE_PATTERN = new RegExp( + `\\b(${MONTH_NAME_PATTERN})\\s+(\\d{1,2})(?:,)?\\s+(\\d{4})\\b`, + 'i' +) +const DAY_FIRST_DATE_PATTERN = new RegExp( + `\\b(\\d{1,2})\\s+(${MONTH_NAME_PATTERN})(?:,)?\\s+(\\d{4})\\b`, + 'i' +) +const MONTH_BY_ABBREVIATION: Record = { + JAN: 1, + FEB: 2, + MAR: 3, + APR: 4, + MAY: 5, + JUN: 6, + JUL: 7, + AUG: 8, + SEP: 9, + OCT: 10, + NOV: 11, + DEC: 12, +} + /** * Legacy shape: old CSV imports stored date-only columns as UTC-midnight * instants. Treated as calendar dates so historical rows render as pure days @@ -183,11 +208,46 @@ function parseLocalizedWallClock(match: RegExpMatchArray): string | null { ) } +interface CalendarFields { + year: number + month: number + day: number +} + +/** Extracts literal calendar fields from supported month-name date forms. */ +function extractMonthNameCalendar(value: string): CalendarFields | null { + const monthFirst = value.match(MONTH_FIRST_DATE_PATTERN) + if (monthFirst) { + return { + year: Number(monthFirst[3]), + month: MONTH_BY_ABBREVIATION[monthFirst[1].slice(0, 3).toUpperCase()], + day: Number(monthFirst[2]), + } + } + const dayFirst = value.match(DAY_FIRST_DATE_PATTERN) + if (!dayFirst) return null + return { + year: Number(dayFirst[3]), + month: MONTH_BY_ABBREVIATION[dayFirst[2].slice(0, 3).toUpperCase()], + day: Number(dayFirst[1]), + } +} + /** Recovers broader naive `Date.parse` inputs without consulting the runtime timezone. */ function parseNaiveWallClockAsUtc(value: string): string | null { + const calendar = extractMonthNameCalendar(value) + if (calendar && !isValidCalendarDay(calendar.year, calendar.month, calendar.day)) return null const ms = Date.parse(`${value} UTC`) if (Number.isNaN(ms)) return null const parsed = new Date(ms) + if ( + calendar && + (parsed.getUTCFullYear() !== calendar.year || + parsed.getUTCMonth() + 1 !== calendar.month || + parsed.getUTCDate() !== calendar.day) + ) { + return null + } return `${toUtcCalendarDate(parsed)}T${pad(parsed.getUTCHours())}:${pad(parsed.getUTCMinutes())}:${pad(parsed.getUTCSeconds())}` }