Skip to content

Commit f618e58

Browse files
committed
fix(utils): pick the timezone abbreviation from the UTC offset, not the formatted date
getTimezoneAbbreviation compared Intl.DateTimeFormat#format() results of two dates in the same year. The formatted string always contains the calendar date, so the comparison reported "DST is observed" for every zone and then "the target date is daylight time" for every date: Los Angeles in January came out as PDT, London as BST, Sydney in July as AEDT. Compare the zone's UTC offsets at the two ends of the year instead. A zone whose offsets match does not observe DST; otherwise the smaller offset is standard time and any larger offset on the target date is daylight time, which covers both hemispheres.
1 parent 2124b78 commit f618e58

2 files changed

Lines changed: 77 additions & 24 deletions

File tree

‎packages/utils/src/formatting.test.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,34 @@ describe('getTimezoneAbbreviation', () => {
88
})
99
})
1010

11+
describe('getTimezoneAbbreviation daylight handling', () => {
12+
it('uses the standard abbreviation for a winter date in a northern zone', () => {
13+
const winter = new Date('2026-01-15T12:00:00Z')
14+
expect(getTimezoneAbbreviation('America/Los_Angeles', winter)).toBe('PST')
15+
expect(getTimezoneAbbreviation('Europe/London', winter)).toBe('GMT')
16+
expect(getTimezoneAbbreviation('America/New_York', winter)).toBe('EST')
17+
})
18+
19+
it('uses the daylight abbreviation for a summer date in a northern zone', () => {
20+
const summer = new Date('2026-07-15T12:00:00Z')
21+
expect(getTimezoneAbbreviation('America/Los_Angeles', summer)).toBe('PDT')
22+
expect(getTimezoneAbbreviation('Europe/London', summer)).toBe('BST')
23+
expect(getTimezoneAbbreviation('Europe/Paris', summer)).toBe('CEST')
24+
})
25+
26+
it('reads the southern-hemisphere seasons the other way round', () => {
27+
const january = new Date('2026-01-15T12:00:00Z')
28+
const july = new Date('2026-07-15T12:00:00Z')
29+
expect(getTimezoneAbbreviation('Australia/Sydney', january)).toBe('AEDT')
30+
expect(getTimezoneAbbreviation('Australia/Sydney', july)).toBe('AEST')
31+
})
32+
33+
it('keeps the standard abbreviation for zones without daylight time', () => {
34+
expect(getTimezoneAbbreviation('Asia/Tokyo', new Date('2026-01-15T12:00:00Z'))).toBe('JST')
35+
expect(getTimezoneAbbreviation('Asia/Singapore', new Date('2026-07-15T12:00:00Z'))).toBe('SGT')
36+
})
37+
})
38+
1139
describe('formatDuration', () => {
1240
it('formats sub-millisecond durations', () => {
1341
expect(formatDuration(0.5)).toBe('0.50ms')

‎packages/utils/src/formatting.ts‎

Lines changed: 49 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,39 @@
1+
/**
2+
* Offset of `timeZone` from UTC at `date`, in minutes east of UTC.
3+
*
4+
* `Date` has no timezone-aware offset accessor, so the wall clock is
5+
* reconstructed as UTC and subtracted from the actual instant.
6+
*/
7+
function getOffsetMinutes(timeZone: string, date: Date): number {
8+
const parts = new Intl.DateTimeFormat('en-US', {
9+
timeZone,
10+
hour12: false,
11+
year: 'numeric',
12+
month: '2-digit',
13+
day: '2-digit',
14+
hour: '2-digit',
15+
minute: '2-digit',
16+
second: '2-digit',
17+
}).formatToParts(date)
18+
19+
const value = (type: string): number => Number(parts.find((part) => part.type === type)?.value)
20+
21+
// Some engines report midnight as hour 24; the day/month fields already
22+
// carry the date, so folding it to 0 keeps the instant identical.
23+
const wallClock = Date.UTC(
24+
value('year'),
25+
value('month') - 1,
26+
value('day'),
27+
value('hour') % 24,
28+
value('minute'),
29+
value('second')
30+
)
31+
32+
// The wall clock only has second precision, so compare against the instant
33+
// truncated to the second.
34+
return Math.round((wallClock - Math.floor(date.getTime() / 1000) * 1000) / 60000)
35+
}
36+
137
/**
238
* Get a user-friendly timezone abbreviation
339
* @param timezone - IANA timezone string
@@ -19,33 +55,22 @@ export function getTimezoneAbbreviation(timezone: string, date: Date = new Date(
1955
'Asia/Singapore': { standard: 'SGT', daylight: 'SGT' }, // Singapore doesn't use DST
2056
}
2157

22-
if (timezone in timezoneMap) {
23-
const januaryDate = new Date(date.getFullYear(), 0, 1)
24-
const julyDate = new Date(date.getFullYear(), 6, 1)
25-
26-
const januaryFormatter = new Intl.DateTimeFormat('en-US', {
27-
timeZone: timezone,
28-
timeZoneName: 'short',
29-
})
30-
31-
const julyFormatter = new Intl.DateTimeFormat('en-US', {
32-
timeZone: timezone,
33-
timeZoneName: 'short',
34-
})
35-
36-
const isDSTObserved = januaryFormatter.format(januaryDate) !== julyFormatter.format(julyDate)
37-
38-
if (isDSTObserved) {
39-
const currentFormatter = new Intl.DateTimeFormat('en-US', {
40-
timeZone: timezone,
41-
timeZoneName: 'short',
42-
})
58+
const zone = timezoneMap[timezone]
59+
if (zone) {
60+
// Compare the zone's UTC offsets across the year: `format()` always
61+
// includes the calendar date, so comparing formatted strings reported
62+
// "DST observed" for the whole year and then "daylight" for every date.
63+
const januaryOffset = getOffsetMinutes(timezone, new Date(date.getFullYear(), 0, 1))
64+
const julyOffset = getOffsetMinutes(timezone, new Date(date.getFullYear(), 6, 1))
4365

44-
const isDST = currentFormatter.format(date) !== januaryFormatter.format(januaryDate)
45-
return isDST ? timezoneMap[timezone].daylight : timezoneMap[timezone].standard
66+
if (januaryOffset === julyOffset) {
67+
return zone.standard
4668
}
4769

48-
return timezoneMap[timezone].standard
70+
// Daylight time always moves the clock forward, in either hemisphere, so
71+
// the smaller of the two offsets is the standard one.
72+
const standardOffset = Math.min(januaryOffset, julyOffset)
73+
return getOffsetMinutes(timezone, date) > standardOffset ? zone.daylight : zone.standard
4974
}
5075

5176
try {

0 commit comments

Comments
 (0)