Skip to content

Commit

Permalink
fix: permanent solution to adjust for winter and summer time. (#282)
Browse files Browse the repository at this point in the history
* 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.
  • Loading branch information
jonasbrunvoll committed Apr 16, 2024
1 parent b5b9c23 commit 998999f
Show file tree
Hide file tree
Showing 6 changed files with 284 additions and 17 deletions.
134 changes: 131 additions & 3 deletions e2e-tests/assistant-search.spec.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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');
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
});
});
4 changes: 2 additions & 2 deletions src/modules/search-time/selector/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -25,7 +25,7 @@ export default function SearchTimeSelector({
const [selectedMode, setSelectedMode] = useState<SearchTime>(initialState);
const initialDate = setTimezone(
'dateTime' in initialState
? new Date(formatLocalTimeToCET(initialState.dateTime))
? new Date(fromLocalTimeToCET(initialState.dateTime))
: new Date(),
) as Date;

Expand Down
4 changes: 2 additions & 2 deletions src/page-modules/assistant/client/journey-planner/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -47,7 +47,7 @@ export function useTripPatterns(
...tripQuery,
searchTime: {
...tripQuery.searchTime,
dateTime: formatLocalTimeToCET(tripQuery.searchTime.dateTime),
dateTime: fromLocalTimeToCET(tripQuery.searchTime.dateTime),
},
},
);
Expand Down
4 changes: 2 additions & 2 deletions src/page-modules/assistant/details/utils.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down Expand Up @@ -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 = {
Expand Down
68 changes: 60 additions & 8 deletions src/utils/date.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
87 changes: 87 additions & 0 deletions src/utils/date/__tests__/date.test.tsx
Original file line number Diff line number Diff line change
@@ -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);
});
});

0 comments on commit 998999f

Please sign in to comment.