diff --git a/packages/utils/src/formatting.test.ts b/packages/utils/src/formatting.test.ts index 33b0f05ad99..f6417662498 100644 --- a/packages/utils/src/formatting.test.ts +++ b/packages/utils/src/formatting.test.ts @@ -8,6 +8,34 @@ describe('getTimezoneAbbreviation', () => { }) }) +describe('getTimezoneAbbreviation daylight handling', () => { + it('uses the standard abbreviation for a winter date in a northern zone', () => { + const winter = new Date('2026-01-15T12:00:00Z') + expect(getTimezoneAbbreviation('America/Los_Angeles', winter)).toBe('PST') + expect(getTimezoneAbbreviation('Europe/London', winter)).toBe('GMT') + expect(getTimezoneAbbreviation('America/New_York', winter)).toBe('EST') + }) + + it('uses the daylight abbreviation for a summer date in a northern zone', () => { + const summer = new Date('2026-07-15T12:00:00Z') + expect(getTimezoneAbbreviation('America/Los_Angeles', summer)).toBe('PDT') + expect(getTimezoneAbbreviation('Europe/London', summer)).toBe('BST') + expect(getTimezoneAbbreviation('Europe/Paris', summer)).toBe('CEST') + }) + + it('reads the southern-hemisphere seasons the other way round', () => { + const january = new Date('2026-01-15T12:00:00Z') + const july = new Date('2026-07-15T12:00:00Z') + expect(getTimezoneAbbreviation('Australia/Sydney', january)).toBe('AEDT') + expect(getTimezoneAbbreviation('Australia/Sydney', july)).toBe('AEST') + }) + + it('keeps the standard abbreviation for zones without daylight time', () => { + expect(getTimezoneAbbreviation('Asia/Tokyo', new Date('2026-01-15T12:00:00Z'))).toBe('JST') + expect(getTimezoneAbbreviation('Asia/Singapore', new Date('2026-07-15T12:00:00Z'))).toBe('SGT') + }) +}) + describe('formatDuration', () => { it('formats sub-millisecond durations', () => { expect(formatDuration(0.5)).toBe('0.50ms') diff --git a/packages/utils/src/formatting.ts b/packages/utils/src/formatting.ts index 5078dedd0a6..767481c3446 100644 --- a/packages/utils/src/formatting.ts +++ b/packages/utils/src/formatting.ts @@ -1,3 +1,39 @@ +/** + * Offset of `timeZone` from UTC at `date`, in minutes east of UTC. + * + * `Date` has no timezone-aware offset accessor, so the wall clock is + * reconstructed as UTC and subtracted from the actual instant. + */ +function getOffsetMinutes(timeZone: string, date: Date): number { + const parts = new Intl.DateTimeFormat('en-US', { + timeZone, + hour12: false, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }).formatToParts(date) + + const value = (type: string): number => Number(parts.find((part) => part.type === type)?.value) + + // Some engines report midnight as hour 24; the day/month fields already + // carry the date, so folding it to 0 keeps the instant identical. + const wallClock = Date.UTC( + value('year'), + value('month') - 1, + value('day'), + value('hour') % 24, + value('minute'), + value('second') + ) + + // The wall clock only has second precision, so compare against the instant + // truncated to the second. + return Math.round((wallClock - Math.floor(date.getTime() / 1000) * 1000) / 60000) +} + /** * Get a user-friendly timezone abbreviation * @param timezone - IANA timezone string @@ -19,33 +55,20 @@ export function getTimezoneAbbreviation(timezone: string, date: Date = new Date( 'Asia/Singapore': { standard: 'SGT', daylight: 'SGT' }, // Singapore doesn't use DST } - if (timezone in timezoneMap) { - const januaryDate = new Date(date.getFullYear(), 0, 1) - const julyDate = new Date(date.getFullYear(), 6, 1) - - const januaryFormatter = new Intl.DateTimeFormat('en-US', { - timeZone: timezone, - timeZoneName: 'short', - }) - - const julyFormatter = new Intl.DateTimeFormat('en-US', { - timeZone: timezone, - timeZoneName: 'short', - }) - - const isDSTObserved = januaryFormatter.format(januaryDate) !== julyFormatter.format(julyDate) - - if (isDSTObserved) { - const currentFormatter = new Intl.DateTimeFormat('en-US', { - timeZone: timezone, - timeZoneName: 'short', - }) + const zone = timezoneMap[timezone] + if (zone) { + // Compare UTC offsets because `format()` also includes the calendar date. + const januaryOffset = getOffsetMinutes(timezone, new Date(date.getFullYear(), 0, 1)) + const julyOffset = getOffsetMinutes(timezone, new Date(date.getFullYear(), 6, 1)) - const isDST = currentFormatter.format(date) !== januaryFormatter.format(januaryDate) - return isDST ? timezoneMap[timezone].daylight : timezoneMap[timezone].standard + if (januaryOffset === julyOffset) { + return zone.standard } - return timezoneMap[timezone].standard + // Daylight time always moves the clock forward, in either hemisphere, so + // the smaller of the two offsets is the standard one. + const standardOffset = Math.min(januaryOffset, julyOffset) + return getOffsetMinutes(timezone, date) > standardOffset ? zone.daylight : zone.standard } try {