diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 5b7bd1d7..af9bf822 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -18,6 +18,7 @@ "track": "Track", "station": "Station", "canceled": "Canceled", + "alerts": "Alerts", "page_not_found": "Page could not be found.", "train_current_composition": "Current composition", "train_composition_change_from_to_station_text": "These train units continue from {{from}} to {{to}}", diff --git a/public/locales/fi/translation.json b/public/locales/fi/translation.json index 9f66059e..16916473 100644 --- a/public/locales/fi/translation.json +++ b/public/locales/fi/translation.json @@ -18,6 +18,7 @@ "track": "Raide", "station": "Asema", "canceled": "Peruttu", + "alerts": "Tiedotteet", "page_not_found": "Etsimääsi sivua ei löytynyt.", "train_current_composition": "Nykyinen kokoonpano", "train_composition_change_from_to_station_text": "Nämä junayksiköt jatkavat asemalta {{from}} asemalle {{to}}", diff --git a/src/components/PassengerInformationMessageAlert.tsx b/src/components/PassengerInformationMessageAlert.tsx new file mode 100644 index 00000000..143ad626 --- /dev/null +++ b/src/components/PassengerInformationMessageAlert.tsx @@ -0,0 +1,48 @@ +import { Alert, ButtonBase } from '@mui/material'; +import { useTranslation } from 'react-i18next'; + +import { + getPassengerInformationMessageForLanguage, + PassengerInformationMessage, +} from '../utils/passengerInformationMessages'; + +type PassengerInformationMessageAlertProps = { + onClick: () => void; + passengerInformationMessages: PassengerInformationMessage[]; +}; + +const PassengerInformationMessageAlert = ({ + onClick, + passengerInformationMessages, +}: PassengerInformationMessageAlertProps) => { + const { i18n } = useTranslation(); + + if (passengerInformationMessages.length === 0) return null; + + const firstMessage = passengerInformationMessages[0]; + + return ( + + + {getPassengerInformationMessageForLanguage( + firstMessage, + i18n.resolvedLanguage + )} + {passengerInformationMessages.length > 1 && ( + + {passengerInformationMessages.length - 1} + )} + + + ); +}; + +export default PassengerInformationMessageAlert; diff --git a/src/components/PassengerInformationMessagesDialog.tsx b/src/components/PassengerInformationMessagesDialog.tsx new file mode 100644 index 00000000..88e6937e --- /dev/null +++ b/src/components/PassengerInformationMessagesDialog.tsx @@ -0,0 +1,49 @@ +import { Alert, DialogActions, DialogContent } from '@mui/material'; +import Dialog from '@mui/material/Dialog'; +import DialogTitle from '@mui/material/DialogTitle'; +import { useTranslation } from 'react-i18next'; + +import { + getPassengerInformationMessageForLanguage, + PassengerInformationMessage, +} from '../utils/passengerInformationMessages'; + +type PassengerInformationMessagesDialogProps = { + onClose: () => void; + open: boolean; + passengerInformationMessages: + | PassengerInformationMessage[] + | null + | undefined; +}; + +const PassengerInformationMessagesDialog = ( + props: PassengerInformationMessagesDialogProps +) => { + const { onClose, open, passengerInformationMessages } = props; + const { i18n } = useTranslation(); + const { t } = useTranslation(); + + const handleClose = () => { + onClose(); + }; + + return ( + + {t('alerts')} + + {passengerInformationMessages?.map((m) => ( + + {getPassengerInformationMessageForLanguage( + m, + i18n.resolvedLanguage + )} + + ))} + + + + ); +}; + +export default PassengerInformationMessagesDialog; diff --git a/src/components/TrainInfoContainer.tsx b/src/components/TrainInfoContainer.tsx index 7a797a7f..5e1e84f2 100644 --- a/src/components/TrainInfoContainer.tsx +++ b/src/components/TrainInfoContainer.tsx @@ -8,9 +8,12 @@ import { useTrainQuery, Wagon, } from '../graphql/generated/digitraffic'; +import usePassengerInformationMessages from '../hooks/usePassengerInformationMessages'; import { formatEET } from '../utils/date'; +import { getPassengerInformationMessagesByStation } from '../utils/passengerInformationMessages'; import { getTrainScheduledDepartureTime } from '../utils/train'; +import PassengerInformationMessagesDialog from './PassengerInformationMessagesDialog'; import TrainComposition from './TrainComposition'; import TrainStationTimeline from './TrainStationTimeline'; import TrainWagonDetailsDialog from './TrainWagonDetailsDialog'; @@ -22,6 +25,8 @@ type TrainInfoContainerProps = { function TrainInfoContainer({ train }: TrainInfoContainerProps) { const [wagonDialogOpen, setWagonDialogOpen] = useState(false); const [selectedWagon, setSelectedWagon] = useState(null); + const [selectedStation, setSelectedStation] = useState(null); + const [stationAlertDialogOpen, setStationAlertDialogOpen] = useState(false); const departureDate = train ? getTrainScheduledDepartureTime(train) : null; const { error, data: realTimeData } = useTrainQuery( train && departureDate @@ -37,6 +42,16 @@ function TrainInfoContainer({ train }: TrainInfoContainerProps) { } : { skip: true } ); + const { messages: passengerInformationMessages } = + usePassengerInformationMessages({ + skip: !train, + refetchIntervalMs: 20000, + trainNumber: train?.trainNumber, + trainDepartureDate: train?.departureDate, + }); + const stationMessages = getPassengerInformationMessagesByStation( + passengerInformationMessages + ); const realTimeTrain = realTimeData?.train?.[0]; @@ -49,6 +64,15 @@ function TrainInfoContainer({ train }: TrainInfoContainerProps) { setWagonDialogOpen(true); }; + const handleStationAlertDialogClose = () => { + setStationAlertDialogOpen(false); + }; + + const handleStationAlertClick = (stationCode: string) => { + setSelectedStation(stationCode); + setStationAlertDialogOpen(true); + }; + return ( <> {train && ( )} + ); } diff --git a/src/components/TrainStationTimeline.tsx b/src/components/TrainStationTimeline.tsx index 88671428..70982c27 100644 --- a/src/components/TrainStationTimeline.tsx +++ b/src/components/TrainStationTimeline.tsx @@ -9,8 +9,10 @@ import getTrainCurrentStation from '../utils/getTrainCurrentStation'; import getTrainLatestArrivalRow from '../utils/getTrainLatestArrivalRow'; import getTrainLatestDepartureTimeTableRow from '../utils/getTrainLatestDepartureTimeTableRow'; import getTrainPreviousStation from '../utils/getTrainPreviousStation'; +import { PassengerInformationMessage } from '../utils/passengerInformationMessages'; import { getTrainStationName } from '../utils/train'; +import PassengerInformationMessageAlert from './PassengerInformationMessageAlert'; import TimelineRouteStopSeparator from './TimelineRouteStopSeparator'; import TimeTableRowTime from './TimeTableRowTime'; import TrainComposition from './TrainComposition'; @@ -19,12 +21,16 @@ type TrainStationTimelineProps = { train?: TrainDetailsFragment | null; realTimeTrain?: TrainDetailsFragment | null; onWagonClick: (w: Wagon) => void; + onStationAlertClick: (stationCode: string) => void; + stationMessages?: Record; }; const TrainStationTimeline = ({ train, realTimeTrain, onWagonClick, + onStationAlertClick, + stationMessages, }: TrainStationTimelineProps) => { const { t } = useTranslation(); @@ -101,6 +107,15 @@ const TrainStationTimeline = ({ /> )} + {stationMessages && + stationMessages[station.shortCode]?.length > 0 && ( + onStationAlertClick(station.shortCode)} + passengerInformationMessages={ + stationMessages[station.shortCode] + } + /> + )} diff --git a/src/hooks/__tests__/usePassengerInformationMessages.test.ts b/src/hooks/__tests__/usePassengerInformationMessages.test.ts new file mode 100644 index 00000000..01cc748b --- /dev/null +++ b/src/hooks/__tests__/usePassengerInformationMessages.test.ts @@ -0,0 +1,238 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { parseISO } from 'date-fns'; + +import { PassengerInformationMessage } from '../../utils/passengerInformationMessages'; +import usePassengerInformationMessages from '../usePassengerInformationMessages'; + +jest.mock('../../utils/passengerInformationMessages', () => ({ + getPassengerInformationMessagesCurrentlyRelevant: ( + msgs: PassengerInformationMessage[] + ) => msgs, +})); + +describe('usePassengerInformationMessages', () => { + const baseUrl = 'https://rata.digitraffic.fi/api/v1/passenger-information'; + const defaultRefetchIntervalMs = 10000; + + const getMockFetchResponse = ( + mockResponse: Partial[] + ) => + ({ + json: async () => mockResponse, + ok: true, + } as Response); + + afterEach(() => { + jest.useRealTimers(); + jest.resetAllMocks(); + }); + + it('should construct the correct URL and call fetch after each refetch interval', async () => { + jest.useFakeTimers().setSystemTime(parseISO('2023-09-12T00:00:00Z')); + + const queryParameters = { + stationCode: 'HKI', + trainNumber: 123, + trainDepartureDate: '2023-09-20', + onlyGeneral: true, + defaultRefetchIntervalMs, + }; + + const fetchMock = jest + .spyOn(window, 'fetch') + .mockResolvedValue(getMockFetchResponse([])); + + renderHook(() => usePassengerInformationMessages(queryParameters)); + + await waitFor(() => { + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + `${baseUrl}/active?station=HKI&train_number=123&train_departure_date=2023-09-20&only_general=true` + ); + }); + + act(() => { + jest.advanceTimersByTime(defaultRefetchIntervalMs); + }); + + await waitFor(() => { + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + `${baseUrl}/updated-after/2023-09-12T00:00:00Z?station=HKI&train_number=123&train_departure_date=2023-09-20&only_general=true` + ); + }); + + act(() => { + jest.advanceTimersByTime(defaultRefetchIntervalMs); + }); + + await waitFor(() => { + expect(fetchMock).toHaveBeenNthCalledWith( + 3, + `${baseUrl}/updated-after/2023-09-12T00:00:10Z?station=HKI&train_number=123&train_departure_date=2023-09-20&only_general=true` + ); + }); + }); + + it('should fetch and return messages', async () => { + const mockResponse = [{ id: '1', text: 'Message 1' }]; + jest + .spyOn(window, 'fetch') + .mockResolvedValueOnce(getMockFetchResponse(mockResponse)); + + const { result } = renderHook(() => + usePassengerInformationMessages({ skip: false }) + ); + + await waitFor(() => { + expect(result.current.messages).toEqual(mockResponse); + expect(result.current.error).toBeUndefined(); + }); + }); + + it('should fetch again based on the interval', async () => { + const refetchIntervalMs = 5000; + const mockResponse1 = [{ id: '1', text: 'Message 1' }]; + const mockResponse2 = [{ id: '2', text: 'Message 2' }]; + const mockResponse3 = [{ id: '2', text: 'Message 2 updated' }]; + jest + .spyOn(window, 'fetch') + .mockResolvedValueOnce(getMockFetchResponse(mockResponse1)) + .mockResolvedValueOnce(getMockFetchResponse(mockResponse2)) + .mockResolvedValueOnce(getMockFetchResponse(mockResponse3)); + jest.useFakeTimers(); + + const { result } = renderHook(() => + usePassengerInformationMessages({ skip: false, refetchIntervalMs }) + ); + + await waitFor(() => { + expect(result.current.messages).toEqual(mockResponse1); + expect(result.current.error).toBeUndefined(); + }); + + act(() => { + jest.advanceTimersByTime(refetchIntervalMs); + }); + + await waitFor(() => { + expect(result.current.messages?.length).toBe(2); + expect(result.current.messages).toEqual([ + ...mockResponse2, + ...mockResponse1, + ]); + expect(result.current.error).toBeUndefined(); + }); + + act(() => { + jest.advanceTimersByTime(refetchIntervalMs); + }); + + await waitFor(() => { + expect(result.current.messages?.length).toBe(2); + expect(result.current.messages).toEqual([ + ...mockResponse3, + ...mockResponse1, + ]); + expect(result.current.error).toBeUndefined(); + }); + }); + + it('should handle fetch error and return it', async () => { + jest.spyOn(window, 'fetch').mockRejectedValueOnce(new Error('Fetch error')); + + const { result } = renderHook(() => + usePassengerInformationMessages({ skip: false }) + ); + + await waitFor(() => { + expect(result.current.messages).toBeUndefined(); + expect(result.current.error).toBeDefined(); + }); + }); + + it('should handle non-ok fetch response and return it', async () => { + jest.spyOn(window, 'fetch').mockResolvedValueOnce({ + ok: false, + } as Response); + + const { result } = renderHook(() => + usePassengerInformationMessages({ skip: false }) + ); + + await waitFor(() => { + expect(result.current.messages).toBeUndefined(); + expect(result.current.error).toBeDefined(); + }); + }); + + it('should not fetch when skip is true', () => { + jest.spyOn(window, 'fetch').mockImplementation(() => { + throw new Error('Fetch should not be called'); + }); + + const { result } = renderHook(() => + usePassengerInformationMessages({ skip: true }) + ); + + expect(result.current.messages).toBeUndefined(); + expect(result.current.error).toBeUndefined(); + }); + + it('should clear messages and use api path /active on first fetch when props change', async () => { + jest.useFakeTimers().setSystemTime(parseISO('2023-09-12T00:00:00Z')); + const mockResponse1 = [{ id: '1', text: 'Message 1 HKI' }]; + const mockResponse2 = [{ id: '1', text: 'Message 1 PSL' }]; + const fetchSpy = jest + .spyOn(window, 'fetch') + .mockResolvedValueOnce(getMockFetchResponse(mockResponse1)) + .mockResolvedValueOnce(getMockFetchResponse(mockResponse2)) + .mockResolvedValueOnce(getMockFetchResponse(mockResponse2)) + .mockResolvedValueOnce(getMockFetchResponse(mockResponse1)); + + const { result, rerender } = renderHook( + (stationCode: string) => + usePassengerInformationMessages({ skip: false, stationCode }), + { initialProps: 'HKI' } + ); + + await waitFor(() => { + expect(result.current.messages).toEqual(mockResponse1); + expect(fetchSpy).toHaveBeenNthCalledWith( + 1, + `${baseUrl}/active?station=HKI` + ); + }); + + rerender('PSL'); + + await waitFor(() => { + expect(result.current.messages).toEqual(mockResponse2); + expect(fetchSpy).toHaveBeenNthCalledWith( + 2, + `${baseUrl}/active?station=PSL` + ); + }); + + act(() => { + jest.advanceTimersByTime(defaultRefetchIntervalMs); + }); + + await waitFor(() => { + expect(fetchSpy).toHaveBeenNthCalledWith( + 3, + `${baseUrl}/updated-after/2023-09-12T00:00:00Z?station=PSL` + ); + }); + + rerender('HKI'); + + await waitFor(() => { + expect(result.current.messages).toEqual(mockResponse1); + expect(fetchSpy).toHaveBeenNthCalledWith( + 4, + `${baseUrl}/active?station=HKI` + ); + }); + }); +}); diff --git a/src/hooks/usePassengerInformationMessages.ts b/src/hooks/usePassengerInformationMessages.ts new file mode 100644 index 00000000..02de6941 --- /dev/null +++ b/src/hooks/usePassengerInformationMessages.ts @@ -0,0 +1,107 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { unionBy } from 'lodash'; + +import { + getPassengerInformationMessagesCurrentlyRelevant, + PassengerInformationMessage, +} from '../utils/passengerInformationMessages'; + +type PassengerInformationMessageQuery = { + skip?: boolean; + stationCode?: string; + trainNumber?: number; + trainDepartureDate?: string; + onlyGeneral?: boolean; + refetchIntervalMs?: number; +}; + +const apiBaseUrl = 'https://rata.digitraffic.fi/api/v1/passenger-information'; + +function createSearchParams(params: Partial>) { + const filteredParams: Record = {}; + + for (const key in params) { + const paramValue = params[key]; + if (paramValue != null) { + filteredParams[key] = paramValue; + } + } + + return new URLSearchParams(filteredParams); +} + +export default function usePassengerInformationMessages({ + skip, + stationCode, + trainNumber, + trainDepartureDate, + onlyGeneral, + refetchIntervalMs = 10000, +}: PassengerInformationMessageQuery) { + const [messages, setMessages] = useState(); + const lastFetchTimeRef = useRef(); + const [error, setError] = useState(); + + const params = useMemo( + () => + createSearchParams({ + station: stationCode, + train_number: trainNumber?.toString(), + train_departure_date: trainDepartureDate, + only_general: onlyGeneral?.toString(), + }), + [onlyGeneral, stationCode, trainDepartureDate, trainNumber] + ); + + const fetchData = useCallback(async () => { + try { + let url: string; + if (!lastFetchTimeRef.current) { + url = `${apiBaseUrl}/active?${params}`; + } else { + url = `${apiBaseUrl}/updated-after/${ + lastFetchTimeRef.current.toISOString().split('.')[0] + 'Z' + }?${params}`; + } + + setError(undefined); + lastFetchTimeRef.current = new Date(); + + const res = await fetch(url); + + if (!res.ok) { + throw new Error(`Failed to fetch data (status ${res.status})`); + } + + const latestMessages = + (await res.json()) as PassengerInformationMessage[]; + + setMessages((msgs) => unionBy(latestMessages, msgs, 'id')); + } catch (error) { + setError(error); + } + }, [params]); + + useEffect(() => { + let interval: NodeJS.Timer | null = null; + if (!skip) { + interval = setInterval(fetchData, refetchIntervalMs); + fetchData(); + } + + return () => { + setMessages(undefined); + lastFetchTimeRef.current = undefined; + if (interval) { + clearInterval(interval); + } + }; + }, [fetchData, refetchIntervalMs, skip]); + + const relevantMessages = messages + ? getPassengerInformationMessagesCurrentlyRelevant(messages) + : undefined; + + return { messages: relevantMessages, error }; +} diff --git a/src/pages/[station].tsx b/src/pages/[station].tsx index ba8b7c49..1ab33655 100644 --- a/src/pages/[station].tsx +++ b/src/pages/[station].tsx @@ -10,6 +10,8 @@ import { useTranslation } from 'react-i18next'; import FavoriteStation from '../components/FavoriteStation'; import MapLayout, { VehicleMapContainerPortal } from '../components/MapLayout'; +import PassengerInformationMessageAlert from '../components/PassengerInformationMessageAlert'; +import PassengerInformationMessagesDialog from '../components/PassengerInformationMessagesDialog'; import StationTimeTable from '../components/StationTimeTable'; import SubNavBar from '../components/SubNavBar'; import { gqlClients, vehiclesVar } from '../graphql/client'; @@ -18,6 +20,7 @@ import { useTrainsByStationQuery, } from '../graphql/generated/digitraffic'; import { useRoutesForRailLazyQuery } from '../graphql/generated/digitransit'; +import usePassengerInformationMessages from '../hooks/usePassengerInformationMessages'; import useTrainLiveTracking from '../hooks/useTrainLiveTracking'; import { isDefined } from '../utils/common'; import getRouteForTrain from '../utils/getRouteForTrain'; @@ -38,6 +41,7 @@ const Station: NextPageWithLayout = () => { null ); const [selectedTrainNo, setSelectedTrainNo] = useState(null); + const [stationAlertDialogOpen, setStationAlertDialogOpen] = useState(false); const [executeRouteSearch, { data: routeData }] = useRoutesForRailLazyQuery(); const station = stationName ? trainStations.find( @@ -61,6 +65,13 @@ const Station: NextPageWithLayout = () => { fetchPolicy: 'no-cache', }); useTrainLiveTracking(data?.trainsByStationAndQuantity?.filter(isDefined)); + const { messages: passengerInformationMessages } = + usePassengerInformationMessages({ + skip: stationCode == null, + refetchIntervalMs: 20000, + onlyGeneral: true, + stationCode: stationCode, + }); const handleVehicleIdSelected = useCallback((vehicleId: number) => { setSelectedVehicleId(vehicleId); @@ -178,6 +189,14 @@ const Station: NextPageWithLayout = () => { {t('arrivals')} + + {passengerInformationMessages && ( + setStationAlertDialogOpen(true)} + passengerInformationMessages={passengerInformationMessages} + /> + )} + {stationCode && !loading && data?.trainsByStationAndQuantity && ( { {error && ( {error.message} )} + setStationAlertDialogOpen(false)} + /> ); }; diff --git a/src/utils/__tests__/passengerInformationMessages.test.ts b/src/utils/__tests__/passengerInformationMessages.test.ts new file mode 100644 index 00000000..ef39700e --- /dev/null +++ b/src/utils/__tests__/passengerInformationMessages.test.ts @@ -0,0 +1,690 @@ +import { parseISO } from 'date-fns'; + +import { + TimeTableRowType, + TrainByStationFragment, +} from '../../graphql/generated/digitraffic'; +import { + getPassengerInformationMessageForLanguage, + getPassengerInformationMessagesByStation, + getPassengerInformationMessagesCurrentlyRelevant, + PassengerInformationMessage, +} from '../passengerInformationMessages'; + +describe('getPassengerInformationMessageForLanguage', () => { + const passengerInformationMessageBase: PassengerInformationMessage = { + id: '', + version: 1, + creationDateTime: '', + startValidity: '', + endValidity: '', + stations: [], + }; + + const videoAudioMessage: PassengerInformationMessage = { + ...passengerInformationMessageBase, + video: { + text: { + en: 'English video text! junalahdot.fi', + fi: 'Finnish video text! junalahdot.fi', + }, + }, + audio: { + text: { + en: 'English audio text! junalahdot.fi', + fi: 'Finnish audio text! junalahdot.fi', + }, + }, + }; + + const audioOnlyMessage: PassengerInformationMessage = { + ...passengerInformationMessageBase, + audio: { + text: { + en: 'English audio text! junalahdot.fi', + sv: 'Swedish audio text!', + fi: 'Finnish audio text! junalahdot.fi', + }, + }, + }; + + it('should return the message from video content when available in the specified language', () => { + const result = getPassengerInformationMessageForLanguage( + videoAudioMessage, + 'en' + ); + expect(result).toBe('English video text! junaan.fi / junalahdot.fi'); + }); + + it('should return the message from audio content when video content is not available in the specified language', () => { + const result = getPassengerInformationMessageForLanguage( + audioOnlyMessage, + 'sv' + ); + expect(result).toBe('Swedish audio text!'); + }); + + it('should return the default language message when the specified language is not available', () => { + const result = getPassengerInformationMessageForLanguage( + videoAudioMessage, + 'fr' + ); + expect(result).toBe('Finnish video text! junaan.fi / junalahdot.fi'); + }); + + it('should return an empty string when both video and audio content are undefined', () => { + const result = getPassengerInformationMessageForLanguage( + passengerInformationMessageBase, + 'en' + ); + expect(result).toBe(''); + }); +}); + +describe('getPassengerInformationMessagesByStation', () => { + it('should add the message to the station present in stations array when there is only single station', () => { + const passengerInformationMessages: PassengerInformationMessage[] = [ + { + id: 'SHM20220818174358380', + version: 94, + creationDateTime: '2023-09-10T14:37:00Z', + startValidity: '2023-09-09T21:00:00Z', + endValidity: '2023-09-10T20:59:00Z', + stations: ['PSL'], + }, + ]; + + const messagesByStation = getPassengerInformationMessagesByStation( + passengerInformationMessages + ); + + expectToBeDefined(messagesByStation); + expect(messagesByStation['PSL']).toBeDefined(); + expect(messagesByStation['PSL'].length).toBe(1); + }); + + it('should only add the message to first and last station in the stations array when there are multiple stations', () => { + const passengerInformationMessages: PassengerInformationMessage[] = [ + { + id: 'SHM20220818174358380', + version: 94, + creationDateTime: '2023-09-10T14:37:00Z', + startValidity: '2023-09-09T21:00:00Z', + endValidity: '2023-09-10T20:59:00Z', + stations: ['HKI', 'PSL', 'TKL', 'KE', 'RI'], + }, + ]; + + const messagesByStation = getPassengerInformationMessagesByStation( + passengerInformationMessages + ); + + expectToBeDefined(messagesByStation); + expect(messagesByStation['HKI']).toBeDefined(); + expect(messagesByStation['HKI'].length).toBe(1); + expect(messagesByStation['PSL']).toBeUndefined(); + expect(messagesByStation['TKL']).toBeUndefined(); + expect(messagesByStation['KE']).toBeUndefined(); + expect(messagesByStation['RI']).toBeDefined(); + expect(messagesByStation['RI'].length).toBe(1); + }); +}); + +describe('getPassengerInformationMessagesCurrentlyRelevant', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + beforeEach(() => { + // Default system time within validity of passengerInformationMessageBase + // that can be overwritten by individual tests + jest.setSystemTime(parseISO('2023-09-09T00:00:00Z')); + }); + + const passengerInformationMessageBase: PassengerInformationMessage = { + id: 'SHM20220818174358380', + version: 94, + creationDateTime: '2023-09-10T14:37:00Z', + startValidity: '2023-09-01T00:00:00Z', + endValidity: '2023-09-14T21:00:00Z', + stations: ['HKI'], + }; + + const defaultPassengerInformationTextContent = { + fi: 'Huomio!', + sv: 'Observera!', + en: 'Attention!', + }; + + describe('overall message validity', () => { + const passengerInformationMessages: PassengerInformationMessage[] = [ + { + ...passengerInformationMessageBase, + startValidity: '2023-09-01T00:00:00Z', + endValidity: '2023-09-14T21:00:00Z', + audio: { + text: defaultPassengerInformationTextContent, + }, + }, + ]; + + it.each(['2023-09-01T00:00:00Z', '2023-09-14T21:00:00Z'])( + 'should be relevant at time %p', + (nowISO: string) => { + jest.setSystemTime(parseISO(nowISO)); + + const relevantMessages = + getPassengerInformationMessagesCurrentlyRelevant( + passengerInformationMessages + ); + + expect(relevantMessages.length).toBe(1); + } + ); + + it.each(['2023-08-31T23:59:59Z', '2023-09-14T21:00:01Z'])( + 'should not be relevant at time %p', + (nowISO: string) => { + jest.setSystemTime(parseISO(nowISO)); + + const relevantMessages = + getPassengerInformationMessagesCurrentlyRelevant( + passengerInformationMessages + ); + + expect(relevantMessages.length).toBe(0); + } + ); + }); + + describe('audio', () => { + it('should not return messages with unsupported deliveryRules type', () => { + const passengerInformationMessages: PassengerInformationMessage[] = [ + { + ...passengerInformationMessageBase, + audio: { + text: defaultPassengerInformationTextContent, + deliveryRules: { + deliveryType: 'NO_SUPPORT_FOR_SUCH_TYPE' as any, + }, + }, + }, + ]; + + const relevantMessages = getPassengerInformationMessagesCurrentlyRelevant( + passengerInformationMessages + ); + + expect(relevantMessages.length).toBe(0); + }); + + it('should return messages that have no deliveryRules', () => { + const passengerInformationMessages: PassengerInformationMessage[] = [ + { + ...passengerInformationMessageBase, + audio: { + text: defaultPassengerInformationTextContent, + }, + }, + ]; + + const relevantMessages = getPassengerInformationMessagesCurrentlyRelevant( + passengerInformationMessages + ); + + expect(relevantMessages.length).toBe(1); + }); + + describe('deliveryRules type NOW', () => { + describe('message with creationDateTime "2023-09-10T14:37:00Z"', () => { + const passengerInformationMessages: PassengerInformationMessage[] = [ + { + ...passengerInformationMessageBase, + creationDateTime: '2023-09-10T14:37:00Z', + audio: { + text: defaultPassengerInformationTextContent, + deliveryRules: { + deliveryType: 'NOW', + }, + }, + }, + ]; + + it.each(['2023-09-10T14:37:00Z', '2023-09-10T14:52:00Z'])( + 'should be relevant at time %p', + (nowISO: string) => { + jest.setSystemTime(parseISO(nowISO)); + + const relevantMessages = + getPassengerInformationMessagesCurrentlyRelevant( + passengerInformationMessages + ); + + expect(relevantMessages.length).toBe(1); + } + ); + + it.each(['2023-09-10T14:36:00Z', '2023-09-10T14:53:00Z'])( + 'should not be relevant at time %p', + (nowISO: string) => { + jest.setSystemTime(parseISO(nowISO)); + + const relevantMessages = + getPassengerInformationMessagesCurrentlyRelevant( + passengerInformationMessages + ); + + expect(relevantMessages.length).toBe(0); + } + ); + }); + }); + + describe('deliveryRules type DELIVERY_AT', () => { + it('should return messages with no deliveryAt specified', () => { + const passengerInformationMessages: PassengerInformationMessage[] = [ + { + ...passengerInformationMessageBase, + audio: { + text: defaultPassengerInformationTextContent, + deliveryRules: { + deliveryType: 'DELIVERY_AT', + }, + }, + }, + ]; + + const relevantMessages = + getPassengerInformationMessagesCurrentlyRelevant( + passengerInformationMessages + ); + + expect(relevantMessages.length).toBe(1); + }); + + describe('message with deliveryAt "2023-09-10T14:30:00Z"', () => { + const passengerInformationMessages: PassengerInformationMessage[] = [ + { + ...passengerInformationMessageBase, + audio: { + text: defaultPassengerInformationTextContent, + deliveryRules: { + deliveryType: 'DELIVERY_AT', + deliveryAt: '2023-09-10T14:30:00Z', + }, + }, + }, + ]; + + it.each(['2023-09-10T14:30:00Z', '2023-09-10T14:45:00Z'])( + 'should be relevant at time %p', + (nowISO: string) => { + jest.setSystemTime(parseISO(nowISO)); + + const relevantMessages = + getPassengerInformationMessagesCurrentlyRelevant( + passengerInformationMessages + ); + + expect(relevantMessages.length).toBe(1); + } + ); + + it.each(['2023-09-10T14:29:00Z', '2023-09-10T14:46:00Z'])( + 'should not be relevant at time %p', + (nowISO: string) => { + jest.setSystemTime(parseISO(nowISO)); + + const relevantMessages = + getPassengerInformationMessagesCurrentlyRelevant( + passengerInformationMessages + ); + + expect(relevantMessages.length).toBe(0); + } + ); + }); + }); + + describe('deliveryRules type REPEAT_EVERY', () => { + describe('message with timespan "2023-09-01T08:20:00+03:00" to "2023-09-03T15:00:00+03:00" on all weekdays', () => { + const passengerInformationMessages: PassengerInformationMessage[] = [ + { + ...passengerInformationMessageBase, + audio: { + text: defaultPassengerInformationTextContent, + deliveryRules: { + startDateTime: '2023-09-01T21:00:00Z', + endDateTime: '2023-09-03T20:59:00Z', + startTime: '8:20', + endTime: '15:00', + weekDays: [ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ], + deliveryType: 'REPEAT_EVERY', + repetitions: 1, + repeatEvery: 20, + }, + }, + }, + ]; + + it.each(['2023-09-02T08:20:00+03:00', '2023-09-03T15:00:00+03:00'])( + 'should be relevant at time %p', + (nowISO: string) => { + jest.setSystemTime(parseISO(nowISO)); + + const relevantMessages = + getPassengerInformationMessagesCurrentlyRelevant( + passengerInformationMessages + ); + + expect(relevantMessages.length).toBe(1); + } + ); + + it.each(['2023-09-02T08:19:00+03:00', '2023-09-03T15:01:00+03:00'])( + 'should not be relevant at time %p', + (nowISO: string) => { + jest.setSystemTime(parseISO(nowISO)); + + const relevantMessages = + getPassengerInformationMessagesCurrentlyRelevant( + passengerInformationMessages + ); + + expect(relevantMessages.length).toBe(0); + } + ); + }); + }); + + describe('deliveryRules type ON_SCHEDULE', () => { + describe('train with departureDate 2023-09-02 and message with stations [LUS=15:51, PUN=16:00]', () => { + const passengerInformationMessages: PassengerInformationMessage[] = [ + { + ...passengerInformationMessageBase, + trainNumber: 750, + trainDepartureDate: '2023-09-02', + stations: ['LUS', 'PUN', 'REE', 'KIÄ', 'PKY'], + audio: { + text: defaultPassengerInformationTextContent, + deliveryRules: { + deliveryType: 'ON_SCHEDULE', + repetitions: 1, + repeatEvery: 0, + }, + }, + }, + ]; + + const train: TrainByStationFragment = { + runningCurrently: true, + trainNumber: 750, + departureDate: '2023-09-02', + version: '1', + trainType: { + name: '', + trainCategory: { + name: '', + }, + }, + operator: { + shortCode: '', + uicCode: 1, + }, + timeTableRows: [ + { + scheduledTime: '2023-09-02T15:51:00Z', + cancelled: false, + station: { + name: '', + shortCode: 'LUS', + }, + trainStopping: true, + type: TimeTableRowType.Arrival, + }, + { + scheduledTime: '2023-09-02T16:00:00Z', + cancelled: false, + station: { + name: '', + shortCode: 'PUN', + }, + trainStopping: true, + type: TimeTableRowType.Arrival, + }, + ], + }; + + it.each([ + '2023-09-02T15:51:00Z', + '2023-09-02T15:52:00Z', + '2023-09-02T16:00:00Z', + '2023-09-02T16:01:00Z', + ])('should be relevant at time %p', (nowISO: string) => { + jest.setSystemTime(parseISO(nowISO)); + + const relevantMessages = + getPassengerInformationMessagesCurrentlyRelevant( + passengerInformationMessages, + train + ); + + expect(relevantMessages.length).toBe(1); + }); + + it.each([ + '2023-09-02T15:50:59Z', + '2023-09-02T15:53:00Z', + '2023-09-02T15:59:59Z', + '2023-09-02T16:02:00Z', + ])('should not be relevant at time %p', (nowISO: string) => { + jest.setSystemTime(parseISO(nowISO)); + + const relevantMessages = + getPassengerInformationMessagesCurrentlyRelevant( + passengerInformationMessages, + train + ); + + expect(relevantMessages.length).toBe(0); + }); + + it('should not return messages if no train is given as parameter', () => { + jest.setSystemTime(parseISO('2023-09-02T15:50:59Z')); + + const relevantMessages = + getPassengerInformationMessagesCurrentlyRelevant( + passengerInformationMessages + ); + + expect(relevantMessages.length).toBe(0); + }); + }); + }); + + describe('deliveryRules type ON_EVENT', () => { + it('should not return messages with type ON_EVENT because they are not currently supported', () => { + const passengerInformationMessages: PassengerInformationMessage[] = [ + { + ...passengerInformationMessageBase, + audio: { + text: defaultPassengerInformationTextContent, + deliveryRules: { + deliveryType: 'ON_EVENT', + }, + }, + }, + ]; + + const relevantMessages = + getPassengerInformationMessagesCurrentlyRelevant( + passengerInformationMessages + ); + + expect(relevantMessages.length).toBe(0); + }); + }); + }); + + describe('video', () => { + it('should not return messages with unsupported deliveryRules type', () => { + const passengerInformationMessages: PassengerInformationMessage[] = [ + { + ...passengerInformationMessageBase, + video: { + text: defaultPassengerInformationTextContent, + deliveryRules: { + deliveryType: 'NO_SUPPORT_FOR_SUCH_TYPE' as any, + }, + }, + }, + ]; + + const relevantMessages = getPassengerInformationMessagesCurrentlyRelevant( + passengerInformationMessages + ); + + expect(relevantMessages.length).toBe(0); + }); + + it('should return messages that have no deliveryRules', () => { + const passengerInformationMessages: PassengerInformationMessage[] = [ + { + ...passengerInformationMessageBase, + video: { + text: defaultPassengerInformationTextContent, + }, + }, + ]; + + const relevantMessages = getPassengerInformationMessagesCurrentlyRelevant( + passengerInformationMessages + ); + + expect(relevantMessages.length).toBe(1); + }); + + describe('deliveryRules type CONTINUOS_VISUALIZATION', () => { + describe('message with timespan "2023-09-11T01:00:00+03:00" to "2023-09-15T04:20:00+03:00" on Mon-Thu', () => { + const passengerInformationMessages: PassengerInformationMessage[] = [ + { + ...passengerInformationMessageBase, + video: { + text: defaultPassengerInformationTextContent, + deliveryRules: { + startDateTime: '2023-09-10T21:00:00Z', + endDateTime: '2023-09-15T20:59:00Z', + startTime: '0:01', + endTime: '4:20', + weekDays: ['MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY'], + deliveryType: 'CONTINUOS_VISUALIZATION', + }, + }, + }, + ]; + + it.each([ + '2023-09-10T21:01:00Z', + '2023-09-12T00:00:00Z', + // 2023-09-14 is Thursday (which is one of the weekDays) and this is one second before Friday + '2023-09-14T23:59:59+03:00', + ])('should be relevant at time %p', (nowISO: string) => { + jest.setSystemTime(parseISO(nowISO)); + + const relevantMessages = + getPassengerInformationMessagesCurrentlyRelevant( + passengerInformationMessages + ); + + expect(relevantMessages.length).toBe(1); + }); + + it.each([ + '2023-09-10T21:00:00Z', + '2023-09-15T01:20:01Z', + // 2023-09-15 is Friday which is not one of the weekDays + '2023-09-15T00:00:00+03:00', + ])('should not be relevant at time %p', (nowISO: string) => { + jest.setSystemTime(parseISO(nowISO)); + + const relevantMessages = + getPassengerInformationMessagesCurrentlyRelevant( + passengerInformationMessages + ); + + expect(relevantMessages.length).toBe(0); + }); + }); + }); + + describe('video deliveryRules type WHEN', () => { + describe('message with timespan "2023-09-08T21:00:00Z" to "2023-09-10T20:59:00Z" on Mon-Sun from 7:45 to 7:50', () => { + const passengerInformationMessages: PassengerInformationMessage[] = [ + { + ...passengerInformationMessageBase, + video: { + text: defaultPassengerInformationTextContent, + deliveryRules: { + startDateTime: '2023-09-08T21:00:00Z', + endDateTime: '2023-09-10T20:59:00Z', + startTime: '7:45', + endTime: '7:50', + weekDays: [ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ], + deliveryType: 'WHEN', + }, + }, + }, + ]; + + it.each([ + '2023-09-09T07:45:00+03:00', + '2023-09-09T07:47:00+03:00', + '2023-09-09T07:50:00+03:00', + '2023-09-10T07:45:00+03:00', + '2023-09-10T07:47:00+03:00', + '2023-09-10T07:50:00+03:00', + ])('should be relevant at time %p', (nowISO: string) => { + jest.setSystemTime(parseISO(nowISO)); + + const relevantMessages = + getPassengerInformationMessagesCurrentlyRelevant( + passengerInformationMessages + ); + + expect(relevantMessages.length).toBe(1); + }); + + it.each([ + '2023-09-09T07:44:59+03:00', + '2023-09-09T07:50:01+03:00', + '2023-09-11T07:47:00+03:00', + ])('should not be relevant at time %p', (nowISO: string) => { + jest.setSystemTime(parseISO(nowISO)); + + const relevantMessages = + getPassengerInformationMessagesCurrentlyRelevant( + passengerInformationMessages + ); + + expect(relevantMessages.length).toBe(0); + }); + }); + }); + }); +}); diff --git a/src/utils/passengerInformationMessages.ts b/src/utils/passengerInformationMessages.ts new file mode 100644 index 00000000..3f311489 --- /dev/null +++ b/src/utils/passengerInformationMessages.ts @@ -0,0 +1,359 @@ +import { differenceInMinutes, format, parseISO, startOfDay } from 'date-fns'; +import { utcToZonedTime, zonedTimeToUtc } from 'date-fns-tz'; +import { orderBy } from 'lodash'; + +import { + TimeTableRowType, + TrainByStationFragment, +} from '../graphql/generated/digitraffic'; +import { getTimeTableRowRealTime } from '../utils/train'; + +export type PassengerInformationMessage = { + id: string; + version: number; + creationDateTime: string; + startValidity: string; + endValidity: string; + stations: string[]; + trainNumber?: number; + trainDepartureDate?: string; + audio?: { + text?: PassengerInformationTextContent; + deliveryRules?: AudioDeliveryRules; + }; + video?: { + text?: PassengerInformationTextContent; + deliveryRules?: VideoDeliveryRules; + }; +}; + +type PassengerInformationTextContent = { + fi?: string; + sv?: string; + en?: string; +}; + +type DeliveryRulesBase = { + /** + * ISO date in format yyyy-MM-ddTHH:mm:ssZ + */ + startDateTime?: string; + weekDays?: Weekday[]; + /** + * Time component in format H:mm + */ + startTime?: string; + endTime?: string; + endDateTime?: string; +}; + +type AudioDeliveryRules = DeliveryRulesBase & { + deliveryType?: + | 'NOW' + | 'DELIVERY_AT' + | 'REPEAT_EVERY' + | 'ON_SCHEDULE' + | 'ON_EVENT'; + deliveryAt?: string; + repeatEvery?: number; + eventType?: 'ARRIVING' | 'DEPARTING'; + repetitions?: number; +}; + +type VideoDeliveryRules = DeliveryRulesBase & { + deliveryType?: 'WHEN' | 'CONTINUOS_VISUALIZATION'; +}; + +type Weekday = + | 'MONDAY' + | 'TUESDAY' + | 'WEDNESDAY' + | 'THURSDAY' + | 'FRIDAY' + | 'SATURDAY' + | 'SUNDAY'; + +export function getPassengerInformationMessageForLanguage( + message: PassengerInformationMessage, + languageCode: string +) { + const textContent: Record | undefined = ( + message.video ?? message.audio + )?.text; + + if (!textContent) { + return ''; + } + + let text: string; + if (languageCode in textContent) { + text = textContent[languageCode]; + } else { + text = textContent.fi; + } + return getImprovedText(text); +} + +export function getPassengerInformationMessagesByStation( + passengerInformationMessages: PassengerInformationMessage[] | undefined +) { + return passengerInformationMessages?.reduce((acc, message) => { + // When there are more than one stations, only add this message to the first + // and last station in the array (which are ordered according to the train + // schedule). + const stationsToAdd = + message.stations.length > 1 + ? [message.stations[0], message.stations[message.stations.length - 1]] + : message.stations; + + stationsToAdd.forEach((station) => { + acc[station] = acc[station] || []; + acc[station].push(message); + }); + return acc; + }, {} as Record); +} + +export function getPassengerInformationMessagesCurrentlyRelevant( + passengerInformationMessages: PassengerInformationMessage[], + train?: TrainByStationFragment +): PassengerInformationMessage[] { + const now = new Date(); + const relevantMessages = passengerInformationMessages.filter((message) => + isMessageRelevant(message, now, train) + ); + return relevantMessages; +} + +function getImprovedText(text: string | undefined): string | undefined { + if (text) { + const idx = text.indexOf('junalahdot.fi'); + + if (idx !== -1) { + return text.slice(0, idx) + 'junaan.fi / ' + text.slice(idx); + } + } + return text; +} + +function isMessageRelevant( + message: PassengerInformationMessage, + now: Date, + train?: TrainByStationFragment +): boolean { + if ( + !isWithinTimeSpan( + { + startDateTime: message.startValidity, + endDateTime: message.endValidity, + }, + now + ) + ) { + return false; + } + if (message.video && isVideoMessageRelevant(message, now)) { + return true; + } + if (message.audio && isAudioMessageRelevant(message, now, train)) { + return true; + } + return false; +} + +function isAudioMessageRelevant( + message: PassengerInformationMessage, + now: Date, + train?: TrainByStationFragment +): boolean { + if (!message.audio) return false; + + const { deliveryRules } = message.audio; + + if (!deliveryRules) return true; + + switch (deliveryRules.deliveryType) { + case 'NOW': { + const diffInMinutes = differenceInMinutes( + now, + parseISO(message.creationDateTime) + ); + return diffInMinutes <= 15 && diffInMinutes >= 0; + } + case 'DELIVERY_AT': { + if (!deliveryRules.deliveryAt) return true; + const diffInMinutes = differenceInMinutes( + now, + parseISO(deliveryRules.deliveryAt) + ); + return diffInMinutes <= 15 && diffInMinutes >= 0; + } + case 'REPEAT_EVERY': + return isWithinTimeSpan(deliveryRules, now); + case 'ON_SCHEDULE': + return isMessageRelevantBasedOnTrainSchedule( + train, + now, + message.stations + ); + case 'ON_EVENT': + // TODO: Not yet supported + return false; + default: + return false; + } +} + +function isVideoMessageRelevant( + message: PassengerInformationMessage, + now: Date +): boolean { + if (!message.video) return false; + + const { deliveryRules } = message.video; + + if (!deliveryRules) return true; + + switch (deliveryRules.deliveryType) { + case 'WHEN': + return isWithinTimeSpanAndHours(deliveryRules, now); + case 'CONTINUOS_VISUALIZATION': + return isWithinTimeSpan(deliveryRules, now); + default: + return false; + } +} + +/** + * Determines whether the messages is relevant based on train schedule and given stations. + * + * @param train The train with time table rows describing scheduled arrival times. + * @param now The current date and time to check whether the station arrival time is within threshold. + * @param stations The stations to check against. + * @returns `true` if the message is determined to be relevant. + */ +function isMessageRelevantBasedOnTrainSchedule( + train: TrainByStationFragment | undefined, + now: Date, + stations: string[] +) { + if (!train) return false; + + // Get train latest arrival row by scheduled times + const latestArrivalRow = orderBy( + train.timeTableRows?.filter( + (r) => + r && + r.type === TimeTableRowType.Arrival && + r.scheduledTime && + parseISO(r.scheduledTime) <= now + ), + (r) => r?.scheduledTime, + 'desc' + )[0]; + + if (!latestArrivalRow) return false; + if (!stations.includes(latestArrivalRow.station.shortCode)) return false; + const latestArrivalRowTime = getTimeTableRowRealTime(latestArrivalRow); + const diffInMinutes = differenceInMinutes(now, latestArrivalRowTime); + return diffInMinutes <= 1 && diffInMinutes >= 0; +} + +/** + * Determines whether the current date and time fall within a specified time span and weekdays. + * + * @param deliveryRules - The rules that define the time span and weekdays. + * @param now - The current date and time to check. + * @returns `true` if the current date and time are within the specified time span and weekdays; otherwise, `false`. + */ +const isWithinTimeSpan = ( + { + startDateTime, + startTime, + endDateTime, + endTime, + weekDays, + }: DeliveryRulesBase, + now: Date +): boolean => { + if (!startDateTime || !endDateTime) return true; + const startDateAdjusted = getDateTime(startDateTime, startTime); + const endDateAdjusted = getDateTime(endDateTime, endTime); + + const isWithinStartAndEnd = + startDateAdjusted <= now && now <= endDateAdjusted; + if (!isWithinStartAndEnd) return false; + + if (!weekDays) return true; + + return weekDays.includes(getCurrentWeekdayInEET(now)); +}; + +/** + * Determines whether the current date and time fall within specified date span + * and the specified time on the specified weekdays. + * + * @param deliveryRules - The rules that define the date span and the time span on given weekdays. + * @param now - The current date and time to check. + * @returns `true` if the current date and time are within the specified rules. + */ +const isWithinTimeSpanAndHours = ( + { + startDateTime, + startTime, + endDateTime, + endTime, + weekDays, + }: DeliveryRulesBase, + now: Date +): boolean => { + if (!startDateTime || !endDateTime) return true; + const startDateAdjusted = parseISO(startDateTime); + const endDateAdjusted = parseISO(endDateTime); + + const isWithinStartAndEnd = + startDateAdjusted <= now && now <= endDateAdjusted; + if (!isWithinStartAndEnd) return false; + + if (!weekDays?.includes(getCurrentWeekdayInEET(now))) return false; + + const sameDayStartDateTime = getDateTime(now, startTime); + const sameDayEndDateTime = getDateTime(now, endTime); + return sameDayStartDateTime <= now && now <= sameDayEndDateTime; +}; + +const getCurrentWeekdayInEET = (date: Date): Weekday => { + const dateEET = utcToZonedTime(date, 'Europe/Helsinki'); + return format(dateEET, 'EEEE').toUpperCase() as Weekday; +}; + +/** + * Converts a date and an optional time string to a new Date object with both date and time components. + * + * The time part represents time in Europe/Helsinki time zone, and if given, + * it overrides the time part of the date parameter. + * + * @param dateTimeISO - The date time in ISO format. + * @param time - The optional time part (format: "H:mm") in Europe/Helsinki (EET) time zone. + * @returns A new Date constructed from the given parameters. + */ +const getDateTime = ( + dateTimeISO: string | Date, + timeISOinEET?: string +): Date => { + let dateTime: Date; + if (dateTimeISO instanceof Date) { + dateTime = dateTimeISO; + } else { + dateTime = parseISO(dateTimeISO); + } + if (timeISOinEET) { + dateTime = utcToZonedTime(dateTime, 'Europe/Helsinki'); + const [hours, minutes] = timeISOinEET.split(':').map(Number); + dateTime = startOfDay(dateTime); + dateTime.setHours(hours); + dateTime.setMinutes(minutes); + dateTime = zonedTimeToUtc(dateTime, 'Europe/Helsinki'); + } + return dateTime; +};