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); + }); +});