From 2114b64987c3db0b71cc33d3bb26f504bb7efe04 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Mon, 27 Apr 2026 12:20:01 -0300 Subject: [PATCH 1/2] fix: normalize legacy Intl timezone names to IANA canonical --- .../client/hooks/useTimezoneNameList.ts | 13 +++-- packages/tools/src/timezone.spec.ts | 30 ++++++++++- packages/tools/src/timezone.ts | 50 +++++++++++++++---- 3 files changed, 76 insertions(+), 17 deletions(-) diff --git a/apps/meteor/client/hooks/useTimezoneNameList.ts b/apps/meteor/client/hooks/useTimezoneNameList.ts index 2cb37b7843523..3c19cba8e56e7 100644 --- a/apps/meteor/client/hooks/useTimezoneNameList.ts +++ b/apps/meteor/client/hooks/useTimezoneNameList.ts @@ -1,9 +1,8 @@ +import { getTimezoneNames } from '@rocket.chat/tools'; import { useMemo } from 'react'; -const getTimeZoneNames = (): string[] => { - const intl = Intl as typeof Intl & { supportedValuesOf?(key: 'timeZone'): string[] }; - const names = typeof intl.supportedValuesOf === 'function' ? intl.supportedValuesOf('timeZone') : []; - return names.includes('UTC') ? names : ['UTC', ...names]; -}; - -export const useTimezoneNameList = (): string[] => useMemo(() => getTimeZoneNames(), []); +export const useTimezoneNameList = (): string[] => + useMemo(() => { + const names = getTimezoneNames(); + return names.includes('UTC') ? names : ['UTC', ...names]; + }, []); diff --git a/packages/tools/src/timezone.spec.ts b/packages/tools/src/timezone.spec.ts index 27db79edd8058..7e1025674e718 100644 --- a/packages/tools/src/timezone.spec.ts +++ b/packages/tools/src/timezone.spec.ts @@ -1,4 +1,4 @@ -import { canonicalizeTimezone } from './timezone'; +import { canonicalizeTimezone, getTimezoneNames } from './timezone'; describe('canonicalizeTimezone', () => { it('returns the same value for a canonical IANA zone', () => { @@ -22,8 +22,36 @@ describe('canonicalizeTimezone', () => { expect(canonicalizeTimezone('Japan')).toBe('Asia/Tokyo'); }); + it('resolves legacy IANA names to modern canonical names', () => { + expect(canonicalizeTimezone('Asia/Calcutta')).toBe('Asia/Kolkata'); + expect(canonicalizeTimezone('Asia/Katmandu')).toBe('Asia/Kathmandu'); + expect(canonicalizeTimezone('Asia/Rangoon')).toBe('Asia/Yangon'); + expect(canonicalizeTimezone('Asia/Saigon')).toBe('Asia/Ho_Chi_Minh'); + expect(canonicalizeTimezone('Europe/Kiev')).toBe('Europe/Kyiv'); + expect(canonicalizeTimezone('America/Godthab')).toBe('America/Nuuk'); + expect(canonicalizeTimezone('Pacific/Enderbury')).toBe('Pacific/Kanton'); + }); + + it('preserves modern canonical names as-is', () => { + expect(canonicalizeTimezone('Asia/Kolkata')).toBe('Asia/Kolkata'); + expect(canonicalizeTimezone('Europe/Kyiv')).toBe('Europe/Kyiv'); + expect(canonicalizeTimezone('Asia/Ho_Chi_Minh')).toBe('Asia/Ho_Chi_Minh'); + }); + it('returns the input unchanged when it is not a recognized zone', () => { const input = 'Not/A_Zone'; expect(canonicalizeTimezone(input)).toBe(input); }); }); + +describe('getTimezoneNames', () => { + it('returns modern canonical names instead of legacy ones', () => { + const names = getTimezoneNames(); + expect(names).toContain('Asia/Kolkata'); + expect(names).not.toContain('Asia/Calcutta'); + expect(names).toContain('Europe/Kyiv'); + expect(names).not.toContain('Europe/Kiev'); + expect(names).toContain('Asia/Yangon'); + expect(names).not.toContain('Asia/Rangoon'); + }); +}); diff --git a/packages/tools/src/timezone.ts b/packages/tools/src/timezone.ts index 7fcb172b7cba1..38e418d240a16 100644 --- a/packages/tools/src/timezone.ts +++ b/packages/tools/src/timezone.ts @@ -1,3 +1,40 @@ +// Zones where Node/browser Intl returns a legacy IANA name instead of the +// current canonical one. Workaround until Temporal lands (~late 2026). +// Source: https://data.iana.org/time-zones/tzdb/backward +// Ref: https://github.com/tc39/proposal-temporal/issues/3249 +const LEGACY_TO_CANONICAL: Record = { + 'America/Buenos_Aires': 'America/Argentina/Buenos_Aires', + 'America/Catamarca': 'America/Argentina/Catamarca', + 'America/Cordoba': 'America/Argentina/Cordoba', + 'America/Godthab': 'America/Nuuk', + 'America/Indianapolis': 'America/Indiana/Indianapolis', + 'America/Jujuy': 'America/Argentina/Jujuy', + 'America/Louisville': 'America/Kentucky/Louisville', + 'America/Mendoza': 'America/Argentina/Mendoza', + 'Asia/Calcutta': 'Asia/Kolkata', + 'Asia/Katmandu': 'Asia/Kathmandu', + 'Asia/Rangoon': 'Asia/Yangon', + 'Asia/Saigon': 'Asia/Ho_Chi_Minh', + 'Atlantic/Faeroe': 'Atlantic/Faroe', + 'Europe/Kiev': 'Europe/Kyiv', + 'Pacific/Enderbury': 'Pacific/Kanton', +}; + +export const canonicalizeTimezone = (name: string): string => { + try { + const resolved = new Intl.DateTimeFormat(undefined, { timeZone: name }).resolvedOptions().timeZone; + return LEGACY_TO_CANONICAL[resolved] ?? resolved; + } catch { + return name; + } +}; + +export const getTimezoneNames = (): string[] => { + const intl = Intl as typeof Intl & { supportedValuesOf?(key: 'timeZone'): string[] }; + const zones = typeof intl.supportedValuesOf === 'function' ? intl.supportedValuesOf('timeZone') : []; + return zones.map((name) => LEGACY_TO_CANONICAL[name] ?? name); +}; + export const guessTimezoneFromOffset = (offset: string | number): string => { const hours = Number(offset); const totalMinutes = Math.round(hours * 60); @@ -21,7 +58,7 @@ export const guessTimezoneFromOffset = (offset: string | number): string => { const tzHours = match[1] ? parseInt(match[1], 10) : 0; const tzMinutes = match[2] ? parseInt(match[2], 10) * (tzHours < 0 ? -1 : 1) : 0; if (tzHours * 60 + tzMinutes === totalMinutes) { - return tz; + return LEGACY_TO_CANONICAL[tz] ?? tz; } } @@ -33,12 +70,7 @@ export const guessTimezoneFromOffset = (offset: string | number): string => { return `Etc/GMT${intHours > 0 ? '-' : '+'}${Math.abs(intHours)}`; }; -export const guessTimezone = (): string => new Intl.DateTimeFormat().resolvedOptions().timeZone; - -export const canonicalizeTimezone = (name: string): string => { - try { - return new Intl.DateTimeFormat(undefined, { timeZone: name }).resolvedOptions().timeZone; - } catch { - return name; - } +export const guessTimezone = (): string => { + const resolved = new Intl.DateTimeFormat().resolvedOptions().timeZone; + return LEGACY_TO_CANONICAL[resolved] ?? resolved; }; From 873bb6e2d5f4760d30842f97688ddb5fa0ac00d5 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Tue, 28 Apr 2026 08:33:33 -0300 Subject: [PATCH 2/2] fix: sort timezone list after legacy-to-canonical name mapping --- packages/tools/src/timezone.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tools/src/timezone.ts b/packages/tools/src/timezone.ts index 38e418d240a16..588a5df937b3c 100644 --- a/packages/tools/src/timezone.ts +++ b/packages/tools/src/timezone.ts @@ -32,7 +32,7 @@ export const canonicalizeTimezone = (name: string): string => { export const getTimezoneNames = (): string[] => { const intl = Intl as typeof Intl & { supportedValuesOf?(key: 'timeZone'): string[] }; const zones = typeof intl.supportedValuesOf === 'function' ? intl.supportedValuesOf('timeZone') : []; - return zones.map((name) => LEGACY_TO_CANONICAL[name] ?? name); + return zones.map((name) => LEGACY_TO_CANONICAL[name] ?? name).sort(); }; export const guessTimezoneFromOffset = (offset: string | number): string => {