diff --git a/src/app/legacy/containers/ArticleTimestamp/index.stories.jsx b/src/app/legacy/containers/ArticleTimestamp/index.stories.jsx index 17568ac090f..9995cbbf4fb 100644 --- a/src/app/legacy/containers/ArticleTimestamp/index.stories.jsx +++ b/src/app/legacy/containers/ArticleTimestamp/index.stories.jsx @@ -1,4 +1,5 @@ import WithTimeMachine from '../../../../testHelpers/withTimeMachine'; +import { ServiceContextProvider } from '#app/contexts/ServiceContext'; import ArticleTimestamp from '.'; import { timestampGenerator } from './testHelpers'; @@ -93,3 +94,46 @@ export const H = () => ( ); H.storyName = 'lastPublished today and more than 10 hours ago and firstPublished before today'; + +// Timezone and locale migration checks for Moment → Temporal +const timezoneLocaleServices = [ + { service: 'news', label: 'English (Europe/London)' }, + { service: 'azeri', label: 'Azeri (Asia/Baku)' }, + { service: 'persian', label: 'Persian (GMT, Jalali calendar)' }, + { service: 'arabic', label: 'Arabic (GMT, RTL)' }, + { service: 'portuguese', label: 'Portuguese (America/Sao_Paulo)' }, + { service: 'nepali', label: 'Nepali (Asia/Kathmandu)' }, +]; + +const dstBoundaryTimestamp = Date.UTC(2021, 2, 28, 1, 0, 0); // 28 March 2021 01:00 UTC (DST boundary) +const fixedOlderTimestamp = Date.UTC(2021, 2, 27, 12, 0, 0); // 27 March 2021 12:00 UTC + +export const TimezoneAndLocaleChecks = () => ( +
+ {timezoneLocaleServices.map(({ service, label }) => ( + +
+ {label} +
+ +
+
+
+ ))} +
+); +TimezoneAndLocaleChecks.parameters = { + chromatic: { + disable: false, + }, +}; +TimezoneAndLocaleChecks.tags = ['!dev']; diff --git a/src/app/legacy/containers/ArticleTimestamp/timeFormatTests/README.md b/src/app/legacy/containers/ArticleTimestamp/timeFormatTests/README.md index 5b5b23d8e8c..81f21d60b07 100644 --- a/src/app/legacy/containers/ArticleTimestamp/timeFormatTests/README.md +++ b/src/app/legacy/containers/ArticleTimestamp/timeFormatTests/README.md @@ -12,7 +12,7 @@ A couple of scenarios that we expect these tests would fail and need updating wo - Change in the timeformat logic [here](../timeFormats) -- Changes in DST times across different timezones. +- Changes in Daylight Saving Time (DST) times across different timezones. - ... please add more if you find any diff --git a/src/app/legacy/containers/ArticleTimestamp/timeFormatTests/testUtils.js b/src/app/legacy/containers/ArticleTimestamp/timeFormatTests/testUtils.js index 8d774147929..769ca1f96da 100644 --- a/src/app/legacy/containers/ArticleTimestamp/timeFormatTests/testUtils.js +++ b/src/app/legacy/containers/ArticleTimestamp/timeFormatTests/testUtils.js @@ -51,3 +51,23 @@ export const format = ({ datetimeLocale, timezone, fixture, altCalendar }) => { return formatted; }; + +// allows precise checks for edge cases without fixture data +export const formatTimestamp = ({ + datetimeLocale, + timezone, + time, + altCalendar, + dateTimeFormat = formatDateAndTime, +}) => { + const dateWithTimezone = moment.tz(time, timezone).locale(datetimeLocale); + const formatted = dateWithTimezone.format(dateTimeFormat(datetimeLocale)); + + if (altCalendar) { + const altCalendarFormatted = altCalendar.formatDate(dateWithTimezone); + + return `${altCalendarFormatted} - ${formatted}`; + } + + return formatted; +}; diff --git a/src/app/legacy/containers/ArticleTimestamp/timeFormatTests/timeFormat.test.js b/src/app/legacy/containers/ArticleTimestamp/timeFormatTests/timeFormat.test.js index 8d2d8e33d48..ae40af92ca1 100644 --- a/src/app/legacy/containers/ArticleTimestamp/timeFormatTests/timeFormat.test.js +++ b/src/app/legacy/containers/ArticleTimestamp/timeFormatTests/timeFormat.test.js @@ -1,6 +1,6 @@ import services from '#utilities/serviceConfigs'; import expectedFormats from './expectedFormats.json'; -import { format, timestampsFixtures } from './testUtils'; +import { format, formatTimestamp, timestampsFixtures } from './testUtils'; describe('Timestamp Formats', () => { Object.keys(services).forEach(service => { @@ -22,4 +22,81 @@ describe('Timestamp Formats', () => { }); }); }); + + describe('Timestamp Edge Cases', () => { + it('should preserve timezone output around DST start for Europe/London', () => { + const beforeDSTStart = Date.UTC(2021, 2, 28, 0, 30, 0); // 00:30 GMT + const afterDSTStart = Date.UTC(2021, 2, 28, 1, 30, 0); // 02:30 BST + + expect( + formatTimestamp({ + datetimeLocale: services.news.default.datetimeLocale, + timezone: services.news.default.timezone, + time: beforeDSTStart, + }), + ).toEqual('28 March 2021, 00:30 GMT'); + + expect( + formatTimestamp({ + datetimeLocale: services.news.default.datetimeLocale, + timezone: services.news.default.timezone, + time: afterDSTStart, + }), + ).toEqual('28 March 2021, 02:30 BST'); + }); + + it('should preserve timezone output around DST end for Europe/London', () => { + const beforeDSTEnd = Date.UTC(2021, 9, 31, 0, 30, 0); // 01:30 BST + const afterDSTEnd = Date.UTC(2021, 9, 31, 1, 30, 0); // 01:30 GMT + + expect( + formatTimestamp({ + datetimeLocale: services.news.default.datetimeLocale, + timezone: services.news.default.timezone, + time: beforeDSTEnd, + }), + ).toEqual('31 October 2021, 01:30 BST'); + + expect( + formatTimestamp({ + datetimeLocale: services.news.default.datetimeLocale, + timezone: services.news.default.timezone, + time: afterDSTEnd, + }), + ).toEqual('31 October 2021, 01:30 GMT'); + }); + + it('should preserve half-hour offset output for Asia/Kabul', () => { + const utcTimestamp = Date.UTC(2021, 5, 15, 12, 0, 0); // 15 June 2021 12:00 UTC + + expect( + formatTimestamp({ + datetimeLocale: services.dari.default.datetimeLocale, + timezone: services.dari.default.timezone, + time: utcTimestamp, + }), + ).toEqual('۱۵ جون ۲۰۲۱ ۱۶:۳۰'); + }); + + it('should preserve timezone output around DST start for Europe/Bucharest', () => { + const beforeDSTStart = Date.UTC(2021, 2, 28, 0, 30, 0); // 02:30 EET + const afterDSTStart = Date.UTC(2021, 2, 28, 1, 30, 0); // 04:30 EEST + + expect( + formatTimestamp({ + datetimeLocale: services.romania.default.datetimeLocale, + timezone: services.romania.default.timezone, + time: beforeDSTStart, + }), + ).toEqual('28 martie 2021, 02:30 EET'); + + expect( + formatTimestamp({ + datetimeLocale: services.romania.default.datetimeLocale, + timezone: services.romania.default.timezone, + time: afterDSTStart, + }), + ).toEqual('28 martie 2021, 04:30 EEST'); + }); + }); }); diff --git a/src/app/legacy/psammead/psammead-timestamp-container/src/utilities/index.test.js b/src/app/legacy/psammead/psammead-timestamp-container/src/utilities/index.test.js index b08ed082a76..92d84eda326 100644 --- a/src/app/legacy/psammead/psammead-timestamp-container/src/utilities/index.test.js +++ b/src/app/legacy/psammead/psammead-timestamp-container/src/utilities/index.test.js @@ -132,6 +132,143 @@ describe('Timestamp utility functions', () => { }); expect(result).toBeUndefined(); }); + + it('should correctly format timezone labels around DST start in Europe/London', () => { + const beforeDSTStart = Date.UTC(2021, 2, 28, 0, 30, 0); // 00:30 GMT + const afterDSTStart = Date.UTC(2021, 2, 28, 1, 30, 0); // 02:30 BST + + expect( + formatUnixTimestamp({ + timestamp: beforeDSTStart, + format: 'D MMMM YYYY, HH:mm z', + timezone, + locale, + }), + ).toEqual('28 March 2021, 00:30 GMT'); + + expect( + formatUnixTimestamp({ + timestamp: afterDSTStart, + format: 'D MMMM YYYY, HH:mm z', + timezone, + locale, + }), + ).toEqual('28 March 2021, 02:30 BST'); + }); + + it('should correctly format timezone labels around DST end in Europe/London', () => { + const beforeDSTEnd = Date.UTC(2021, 9, 31, 0, 30, 0); // 01:30 BST + const afterDSTEnd = Date.UTC(2021, 9, 31, 1, 30, 0); // 01:30 GMT + + expect( + formatUnixTimestamp({ + timestamp: beforeDSTEnd, + format: 'D MMMM YYYY, HH:mm z', + timezone, + locale, + }), + ).toEqual('31 October 2021, 01:30 BST'); + + expect( + formatUnixTimestamp({ + timestamp: afterDSTEnd, + format: 'D MMMM YYYY, HH:mm z', + timezone, + locale, + }), + ).toEqual('31 October 2021, 01:30 GMT'); + }); + + it('should apply a fixed UTC+1 offset for Africa/Lagos (no DST)', () => { + const utcTimestamp = Date.UTC(2021, 5, 15, 12, 0, 0); // 15 June 2021 12:00 UTC + + expect( + formatUnixTimestamp({ + timestamp: utcTimestamp, + format: 'D MMMM YYYY, HH:mm z', + timezone: 'Africa/Lagos', + locale, + }), + ).toEqual('15 June 2021, 13:00 WAT'); + }); + + it('should apply a +05:45 offset for Asia/Kathmandu (no DST, non-whole-hour offset)', () => { + const utcTimestamp = Date.UTC(2021, 5, 15, 12, 0, 0); // 15 June 2021 12:00 UTC + + expect( + formatUnixTimestamp({ + timestamp: utcTimestamp, + format: 'D MMMM YYYY, HH:mm z', + timezone: 'Asia/Kathmandu', + locale, + }), + ).toEqual('15 June 2021, 17:45 +0545'); + }); + + it('should apply a negative UTC-3 offset for America/Sao_Paulo', () => { + const utcTimestamp = Date.UTC(2021, 5, 15, 12, 0, 0); // 15 June 2021 12:00 UTC + + expect( + formatUnixTimestamp({ + timestamp: utcTimestamp, + format: 'D MMMM YYYY, HH:mm z', + timezone: 'America/Sao_Paulo', + locale, + }), + ).toEqual('15 June 2021, 09:00 -03'); + }); + + it('should format correctly for GMT (non-region-style IANA identifier)', () => { + const utcTimestamp = Date.UTC(2021, 5, 15, 12, 0, 0); // 15 June 2021 12:00 UTC + + expect( + formatUnixTimestamp({ + timestamp: utcTimestamp, + format: 'D MMMM YYYY, HH:mm z', + timezone: 'GMT', + locale, + }), + ).toEqual('15 June 2021, 12:00 GMT'); + }); + + it('should translate month names for a non-Latin locale', () => { + expect( + formatUnixTimestamp({ + timestamp, + format: 'D MMMM YYYY', + timezone: 'GMT', + locale: 'ar', + }), + ).toEqual('١٩ أكتوبر ٢٠١٨'); + }); + + it('should apply locale-sensitive format tokens (LL, LT) for a non-Latin locale', () => { + expect( + formatUnixTimestamp({ + timestamp, + format: null, + timezone: 'GMT', + locale: 'ar', + }), + ).toEqual('١٩ أكتوبر ٢٠١٨، ١٧:١٠ GMT'); + }); + + it('should return relative timestamp in the provided locale', () => { + const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(1704110400000); // 1 January 2024 12:00:00 UTC + try { + const nineHoursAgo = timestampGenerator({ hours: 9 }); + const output = formatUnixTimestamp({ + timestamp: nineHoursAgo, + format: 'D MMMM YYYY', + timezone: 'GMT', + locale: 'ar', + isRelative: true, + }); + expect(output).toEqual('منذ ٩ ساعات'); + } finally { + nowSpy.mockRestore(); + } + }); }); }); diff --git a/src/app/lib/config/services/azeri.ts b/src/app/lib/config/services/azeri.ts index 72d91879179..0a2790f51dd 100644 --- a/src/app/lib/config/services/azeri.ts +++ b/src/app/lib/config/services/azeri.ts @@ -464,7 +464,7 @@ export const service: DefaultServiceConfig = { ], copyrightText: 'BBC. BBC kənar saytların məzmununa məsul deyil.', }, - timezone: 'Asia/baku', + timezone: 'Asia/Baku', navigation: [ { title: 'Xəbərlər',