Skip to content

Commit 01f58f2

Browse files
committed
fix(timezone): preserve ambiguous date semantics
1 parent ed26bf2 commit 01f58f2

4 files changed

Lines changed: 85 additions & 7 deletions

File tree

apps/sim/lib/core/utils/timezone.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -136,11 +136,11 @@ describe('zonedWallClockToUtc', () => {
136136
)
137137

138138
it.each([
139-
['America/New_York', '2026-11-01T01:30', '2026-11-01T05:30:00.000Z', '-04:00'],
140-
['Europe/Berlin', '2026-10-25T02:30', '2026-10-25T00:30:00.000Z', '+02:00'],
141-
['Australia/Lord_Howe', '2026-04-05T01:45', '2026-04-04T14:45:00.000Z', '+11:00'],
139+
['America/New_York', '2026-11-01T01:30', '2026-11-01T06:30:00.000Z', '-05:00'],
140+
['Europe/Berlin', '2026-10-25T02:30', '2026-10-25T01:30:00.000Z', '+01:00'],
141+
['Australia/Lord_Howe', '2026-04-05T01:45', '2026-04-04T15:15:00.000Z', '+10:30'],
142142
])(
143-
'keeps the earlier instant for an ambiguous fall-back wall-clock in %s',
143+
'chooses the later post-transition instant for an ambiguous fall-back wall-clock in %s',
144144
(timeZone, wallClock, expectedInstant, expectedOffset) => {
145145
const instant = zonedWallClockToUtc(wallClock, timeZone)
146146
const stampedWallClock = zonedWallClockWithOffset(wallClock, timeZone)

apps/sim/lib/core/utils/timezone.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,7 @@ function resolveZonedWallClock(wallClock: string, timeZone: string): ZonedWallCl
217217
})
218218
const exactCandidate = candidates
219219
.filter(({ wallClockMs }) => wallClockMs === utcGuess)
220-
.sort((a, b) => a.instantMs - b.instantMs)[0]
220+
.sort((a, b) => b.instantMs - a.instantMs)[0]
221221
const compatibleCandidate = candidates
222222
.filter(({ wallClockMs }) => wallClockMs > utcGuess)
223223
.sort((a, b) => a.wallClockMs - b.wallClockMs || a.instantMs - b.instantMs)[0]
@@ -236,7 +236,7 @@ function resolveZonedWallClock(wallClock: string, timeZone: string): ZonedWallCl
236236
* date (including future ones whose offset differs from today's) and across DST:
237237
* a naive single pass reads the offset on the wrong side of a same-day boundary
238238
* — notably the autumn fall-back hour — and lands an hour off. For an ambiguous
239-
* fall-back wall-clock the earlier instant is chosen; a
239+
* fall-back wall-clock the later, post-transition instant is chosen; a
240240
* wall-clock in the spring-forward gap (a nonexistent local hour) has no
241241
* self-consistent instant and resolves forward by the DST shift, matching how
242242
* calendar apps treat that once-a-year hour.

apps/sim/lib/table/dates.test.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,8 +97,26 @@ describe('normalizeDateCellValue', () => {
9797
)
9898
})
9999

100+
it('rejects impossible month-name calendar dates', () => {
101+
expect(
102+
normalizeDateCellValue('February 29, 2025 2:30 AM', { timezone: 'America/New_York' })
103+
).toBeNull()
104+
expect(
105+
normalizeDateCellValue('April 31, 2026 4:04 PM', { timezone: 'America/New_York' })
106+
).toBeNull()
107+
})
108+
109+
it('accepts valid leap-day month-name wall clocks in either date order', () => {
110+
expect(
111+
normalizeDateCellValue('February 29, 2024 4:04 PM', { timezone: 'America/New_York' })
112+
).toBe('2024-02-29T16:04:00-05:00')
113+
expect(normalizeDateCellValue('29 Feb 2024 4:04 PM', { timezone: 'America/New_York' })).toBe(
114+
'2024-02-29T16:04:00-05:00'
115+
)
116+
})
117+
100118
it.each([
101-
['America/New_York', '2026-11-01 01:30:00', '2026-11-01T01:30:00-04:00'],
119+
['America/New_York', '2026-11-01 01:30:00', '2026-11-01T01:30:00-05:00'],
102120
['America/New_York', '2026-03-08 02:30:00', '2026-03-08T02:30:00-05:00'],
103121
['Asia/Kathmandu', '2026-06-15 09:00:00', '2026-06-15T09:00:00+05:45'],
104122
['Australia/Lord_Howe', '2026-06-15 09:00:00', '2026-06-15T09:00:00+10:30'],

apps/sim/lib/table/dates.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,31 @@ const WALL_INSTANT_PATTERN =
3838
const LOCALIZED_WALL_CLOCK_PATTERN =
3939
/^(\d{1,2})\/(\d{1,2})\/(\d{4})[ ,]+(\d{1,2}):(\d{2})(?::(\d{2}))?(?:\s*(AM|PM))?$/i
4040

41+
const MONTH_NAME_PATTERN =
42+
'Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:t(?:ember)?)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?'
43+
const MONTH_FIRST_DATE_PATTERN = new RegExp(
44+
`\\b(${MONTH_NAME_PATTERN})\\s+(\\d{1,2})(?:,)?\\s+(\\d{4})\\b`,
45+
'i'
46+
)
47+
const DAY_FIRST_DATE_PATTERN = new RegExp(
48+
`\\b(\\d{1,2})\\s+(${MONTH_NAME_PATTERN})(?:,)?\\s+(\\d{4})\\b`,
49+
'i'
50+
)
51+
const MONTH_BY_ABBREVIATION: Record<string, number> = {
52+
JAN: 1,
53+
FEB: 2,
54+
MAR: 3,
55+
APR: 4,
56+
MAY: 5,
57+
JUN: 6,
58+
JUL: 7,
59+
AUG: 8,
60+
SEP: 9,
61+
OCT: 10,
62+
NOV: 11,
63+
DEC: 12,
64+
}
65+
4166
/**
4267
* Legacy shape: old CSV imports stored date-only columns as UTC-midnight
4368
* instants. Treated as calendar dates so historical rows render as pure days
@@ -183,11 +208,46 @@ function parseLocalizedWallClock(match: RegExpMatchArray): string | null {
183208
)
184209
}
185210

211+
interface CalendarFields {
212+
year: number
213+
month: number
214+
day: number
215+
}
216+
217+
/** Extracts literal calendar fields from supported month-name date forms. */
218+
function extractMonthNameCalendar(value: string): CalendarFields | null {
219+
const monthFirst = value.match(MONTH_FIRST_DATE_PATTERN)
220+
if (monthFirst) {
221+
return {
222+
year: Number(monthFirst[3]),
223+
month: MONTH_BY_ABBREVIATION[monthFirst[1].slice(0, 3).toUpperCase()],
224+
day: Number(monthFirst[2]),
225+
}
226+
}
227+
const dayFirst = value.match(DAY_FIRST_DATE_PATTERN)
228+
if (!dayFirst) return null
229+
return {
230+
year: Number(dayFirst[3]),
231+
month: MONTH_BY_ABBREVIATION[dayFirst[2].slice(0, 3).toUpperCase()],
232+
day: Number(dayFirst[1]),
233+
}
234+
}
235+
186236
/** Recovers broader naive `Date.parse` inputs without consulting the runtime timezone. */
187237
function parseNaiveWallClockAsUtc(value: string): string | null {
238+
const calendar = extractMonthNameCalendar(value)
239+
if (calendar && !isValidCalendarDay(calendar.year, calendar.month, calendar.day)) return null
188240
const ms = Date.parse(`${value} UTC`)
189241
if (Number.isNaN(ms)) return null
190242
const parsed = new Date(ms)
243+
if (
244+
calendar &&
245+
(parsed.getUTCFullYear() !== calendar.year ||
246+
parsed.getUTCMonth() + 1 !== calendar.month ||
247+
parsed.getUTCDate() !== calendar.day)
248+
) {
249+
return null
250+
}
191251
return `${toUtcCalendarDate(parsed)}T${pad(parsed.getUTCHours())}:${pad(parsed.getUTCMinutes())}:${pad(parsed.getUTCSeconds())}`
192252
}
193253

0 commit comments

Comments
 (0)