Skip to content

Commit 8ceba9d

Browse files
committed
fix(timezone): consolidate table wall-clock conversion
1 parent 9a22284 commit 8ceba9d

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
@@ -21,6 +21,41 @@ const COMMON_TIMEZONES = [
2121
'Australia/Sydney',
2222
]
2323

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

85120
/**
86-
* An instant's wall-clock time in `timeZone` as a naive `yyyy-MM-ddTHH:mm`
87-
* string. Lets callers reason about a user's local date/time without UTC — e.g.
88-
* to recover the local date/time a stored task instant represents in its zone.
121+
* The wall-clock fields of `instant` in `timeZone`, or in the runtime's local
122+
* timezone when omitted.
89123
*/
90-
export function zonedWallClock(instant: Date, timeZone: string): string {
91-
const parts = new Intl.DateTimeFormat('en-CA', {
124+
export function getWallClockParts(instant: Date, timeZone?: string): WallClockParts {
125+
if (!timeZone) {
126+
return {
127+
year: instant.getFullYear(),
128+
month: instant.getMonth() + 1,
129+
day: instant.getDate(),
130+
hour: instant.getHours(),
131+
minute: instant.getMinutes(),
132+
second: instant.getSeconds(),
133+
}
134+
}
135+
136+
const parts = new Intl.DateTimeFormat('en-US', {
92137
timeZone,
138+
hourCycle: 'h23',
93139
year: 'numeric',
94140
month: '2-digit',
95141
day: '2-digit',
96142
hour: '2-digit',
97143
minute: '2-digit',
98-
hourCycle: 'h23',
144+
second: '2-digit',
99145
}).formatToParts(instant)
100-
const get = (type: string) => parts.find((p) => p.type === type)?.value ?? '00'
101-
return `${get('year')}-${get('month')}-${get('day')}T${get('hour')}:${get('minute')}`
146+
const get = (type: string) => Number(parts.find((part) => part.type === type)?.value)
147+
return {
148+
year: get('year'),
149+
month: get('month'),
150+
day: get('day'),
151+
hour: get('hour'),
152+
minute: get('minute'),
153+
second: get('second'),
154+
}
155+
}
156+
157+
/** Formats an instant as an RFC 3339 wall time in an IANA timezone. */
158+
export function formatInstantInTimeZone(instant: Date, timeZone: string): string {
159+
const wall = getWallClockParts(instant, timeZone)
160+
const offsetMinutes = Math.round(offsetMsFromWallClock(instant, wall) / 60_000)
161+
return `${wall.year}-${pad(wall.month)}-${pad(wall.day)}T${pad(wall.hour)}:${pad(wall.minute)}:${pad(wall.second)}${formatUtcOffsetSuffix(offsetMinutes)}`
162+
}
163+
164+
/**
165+
* An instant's wall-clock time in `timeZone` as a naive `yyyy-MM-ddTHH:mm`
166+
* string. Lets callers reason about a user's local date/time without UTC — e.g.
167+
* to recover the local date/time a stored task instant represents in its zone.
168+
*/
169+
export function zonedWallClock(instant: Date, timeZone: string): string {
170+
const wall = getWallClockParts(instant, timeZone)
171+
return `${wall.year}-${pad(wall.month)}-${pad(wall.day)}T${pad(wall.hour)}:${pad(wall.minute)}`
102172
}
103173

104174
/** 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 {
123193

124194
/** The UTC offset (ms, east-positive) of `timeZone` at a given instant. */
125195
function timezoneOffsetMs(instant: Date, timeZone: string): number {
126-
const parts = new Intl.DateTimeFormat('en-US', {
127-
timeZone,
128-
hourCycle: 'h23',
129-
year: 'numeric',
130-
month: '2-digit',
131-
day: '2-digit',
132-
hour: '2-digit',
133-
minute: '2-digit',
134-
second: '2-digit',
135-
}).formatToParts(instant)
136-
const get = (type: string) => Number(parts.find((p) => p.type === type)?.value)
137-
const asUtc = Date.UTC(
138-
get('year'),
139-
get('month') - 1,
140-
get('day'),
141-
get('hour'),
142-
get('minute'),
143-
get('second')
196+
return offsetMsFromWallClock(instant, getWallClockParts(instant, timeZone))
197+
}
198+
199+
interface ZonedWallClockResolution {
200+
instant: Date
201+
offsetMinutes: number
202+
}
203+
204+
function resolveZonedWallClock(wallClock: string, timeZone: string): ZonedWallClockResolution {
205+
const [datePart, timePart] = wallClock.split('T')
206+
const [year, month, day] = datePart.split('-').map(Number)
207+
const [hour, minute, second = 0] = timePart.split(':').map(Number)
208+
const utcGuess = Date.UTC(year, month - 1, day, hour, minute, second)
209+
const dayMs = 24 * 60 * 60 * 1000
210+
const offsets = new Set(
211+
[-dayMs, 0, dayMs].map((distance) => timezoneOffsetMs(new Date(utcGuess + distance), timeZone))
144212
)
145-
return asUtc - instant.getTime()
213+
const candidates = [...offsets].map((offset) => {
214+
const instantMs = utcGuess - offset
215+
const actualOffset = timezoneOffsetMs(new Date(instantMs), timeZone)
216+
return { instantMs, wallClockMs: instantMs + actualOffset }
217+
})
218+
const exactCandidate = candidates
219+
.filter(({ wallClockMs }) => wallClockMs === utcGuess)
220+
.sort((a, b) => a.instantMs - b.instantMs)[0]
221+
const compatibleCandidate = candidates
222+
.filter(({ wallClockMs }) => wallClockMs > utcGuess)
223+
.sort((a, b) => a.wallClockMs - b.wallClockMs || a.instantMs - b.instantMs)[0]
224+
const chosenCandidate = exactCandidate ?? compatibleCandidate ?? candidates[0]
225+
const instantMs = chosenCandidate.instantMs
226+
return {
227+
instant: new Date(instantMs),
228+
offsetMinutes: Math.round((utcGuess - instantMs) / 60_000),
229+
}
146230
}
147231

148232
/**
@@ -152,22 +236,17 @@ function timezoneOffsetMs(instant: Date, timeZone: string): number {
152236
* date (including future ones whose offset differs from today's) and across DST:
153237
* a naive single pass reads the offset on the wrong side of a same-day boundary
154238
* — notably the autumn fall-back hour — and lands an hour off. For an ambiguous
155-
* fall-back wall-clock the later (post-transition) instant is chosen; a
239+
* fall-back wall-clock the earlier instant is chosen; a
156240
* wall-clock in the spring-forward gap (a nonexistent local hour) has no
157241
* self-consistent instant and resolves forward by the DST shift, matching how
158242
* calendar apps treat that once-a-year hour.
159243
*/
160244
export function zonedWallClockToUtc(wallClock: string, timeZone: string): Date {
161-
const [datePart, timePart] = wallClock.split('T')
162-
const [year, month, day] = datePart.split('-').map(Number)
163-
const [hour, minute, second = 0] = timePart.split(':').map(Number)
164-
const utcGuess = Date.UTC(year, month - 1, day, hour, minute, second)
165-
const guessOffset = timezoneOffsetMs(new Date(utcGuess), timeZone)
166-
const candidate = utcGuess - guessOffset
167-
const candidateOffset = timezoneOffsetMs(new Date(candidate), timeZone)
168-
if (candidateOffset === guessOffset) return new Date(candidate)
169-
const adjusted = utcGuess - candidateOffset
170-
return timezoneOffsetMs(new Date(adjusted), timeZone) === candidateOffset
171-
? new Date(adjusted)
172-
: new Date(candidate)
245+
return resolveZonedWallClock(wallClock, timeZone).instant
246+
}
247+
248+
/** Stamps a naive wall-clock with the offset selected by the shared timezone resolver. */
249+
export function zonedWallClockWithOffset(wallClock: string, timeZone: string): string {
250+
const { offsetMinutes } = resolveZonedWallClock(wallClock, timeZone)
251+
return `${wallClock}${formatUtcOffsetSuffix(offsetMinutes)}`
173252
}

0 commit comments

Comments
 (0)