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
28 changes: 28 additions & 0 deletions packages/utils/src/formatting.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
73 changes: 49 additions & 24 deletions packages/utils/src/formatting.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.
Comment on lines +60 to +62

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Comment records change history

The new comment recounts the previous bug, but CLAUDE.md says change history belongs in commit messages. Keep the explanation that format() includes the calendar date and remove the account of the old behavior. This repository requirement must be satisfied before merging.

Suggested change
// 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.

Context Used: CLAUDE.md (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

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 {
Expand Down