From 998999f05cd63a2718654e5324bf0a17d74dfbc9 Mon Sep 17 00:00:00 2001 From: Jonas Brunvoll Larsson <59939294+jonasbrunvoll@users.noreply.github.com> Date: Tue, 16 Apr 2024 08:10:00 +0200 Subject: [PATCH] fix: permanent solution to adjust for winter and summer time. (#282) * Permanent solution to take into account winter and summer time. * Add test to verify that the departure time when selecting departure by is whitin the same timezone as the CET (norwegian time), regardless of which timezone the user is located in * Added unit tests, updated logic and function names, updated playwright tests. * Move tests from assistant.test.tsx to date.test.tsx. --- e2e-tests/assistant-search.spec.ts | 134 +++++++++++++++++- src/modules/search-time/selector/index.tsx | 4 +- .../assistant/client/journey-planner/index.ts | 4 +- src/page-modules/assistant/details/utils.ts | 4 +- src/utils/date.ts | 68 +++++++-- src/utils/date/__tests__/date.test.tsx | 87 ++++++++++++ 6 files changed, 284 insertions(+), 17 deletions(-) create mode 100644 src/utils/date/__tests__/date.test.tsx diff --git a/e2e-tests/assistant-search.spec.ts b/e2e-tests/assistant-search.spec.ts index 2be490c3..3a5782e5 100644 --- a/e2e-tests/assistant-search.spec.ts +++ b/e2e-tests/assistant-search.spec.ts @@ -1,10 +1,12 @@ +import { FALLBACK_LANGUAGE } from '@atb/translations/commons'; import { test, expect } from '@playwright/test'; const JST = 'Asia/Tokyo'; const EET = 'Europe/Helsinki'; -const CTU = 'Europe/Oslo'; +const CET = 'Europe/Oslo'; const UTC = 'Europe/London'; const PST = 'America/Los_Angeles'; +const IST = 'Asia/Kolkata'; const fromTextbox = 'Kristiansund'; const fromOption = 'Kristiansund trafikkterminal'; @@ -13,6 +15,29 @@ const toOption = 'Molde trafikkterminal'; const searchTime = '15:30'; const expectedDeparture = '16:00 -'; +const localTimeCET = new Date().toLocaleTimeString(FALLBACK_LANGUAGE, { + timeZone: CET, + hour12: false, + hour: '2-digit', + minute: '2-digit', +}); + +function timeDifferenceInMinutes(time1: string, time2: string): number { + const [hour1, minute1] = time1.split(':').map(Number); + const [hour2, minute2] = time2.split(':').map(Number); + + const totalMinutes1 = hour1 * 60 + minute1; + const totalMinutes2 = hour2 * 60 + minute2; + + return Math.abs(totalMinutes1 - totalMinutes2); +} + +// True if the difference between time1 and time2 is less than 60 minutes. +function sameTimezone(time1: string, time2: string): boolean { + const minutesDifference = timeDifferenceInMinutes(time1, time2); + return minutesDifference < 60; +} + test.describe('Trip search from different timezones - detailed view.', () => { test.beforeEach(async ({ page }) => { await page.goto(process.env.E2E_URL ?? 'http://localhost:3000'); @@ -69,9 +94,9 @@ test.describe('Trip search from different timezones - detailed view.', () => { }); test.use({ - timezoneId: CTU, + timezoneId: CET, }); - test(CTU + ' - (UTC +0100)', async ({ page }) => { + test(CET + ' - (UTC +0100)', async ({ page }) => { await page.getByTestId('searchTimeSelector-departBy').click(); await page.getByTestId('searchTimeSelector-time').click(); await page.getByTestId('searchTimeSelector-time').fill(searchTime); @@ -142,4 +167,107 @@ test.describe('Trip search from different timezones - detailed view.', () => { expect(elementText?.includes(expectedDeparture)); }); + + test.use({ + timezoneId: IST, + }); + test(IST + ' - (UTC +0550)', async ({ page }) => { + await page.getByTestId('searchTimeSelector-departBy').click(); + await page.getByTestId('searchTimeSelector-time').click(); + await page.getByTestId('searchTimeSelector-time').fill(searchTime); + await page.getByRole('textbox', { name: 'From' }).click(); + await page.getByRole('textbox', { name: 'From' }).fill(fromTextbox); + await page.getByRole('option', { name: fromOption }).click(); + await page.getByRole('textbox', { name: 'To' }).click(); + await page.getByRole('textbox', { name: 'To' }).fill(toTextbox); + await page + .getByRole('option', { + name: toOption, + }) + .click(); + await page.getByTestId('tripPattern-0-0').click(); + const elementText = await page + .getByTestId('detailsHeader-duration') + .textContent(); + + expect(elementText?.includes(expectedDeparture)); + }); +}); + +test.describe('Adjusts for summer and winter time - detailed view.', () => { + test.beforeEach(async ({ page }) => { + await page.goto(process.env.E2E_URL ?? 'http://localhost:3000'); + }); + + test.use({ + timezoneId: JST, + }); + test(JST + ' - (UTC +0900)', async ({ page }) => { + await page.getByTestId('searchTimeSelector-departBy').click(); + await page.getByTestId('searchTimeSelector-time').click(); + const departureTime = await page + .getByTestId('searchTimeSelector-time') + .inputValue(); + expect(sameTimezone(localTimeCET, departureTime)).toBeTruthy(); + }); + + test.use({ + timezoneId: EET, + }); + test(EET + ' - (UTC +0200)', async ({ page }) => { + await page.getByTestId('searchTimeSelector-departBy').click(); + await page.getByTestId('searchTimeSelector-time').click(); + const departureTime = await page + .getByTestId('searchTimeSelector-time') + .inputValue(); + expect(sameTimezone(localTimeCET, departureTime)).toBeTruthy(); + }); + + test.use({ + timezoneId: CET, + }); + test(CET + ' - (UTC +0100)', async ({ page }) => { + await page.getByTestId('searchTimeSelector-departBy').click(); + await page.getByTestId('searchTimeSelector-time').click(); + const departureTime = await page + .getByTestId('searchTimeSelector-time') + .inputValue(); + expect(sameTimezone(localTimeCET, departureTime)).toBeTruthy(); + }); + + test.use({ + timezoneId: UTC, + }); + test(UTC + ' - (UTC +0000)', async ({ page }) => { + await page.getByTestId('searchTimeSelector-departBy').click(); + await page.getByTestId('searchTimeSelector-time').click(); + const departureTime = await page + .getByTestId('searchTimeSelector-time') + .inputValue(); + expect(sameTimezone(localTimeCET, departureTime)).toBeTruthy(); + }); + + test.use({ + timezoneId: PST, + }); + test(PST + ' - (UTC -0800)', async ({ page }) => { + await page.getByTestId('searchTimeSelector-departBy').click(); + await page.getByTestId('searchTimeSelector-time').click(); + const departureTime = await page + .getByTestId('searchTimeSelector-time') + .inputValue(); + expect(sameTimezone(localTimeCET, departureTime)).toBeTruthy(); + }); + + test.use({ + timezoneId: IST, + }); + test(IST + ' - (UTC +0550)', async ({ page }) => { + await page.getByTestId('searchTimeSelector-departBy').click(); + await page.getByTestId('searchTimeSelector-time').click(); + const departureTime = await page + .getByTestId('searchTimeSelector-time') + .inputValue(); + expect(sameTimezone(localTimeCET, departureTime)).toBeTruthy(); + }); }); diff --git a/src/modules/search-time/selector/index.tsx b/src/modules/search-time/selector/index.tsx index a2ed936e..a0e1ce12 100644 --- a/src/modules/search-time/selector/index.tsx +++ b/src/modules/search-time/selector/index.tsx @@ -8,7 +8,7 @@ import { } from '@atb/translations'; import { SEARCH_MODES, SearchMode, SearchTime } from '../types'; import style from './selector.module.css'; -import { formatLocalTimeToCET, setTimezone } from '@atb/utils/date'; +import { fromLocalTimeToCET, setTimezone } from '@atb/utils/date'; type SearchTimeSelectorProps = { onChange: (state: SearchTime) => void; @@ -25,7 +25,7 @@ export default function SearchTimeSelector({ const [selectedMode, setSelectedMode] = useState(initialState); const initialDate = setTimezone( 'dateTime' in initialState - ? new Date(formatLocalTimeToCET(initialState.dateTime)) + ? new Date(fromLocalTimeToCET(initialState.dateTime)) : new Date(), ) as Date; diff --git a/src/page-modules/assistant/client/journey-planner/index.ts b/src/page-modules/assistant/client/journey-planner/index.ts index 21da9d2f..151b404d 100644 --- a/src/page-modules/assistant/client/journey-planner/index.ts +++ b/src/page-modules/assistant/client/journey-planner/index.ts @@ -9,7 +9,7 @@ import { swrFetcher } from '@atb/modules/api-browser'; import useSWRInfinite from 'swr/infinite'; import { createTripQuery, tripQueryToQueryString } from '../../utils'; import { useEffect, useState } from 'react'; -import { formatLocalTimeToCET } from '@atb/utils/date'; +import { fromLocalTimeToCET } from '@atb/utils/date'; import { LineData } from '../../server/journey-planner/validators'; const MAX_NUMBER_OF_INITIAL_SEARCH_ATTEMPTS = 3; @@ -47,7 +47,7 @@ export function useTripPatterns( ...tripQuery, searchTime: { ...tripQuery.searchTime, - dateTime: formatLocalTimeToCET(tripQuery.searchTime.dateTime), + dateTime: fromLocalTimeToCET(tripQuery.searchTime.dateTime), }, }, ); diff --git a/src/page-modules/assistant/details/utils.ts b/src/page-modules/assistant/details/utils.ts index beb4c524..b313b046 100644 --- a/src/page-modules/assistant/details/utils.ts +++ b/src/page-modules/assistant/details/utils.ts @@ -1,4 +1,4 @@ -import { formatCETToLocalTime } from '@atb/utils/date'; +import { fromCETToLocalTime } from '@atb/utils/date'; import { parseTripQueryString } from '../server/journey-planner'; export function formatQuayName(quayName?: string, publicCode?: string | null) { @@ -53,7 +53,7 @@ export function tripQueryStringToQueryParams( const fromLayer = from.place?.includes('StopPlace') ? 'venue' : 'address'; const toLayer = to.place?.includes('StopPlace') ? 'venue' : 'address'; const searchTime = String( - formatCETToLocalTime(new Date(originalSearchTime).getTime()), + fromCETToLocalTime(new Date(originalSearchTime).getTime()), ); const params = { diff --git a/src/utils/date.ts b/src/utils/date.ts index 11680b5e..3465b467 100644 --- a/src/utils/date.ts +++ b/src/utils/date.ts @@ -370,18 +370,70 @@ export function setTimezone(date: Date): Date { return new Date(date.toLocaleString(FALLBACK_LANGUAGE, { timeZone: CET })); } -export function formatLocalTimeToCET(localTime: number) { - const offset = getOffsetTimezone(); - return localTime + ONE_HOUR * (offset - 2); +export function fromLocalTimeToCET(localTime: number) { + const hoursDifference = getHoursDifferenceFromCET(localTime); // difference from CET. + return localTime + ONE_HOUR * hoursDifference; } -export function formatCETToLocalTime(cet: number) { - const offset = getOffsetTimezone(); - return cet - ONE_HOUR * (offset - 2); +export function fromCETToLocalTime(cet: number) { + const hoursDifference = getHoursDifferenceFromCET(cet); + return cet - ONE_HOUR * hoursDifference; } -function getOffsetTimezone() { - return (-1 * new Date().getTimezoneOffset()) / 60; +function getUTCOffset(date: Date, timeZone: string): number { + const offsetString = date + .toLocaleString('ia', { + timeZoneName: 'longOffset', + timeZone, + }) + .replace(/^.*? GMT/, ''); + + const [offsetHour, offsetMinutes] = offsetString.split(':'); + const utcOffset = + parseInt(offsetHour || '0') + + (offsetMinutes ? parseInt(offsetMinutes) / 60 : 0); + + return utcOffset; +} + +export function getHoursDifferenceFromCET(time: number, timeZone?: string) { + const date = timeZone + ? new Date(new Date(time).toLocaleString('en-US', { timeZone })) + : new Date(time); + + let offsetUTC; + if (timeZone) offsetUTC = getUTCOffset(new Date(time), timeZone) * -60; + else offsetUTC = date.getTimezoneOffset(); + const isDST = isDaylightSavingTime(date); + + let offsetCET = 1; // Winter time + if (isDST) offsetCET = 2; // Summer time + + let hoursDifferenceToCET = -(offsetCET + offsetUTC / 60); + + // If 0 heours difference, remove '-' + hoursDifferenceToCET = hoursDifferenceToCET === -0 ? 0 : hoursDifferenceToCET; + + return hoursDifferenceToCET; +} + +export function isDaylightSavingTime(date: Date): boolean { + const dstStart = getLastSundayOfMonthAndSetTime(date.getFullYear(), 2, 2); // Last Sunday of March at 02:00 + const dstEnd = getLastSundayOfMonthAndSetTime(date.getFullYear(), 9, 3); // Last Sunday of October at 03:00 + + return date >= dstStart && date < dstEnd; +} + +export function getLastSundayOfMonthAndSetTime( + year: number, + month: number, + hour: number, +) { + let daysInMonth = new Date(year, month + 1, 0).getDate(); + const date = new Date(year, month, daysInMonth); + date.setDate(date.getDate() - ((date.getDay() + 7) % 7)); // Last sunday in month + date.setHours(hour); // Set time. + return date; } export function dateWithReplacedTime( diff --git a/src/utils/date/__tests__/date.test.tsx b/src/utils/date/__tests__/date.test.tsx new file mode 100644 index 00000000..01f03dce --- /dev/null +++ b/src/utils/date/__tests__/date.test.tsx @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest'; + +import { + getHoursDifferenceFromCET, + getLastSundayOfMonthAndSetTime, + isDaylightSavingTime, +} from '@atb/utils/date'; + +describe('isDaylightSavingTime', () => { + const winterTime = new Date(2024, 0, 2); + const summerTime = new Date(2024, 6, 2); + + it(`should rejects that date ${winterTime} is within daylight saving time (DST).`, () => { + const isWhitinDST = isDaylightSavingTime(winterTime); + expect(isWhitinDST).toBeFalsy(); + }); + + it(`should confime that date ${summerTime} is within daylight saving time (DST).`, () => { + const isWhitinDST = isDaylightSavingTime(summerTime); + expect(isWhitinDST).toBeTruthy(); + }); +}); + +describe('getCETOffset', () => { + const timezones = [ + { name: 'Asia/Tokyo', etcOffsetWinterTime: 8, etcOffsetSummerTime: 7 }, + { name: 'Europe/Helsinki', etcOffsetWinterTime: 1, etcOffsetSummerTime: 1 }, + { name: 'Europe/Oslo', etcOffsetWinterTime: 0, etcOffsetSummerTime: 0 }, + { name: 'Europe/London', etcOffsetWinterTime: -1, etcOffsetSummerTime: -1 }, + { + name: 'America/Los_Angeles', + etcOffsetWinterTime: -9, + etcOffsetSummerTime: -9, + }, + { + name: 'Asia/Kolkata', + etcOffsetWinterTime: 4.5, + etcOffsetSummerTime: 3.5, + }, + ]; + + timezones.forEach((timezone) => { + it(`should return the offset from Europe/Oslo to ${timezone.name}.`, () => { + const winterTime = new Date(2024, 0, 2); + let hoursDifference = getHoursDifferenceFromCET( + winterTime.getTime(), + timezone.name, + ); + expect(hoursDifference).toEqual(timezone.etcOffsetWinterTime); + }); + + it(`should return the offset from Europe/Oslo to ${timezone.name} when DST in Norway.`, () => { + const summerTime = new Date(2024, 6, 2); + let hoursDifference = getHoursDifferenceFromCET( + summerTime.getTime(), + timezone.name, + ); + expect(hoursDifference).toEqual(timezone.etcOffsetSummerTime); + }); + }); +}); + +describe('getLastSundayOfMonthWithTime', () => { + it('should return the date of the last sunday in given year, month, and with specified time.', () => { + const year = 2024; + const march = 2; + const day = 31; + const hour = 2; + + const dateLastSundayInMarch2024 = new Date(year, march, day, hour); + const result = getLastSundayOfMonthAndSetTime(year, march, hour); + + expect(result).toEqual(dateLastSundayInMarch2024); + }); + + it('should return the date of the last sunday in given year, month, and with specified time.', () => { + const year = 2024; + const october = 9; + const day = 27; + const hour = 3; + + const dateLastSundayInOctober2024 = new Date(year, october, day, hour); + const result = getLastSundayOfMonthAndSetTime(year, october, hour); + + expect(result).toEqual(dateLastSundayInOctober2024); + }); +});