From 1fd546a2a629b737b4a4d472b0858f0f2d649fbe Mon Sep 17 00:00:00 2001 From: Heikki Vuorinen Date: Tue, 12 May 2026 12:48:46 +0300 Subject: [PATCH 01/17] feat: Canceled trips view for Traffic now --- app/component/Card.js | 4 +- app/component/Icon.js | 5 + app/component/icon/IconBackground.js | 20 +- app/component/trafficnow/Alerts.js | 70 --- app/component/trafficnow/CanceledTripCard.js | 119 ++++ app/component/trafficnow/CanceledTrips.js | 193 ++++++ .../trafficnow/CanceledTripsContainer.js | 20 + .../trafficnow/CanceledTripsModal.js | 120 ++++ .../DisruptionBadge.js} | 22 +- app/component/trafficnow/DisruptionCard.js | 62 +- app/component/trafficnow/Disruptions.js | 116 ++++ app/component/trafficnow/RouteBadges.js | 60 +- app/component/trafficnow/TrafficNow.js | 109 ++-- .../{Header.js => TrafficNowHeader.js} | 9 +- .../components/CancellationContainer.js | 94 +++ .../components/CancelledDepartures.js | 27 + .../NoDisruptions.js} | 10 +- .../components/PatternWithCancellations.js | 74 +++ .../components/PatternWithCancellations.scss | 13 + .../components/ResultsProgressBar.js | 40 ++ .../trafficnow/components/RouteBadgeGroup.js | 94 +++ .../trafficnow/filters/EntitySearch.js | 4 +- app/component/trafficnow/filters/Filters.js | 12 +- .../trafficnow/filters/FiltersContext.js | 6 +- .../trafficnow/filters/FiltersModal.js | 4 +- .../trafficnow/filters/ToggleableFilters.js | 7 +- .../trafficnow/filters/filterUtils.js | 10 + .../queries/CanceledTripNodeFieldsFragment.js | 39 ++ .../queries/CanceledTripsForModeQuery.js | 12 + .../queries/CanceledTripsOverviewFragment.js | 17 + .../queries/CanceledTripsOverviewQuery.js | 46 ++ .../CanceledTripsPaginationFragment.js | 24 + app/component/trafficnow/trafficnow.scss | 548 ++++++++++++++---- app/component/trafficnow/utils.js | 21 +- app/component/util.scss | 5 +- app/hooks/useLogo.js | 6 +- app/routes.js | 27 +- sass/_main.scss | 1 + sass/base/_typography.scss | 5 + static/assets/svg-sprite.default.svg | 9 + static/assets/svg-sprite.hsl.svg | 19 + 41 files changed, 1730 insertions(+), 373 deletions(-) delete mode 100644 app/component/trafficnow/Alerts.js create mode 100644 app/component/trafficnow/CanceledTripCard.js create mode 100644 app/component/trafficnow/CanceledTrips.js create mode 100644 app/component/trafficnow/CanceledTripsContainer.js create mode 100644 app/component/trafficnow/CanceledTripsModal.js rename app/component/{Badge.js => trafficnow/DisruptionBadge.js} (84%) create mode 100644 app/component/trafficnow/Disruptions.js rename app/component/trafficnow/{Header.js => TrafficNowHeader.js} (95%) create mode 100644 app/component/trafficnow/components/CancellationContainer.js create mode 100644 app/component/trafficnow/components/CancelledDepartures.js rename app/component/trafficnow/{NoAlerts.js => components/NoDisruptions.js} (76%) create mode 100644 app/component/trafficnow/components/PatternWithCancellations.js create mode 100644 app/component/trafficnow/components/PatternWithCancellations.scss create mode 100644 app/component/trafficnow/components/ResultsProgressBar.js create mode 100644 app/component/trafficnow/components/RouteBadgeGroup.js create mode 100644 app/component/trafficnow/queries/CanceledTripNodeFieldsFragment.js create mode 100644 app/component/trafficnow/queries/CanceledTripsForModeQuery.js create mode 100644 app/component/trafficnow/queries/CanceledTripsOverviewFragment.js create mode 100644 app/component/trafficnow/queries/CanceledTripsOverviewQuery.js create mode 100644 app/component/trafficnow/queries/CanceledTripsPaginationFragment.js diff --git a/app/component/Card.js b/app/component/Card.js index 0c0f382ff8..207a318980 100644 --- a/app/component/Card.js +++ b/app/component/Card.js @@ -2,7 +2,7 @@ import PropTypes from 'prop-types'; import React, { forwardRef } from 'react'; import cx from 'classnames'; -const Card = forwardRef(({ className, children, ...rest }, ref) => { +const Card = forwardRef(({ className = undefined, children, ...rest }, ref) => { return (
{children} @@ -17,6 +17,4 @@ Card.propTypes = { children: PropTypes.node.isRequired, }; -Card.defaultProps = { className: undefined }; - export default Card; diff --git a/app/component/Icon.js b/app/component/Icon.js index 01bde2102a..aebb8c9ece 100644 --- a/app/component/Icon.js +++ b/app/component/Icon.js @@ -31,6 +31,11 @@ const Icon = ({ viewBox={!omitViewBox ? viewBox : null} className={cx('icon', className)} aria-label={ariaLabel} + transform={ + background?.props?.shape === 'stopsign' + ? 'translate(0, -3.33)' + : undefined + } > {background} { @@ -10,23 +10,23 @@ const IconBackground = ({ shape, color }) => { } return ( <> - {shape === 'stopsign' && ( )} + {shape === 'square' && ( { - setActiveAlertId(id); - }; - - const { alerts } = useLazyLoadQuery(AlertsQuery, { - feedIds, - }); - - const filteredAlerts = useMemo( - () => filterAndSortAlerts(alerts, selectedFilters), - [alerts, selectedFilters], - ); - - const desktop = breakpoint === 'large'; - - return ( -
- {filteredAlerts.length === 0 ? ( - - ) : ( - <> - - {msg =>

{msg}

} -
-
- {filteredAlerts.map(a => ( - - ))} -
- - )} -
- ); -} - -Alerts.propTypes = {}; -Alerts.defaultProps = {}; diff --git a/app/component/trafficnow/CanceledTripCard.js b/app/component/trafficnow/CanceledTripCard.js new file mode 100644 index 0000000000..4670b51a40 --- /dev/null +++ b/app/component/trafficnow/CanceledTripCard.js @@ -0,0 +1,119 @@ +import React from 'react'; +import { useRouter } from 'found'; +import { DateTime } from 'luxon'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; +import { useConfigContext } from '../../configurations/ConfigContext'; +import { PREFIX_TIMETABLE, routePagePath } from '../../util/path'; +import Card from '../Card'; +import Icon from '../Icon'; +import CancelledDepartures from './components/CancelledDepartures'; +import RouteBadgeGroup from './components/RouteBadgeGroup'; +import DisruptionBadge from './DisruptionBadge'; + +const CanceledTripCard = ({ mode, totalCount, trips }) => { + const { router } = useRouter(); + const { colors } = useConfigContext(); + + const handleRouteBadgeClick = url => e => { + e.preventDefault(); + e.stopPropagation(); + router.push(url); + }; + + /* eslint-disable no-param-reassign */ + const groupedTrips = trips.reduce((container, { start, trip }) => { + if (!trip?.route?.gtfsId || !start?.schedule?.time?.departure) { + return container; + } + + const shortName = trip?.route?.shortName || 'unknown'; + if (container[shortName]) { + container[shortName].trips.push({ + ...trip, + departureTime: DateTime.fromISO( + start?.schedule.time.departure, + ).toFormat('HH:mm'), + }); + } else { + container[shortName] = { + routeGtfsId: trip.route.gtfsId, + trips: [ + { + ...trip, + departureTime: DateTime.fromISO( + start?.schedule.time.departure, + ).toFormat('HH:mm'), + }, + ], + }; + } + return container; + }, {}); + + const isSingleRoute = Object.keys(groupedTrips).length === 1; + + return ( + +
+ + +
+
+ ({ + id: shortName, + name: shortName, + url: routePagePath(routeGtfsId, PREFIX_TIMETABLE), + gtfsId: routeGtfsId, + trips: groupedRouteTrips, + }), + )} + renderRouteSuffix={({ trips: groupedRouteTrips }) => + isSingleRoute ? ( + ({ + tripId, + departureTime, + }), + )} + /> + ) : null + } + renderSuffix={ + totalCount > trips.length ? ( + + + + ) : null + } + /> +
+
+ + +
+
+ ); +}; + +CanceledTripCard.propTypes = { + mode: PropTypes.string.isRequired, + totalCount: PropTypes.number.isRequired, + trips: PropTypes.arrayOf(PropTypes.shape({})).isRequired, +}; + +export default CanceledTripCard; diff --git a/app/component/trafficnow/CanceledTrips.js b/app/component/trafficnow/CanceledTrips.js new file mode 100644 index 0000000000..d188a98eae --- /dev/null +++ b/app/component/trafficnow/CanceledTrips.js @@ -0,0 +1,193 @@ +import React, { useMemo, useState } from 'react'; +import Button from '@hsl-fi/button'; +import cx from 'classnames'; +import Link from 'found/Link'; +import { DateTime } from 'luxon'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; +import { usePaginationFragment } from 'react-relay/hooks'; +import { useConfigContext } from '../../configurations/ConfigContext'; +import { useTranslationsContext } from '../../util/useTranslationsContext'; +import Card from '../Card'; +import Icon from '../Icon'; +import CanceledTripsModal from './CanceledTripsModal'; +import CancellationContainer from './components/CancellationContainer'; +import ResultsProgressBar from './components/ResultsProgressBar'; +import DisruptionBadge from './DisruptionBadge'; +import CanceledTripsPaginationFragment from './queries/CanceledTripsPaginationFragment'; + +const CANCELED_TRIPS_QUERY_AMOUNT = 1; + +const CanceledTrips = ({ query, isMobile = false, ...props }) => { + const { colors } = useConfigContext(); + const intl = useTranslationsContext(); + const [detailsKey, setDetailsKey] = useState(null); + + const { + data: { canceledTrips }, + loadNext, + isLoadingNext, + hasNext, + } = usePaginationFragment(CanceledTripsPaginationFragment, query); + + const mode = props.mode.toLowerCase(); + + const allEdges = canceledTrips?.edges ?? []; + + const trips = useMemo( + () => + /* eslint-disable no-param-reassign */ + allEdges.reduce((routeGroups, { node }) => { + if ( + !node?.trip?.route?.gtfsId || + !node?.start?.schedule?.time?.departure + ) { + return routeGroups; + } + + const { start, end, trip } = node; + const routeShortName = trip?.route?.shortName; + const patternCode = trip?.pattern?.code; + + if (!routeGroups[routeShortName]) { + routeGroups[routeShortName] = { + routeGtfsId: trip.route.gtfsId, + patterns: {}, + }; + } + + if (routeGroups[routeShortName].patterns[patternCode]) { + routeGroups[routeShortName].patterns[ + patternCode + ].cancelledDepartures.push( + DateTime.fromISO(start?.schedule.time.departure).toFormat('HH:mm'), + ); + } else { + routeGroups[routeShortName].patterns[patternCode] = { + start, + end, + trip, + cancelledDepartures: [ + DateTime.fromISO(start?.schedule.time.departure).toFormat( + 'HH:mm', + ), + ], + }; + } + + return routeGroups; + }, {}), + [allEdges], + ); + + const content = ( + <> +
+ +
+ + +
+
+
+ {Object.entries(trips).map( + ([routeShortName, { routeGtfsId, patterns }], i, arr) => + isMobile ? ( + + + + ) : ( + + ), + )} +
+
+
+ + + {hasNext && ( +
+
+ + ); + + return ( + <> +
+ + + + {isMobile &&
} + +
+ +
+ {isMobile ? content : {content}} +
+ {!!detailsKey && ( + setDetailsKey(null)} + /> + )} + + ); +}; + +CanceledTrips.propTypes = { + mode: PropTypes.string.isRequired, + query: PropTypes.shape({}).isRequired, + isMobile: PropTypes.bool, +}; + +export default CanceledTrips; diff --git a/app/component/trafficnow/CanceledTripsContainer.js b/app/component/trafficnow/CanceledTripsContainer.js new file mode 100644 index 0000000000..bca45b2be8 --- /dev/null +++ b/app/component/trafficnow/CanceledTripsContainer.js @@ -0,0 +1,20 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { useLazyLoadQuery } from 'react-relay/hooks'; +import CanceledTrips from './CanceledTrips'; +import CanceledTripsForModeQuery from './queries/CanceledTripsForModeQuery'; + +const CanceledTripsContainer = ({ mode, isMobile }) => { + const queryData = useLazyLoadQuery(CanceledTripsForModeQuery, { + first: 1, + mode: mode.toUpperCase(), + }); + + return ; +}; +CanceledTripsContainer.propTypes = { + mode: PropTypes.string.isRequired, + isMobile: PropTypes.bool, +}; + +export default CanceledTripsContainer; diff --git a/app/component/trafficnow/CanceledTripsModal.js b/app/component/trafficnow/CanceledTripsModal.js new file mode 100644 index 0000000000..774785f459 --- /dev/null +++ b/app/component/trafficnow/CanceledTripsModal.js @@ -0,0 +1,120 @@ +import React from 'react'; +import Button from '@hsl-fi/button'; +import Modal from '@hsl-fi/modal'; +import PropTypes from 'prop-types'; +import { useRouter } from 'found'; +import { PREFIX_TIMETABLE, routePagePath } from '../../util/path'; +import Icon from '../Icon'; +import IconBackground from '../icon/IconBackground'; +import PatternWithCancellations from './components/PatternWithCancellations'; +import RouteBadgeGroup from './components/RouteBadgeGroup'; + +const CanceledTripsModal = ({ + mode, + detailsKey = undefined, + trips, + onClose, +}) => { + const { router } = useRouter(); + + const handleRouteBadgeClick = url => e => { + e.preventDefault(); + router.push(url); + }; + + return ( + +
+ + +
+
+ {Object.entries(trips[detailsKey].patterns).map( + ([patternCode, pattern], i, arr) => ( + +
+ +
+ {i + 1 < arr.length && ( +
+ )} + + ), + )} +
+ + ); +}; + +CanceledTripsModal.propTypes = { + mode: PropTypes.string.isRequired, + detailsKey: PropTypes.string, + trips: PropTypes.shape({}).isRequired, + onClose: PropTypes.func.isRequired, + appElement: PropTypes.shape({}), +}; + +export default CanceledTripsModal; diff --git a/app/component/Badge.js b/app/component/trafficnow/DisruptionBadge.js similarity index 84% rename from app/component/Badge.js rename to app/component/trafficnow/DisruptionBadge.js index e46150470f..57a04e674f 100644 --- a/app/component/Badge.js +++ b/app/component/trafficnow/DisruptionBadge.js @@ -3,8 +3,8 @@ import PropTypes from 'prop-types'; import cx from 'classnames'; import { FormattedMessage } from 'react-intl'; import capitalize from 'lodash/capitalize'; -import Icon from './Icon'; -import { AlertSeverityLevelType } from '../constants'; +import Icon from '../Icon'; +import { AlertSeverityLevelType } from '../../constants'; const DISRUPTION_BADGE_PREFIX = 'disruption-badge-'; @@ -35,11 +35,11 @@ const getIcon = variant => { } }; -export default function Badge({ - label, - showIcon, - variant, - className, +export default function DisruptionBadge({ + label = undefined, + showIcon = false, + variant = 'info', + className = undefined, ...rest }) { return ( @@ -56,15 +56,9 @@ export default function Badge({ ); } -Badge.propTypes = { +DisruptionBadge.propTypes = { label: PropTypes.string, showIcon: PropTypes.bool, variant: variantValidator, className: PropTypes.string, }; -Badge.defaultProps = { - label: undefined, - variant: 'info', - showIcon: false, - className: undefined, -}; diff --git a/app/component/trafficnow/DisruptionCard.js b/app/component/trafficnow/DisruptionCard.js index c7d00822f7..b461035367 100644 --- a/app/component/trafficnow/DisruptionCard.js +++ b/app/component/trafficnow/DisruptionCard.js @@ -1,16 +1,16 @@ -import React, { useRef } from 'react'; -import cx from 'classnames'; -import { FormattedMessage } from 'react-intl'; +import React from 'react'; import Button from '@hsl-fi/button'; +import cx from 'classnames'; import PropTypes from 'prop-types'; -import Card from '../Card'; +import { FormattedMessage } from 'react-intl'; +import { useConfigContext } from '../../configurations/ConfigContext'; +import { AlertSeverityLevelType } from '../../constants'; import { alertShape } from '../../util/shapes'; +import { getFormattedTimeDate } from '../../util/timeUtils'; +import Card from '../Card'; +import DisruptionBadge from './DisruptionBadge'; import Icon from '../Icon'; -import { useConfigContext } from '../../configurations/ConfigContext'; -import Badge from '../Badge'; import RouteBadges from './RouteBadges'; -import { getFormattedTimeDate } from '../../util/timeUtils'; -import { AlertSeverityLevelType } from '../../constants'; const DATE_FORMAT = 'd.L.yyyy'; @@ -19,8 +19,9 @@ const handleExtraInfoClick = url => e => { window.location.href = url; }; -export default function DisruptionCard({ alert, isOpen, onClick }) { +export default function DisruptionCard({ alert, isOpen, onClick = () => {} }) { const { + id, alertSeverityLevel, alertEffect, alertHeaderText, @@ -31,7 +32,6 @@ export default function DisruptionCard({ alert, isOpen, onClick }) { alertUrl, } = alert; const { colors } = useConfigContext(); - const cardRef = useRef(null); const now = Date.now(); const isValid = @@ -48,42 +48,39 @@ export default function DisruptionCard({ alert, isOpen, onClick }) { return ( { - cardRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' }); - onClick(isOpen ? undefined : alert.id); + onClick(isOpen ? undefined : id); }} > -
- +
+ -
- {entities && ( - - )} + + {entities && }

{alertHeaderText}

{alertDescriptionText}

-
-
-
+
+
+
{isValid ? ( <> @@ -105,7 +102,7 @@ export default function DisruptionCard({ alert, isOpen, onClick }) { )}
{alertUrl && isOpen && ( -
+
- )} + {mode ? ( }> - + - + ) : ( + + {!mobile ? ( +
+ +
+ ) : ( +
+ setShowFiltersModal(false)} + /> +
+ )} + }> + + +
+ )}
); -} - -TrafficNow.propTypes = {}; +}; -TrafficNow.defaultProps = {}; +export default TrafficNow; diff --git a/app/component/trafficnow/Header.js b/app/component/trafficnow/TrafficNowHeader.js similarity index 95% rename from app/component/trafficnow/Header.js rename to app/component/trafficnow/TrafficNowHeader.js index fb771c22a5..a881681c0e 100644 --- a/app/component/trafficnow/Header.js +++ b/app/component/trafficnow/TrafficNowHeader.js @@ -53,13 +53,13 @@ const AdditionalDescription = () => { ); }; -export default function Header() { +export default function TrafficNowHeader() { const breakpoint = useBreakpoint(); const { CONFIG } = useConfigContext(); const desktop = breakpoint === 'large'; return ( -
{CONFIG === 'hsl' && }

-
+ ); } -Header.propTypes = {}; -Header.defaultProps = {}; +TrafficNowHeader.propTypes = {}; diff --git a/app/component/trafficnow/components/CancellationContainer.js b/app/component/trafficnow/components/CancellationContainer.js new file mode 100644 index 0000000000..3066a6011a --- /dev/null +++ b/app/component/trafficnow/components/CancellationContainer.js @@ -0,0 +1,94 @@ +import React from 'react'; +import Button from '@hsl-fi/button'; +import PropTypes from 'prop-types'; +import FavouriteRouteContainer from '../../routepage/FavouriteRouteContainer'; +import { PREFIX_TIMETABLE, routePagePath } from '../../../util/path'; +import { useTranslationsContext } from '../../../util/useTranslationsContext'; +import Icon from '../../Icon'; +import PatternWithCancellations from './PatternWithCancellations'; +import RouteBadgeGroup from './RouteBadgeGroup'; + +const CancellationContainer = ({ + item, + mode, + isMobile, + colors, + onShowDetailsClick, +}) => { + const { routeShortName, routeGtfsId, patterns, index, total } = item; + const intl = useTranslationsContext(); + + return ( +
+
+
+ +
+ +
+
+
+ {Object.entries(patterns).map(([patternCode, pattern]) => ( + + + + ))} +
+ {isMobile && ( + + )} +
+ + {!isMobile && ( +
- )} + + {mode ? ( }> - + - - )} + ) : ( + <> + {!mobile ? ( +
+ +
+ ) : ( +
+ setShowFiltersModal(false)} + /> +
+ )} + }> + + + + )} +
From 330fac56ba89122069e86fb91d484c5ebaaead4e Mon Sep 17 00:00:00 2001 From: Heikki Vuorinen Date: Mon, 18 May 2026 16:29:17 +0300 Subject: [PATCH 09/17] chore: update todos --- app/component/trafficnow/README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/component/trafficnow/README.md b/app/component/trafficnow/README.md index 78f23b302f..59183b8614 100644 --- a/app/component/trafficnow/README.md +++ b/app/component/trafficnow/README.md @@ -144,7 +144,10 @@ Important behavior: - Change CanceledTrips view to render on route basis when OTP endpoint supports such response - Currently the response contains cancellations on cancellation basis which results in bad UX if all cancellations don't fit in the initial 20 node quota. - Change `Disruptions` view to render paginated results when OTP endpoint supports pagination + - Blocked until API updates - Change `DisruptionCard` onClick behavior to "drill" into a new view with the card content instead of expanding the card. - Define whether and how to present `canceledTrips` that are in future (i.e. tomorrow and beyond) + - Show cancelations on the current day as ongoing, future cancelations as upcoming + - Needs to wait until api updates - Add transport mode icons to `VehicleModesFilter` - `TrafficNowHeader` logo needs to be added. Can be implemented using useLogo hook From 38b73c3099c8062ea615ad9b4e0ff92bd983b016 Mon Sep 17 00:00:00 2001 From: Heikki Vuorinen Date: Wed, 20 May 2026 09:04:16 +0300 Subject: [PATCH 10/17] feat: add mode icon to filters --- app/component/trafficnow/README.md | 1 - .../trafficnow/filters/VehicleModesFilter.js | 35 +++++++++++-------- app/component/trafficnow/styles/layout.scss | 9 +++++ 3 files changed, 29 insertions(+), 16 deletions(-) diff --git a/app/component/trafficnow/README.md b/app/component/trafficnow/README.md index 59183b8614..d53e05b511 100644 --- a/app/component/trafficnow/README.md +++ b/app/component/trafficnow/README.md @@ -149,5 +149,4 @@ Important behavior: - Define whether and how to present `canceledTrips` that are in future (i.e. tomorrow and beyond) - Show cancelations on the current day as ongoing, future cancelations as upcoming - Needs to wait until api updates -- Add transport mode icons to `VehicleModesFilter` - `TrafficNowHeader` logo needs to be added. Can be implemented using useLogo hook diff --git a/app/component/trafficnow/filters/VehicleModesFilter.js b/app/component/trafficnow/filters/VehicleModesFilter.js index da44cb34cd..9fa369925c 100644 --- a/app/component/trafficnow/filters/VehicleModesFilter.js +++ b/app/component/trafficnow/filters/VehicleModesFilter.js @@ -1,13 +1,15 @@ import React from 'react'; -import { FormattedMessage } from 'react-intl'; +import { useIntl } from 'react-intl'; import PropTypes from 'prop-types'; import { useFilterContext } from './FiltersContext'; import { useConfigContext } from '../../../configurations/ConfigContext'; import { getTransportModes } from '../../../util/modeUtils'; import { TrafficNowTransportModes } from '../../../constants'; +import Icon from '../../Icon'; const VehicleModesFilter = ({ filterId }) => { const config = useConfigContext(); + const intl = useIntl(); const { selectedFilters, setFilter } = useFilterContext(); const handleCheck = option => { @@ -35,21 +37,25 @@ const VehicleModesFilter = ({ filterId }) => { }, [], ); - return (
- - {msg => {msg}} - + + {intl.formatMessage({ + id: 'traffic-now_filters_vehicle-mode', + defaultMessage: 'Filter by vehicle mode', + })} + {availableModes.map(option => ( -
); diff --git a/app/component/trafficnow/styles/layout.scss b/app/component/trafficnow/styles/layout.scss index 90b6296e8e..ebb3c6599b 100644 --- a/app/component/trafficnow/styles/layout.scss +++ b/app/component/trafficnow/styles/layout.scss @@ -125,6 +125,15 @@ position: sticky; top: 0; + &-mode-option { + border: solid $light-gray 1px; + border-radius: var(--radius-s); + padding: var(--space-xxs) var(--space-xs); + display: flex; + justify-content: space-between; + align-items: center; + } + &-mobile { padding: var(--space-xl) var(--space-m); } From 06fda6a82a2701b9328dd050ba6cc443ddc3dc17 Mon Sep 17 00:00:00 2001 From: Heikki Vuorinen Date: Wed, 20 May 2026 13:21:36 +0300 Subject: [PATCH 11/17] feat: add header graphic --- app/component/trafficnow/README.md | 1 - app/component/trafficnow/TrafficNowHeader.js | 7 +++- app/component/trafficnow/styles/layout.scss | 10 ++++++ app/configurations/config.hsl.js | 1 + .../images/hsl/trafficnow-header.svg | 36 +++++++++++++++++++ 5 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 app/configurations/images/hsl/trafficnow-header.svg diff --git a/app/component/trafficnow/README.md b/app/component/trafficnow/README.md index d53e05b511..abb0067ad4 100644 --- a/app/component/trafficnow/README.md +++ b/app/component/trafficnow/README.md @@ -149,4 +149,3 @@ Important behavior: - Define whether and how to present `canceledTrips` that are in future (i.e. tomorrow and beyond) - Show cancelations on the current day as ongoing, future cancelations as upcoming - Needs to wait until api updates -- `TrafficNowHeader` logo needs to be added. Can be implemented using useLogo hook diff --git a/app/component/trafficnow/TrafficNowHeader.js b/app/component/trafficnow/TrafficNowHeader.js index a881681c0e..07da95ee62 100644 --- a/app/component/trafficnow/TrafficNowHeader.js +++ b/app/component/trafficnow/TrafficNowHeader.js @@ -5,6 +5,7 @@ import cx from 'classnames'; import Icon from '../Icon'; import { useBreakpoint } from '../../util/withBreakpoint'; import { useConfigContext } from '../../configurations/ConfigContext'; +import { useLogo } from '../../hooks/useLogo'; const AdditionalDescription = () => { const intl = useIntl(); @@ -55,8 +56,9 @@ const AdditionalDescription = () => { export default function TrafficNowHeader() { const breakpoint = useBreakpoint(); - const { CONFIG } = useConfigContext(); + const { CONFIG, trafficNowHeaderGraphic } = useConfigContext(); + const { logo } = useLogo(trafficNowHeaderGraphic); const desktop = breakpoint === 'large'; return (
{CONFIG === 'hsl' && }

+ {logo && desktop && ( + + )}
); } diff --git a/app/component/trafficnow/styles/layout.scss b/app/component/trafficnow/styles/layout.scss index ebb3c6599b..d83548c614 100644 --- a/app/component/trafficnow/styles/layout.scss +++ b/app/component/trafficnow/styles/layout.scss @@ -35,6 +35,10 @@ } } + .gutterer-content { + position: relative; + } + &__header { display: flex; flex-direction: column; @@ -43,6 +47,12 @@ $padding-horizontal-gutter; flex: 0 0 50%; + &-image { + position: absolute; + right: 0; + bottom: 0; + } + &-breadcrumb { display: inline-flex; align-items: center; diff --git a/app/configurations/config.hsl.js b/app/configurations/config.hsl.js index 35b0580923..821d436064 100644 --- a/app/configurations/config.hsl.js +++ b/app/configurations/config.hsl.js @@ -797,6 +797,7 @@ export default { trafficLightGraphic: 'hsl/traffic-light.svg', naviGeolocationGraphic: 'hsl/geolocation.svg', notFoundGraphic: 'hsl/not-found.svg', + trafficNowHeaderGraphic: 'hsl/trafficnow-header.svg', navigation: true, crazyEgg: true, diff --git a/app/configurations/images/hsl/trafficnow-header.svg b/app/configurations/images/hsl/trafficnow-header.svg new file mode 100644 index 0000000000..40ee647a34 --- /dev/null +++ b/app/configurations/images/hsl/trafficnow-header.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 25854fc892dec8c7f8ba47a7a7f8c3856b3b1d4d Mon Sep 17 00:00:00 2001 From: Heikki Vuorinen Date: Fri, 22 May 2026 09:05:57 +0300 Subject: [PATCH 12/17] remove broken font definition that was overriding the correct one --- app/component/trafficnow/styles/tokens.scss | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/component/trafficnow/styles/tokens.scss b/app/component/trafficnow/styles/tokens.scss index 73c85e3f34..5a2f110863 100644 --- a/app/component/trafficnow/styles/tokens.scss +++ b/app/component/trafficnow/styles/tokens.scss @@ -4,12 +4,10 @@ // Shared SCSS constants and component-level CSS custom properties. // Font weight definitions must be set before typography import in main.scss -$font-family-narrow: 450; $font-weight-book: 350; $font-weight-medium: 500; :root { - --font-family-narrow: #{$font-family-narrow}; --font-weight-book: #{$font-weight-book}; --font-weight-medium: #{$font-weight-medium}; } From 6fc8d49860a396751d4be584036906b0f5e1dac5 Mon Sep 17 00:00:00 2001 From: Heikki Vuorinen Date: Fri, 22 May 2026 09:41:07 +0300 Subject: [PATCH 13/17] fix: remove duplicate symbols --- static/assets/svg-sprite.default.svg | 6 ------ static/assets/svg-sprite.hsl.svg | 17 ----------------- 2 files changed, 23 deletions(-) diff --git a/static/assets/svg-sprite.default.svg b/static/assets/svg-sprite.default.svg index 44e576bf3e..40e428ed30 100644 --- a/static/assets/svg-sprite.default.svg +++ b/static/assets/svg-sprite.default.svg @@ -1335,12 +1335,6 @@ - - - - - - diff --git a/static/assets/svg-sprite.hsl.svg b/static/assets/svg-sprite.hsl.svg index d3d4835f5c..3a157f35ed 100644 --- a/static/assets/svg-sprite.hsl.svg +++ b/static/assets/svg-sprite.hsl.svg @@ -1430,17 +1430,6 @@ - - - - - - - - - - - @@ -1519,12 +1508,6 @@ - - - - - - From b62d35ad929c49ebc05c8621f61d06a856376b74 Mon Sep 17 00:00:00 2001 From: Heikki Vuorinen Date: Fri, 22 May 2026 10:37:21 +0300 Subject: [PATCH 14/17] fix: use route gtfsId as key instead of shortname --- app/component/trafficnow/components/RouteBadgeGroup.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/component/trafficnow/components/RouteBadgeGroup.js b/app/component/trafficnow/components/RouteBadgeGroup.js index 610eb6a02f..83cde1c20b 100644 --- a/app/component/trafficnow/components/RouteBadgeGroup.js +++ b/app/component/trafficnow/components/RouteBadgeGroup.js @@ -42,8 +42,8 @@ const RouteBadgeGroup = ({ />
{routes.map(route => { - const { id, name, url, gtfsId } = route; - const routeKey = id || `${name}-${url}`; + const { name, url, gtfsId } = route; + const routeKey = gtfsId || `${name}-${url}`; const link = ( Date: Fri, 22 May 2026 11:35:08 +0300 Subject: [PATCH 15/17] refactor: rename cancelled to canceled fix: use TRAFFICNOW constant fix: translation defaults fix --- app/component/trafficnow/CanceledTripCard.js | 13 +++++++------ app/component/trafficnow/CanceledTrips.js | 4 ++-- app/component/trafficnow/CanceledTripsModal.js | 12 ++---------- ...CancelledDepartures.js => CanceledDepartures.js} | 6 +++--- .../components/PatternWithCancellations.js | 12 ++++++------ app/component/trafficnow/filters/filterUtils.js | 2 +- app/component/trafficnow/styles/modal.scss | 7 +++++++ 7 files changed, 28 insertions(+), 28 deletions(-) rename app/component/trafficnow/components/{CancelledDepartures.js => CanceledDepartures.js} (83%) diff --git a/app/component/trafficnow/CanceledTripCard.js b/app/component/trafficnow/CanceledTripCard.js index 4670b51a40..6b8d37a311 100644 --- a/app/component/trafficnow/CanceledTripCard.js +++ b/app/component/trafficnow/CanceledTripCard.js @@ -2,18 +2,19 @@ import React from 'react'; import { useRouter } from 'found'; import { DateTime } from 'luxon'; import PropTypes from 'prop-types'; -import { FormattedMessage } from 'react-intl'; +import { useIntl } from 'react-intl'; import { useConfigContext } from '../../configurations/ConfigContext'; -import { PREFIX_TIMETABLE, routePagePath } from '../../util/path'; +import { PREFIX_TIMETABLE, TRAFFICNOW, routePagePath } from '../../util/path'; import Card from '../Card'; import Icon from '../Icon'; -import CancelledDepartures from './components/CancelledDepartures'; +import CanceledDepartures from './components/CanceledDepartures'; import RouteBadgeGroup from './components/RouteBadgeGroup'; import DisruptionBadge from './DisruptionBadge'; const CanceledTripCard = ({ mode, totalCount, trips }) => { const { router } = useRouter(); const { colors } = useConfigContext(); + const intl = useIntl(); const handleRouteBadgeClick = url => e => { e.preventDefault(); @@ -56,7 +57,7 @@ const CanceledTripCard = ({ mode, totalCount, trips }) => { return (
@@ -83,7 +84,7 @@ const CanceledTripCard = ({ mode, totalCount, trips }) => { )} renderRouteSuffix={({ trips: groupedRouteTrips }) => isSingleRoute ? ( - ({ tripId, @@ -104,7 +105,7 @@ const CanceledTripCard = ({ mode, totalCount, trips }) => {
- + {intl.formatMessage({ id: 'valid', defaultMessage: 'Active' })}
); diff --git a/app/component/trafficnow/CanceledTrips.js b/app/component/trafficnow/CanceledTrips.js index aa93b98aab..2dff90fc92 100644 --- a/app/component/trafficnow/CanceledTrips.js +++ b/app/component/trafficnow/CanceledTrips.js @@ -58,7 +58,7 @@ const CanceledTrips = ({ query, isMobile = false, ...props }) => { if (routeGroups[routeShortName].patterns[patternCode]) { routeGroups[routeShortName].patterns[ patternCode - ].cancelledDepartures.push( + ].canceledDepartures.push( DateTime.fromISO(start?.schedule.time.departure).toFormat('HH:mm'), ); } else { @@ -66,7 +66,7 @@ const CanceledTrips = ({ query, isMobile = false, ...props }) => { start, end, trip, - cancelledDepartures: [ + canceledDepartures: [ DateTime.fromISO(start?.schedule.time.departure).toFormat( 'HH:mm', ), diff --git a/app/component/trafficnow/CanceledTripsModal.js b/app/component/trafficnow/CanceledTripsModal.js index d20932275a..1a82ed23c0 100644 --- a/app/component/trafficnow/CanceledTripsModal.js +++ b/app/component/trafficnow/CanceledTripsModal.js @@ -28,7 +28,7 @@ const CanceledTripsModal = ({ isOpen={!!detailsKey} shouldCloseOnEsc shouldCloseOnOverlayClick - contentLabel="Foobar" + contentLabel="Canceled trips modal" onRequestClose={onClose} variant="large" className="traffic-now traffic-now__modal sheet design-system" @@ -65,15 +65,7 @@ const CanceledTripsModal = ({ {Object.entries(trips[detailsKey].patterns).map( ([patternCode, pattern], i, arr) => ( -
+
( +const CanceledDepartures = ({ departures }) => (
{departures.map(({ tripId, departureTime }) => ( (
); -CancelledDepartures.propTypes = { +CanceledDepartures.propTypes = { departures: PropTypes.arrayOf( PropTypes.shape({ tripId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]) @@ -24,4 +24,4 @@ CancelledDepartures.propTypes = { ).isRequired, }; -export default CancelledDepartures; +export default CanceledDepartures; diff --git a/app/component/trafficnow/components/PatternWithCancellations.js b/app/component/trafficnow/components/PatternWithCancellations.js index 3eb0d2aa0f..cd9cdb9d4b 100644 --- a/app/component/trafficnow/components/PatternWithCancellations.js +++ b/app/component/trafficnow/components/PatternWithCancellations.js @@ -3,7 +3,7 @@ import PropTypes from 'prop-types'; import { FormattedMessage } from 'react-intl'; import { useConfigContext } from '../../../configurations/ConfigContext'; import Icon from '../../Icon'; -import CancelledDepartures from './CancelledDepartures'; +import CanceledDepartures from './CanceledDepartures'; import './PatternWithCancellations.scss'; @@ -13,7 +13,7 @@ const PatternWithCancellations = ({ withDeparturesAmount = false, }) => { const { colors } = useConfigContext(); - const { start, end, trip, cancelledDepartures } = pattern; + const { start, end, trip, canceledDepartures } = pattern; return (
)} {withDepartureBadges && ( - ({ + ({ tripId: trip.tripId, departureTime, }))} @@ -65,7 +65,7 @@ PatternWithCancellations.propTypes = { headsign: PropTypes.string, }), }), - cancelledDepartures: PropTypes.arrayOf(PropTypes.shape({})), + canceledDepartures: PropTypes.arrayOf(PropTypes.shape({})), }).isRequired, withDepartureBadges: PropTypes.bool, withDeparturesAmount: PropTypes.bool, diff --git a/app/component/trafficnow/filters/filterUtils.js b/app/component/trafficnow/filters/filterUtils.js index e780915960..0c09b7b633 100644 --- a/app/component/trafficnow/filters/filterUtils.js +++ b/app/component/trafficnow/filters/filterUtils.js @@ -42,7 +42,7 @@ const favouriteFilter = ({ entities }, { favourites }) => !favourites || entities.some(e => favourites.has(e.gtfsId)); /** - * If this filter is present, only cancelledTrips should be shown + * If this filter is present, only canceledTrips should be shown */ const cancellationsFilter = ({ __typename }, { cancellations }) => !cancellations || __typename !== 'Alert'; diff --git a/app/component/trafficnow/styles/modal.scss b/app/component/trafficnow/styles/modal.scss index ba3d66b966..e03f49eb55 100644 --- a/app/component/trafficnow/styles/modal.scss +++ b/app/component/trafficnow/styles/modal.scss @@ -69,6 +69,13 @@ display: flex; flex-direction: column; padding: 0 var(--space-l) var(--space-l) var(--space-l); + + &-pattern { + display: flex; + flex-direction: column; + gap: var(--space-xs); + padding: var(--space-xs) 0; + } } .routepage-button { From 6ee6b08f9939bc82ff7aa98554ab48197f91b3b7 Mon Sep 17 00:00:00 2001 From: Heikki Vuorinen Date: Fri, 22 May 2026 13:26:55 +0300 Subject: [PATCH 16/17] fix: add translation to route page link --- app/component/trafficnow/CanceledTrips.js | 2 +- app/component/trafficnow/CanceledTripsModal.js | 6 +++++- app/translations/en.js | 1 + app/translations/fi.js | 1 + app/translations/sv.js | 1 + 5 files changed, 9 insertions(+), 2 deletions(-) diff --git a/app/component/trafficnow/CanceledTrips.js b/app/component/trafficnow/CanceledTrips.js index 2dff90fc92..e2a83ca181 100644 --- a/app/component/trafficnow/CanceledTrips.js +++ b/app/component/trafficnow/CanceledTrips.js @@ -85,7 +85,7 @@ const CanceledTrips = ({ query, isMobile = false, ...props }) => {
- + {intl.formatMessage({ id: 'valid', defaultMessage: 'Active' })}
diff --git a/app/component/trafficnow/CanceledTripsModal.js b/app/component/trafficnow/CanceledTripsModal.js index 1a82ed23c0..5eb1e501c3 100644 --- a/app/component/trafficnow/CanceledTripsModal.js +++ b/app/component/trafficnow/CanceledTripsModal.js @@ -2,6 +2,7 @@ import React from 'react'; import Button from '@hsl-fi/button'; import Modal from '@hsl-fi/modal'; import PropTypes from 'prop-types'; +import { useIntl } from 'react-intl'; import { useRouter } from 'found'; import { PREFIX_TIMETABLE, routePagePath } from '../../util/path'; import Icon from '../Icon'; @@ -15,6 +16,7 @@ const CanceledTripsModal = ({ trips, onClose, }) => { + const intl = useIntl(); const { router } = useRouter(); const handleRouteBadgeClick = url => e => { @@ -75,7 +77,9 @@ const CanceledTripsModal = ({ size="small" fullWidth={false} variant="white" - value="Siirry linjasivulle" + value={intl.formatMessage({ + id: 'traffic-now_go-to-route-page', + })} href={routePagePath( trips[detailsKey].routeGtfsId, PREFIX_TIMETABLE, diff --git a/app/translations/en.js b/app/translations/en.js index dd84973133..c8a52a9a5c 100644 --- a/app/translations/en.js +++ b/app/translations/en.js @@ -834,6 +834,7 @@ export default { 'traffic-now_filters_vehicle-mode': 'Filter by vehicle mode', 'traffic-now_filters_view-results': 'View results', 'traffic-now_go-back': 'Go back', + 'traffic-now_go-to-route-page': 'View route', 'traffic-now_link': 'Services now', 'traffic-now_link-description': 'See changes and disruptions', trafficnow: 'Traffic now', diff --git a/app/translations/fi.js b/app/translations/fi.js index 22b398d31e..839ef22c72 100644 --- a/app/translations/fi.js +++ b/app/translations/fi.js @@ -819,6 +819,7 @@ export default { 'traffic-now_filters_vehicle-mode': 'Näytä liikennevälineen mukaan', 'traffic-now_filters_view-results': 'Näytä tulokset', 'traffic-now_go-back': 'Palaa takaisin', + 'traffic-now_go-to-route-page': 'Siirry linjasivulle', 'traffic-now_link': 'Liikennetilanne nyt', 'traffic-now_link-description': 'Katso häiriöt ja poikkeukset', trafficnow: 'Liikenne nyt', diff --git a/app/translations/sv.js b/app/translations/sv.js index 75c27be642..513ecbc7e4 100644 --- a/app/translations/sv.js +++ b/app/translations/sv.js @@ -827,6 +827,7 @@ export default { 'traffic-now_filters_vehicle-mode': 'Filtrera efter fordonsläge', 'traffic-now_filters_view-results': 'Visa resultat', 'traffic-now_go-back': 'Gå tillbaka', + 'traffic-now_go-to-route-page': 'Visa linje', 'traffic-now_link': 'Trafikläget nu', 'traffic-now_link-description': 'Se störningar och förändringar', trafficnow: 'Trafikläget nu', From d228e51abe3731dbdb1339dfea4ddf2fa706b453 Mon Sep 17 00:00:00 2001 From: Heikki Vuorinen Date: Fri, 22 May 2026 15:02:30 +0300 Subject: [PATCH 17/17] fix: details link opens in new tab --- app/component/trafficnow/DisruptionCard.js | 29 +++++++++------------- 1 file changed, 12 insertions(+), 17 deletions(-) diff --git a/app/component/trafficnow/DisruptionCard.js b/app/component/trafficnow/DisruptionCard.js index b461035367..85f23f3d0c 100644 --- a/app/component/trafficnow/DisruptionCard.js +++ b/app/component/trafficnow/DisruptionCard.js @@ -1,5 +1,5 @@ import React from 'react'; -import Button from '@hsl-fi/button'; +import { ButtonLink } from '@hsl-fi/layout-primitives'; import cx from 'classnames'; import PropTypes from 'prop-types'; import { FormattedMessage } from 'react-intl'; @@ -14,11 +14,6 @@ import RouteBadges from './RouteBadges'; const DATE_FORMAT = 'd.L.yyyy'; -const handleExtraInfoClick = url => e => { - e.stopPropagation(); - window.location.href = url; -}; - export default function DisruptionCard({ alert, isOpen, onClick = () => {} }) { const { id, @@ -103,17 +98,17 @@ export default function DisruptionCard({ alert, isOpen, onClick = () => {} }) {
{alertUrl && isOpen && (
-
)}