diff --git a/packages/@internationalized/date/src/conversion.ts b/packages/@internationalized/date/src/conversion.ts index d34b256ae0e..6f89a03eb79 100644 --- a/packages/@internationalized/date/src/conversion.ts +++ b/packages/@internationalized/date/src/conversion.ts @@ -17,7 +17,7 @@ import {AnyCalendarDate, AnyDateTime, AnyTime, Calendar, DateFields, Disambiguat import {CalendarDate, CalendarDateTime, Time, ZonedDateTime} from './CalendarDate'; import {constrain} from './manipulation'; import {getExtendedYear, GregorianCalendar} from './calendars/GregorianCalendar'; -import {getLocalTimeZone, isEqualCalendar} from './queries'; +import {getLocalTimeZone, isEqualCalendar, isLocalTimeZoneOverridden} from './queries'; import {Mutable} from './utils'; export function epochFromDate(date: AnyDateTime): number { @@ -42,7 +42,9 @@ export function getTimeZoneOffset(ms: number, timeZone: string): number { } // Fast path: for local timezone after 1970, use native Date. - if (ms > 0 && timeZone === getLocalTimeZone()) { + // Skip this fast path if the local timezone was explicitly overridden via setLocalTimeZone, + // since native Date always uses the browser's timezone, not the overridden one. + if (ms > 0 && timeZone === getLocalTimeZone() && !isLocalTimeZoneOverridden()) { return new Date(ms).getTimezoneOffset() * -60 * 1000; } @@ -124,7 +126,9 @@ export function toAbsolute(date: CalendarDate | CalendarDateTime, timeZone: stri } // Fast path: if the time zone is the local timezone and disambiguation is compatible, use native Date. - if (timeZone === getLocalTimeZone() && disambiguation === 'compatible') { + // Skip this fast path if the local timezone was explicitly overridden via setLocalTimeZone, + // since native Date always uses the browser's timezone, not the overridden one. + if (timeZone === getLocalTimeZone() && disambiguation === 'compatible' && !isLocalTimeZoneOverridden()) { dateTime = toCalendar(dateTime, new GregorianCalendar()); // Don't use Date constructor here because two-digit years are interpreted in the 20th century. diff --git a/packages/@internationalized/date/src/index.ts b/packages/@internationalized/date/src/index.ts index 5d373c842f1..02752a6045d 100644 --- a/packages/@internationalized/date/src/index.ts +++ b/packages/@internationalized/date/src/index.ts @@ -66,6 +66,7 @@ export { getLocalTimeZone, setLocalTimeZone, resetLocalTimeZone, + isLocalTimeZoneOverridden, startOfMonth, startOfWeek, startOfYear, diff --git a/packages/@internationalized/date/src/queries.ts b/packages/@internationalized/date/src/queries.ts index ce48d95d75c..e27d50c377b 100644 --- a/packages/@internationalized/date/src/queries.ts +++ b/packages/@internationalized/date/src/queries.ts @@ -130,6 +130,7 @@ export function getHoursInDay(a: CalendarDate, timeZone: string): number { } let localTimeZone: string | null = null; +let localTimeZoneOverride = false; /** Returns the time zone identifier for the current user. */ export function getLocalTimeZone(): string { @@ -142,14 +143,21 @@ export function getLocalTimeZone(): string { /** Sets the time zone identifier for the current user. */ export function setLocalTimeZone(timeZone: string): void { + localTimeZoneOverride = true; localTimeZone = timeZone; } /** Resets the time zone identifier for the current user. */ export function resetLocalTimeZone(): void { + localTimeZoneOverride = false; localTimeZone = null; } +/** Returns whether the local time zone has been explicitly overridden via `setLocalTimeZone`. */ +export function isLocalTimeZoneOverridden(): boolean { + return localTimeZoneOverride; +} + /** Returns the first date of the month for the given date. */ export function startOfMonth(date: ZonedDateTime): ZonedDateTime; export function startOfMonth(date: CalendarDateTime): CalendarDateTime; diff --git a/packages/@internationalized/date/tests/conversion.test.js b/packages/@internationalized/date/tests/conversion.test.js index 7fdc06c1792..766bc52c480 100644 --- a/packages/@internationalized/date/tests/conversion.test.js +++ b/packages/@internationalized/date/tests/conversion.test.js @@ -10,9 +10,9 @@ * governing permissions and limitations under the License. */ -import {BuddhistCalendar, CalendarDate, CalendarDateTime, EthiopicAmeteAlemCalendar, EthiopicCalendar, GregorianCalendar, HebrewCalendar, IndianCalendar, IslamicCivilCalendar, IslamicTabularCalendar, IslamicUmalquraCalendar, JapaneseCalendar, PersianCalendar, TaiwanCalendar, Time, toCalendar, toCalendarDate, toCalendarDateTime, toTime, ZonedDateTime} from '..'; +import {BuddhistCalendar, CalendarDate, CalendarDateTime, EthiopicAmeteAlemCalendar, EthiopicCalendar, GregorianCalendar, HebrewCalendar, IndianCalendar, IslamicCivilCalendar, IslamicTabularCalendar, IslamicUmalquraCalendar, JapaneseCalendar, PersianCalendar, resetLocalTimeZone, setLocalTimeZone, TaiwanCalendar, Time, toCalendar, toCalendarDate, toCalendarDateTime, toTime, ZonedDateTime} from '..'; import {Custom454Calendar} from './customCalendarImpl'; -import {fromAbsolute, possibleAbsolutes, toAbsolute, toDate} from '../src/conversion'; +import {fromAbsolute, getTimeZoneOffset, possibleAbsolutes, toAbsolute, toDate} from '../src/conversion'; describe('CalendarDate conversion', function () { describe('toAbsolute', function () { @@ -522,4 +522,51 @@ describe('CalendarDate conversion', function () { expect(toTime(dateTime)).toEqual(new Time(8, 23, 10, 80)); }); }); + + describe('setLocalTimeZone', function () { + afterEach(() => { + resetLocalTimeZone(); + }); + + it('should use the overridden timezone in getTimeZoneOffset instead of native Date', function () { + let ms = new Date('2020-06-15T12:00:00Z').getTime(); + + // Get the offset using the Intl-based slow path for a non-local timezone + let expectedOffset = getTimeZoneOffset(ms, 'Etc/GMT-10'); + + // Now override the local timezone to 'Etc/GMT-10' and verify it still computes correctly + setLocalTimeZone('Etc/GMT-10'); + let actualOffset = getTimeZoneOffset(ms, 'Etc/GMT-10'); + + expect(actualOffset).toBe(expectedOffset); + }); + + it('should use the overridden timezone in toAbsolute instead of native Date', function () { + let date = new CalendarDateTime(2020, 6, 15, 12, 0, 0); + + // Get the expected result using the Intl-based slow path for a non-local timezone + let expected = toAbsolute(date, 'Etc/GMT-10'); + + // Now override the local timezone and verify it still computes correctly + setLocalTimeZone('Etc/GMT-10'); + let actual = toAbsolute(date, 'Etc/GMT-10'); + + expect(actual).toBe(expected); + }); + + it('should produce correct results after resetLocalTimeZone', function () { + let ms = new Date('2020-06-15T12:00:00Z').getTime(); + let tz = Intl.DateTimeFormat().resolvedOptions().timeZone; + + // Get the offset before any override + let offsetBefore = getTimeZoneOffset(ms, tz); + + setLocalTimeZone('Etc/GMT-10'); + resetLocalTimeZone(); + + // After reset, the fast path should be restored and produce the same result + let offsetAfter = getTimeZoneOffset(ms, tz); + expect(offsetAfter).toBe(offsetBefore); + }); + }); }); diff --git a/packages/@internationalized/date/tests/queries.test.js b/packages/@internationalized/date/tests/queries.test.js index 40fceb3494e..a80166ed0db 100644 --- a/packages/@internationalized/date/tests/queries.test.js +++ b/packages/@internationalized/date/tests/queries.test.js @@ -25,6 +25,7 @@ import { isEqualMonth, isEqualYear, IslamicUmalquraCalendar, + isLocalTimeZoneOverridden, isSameDay, isSameMonth, isSameYear, @@ -371,4 +372,26 @@ describe('queries', function () { expect(getLocalTimeZone()).toBe(systemTimeZone); }); }); + + describe('isLocalTimeZoneOverridden', function () { + afterEach(() => { + resetLocalTimeZone(); + }); + + it('returns false by default', function () { + expect(isLocalTimeZoneOverridden()).toBe(false); + }); + + it('returns true after setLocalTimeZone', function () { + setLocalTimeZone('America/Denver'); + expect(isLocalTimeZoneOverridden()).toBe(true); + }); + + it('returns false after resetLocalTimeZone', function () { + setLocalTimeZone('America/Denver'); + expect(isLocalTimeZoneOverridden()).toBe(true); + resetLocalTimeZone(); + expect(isLocalTimeZoneOverridden()).toBe(false); + }); + }); });