Skip to content

Commit 57ff96c

Browse files
committed
fix(timezone): consolidate table wall-clock conversion
1 parent e650905 commit 57ff96c

7 files changed

Lines changed: 424 additions & 154 deletions

File tree

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1+
import { getWallClockParts } from '@/lib/core/utils/timezone'
12
import type { ColumnDefinition, JsonValue } from '@/lib/table'
23
import type { ColumnType } from '@/lib/table/column-types'
34
import { columnTypeById, columnTypeOf } from '@/lib/table/column-types'
4-
import { formatDateCellDisplay, getWallClockParts, normalizeDateCellValue } from '@/lib/table/dates'
5+
import { formatDateCellDisplay, normalizeDateCellValue } from '@/lib/table/dates'
56

67
/**
78
* Pick a fresh "untitled[_N]" name not already taken by `columns`. Used by

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

Lines changed: 101 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,63 @@
11
import { describe, expect, it } from 'vitest'
22
import {
3+
formatInstantInTimeZone,
34
getSupportedTimezones,
45
getTimezoneOptions,
6+
getWallClockParts,
57
wallClockNow,
68
zonedClockDate,
79
zonedWallClockToUtc,
10+
zonedWallClockWithOffset,
811
} from './timezone'
912

13+
describe('formatInstantInTimeZone', () => {
14+
it.each([
15+
['UTC', '2026-06-15T00:15:30Z', '2026-06-15T00:15:30Z'],
16+
['America/Los_Angeles', '2026-06-15T00:15:30Z', '2026-06-14T17:15:30-07:00'],
17+
['Asia/Tokyo', '2026-06-15T00:15:30Z', '2026-06-15T09:15:30+09:00'],
18+
['Asia/Kathmandu', '2026-06-15T00:15:30Z', '2026-06-15T06:00:30+05:45'],
19+
['Australia/Lord_Howe', '2026-06-15T00:15:30Z', '2026-06-15T10:45:30+10:30'],
20+
])('formats an instant in %s with its exact offset', (timeZone, iso, expected) => {
21+
expect(formatInstantInTimeZone(new Date(iso), timeZone)).toBe(expected)
22+
})
23+
24+
it('distinguishes both copies of an autumn daylight-saving hour', () => {
25+
expect(formatInstantInTimeZone(new Date('2026-11-01T05:30:00Z'), 'America/New_York')).toBe(
26+
'2026-11-01T01:30:00-04:00'
27+
)
28+
expect(formatInstantInTimeZone(new Date('2026-11-01T06:30:00Z'), 'America/New_York')).toBe(
29+
'2026-11-01T01:30:00-05:00'
30+
)
31+
})
32+
33+
it('round-trips the same instant after changing display timezones', () => {
34+
const instant = new Date('2026-11-01T06:30:00Z')
35+
for (const timeZone of [
36+
'UTC',
37+
'America/Los_Angeles',
38+
'America/New_York',
39+
'Asia/Kathmandu',
40+
'Australia/Lord_Howe',
41+
]) {
42+
const editable = formatInstantInTimeZone(instant, timeZone)
43+
expect(new Date(editable).getTime()).toBe(instant.getTime())
44+
}
45+
})
46+
})
47+
48+
describe('getWallClockParts', () => {
49+
it('returns the calendar fields of an instant in the requested timezone', () => {
50+
expect(getWallClockParts(new Date('2026-06-15T00:15:30Z'), 'America/Los_Angeles')).toEqual({
51+
year: 2026,
52+
month: 6,
53+
day: 14,
54+
hour: 17,
55+
minute: 15,
56+
second: 30,
57+
})
58+
})
59+
})
60+
1061
describe('zonedWallClockToUtc', () => {
1162
it('treats a UTC wall-clock as the same instant', () => {
1263
expect(zonedWallClockToUtc('2026-06-15T09:00', 'UTC').toISOString()).toBe(
@@ -48,11 +99,57 @@ describe('zonedWallClockToUtc', () => {
4899
})
49100

50101
it('resolves a spring-forward gap wall-clock forward by the DST shift', () => {
51-
// 2026-03-08 02:00–02:59 does not exist in America/New_York (EST→EDT).
52-
expect(zonedWallClockToUtc('2026-03-08T02:30', 'America/New_York').toISOString()).toBe(
53-
'2026-03-08T07:30:00.000Z'
54-
)
102+
const instant = zonedWallClockToUtc('2026-03-08T02:30', 'America/New_York')
103+
const stampedWallClock = zonedWallClockWithOffset('2026-03-08T02:30', 'America/New_York')
104+
105+
expect(instant.toISOString()).toBe('2026-03-08T07:30:00.000Z')
106+
expect(stampedWallClock).toBe('2026-03-08T02:30-05:00')
107+
expect(new Date(stampedWallClock).toISOString()).toBe(instant.toISOString())
55108
})
109+
110+
it.each([
111+
[
112+
'Europe/Berlin',
113+
'2026-03-29T02:30',
114+
'2026-03-29T01:30:00.000Z',
115+
'2026-03-29T03:30:00+02:00',
116+
'2026-03-29T02:30+01:00',
117+
],
118+
[
119+
'Australia/Lord_Howe',
120+
'2026-10-04T02:15',
121+
'2026-10-03T15:45:00.000Z',
122+
'2026-10-04T02:45:00+11:00',
123+
'2026-10-04T02:15+10:30',
124+
],
125+
])(
126+
'resolves an east-of-UTC spring-forward gap in %s to the first compatible wall-clock',
127+
(timeZone, wallClock, expectedInstant, expectedRenderedWallClock, expectedStampedWallClock) => {
128+
const instant = zonedWallClockToUtc(wallClock, timeZone)
129+
const stampedWallClock = zonedWallClockWithOffset(wallClock, timeZone)
130+
131+
expect(instant.toISOString()).toBe(expectedInstant)
132+
expect(formatInstantInTimeZone(instant, timeZone)).toBe(expectedRenderedWallClock)
133+
expect(stampedWallClock).toBe(expectedStampedWallClock)
134+
expect(new Date(stampedWallClock).toISOString()).toBe(expectedInstant)
135+
}
136+
)
137+
138+
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'],
142+
])(
143+
'keeps the earlier instant for an ambiguous fall-back wall-clock in %s',
144+
(timeZone, wallClock, expectedInstant, expectedOffset) => {
145+
const instant = zonedWallClockToUtc(wallClock, timeZone)
146+
const stampedWallClock = zonedWallClockWithOffset(wallClock, timeZone)
147+
148+
expect(instant.toISOString()).toBe(expectedInstant)
149+
expect(stampedWallClock).toBe(`${wallClock}${expectedOffset}`)
150+
expect(new Date(stampedWallClock).toISOString()).toBe(expectedInstant)
151+
}
152+
)
56153
})
57154

58155
describe('wallClockNow', () => {

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

Lines changed: 119 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,41 @@ const COMMON_TIMEZONES = [
2323
'Australia/Sydney',
2424
]
2525

26+
/** A wall-clock reading of an instant in some timezone. */
27+
export interface WallClockParts {
28+
year: number
29+
/** 1-based month. */
30+
month: number
31+
day: number
32+
hour: number
33+
minute: number
34+
second: number
35+
}
36+
37+
function pad(value: number): string {
38+
return String(value).padStart(2, '0')
39+
}
40+
41+
/** RFC 3339 offset suffix: `Z` for zero, else `±HH:MM`. */
42+
export function formatUtcOffsetSuffix(offsetMinutes: number): string {
43+
if (offsetMinutes === 0) return 'Z'
44+
const sign = offsetMinutes > 0 ? '+' : '-'
45+
const absoluteMinutes = Math.abs(offsetMinutes)
46+
return `${sign}${pad(Math.floor(absoluteMinutes / 60))}:${pad(absoluteMinutes % 60)}`
47+
}
48+
49+
function offsetMsFromWallClock(instant: Date, wall: WallClockParts): number {
50+
const wallAsUtc = Date.UTC(
51+
wall.year,
52+
wall.month - 1,
53+
wall.day,
54+
wall.hour,
55+
wall.minute,
56+
wall.second
57+
)
58+
return wallAsUtc - instant.getTime()
59+
}
60+
2661
/** The IANA timezone the current runtime resolves to (e.g. `America/New_York`). */
2762
export function getBrowserTimezone(): string {
2863
return Intl.DateTimeFormat().resolvedOptions().timeZone
@@ -116,22 +151,57 @@ export function getTimezoneOptions(): TimezoneOption[] {
116151
}
117152

118153
/**
119-
* An instant's wall-clock time in `timeZone` as a naive `yyyy-MM-ddTHH:mm`
120-
* string. Lets callers reason about a user's local date/time without UTC — e.g.
121-
* to recover the local date/time a stored task instant represents in its zone.
154+
* The wall-clock fields of `instant` in `timeZone`, or in the runtime's local
155+
* timezone when omitted.
122156
*/
123-
export function zonedWallClock(instant: Date, timeZone: string): string {
124-
const parts = new Intl.DateTimeFormat('en-CA', {
157+
export function getWallClockParts(instant: Date, timeZone?: string): WallClockParts {
158+
if (!timeZone) {
159+
return {
160+
year: instant.getFullYear(),
161+
month: instant.getMonth() + 1,
162+
day: instant.getDate(),
163+
hour: instant.getHours(),
164+
minute: instant.getMinutes(),
165+
second: instant.getSeconds(),
166+
}
167+
}
168+
169+
const parts = new Intl.DateTimeFormat('en-US', {
125170
timeZone,
171+
hourCycle: 'h23',
126172
year: 'numeric',
127173
month: '2-digit',
128174
day: '2-digit',
129175
hour: '2-digit',
130176
minute: '2-digit',
131-
hourCycle: 'h23',
177+
second: '2-digit',
132178
}).formatToParts(instant)
133-
const get = (type: string) => parts.find((p) => p.type === type)?.value ?? '00'
134-
return `${get('year')}-${get('month')}-${get('day')}T${get('hour')}:${get('minute')}`
179+
const get = (type: string) => Number(parts.find((part) => part.type === type)?.value)
180+
return {
181+
year: get('year'),
182+
month: get('month'),
183+
day: get('day'),
184+
hour: get('hour'),
185+
minute: get('minute'),
186+
second: get('second'),
187+
}
188+
}
189+
190+
/** Formats an instant as an RFC 3339 wall time in an IANA timezone. */
191+
export function formatInstantInTimeZone(instant: Date, timeZone: string): string {
192+
const wall = getWallClockParts(instant, timeZone)
193+
const offsetMinutes = Math.round(offsetMsFromWallClock(instant, wall) / 60_000)
194+
return `${wall.year}-${pad(wall.month)}-${pad(wall.day)}T${pad(wall.hour)}:${pad(wall.minute)}:${pad(wall.second)}${formatUtcOffsetSuffix(offsetMinutes)}`
195+
}
196+
197+
/**
198+
* An instant's wall-clock time in `timeZone` as a naive `yyyy-MM-ddTHH:mm`
199+
* string. Lets callers reason about a user's local date/time without UTC — e.g.
200+
* to recover the local date/time a stored task instant represents in its zone.
201+
*/
202+
export function zonedWallClock(instant: Date, timeZone: string): string {
203+
const wall = getWallClockParts(instant, timeZone)
204+
return `${wall.year}-${pad(wall.month)}-${pad(wall.day)}T${pad(wall.hour)}:${pad(wall.minute)}`
135205
}
136206

137207
/** The current wall-clock time in `timeZone` as a naive `yyyy-MM-ddTHH:mm` string. */
@@ -156,26 +226,40 @@ export function zonedClockDate(instant: Date, timeZone: string): Date {
156226

157227
/** The UTC offset (ms, east-positive) of `timeZone` at a given instant. */
158228
function timezoneOffsetMs(instant: Date, timeZone: string): number {
159-
const parts = new Intl.DateTimeFormat('en-US', {
160-
timeZone,
161-
hourCycle: 'h23',
162-
year: 'numeric',
163-
month: '2-digit',
164-
day: '2-digit',
165-
hour: '2-digit',
166-
minute: '2-digit',
167-
second: '2-digit',
168-
}).formatToParts(instant)
169-
const get = (type: string) => Number(parts.find((p) => p.type === type)?.value)
170-
const asUtc = Date.UTC(
171-
get('year'),
172-
get('month') - 1,
173-
get('day'),
174-
get('hour'),
175-
get('minute'),
176-
get('second')
229+
return offsetMsFromWallClock(instant, getWallClockParts(instant, timeZone))
230+
}
231+
232+
interface ZonedWallClockResolution {
233+
instant: Date
234+
offsetMinutes: number
235+
}
236+
237+
function resolveZonedWallClock(wallClock: string, timeZone: string): ZonedWallClockResolution {
238+
const [datePart, timePart] = wallClock.split('T')
239+
const [year, month, day] = datePart.split('-').map(Number)
240+
const [hour, minute, second = 0] = timePart.split(':').map(Number)
241+
const utcGuess = Date.UTC(year, month - 1, day, hour, minute, second)
242+
const dayMs = 24 * 60 * 60 * 1000
243+
const offsets = new Set(
244+
[-dayMs, 0, dayMs].map((distance) => timezoneOffsetMs(new Date(utcGuess + distance), timeZone))
177245
)
178-
return asUtc - instant.getTime()
246+
const candidates = [...offsets].map((offset) => {
247+
const instantMs = utcGuess - offset
248+
const actualOffset = timezoneOffsetMs(new Date(instantMs), timeZone)
249+
return { instantMs, wallClockMs: instantMs + actualOffset }
250+
})
251+
const exactCandidate = candidates
252+
.filter(({ wallClockMs }) => wallClockMs === utcGuess)
253+
.sort((a, b) => a.instantMs - b.instantMs)[0]
254+
const compatibleCandidate = candidates
255+
.filter(({ wallClockMs }) => wallClockMs > utcGuess)
256+
.sort((a, b) => a.wallClockMs - b.wallClockMs || a.instantMs - b.instantMs)[0]
257+
const chosenCandidate = exactCandidate ?? compatibleCandidate ?? candidates[0]
258+
const instantMs = chosenCandidate.instantMs
259+
return {
260+
instant: new Date(instantMs),
261+
offsetMinutes: Math.round((utcGuess - instantMs) / 60_000),
262+
}
179263
}
180264

181265
/**
@@ -185,22 +269,17 @@ function timezoneOffsetMs(instant: Date, timeZone: string): number {
185269
* date (including future ones whose offset differs from today's) and across DST:
186270
* a naive single pass reads the offset on the wrong side of a same-day boundary
187271
* — notably the autumn fall-back hour — and lands an hour off. For an ambiguous
188-
* fall-back wall-clock the later (post-transition) instant is chosen; a
272+
* fall-back wall-clock the earlier instant is chosen; a
189273
* wall-clock in the spring-forward gap (a nonexistent local hour) has no
190274
* self-consistent instant and resolves forward by the DST shift, matching how
191275
* calendar apps treat that once-a-year hour.
192276
*/
193277
export function zonedWallClockToUtc(wallClock: string, timeZone: string): Date {
194-
const [datePart, timePart] = wallClock.split('T')
195-
const [year, month, day] = datePart.split('-').map(Number)
196-
const [hour, minute, second = 0] = timePart.split(':').map(Number)
197-
const utcGuess = Date.UTC(year, month - 1, day, hour, minute, second)
198-
const guessOffset = timezoneOffsetMs(new Date(utcGuess), timeZone)
199-
const candidate = utcGuess - guessOffset
200-
const candidateOffset = timezoneOffsetMs(new Date(candidate), timeZone)
201-
if (candidateOffset === guessOffset) return new Date(candidate)
202-
const adjusted = utcGuess - candidateOffset
203-
return timezoneOffsetMs(new Date(adjusted), timeZone) === candidateOffset
204-
? new Date(adjusted)
205-
: new Date(candidate)
278+
return resolveZonedWallClock(wallClock, timeZone).instant
279+
}
280+
281+
/** Stamps a naive wall-clock with the offset selected by the shared timezone resolver. */
282+
export function zonedWallClockWithOffset(wallClock: string, timeZone: string): string {
283+
const { offsetMinutes } = resolveZonedWallClock(wallClock, timeZone)
284+
return `${wallClock}${formatUtcOffsetSuffix(offsetMinutes)}`
206285
}

0 commit comments

Comments
 (0)