diff --git a/packages/timezone-data/src/index.ts b/packages/timezone-data/src/index.ts index 31440e640..5a445d869 100644 --- a/packages/timezone-data/src/index.ts +++ b/packages/timezone-data/src/index.ts @@ -1,272 +1,334 @@ const timezoneData: {name: string; label: string}[] = [ { name: 'Pacific/Pago_Pago', - label: '(GMT -11:00) Midway Island, Samoa' + label: 'Midway Island, Samoa' }, { name: 'Pacific/Honolulu', - label: '(GMT -10:00) Hawaii' + label: 'Hawaii' }, { name: 'America/Anchorage', - label: '(GMT -9:00) Alaska' + label: 'Alaska' }, { name: 'America/Tijuana', - label: '(GMT -8:00) Chihuahua, La Paz, Mazatlan' + label: 'Chihuahua, La Paz, Mazatlan' }, { name: 'America/Los_Angeles', - label: '(GMT -8:00) Pacific Time (US & Canada); Tijuana' + label: 'Pacific Time (US & Canada); Tijuana' }, { name: 'America/Phoenix', - label: '(GMT -7:00) Arizona' + label: 'Arizona' }, { name: 'America/Denver', - label: '(GMT -7:00) Mountain Time (US & Canada)' + label: 'Mountain Time (US & Canada)' }, { name: 'America/Costa_Rica', - label: '(GMT -6:00) Central America' + label: 'Central America' }, { name: 'America/Chicago', - label: '(GMT -6:00) Central Time (US & Canada)' + label: 'Central Time (US & Canada)' }, { name: 'America/Mexico_City', - label: '(GMT -6:00) Guadalajara, Mexico City, Monterrey' + label: 'Guadalajara, Mexico City, Monterrey' }, { name: 'America/Regina', - label: '(GMT -6:00) Saskatchewan' + label: 'Saskatchewan' }, { name: 'America/Bogota', - label: '(GMT -5:00) Bogota, Lima, Quito' + label: 'Bogota, Lima, Quito' }, { name: 'America/New_York', - label: '(GMT -5:00) Eastern Time (US & Canada)' + label: 'Eastern Time (US & Canada)' }, { name: 'America/Fort_Wayne', - label: '(GMT -5:00) Indiana (East)' + label: 'Indiana (East)' }, { name: 'America/Caracas', - label: '(GMT -4:00) Caracas, La Paz' + label: 'Caracas, La Paz' }, { name: 'America/Halifax', - label: '(GMT -4:00) Atlantic Time (Canada); Greenland' + label: 'Atlantic Time (Canada); Greenland' }, { name: 'America/Santiago', - label: '(GMT -4:00) Santiago' + label: 'Santiago' }, { name: 'America/St_Johns', - label: '(GMT -3:30) Newfoundland' + label: 'Newfoundland' }, { name: 'America/Argentina/Buenos_Aires', - label: '(GMT -3:00) Buenos Aires, Brasilia, Georgetown' + label: 'Buenos Aires, Brasilia, Georgetown' }, { name: 'America/Noronha', - label: '(GMT -2:00) Fernando de Noronha' + label: 'Fernando de Noronha' }, { name: 'Atlantic/Azores', - label: '(GMT -1:00) Azores' + label: 'Azores' }, { name: 'Atlantic/Cape_Verde', - label: '(GMT -1:00) Cape Verde Is.' + label: 'Cape Verde Is.' }, { name: 'Etc/UTC', - label: '(GMT) UTC' + label: 'UTC' }, { name: 'Africa/Casablanca', - label: '(GMT +0:00) Casablanca, Monrovia' + label: 'Casablanca, Monrovia' }, { name: 'Europe/Dublin', - label: '(GMT +0:00) Dublin, Edinburgh, London' + label: 'Dublin, Edinburgh, London' }, { name: 'Europe/Amsterdam', - label: '(GMT +1:00) Amsterdam, Berlin, Rome, Stockholm, Vienna' + label: ' Amsterdam, Berlin, Rome, Stockholm, Vienna' }, { name: 'Europe/Prague', - label: '(GMT +1:00) Belgrade, Bratislava, Budapest, Prague' + label: 'Belgrade, Bratislava, Budapest, Prague' }, { name: 'Europe/Paris', - label: '(GMT +1:00) Brussels, Copenhagen, Madrid, Paris' + label: 'Brussels, Copenhagen, Madrid, Paris' }, { name: 'Europe/Warsaw', - label: '(GMT +1:00) Sarajevo, Skopje, Warsaw, Zagreb' + label: 'Sarajevo, Skopje, Warsaw, Zagreb' }, { name: 'Africa/Lagos', - label: '(GMT +1:00) West Central Africa' + label: 'West Central Africa' }, { name: 'Europe/Athens', - label: '(GMT +2:00) Athens, Beirut, Bucharest' + label: 'Athens, Beirut, Bucharest' }, { name: 'Africa/Cairo', - label: '(GMT +2:00) Cairo, Egypt' + label: 'Cairo, Egypt' }, { name: 'Africa/Maputo', - label: '(GMT +2:00) Harare' + label: 'Harare' }, { name: 'Europe/Kiev', // Changing name to 'Europe/Kiev', and keeping the UI with Kyiv. Change this once we are passed the moment lib update. - label: '(GMT +2:00) Helsinki, Kyiv, Riga, Sofia, Tallinn, Vilnius' + label: 'Helsinki, Kyiv, Riga, Sofia, Tallinn, Vilnius' }, { name: 'Asia/Jerusalem', - label: '(GMT +2:00) Jerusalem' + label: 'Jerusalem' }, { name: 'Africa/Johannesburg', - label: '(GMT +2:00) Pretoria' + label: 'Pretoria' }, { name: 'Asia/Baghdad', - label: '(GMT +3:00) Baghdad' + label: 'Baghdad' }, { name: 'Asia/Riyadh', - label: '(GMT +3:00) Kuwait, Nairobi, Riyadh' + label: 'Kuwait, Nairobi, Riyadh' }, { name: 'Europe/Istanbul', - label: '(GMT +3:00) Istanbul, Ankara' + label: 'Istanbul, Ankara' }, { name: 'Europe/Moscow', - label: '(GMT +3:00) Moscow, St. Petersburg, Volgograd' + label: 'Moscow, St. Petersburg, Volgograd' }, { name: 'Asia/Tehran', - label: '(GMT +3:30) Tehran' + label: 'Tehran' }, { name: 'Asia/Dubai', - label: '(GMT +4:00) Abu Dhabi, Muscat' + label: 'Abu Dhabi, Muscat' }, { name: 'Asia/Baku', - label: '(GMT +4:00) Baku, Tbilisi, Yerevan' + label: 'Baku, Tbilisi, Yerevan' }, { name: 'Asia/Kabul', - label: '(GMT +4:30) Kabul' + label: 'Kabul' }, { name: 'Asia/Karachi', - label: '(GMT +5:00) Islamabad, Karachi, Tashkent' + label: 'Islamabad, Karachi, Tashkent' }, { name: 'Asia/Yekaterinburg', - label: '(GMT +5:00) Yekaterinburg' + label: 'Yekaterinburg' }, { name: 'Asia/Kolkata', - label: '(GMT +5:30) Chennai, Calcutta, Mumbai, New Delhi' + label: 'Chennai, Kolkata, Mumbai, New Delhi' }, { name: 'Asia/Kathmandu', - label: '(GMT +5:45) Katmandu' + label: 'Katmandu' }, { name: 'Asia/Almaty', - label: '(GMT +6:00) Almaty, Novosibirsk' + label: 'Almaty, Novosibirsk' }, { name: 'Asia/Dhaka', - label: '(GMT +6:00) Astana, Dhaka, Sri Jayawardenepura' + label: 'Astana, Dhaka, Sri Jayawardenepura' }, { name: 'Asia/Rangoon', - label: '(GMT +6:30) Rangoon' + label: 'Rangoon' }, { name: 'Asia/Bangkok', - label: '(GMT +7:00) Bangkok, Hanoi, Jakarta' + label: 'Bangkok, Hanoi, Jakarta' }, { name: 'Asia/Krasnoyarsk', - label: '(GMT +7:00) Krasnoyarsk' + label: 'Krasnoyarsk' }, { name: 'Asia/Hong_Kong', - label: '(GMT +8:00) Beijing, Chongqing, Hong Kong, Urumqi' + label: 'Beijing, Chongqing, Hong Kong, Urumqi' }, { name: 'Asia/Irkutsk', - label: '(GMT +8:00) Irkutsk, Ulaan Bataar' + label: 'Irkutsk, Ulaan Bataar' }, { name: 'Asia/Singapore', - label: '(GMT +8:00) Kuala Lumpur, Perth, Singapore, Taipei' + label: 'Kuala Lumpur, Perth, Singapore, Taipei' }, { name: 'Asia/Tokyo', - label: '(GMT +9:00) Osaka, Sapporo, Tokyo' + label: 'Osaka, Sapporo, Tokyo' }, { name: 'Asia/Seoul', - label: '(GMT +9:00) Seoul' + label: 'Seoul' }, { name: 'Asia/Yakutsk', - label: '(GMT +9:00) Yakutsk' + label: 'Yakutsk' }, { name: 'Australia/Adelaide', - label: '(GMT +9:30) Adelaide' + label: 'Adelaide' }, { name: 'Australia/Darwin', - label: '(GMT +9:30) Darwin' + label: 'Darwin' }, { name: 'Australia/Brisbane', - label: '(GMT +10:00) Brisbane, Guam, Port Moresby' + label: 'Brisbane, Guam, Port Moresby' }, { name: 'Australia/Sydney', - label: '(GMT +10:00) Canberra, Hobart, Melbourne, Sydney, Vladivostok' + label: 'Canberra, Hobart, Melbourne, Sydney, Vladivostok' }, { name: 'Asia/Magadan', - label: '(GMT +11:00) Magadan, Soloman Is., New Caledonia' + label: 'Magadan, Soloman Is., New Caledonia' }, { name: 'Pacific/Auckland', - label: '(GMT +12:00) Auckland, Wellington' + label: 'Auckland, Wellington' }, { name: 'Pacific/Fiji', - label: '(GMT +12:00) Fiji, Kamchatka, Marshall Is.' + label: 'Fiji, Kamchatka, Marshall Is.' }, { name: 'Pacific/Kwajalein', - label: '(GMT +12:00) International Date Line West' + label: 'International Date Line West' } ]; +interface GMTOffsetData { + offsetString: string | null; + offsetMinutes: number; +} + +interface TimezoneDataWithOffset { + name: string; + label: string; + offsetMinutes: number; +} + +export const getGMTOffset = (timeZone: string): GMTOffsetData => { + const options: Intl.DateTimeFormatOptions = { + timeZone, + timeZoneName: 'longOffset' + }; + + try { + const formatter = new Intl.DateTimeFormat('en-GB', options); + const parts = formatter.formatToParts(new Date()); + const offsetPart = parts.find(part => part.type === 'timeZoneName')?.value; + + if (!offsetPart) { + return {offsetString: null, offsetMinutes: 0}; + } + + // Expecting formats like "GMT+05:30" or "GMT-08:00" + const match = offsetPart.match(/^GMT([+-])(\d{2}):(\d{2})$/); + + if (!match) { + return {offsetString: offsetPart, offsetMinutes: 0}; + } + + const [, sign, hourStr, minuteStr] = match; + const hour = parseInt(hourStr, 10); + const minute = parseInt(minuteStr, 10); + const totalMinutes = sign === '+' ? (hour * 60 + minute) : -(hour * 60 + minute); + const offsetString = `GMT ${sign}${hour}:${minute.toString().padStart(2, '0')}`; + + return {offsetString, offsetMinutes: totalMinutes}; + } catch (error) { + return {offsetString: null, offsetMinutes: 0}; + } +}; + +const labelWithGMTOffset = (label: string, offsetString: string): string => { + return `(${offsetString}) ${label}`; +}; + +export const timezoneDataWithGMTOffset = (): TimezoneDataWithOffset[] => { + return timezoneData + .map(({name, label}) => { + const {offsetString, offsetMinutes} = getGMTOffset(name); + return { + name, + label: offsetString ? labelWithGMTOffset(label, offsetString) : label, + offsetMinutes + }; + }) + .sort((a, b) => a.offsetMinutes - b.offsetMinutes); +}; + export default timezoneData; diff --git a/packages/timezone-data/test/hello.test.js b/packages/timezone-data/test/hello.test.js deleted file mode 100644 index 85d69d1e0..000000000 --- a/packages/timezone-data/test/hello.test.js +++ /dev/null @@ -1,10 +0,0 @@ -// Switch these lines once there are useful utils -// const testUtils = require('./utils'); -require('./utils'); - -describe('Hello world', function () { - it('Runs a test', function () { - // TODO: Write me! - 'hello'.should.eql('hello'); - }); -}); diff --git a/packages/timezone-data/test/timezone-data.test.js b/packages/timezone-data/test/timezone-data.test.js new file mode 100644 index 000000000..1894a6eb6 --- /dev/null +++ b/packages/timezone-data/test/timezone-data.test.js @@ -0,0 +1,130 @@ +require('./utils'); +const {getGMTOffset, timezoneDataWithGMTOffset} = require('../'); +const timezoneData = require('../').default; + +describe('Timezone Data with GMT Offset', function () { + let clock; + + afterEach(function () { + if (clock) { + clock.restore(); + } + }); + + describe('getGMTOffset', function () { + it('should return an object with offsetString and offsetMinutes properties', function () { + const result = getGMTOffset('Etc/UTC'); + result.should.be.an.Object(); + result.should.have.properties(['offsetString', 'offsetMinutes']); + }); + + it('should return the correct offset for Etc/UTC', function () { + const result = getGMTOffset('Etc/UTC'); + result.should.be.an.Object(); + result.offsetMinutes.should.equal(0); + result.offsetString.should.equal('GMT'); + }); + + it('should handle timezone with negative offset', function () { + const result = getGMTOffset('America/Phoenix'); + result.should.be.an.Object(); + result.offsetMinutes.should.equal(-420); + result.offsetString.should.equal('GMT -7:00'); + }); + + it('should handle timezone with half-hour offset like Asia/Kolkata', function () { + const result = getGMTOffset('Asia/Kolkata'); + result.should.be.an.Object(); + result.offsetMinutes.should.equal(330); + result.offsetString.should.equal('GMT +5:30'); + }); + + it('should handle invalid timezone gracefully', function () { + const result = getGMTOffset('Invalid/Timezone'); + result.should.be.an.Object(); + result.should.have.properties(['offsetString', 'offsetMinutes']); + (result.offsetString === null).should.be.true(); + result.offsetMinutes.should.equal(0); + }); + + it('should provide consistent results for non-DST timezone', function () { + clock = sinon.useFakeTimers(new Date('2024-07-01T12:00:00Z')); + const summerResult = getGMTOffset('America/Phoenix'); + clock.restore(); + + clock = sinon.useFakeTimers(new Date('2024-12-01T12:00:00Z')); + const winterResult = getGMTOffset('America/Phoenix'); + + // Arizona doesn't observe DST + winterResult.offsetMinutes.should.equal(summerResult.offsetMinutes); + }); + + it('should detect DST changes for timezone that observes DST', function () { + clock = sinon.useFakeTimers(new Date('2024-12-01T12:00:00Z')); // Winter + const winterResult = getGMTOffset('America/New_York'); + clock.restore(); + + clock = sinon.useFakeTimers(new Date('2024-07-01T12:00:00Z')); // Summer + const summerResult = getGMTOffset('America/New_York'); + + // Summer is 1 hour ahead of winter + (summerResult.offsetMinutes - winterResult.offsetMinutes).should.equal(60); + summerResult.offsetString.should.equal('GMT -4:00'); + summerResult.offsetMinutes.should.equal(-240); + winterResult.offsetString.should.equal('GMT -5:00'); + winterResult.offsetMinutes.should.equal(-300); + }); + }); + + describe('timezoneDataWithGMTOffset', function () { + it('should return an array of timezone data with GMT offsets', function () { + const result = timezoneDataWithGMTOffset(); + + result.should.be.an.Array(); + result.length.should.equal(67); + + result.forEach((item) => { + item.should.have.properties(['name', 'label', 'offsetMinutes']); + }); + }); + + it('should include GMT offset in labels', function () { + const result = timezoneDataWithGMTOffset(); + + const utcTimezone = result.find(tz => tz.name === 'Etc/UTC'); + utcTimezone.should.be.ok(); + utcTimezone.label.should.match(/^\(GMT\) UTC$/); + + const nyTimezone = result.find(tz => tz.name === 'America/New_York'); + nyTimezone.should.be.ok(); + nyTimezone.label.should.match(/^\(GMT -[45]:00\) Eastern Time \(US & Canada\)$/); + }); + + it('should preserve original timezone name', function () { + const result = timezoneDataWithGMTOffset(); + + const namesinResult = result.map(obj => obj.name).sort(); + const namesInTzData = timezoneData.map(obj => obj.name).sort(); + + namesinResult.should.deepEqual(namesInTzData); + }); + + it('should maintain consistent sorting across DST changes', function () { + clock = sinon.useFakeTimers(new Date('2024-12-01T12:00:00Z')); + const winterResult = timezoneDataWithGMTOffset(); + + clock.restore(); + clock = sinon.useFakeTimers(new Date('2024-07-01T12:00:00Z')); + const summerResult = timezoneDataWithGMTOffset(); + + // Both results should be properly sorted + for (let i = 1; i < winterResult.length; i++) { + winterResult[i].offsetMinutes.should.be.greaterThanOrEqual(winterResult[i - 1].offsetMinutes); + } + + for (let i = 1; i < summerResult.length; i++) { + summerResult[i].offsetMinutes.should.be.greaterThanOrEqual(summerResult[i - 1].offsetMinutes); + } + }); + }); +}); diff --git a/packages/timezone-data/tsconfig.json b/packages/timezone-data/tsconfig.json index 1825fce5f..4eaf4e3f7 100644 --- a/packages/timezone-data/tsconfig.json +++ b/packages/timezone-data/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "es2016", + "target": "es2020", "module": "ESNext", "esModuleInterop": true, "forceConsistentCasingInFileNames": true,