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