From f618e58d3fa71f52a59d7a9bc6e2c49caa7e18b5 Mon Sep 17 00:00:00 2001 From: laoyyu Date: Thu, 12 Nov 2026 12:19:43 +0800 Subject: [PATCH 1/2] 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. --- packages/utils/src/formatting.test.ts | 28 ++++++++++ packages/utils/src/formatting.ts | 73 ++++++++++++++++++--------- 2 files changed, 77 insertions(+), 24 deletions(-) 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..e27020b30a3 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,22 @@ 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 the zone's UTC offsets across the year: `format()` always + // includes the calendar date, so comparing formatted strings reported + // "DST observed" for the whole year and then "daylight" for every 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 { From b9d53b8af75aa5414a2bde665ffb9cd852c0d031 Mon Sep 17 00:00:00 2001 From: laoyyu Date: Fri, 13 Nov 2026 12:57:44 +0800 Subject: [PATCH 2/2] chore(utils): keep the offset comparison comment to its rationale CLAUDE.md keeps change history in commit messages, so the comment only states why the offsets are compared instead of recounting the previous behaviour. Signed-off-by: laoyyu --- packages/utils/src/formatting.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/utils/src/formatting.ts b/packages/utils/src/formatting.ts index e27020b30a3..767481c3446 100644 --- a/packages/utils/src/formatting.ts +++ b/packages/utils/src/formatting.ts @@ -57,9 +57,7 @@ export function getTimezoneAbbreviation(timezone: string, date: Date = new Date( const zone = timezoneMap[timezone] if (zone) { - // Compare the zone's UTC offsets across the year: `format()` always - // includes the calendar date, so comparing formatted strings reported - // "DST observed" for the whole year and then "daylight" for every date. + // 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))