Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
Expand Down
105 changes: 101 additions & 4 deletions apps/sim/lib/core/utils/timezone.test.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -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-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'],
])(
'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)

expect(instant.toISOString()).toBe(expectedInstant)
expect(stampedWallClock).toBe(`${wallClock}${expectedOffset}`)
expect(new Date(stampedWallClock).toISOString()).toBe(expectedInstant)
}
)
})

describe('wallClockNow', () => {
Expand Down
159 changes: 119 additions & 40 deletions apps/sim/lib/core/utils/timezone.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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. */
Expand All @@ -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) => b.instantMs - a.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),
}
}

/**
Expand All @@ -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 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.
*/
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)}`
}
Loading
Loading