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..6b8d37a311 --- /dev/null +++ b/app/component/trafficnow/CanceledTripCard.js @@ -0,0 +1,120 @@ +import React from 'react'; +import { useRouter } from 'found'; +import { DateTime } from 'luxon'; +import PropTypes from 'prop-types'; +import { useIntl } from 'react-intl'; +import { useConfigContext } from '../../configurations/ConfigContext'; +import { PREFIX_TIMETABLE, TRAFFICNOW, routePagePath } from '../../util/path'; +import Card from '../Card'; +import Icon from '../Icon'; +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(); + 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 + } + /> +
+
+ + {intl.formatMessage({ id: 'valid', defaultMessage: 'Active' })} +
+
+ ); +}; + +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..e2a83ca181 --- /dev/null +++ b/app/component/trafficnow/CanceledTrips.js @@ -0,0 +1,192 @@ +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 { useIntl, FormattedMessage } from 'react-intl'; +import { usePaginationFragment } from 'react-relay/hooks'; +import { useConfigContext } from '../../configurations/ConfigContext'; +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 = 20; + +const CanceledTrips = ({ query, isMobile = false, ...props }) => { + const { colors } = useConfigContext(); + const intl = useIntl(); + 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 + ].canceledDepartures.push( + DateTime.fromISO(start?.schedule.time.departure).toFormat('HH:mm'), + ); + } else { + routeGroups[routeShortName].patterns[patternCode] = { + start, + end, + trip, + canceledDepartures: [ + DateTime.fromISO(start?.schedule.time.departure).toFormat( + 'HH:mm', + ), + ], + }; + } + + return routeGroups; + }, {}), + [allEdges], + ); + + const content = ( + <> +
+ +
+ + {intl.formatMessage({ id: 'valid', defaultMessage: 'Active' })} +
+
+
+ {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..7c234853cd --- /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: 20, + 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..5eb1e501c3 --- /dev/null +++ b/app/component/trafficnow/CanceledTripsModal.js @@ -0,0 +1,116 @@ +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'; +import IconBackground from '../icon/IconBackground'; +import PatternWithCancellations from './components/PatternWithCancellations'; +import RouteBadgeGroup from './components/RouteBadgeGroup'; + +const CanceledTripsModal = ({ + mode, + detailsKey = undefined, + trips, + onClose, +}) => { + const intl = useIntl(); + 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..85f23f3d0c 100644 --- a/app/component/trafficnow/DisruptionCard.js +++ b/app/component/trafficnow/DisruptionCard.js @@ -1,26 +1,22 @@ -import React, { useRef } from 'react'; +import React from 'react'; +import { ButtonLink } from '@hsl-fi/layout-primitives'; import cx from 'classnames'; -import { FormattedMessage } from 'react-intl'; -import Button from '@hsl-fi/button'; 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'; -const handleExtraInfoClick = url => e => { - e.stopPropagation(); - 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 +27,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 +43,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,18 +97,18 @@ export default function DisruptionCard({ alert, isOpen, onClick }) { )}
{alertUrl && isOpen && ( -
-
@@ -129,4 +121,3 @@ DisruptionCard.propTypes = { isOpen: PropTypes.bool.isRequired, onClick: PropTypes.func, }; -DisruptionCard.defaultProps = { onClick: () => {} }; diff --git a/app/component/trafficnow/Disruptions.js b/app/component/trafficnow/Disruptions.js new file mode 100644 index 0000000000..ce518904f8 --- /dev/null +++ b/app/component/trafficnow/Disruptions.js @@ -0,0 +1,124 @@ +import React, { useMemo, useRef, useState } from 'react'; +import cx from 'classnames'; +import { FormattedMessage } from 'react-intl'; +import { useLazyLoadQuery } from 'react-relay/hooks'; +import { useConfigContext } from '../../configurations/ConfigContext'; +import { TransportMode } from '../../constants'; +import { useBreakpoint } from '../../util/withBreakpoint'; +import CanceledTripCard from './CanceledTripCard'; +import DisruptionCard from './DisruptionCard'; +import NoDisruptions from './components/NoDisruptions'; +import { useFilterContext } from './filters/FiltersContext'; +import { filterAndSortAlerts } from './filters/filterUtils'; +import AlertsQuery from './queries/AlertsQuery'; +import CanceledTripsOverviewQuery from './queries/CanceledTripsOverviewQuery'; + +const CANCELED_TRIPS_OVERVIEW_QUERY_AMOUNT = 20; + +export default function Disruptions() { + const breakpoint = useBreakpoint(); + const config = useConfigContext(); + const [activeAlertId, setActiveAlertId] = useState(); + const ref = useRef(); + const { selectedFilters } = useFilterContext(); + + const handleCardClick = id => { + setActiveAlertId(id); + }; + + const { alerts } = useLazyLoadQuery(AlertsQuery, { + feedIds: config.feedIds, + }); + + // If no modes are selected, fetch cancelations for all + const modesToFetch = + selectedFilters.vehicleModes.length === 0 + ? [ + TransportMode.Bus, + TransportMode.Tram, + TransportMode.Rail, + TransportMode.Subway, + TransportMode.Ferry, + ] + : selectedFilters.vehicleModes.map(mode => mode.toUpperCase()); + const canceledTripsVars = { + amount: CANCELED_TRIPS_OVERVIEW_QUERY_AMOUNT, + fetchBus: modesToFetch.includes(TransportMode.Bus), + fetchTram: modesToFetch.includes(TransportMode.Tram), + fetchRail: modesToFetch.includes(TransportMode.Rail), + fetchSubway: modesToFetch.includes(TransportMode.Subway), + fetchFerry: modesToFetch.includes(TransportMode.Ferry), + }; + + const { bus, tram, rail, subway, ferry } = useLazyLoadQuery( + CanceledTripsOverviewQuery, + canceledTripsVars, + ); + + const disruptions = useMemo( + () => filterAndSortAlerts(alerts, selectedFilters), + [alerts, selectedFilters], + ); + + const mobile = breakpoint !== 'large'; + + const canceledModes = [ + bus && { key: 'bus', ...bus }, + tram && { key: 'tram', ...tram }, + rail && { key: 'rail', ...rail }, + subway && { key: 'subway', ...subway }, + ferry && { key: 'ferry', ...ferry }, + ].filter(Boolean); + + const noResults = + !disruptions.length && !canceledModes.some(mode => mode.totalCount > 0); + + const resultAmount = canceledModes.reduce( + (sum, mode) => (mode.totalCount > 0 ? sum + 1 : sum), + disruptions.length, + ); + + return ( +
+ {noResults ? ( + + ) : ( + <> + + {msg =>

{msg}

} +
+
+ {canceledModes.map( + ({ key, totalCount, edges }) => + edges.length > 0 && ( + node)} + /> + ), + )} + {disruptions.map(a => ( + + ))} +
+ + )} +
+ ); +} diff --git a/app/component/trafficnow/README.md b/app/component/trafficnow/README.md new file mode 100644 index 0000000000..abb0067ad4 --- /dev/null +++ b/app/component/trafficnow/README.md @@ -0,0 +1,151 @@ +# TrafficNow + +## Overview +TrafficNow queries and renders disruption information from two separate OTP data sources in one view: +- Alert disruptions from GraphQL `alerts` +- Canceled departures from GraphQL `canceledTrips` + +The feature has two user flows: +1. Overview flow: mixed disruption list with filtering. +2. Mode-specific flow: detailed canceled departures for a selected mode. + +## Architecture and Data Flow + +### Entry routing +- `TrafficNow.js` is the feature root. +- If route param `mode` exists, TrafficNow renders mode-specific canceled trips (`CanceledTripsContainer`). +- Otherwise, it renders overview disruptions (`Disruptions`) inside `FilterContextProvider`. + +### Overview flow +1. `Disruptions.js` loads alerts with `AlertsQuery`. +2. `Disruptions.js` loads canceled trips overview with `CanceledTripsOverviewQuery`. +3. Alerts are filtered and sorted by `filterAndSortAlerts` from `filters/filterUtils.js`. +4. UI list rendering order is: +- canceled trips cards first (`CanceledTripCard`) +- alert cards second (`DisruptionCard`) + +### Mode-specific cancellations flow +1. `CanceledTripsContainer.js` and `CanceledTrips.js` load paginated mode-specific data via Relay pagination fragment/query. +2. Trips are grouped for display by: +- `routeShortName` +- then `patternCode` +3. Details are shown in `CanceledTripsModal` when selected. + +## Data Sources and GraphQL + +### Alerts +`queries/AlertsQuery.js` fetches: +- alert metadata (`id`, severity, effect, header, description, URL) +- active period (`effectiveStartDate`, `effectiveEndDate`) +- entities (`Stop`, `Route`, `StopOnRoute`) + +### Canceled trips overview +`queries/CanceledTripsOverviewQuery.js` requests one canceledTrips connection per mode: +- BUS +- TRAM +- RAIL +- SUBWAY +- FERRY + +Each mode query is conditionally included with `@include` booleans from available config modes. + +### Canceled trips mode details +`queries/CanceledTripsForModeQuery.js` uses a pagination fragment (`CanceledTripsPaginationFragment`) for incremental loading in mode view. + +## Card Types and Grouping Rules + +### Canceled trips cards (`CanceledTripCard.js`) +Rules in overview: +1. One card per mode appears when that mode has `edges.length > 0`. +2. A node is skipped if either is missing: +- `trip.route.gtfsId` +- `start.schedule.time.departure` +3. Card-internal grouping key is `trip.route.shortName`. +4. Departure times are formatted to `HH:mm`. +5. If exactly one grouped route exists, canceled departure times are displayed directly in the card. +6. If `totalCount > trips.length`, an ellipsis indicator is shown. + +### Alert cards (`DisruptionCard.js`) +Rules: +1. One alert equals one card. +2. Badge variant is based on `alertSeverityLevel`; label is `alertEffect`. +3. Card status is Active or Upcoming from effective timestamps. +4. Expanded card can show external details button if `alertUrl` exists. + +## Filtering and Sorting + +### Filter state +`filters/FiltersContext.js` default filters: +- `now: Date.now()` +- `noEffect: 'NO_EFFECT'` +- `validityPeriod: 'ALL'` +- `vehicleModes: []` + +### Filter chain +`filters/filterUtils.js` applies filters in this exact order: +1. `pastFilter` +2. `noEffectFilter` +3. `validityPeriodFilter` +4. `vehicleModesFilter` +5. `entityFilter` +6. `favouriteFilter` +7. `cancellationsFilter` + +Important behavior: +- `cancellationsFilter` removes GraphQL alerts (`__typename === 'Alert'`) when cancellations-only toggle is active. +- Canceled trips still remain visible, because they are rendered from separate `canceledTrips` data. + +### Sorting order +`filterAndSortAlerts` sorting behavior: +1. Active non-info alerts (warning/severe) before others. +2. If both are active warnings, sort by severity order: +- `SEVERE` +- `WARNING` +- `INFO` +- default fallback +3. Then by `effectiveStartDate` ascending. +4. Non-prioritized alerts are also sorted by `effectiveStartDate` ascending. + +## Badge Behavior + +### Alert entity badges +`RouteBadges.js` and `utils.js` behavior: +1. If all entities are `Unknown`, no badges are rendered. +2. Entities are grouped by mode and by type (route vs stop/station). +3. Duplicates are removed by entity id. +4. Group members are sorted alphanumerically by display name. +5. `RouteBadgeGroup` can highlight currently selected entity via `highlightedGtfsId`. + +## Key Files +- `TrafficNow.js`: entry component and route split. +- `Disruptions.js`: overview data load, filter application, mixed rendering. +- `CanceledTripCard.js`: overview canceled-trip grouping and card content. +- `CanceledTrips.js`: mode-specific grouping and pagination behavior. +- `DisruptionCard.js`: alert card UI and expansion behavior. +- `filters/FiltersContext.js`: filter defaults and state API. +- `filters/filterUtils.js`: filter chain and sorting implementation. +- `RouteBadges.js`: entity badge rendering orchestration. +- `utils.js`: mode availability and entity grouping utilities. +- `queries/*`: feature GraphQL documents and fragments. + +## Maintenance Notes +1. When adding a new filter, update both `DEFAULT_FILTERS` in `FiltersContext.js` and filter execution order in `filterUtils.js`. +2. When changing card composition order in overview, update `Disruptions.js` render sequence. +3. When adding a new transport mode, verify: +- mode availability from config (`getAvailableModes`) +- query include variables in `CanceledTripsOverviewQuery` +- mode rendering in cards and filters +4. If cancellation grouping logic changes, ensure overview (`CanceledTripCard.js`) and mode detail (`CanceledTrips.js`) stay conceptually aligned. + +## TODO +- Move `CanceledTripsContainer` under `FilterContextProvider` and have the selected filters affect CanceledTrips as well + - Added filtering based on mode selection, more filters could be added when the api supports them +- Add a reasonable unit test suite +- 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 diff --git a/app/component/trafficnow/RouteBadges.js b/app/component/trafficnow/RouteBadges.js index ef142ffefa..4b2a4a95a9 100644 --- a/app/component/trafficnow/RouteBadges.js +++ b/app/component/trafficnow/RouteBadges.js @@ -1,29 +1,17 @@ /* eslint-disable no-underscore-dangle */ import React, { useMemo } from 'react'; import PropTypes from 'prop-types'; -import cx from 'classnames'; -import { useRouter } from 'found'; import { entityShape } from '../../util/shapes'; import { useConfigContext } from '../../configurations/ConfigContext'; import { AlertEntityType } from '../../constants'; import { groupEntitiesByMode } from './utils'; -import Icon from '../Icon'; -import IconBackground from '../icon/IconBackground'; import { useFilterContext } from './filters/FiltersContext'; - -const STOP_SIGN_ICON_SCALE = 0.5; -const NORMAL_ICON_SCALE = 1; +import RouteBadgeGroup from './components/RouteBadgeGroup'; export default function RouteBadges({ entities: rawEntities }) { - const { match } = useRouter(); const config = useConfigContext(); const { selectedFilters } = useFilterContext(); - const handleRouteBadgeClick = url => e => { - e.preventDefault(); - match.router.push(url); - }; - if (rawEntities.every(e => e.__typename === AlertEntityType.Unknown)) { return null; } @@ -34,43 +22,22 @@ export default function RouteBadges({ entities: rawEntities }) { ); return ( -
+
{Object.entries(entitiesByMode).map( ([key, { mode, isRoute, entities }]) => mode && ( -
- - ) - } - /> -
- {entities.map(({ id, name, url, gtfsId }) => ( - - - {name} - - - ))} -
-
+ mode={mode} + routes={entities.map(({ id, name, url, gtfsId }) => ({ + id, + name, + url, + gtfsId, + }))} + isStop={!isRoute} + highlightedGtfsId={selectedFilters.entity?.gtfsId} + /> ), )}
@@ -80,4 +47,3 @@ export default function RouteBadges({ entities: rawEntities }) { RouteBadges.propTypes = { entities: PropTypes.arrayOf(entityShape).isRequired, }; -RouteBadges.defaultProps = {}; diff --git a/app/component/trafficnow/TrafficNow.js b/app/component/trafficnow/TrafficNow.js index cc8a2c13d4..b91e8ab117 100644 --- a/app/component/trafficnow/TrafficNow.js +++ b/app/component/trafficnow/TrafficNow.js @@ -1,31 +1,48 @@ -import React, { Suspense, useState } from 'react'; import { useIntl } from 'react-intl'; import cx from 'classnames'; +import React, { useEffect, Suspense, useState } from 'react'; import Button from '@hsl-fi/button'; -import Header from './Header'; -import Filters from './filters/Filters'; -import FiltersModal from './filters/FiltersModal'; -import Alerts from './Alerts'; +import { useRouter } from 'found'; +import ReactModal from 'react-modal'; import { useBreakpoint } from '../../util/withBreakpoint'; import Gutterer from '../Gutterer'; import Loading from '../Loading'; +import CanceledTripsContainer from './CanceledTripsContainer'; +import Disruptions from './Disruptions'; +import TrafficNowHeader from './TrafficNowHeader'; +import Filters from './filters/Filters'; import { FilterContextProvider } from './filters/FiltersContext'; +import FiltersModal from './filters/FiltersModal'; -export default function TrafficNow() { +const TrafficNow = () => { + const { + match: { + params: { mode }, + }, + } = useRouter(); const intl = useIntl(); const breakpoint = useBreakpoint(); const [showFiltersModal, setShowFiltersModal] = useState(false); const mobile = breakpoint !== 'large'; + useEffect(() => ReactModal.setAppElement(document.querySelector('#app')), []); + + const isMobileCanceledTripsView = !!mode && mobile; + return ( -
- -
- -
+
+ {!isMobileCanceledTripsView && ( + <> + + + +
+ + )}
- {!mobile ? ( -
- -
+ {mode ? ( + }> + + ) : ( -
- setShowFiltersModal(false)} - /> -
+ <> + {!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 85% rename from app/component/trafficnow/Header.js rename to app/component/trafficnow/TrafficNowHeader.js index fb771c22a5..07da95ee62 100644 --- a/app/component/trafficnow/Header.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(); @@ -53,13 +54,14 @@ const AdditionalDescription = () => { ); }; -export default function Header() { +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 && ( + + )} +
); } -Header.propTypes = {}; -Header.defaultProps = {}; +TrafficNowHeader.propTypes = {}; diff --git a/app/component/trafficnow/components/CanceledDepartures.js b/app/component/trafficnow/components/CanceledDepartures.js new file mode 100644 index 0000000000..dba5d3db11 --- /dev/null +++ b/app/component/trafficnow/components/CanceledDepartures.js @@ -0,0 +1,27 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +const CanceledDepartures = ({ departures }) => ( +
+ {departures.map(({ tripId, departureTime }) => ( + + {departureTime} + + ))} +
+); + +CanceledDepartures.propTypes = { + departures: PropTypes.arrayOf( + PropTypes.shape({ + tripId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]) + .isRequired, + departureTime: PropTypes.string.isRequired, + }), + ).isRequired, +}; + +export default CanceledDepartures; diff --git a/app/component/trafficnow/components/CancellationContainer.js b/app/component/trafficnow/components/CancellationContainer.js new file mode 100644 index 0000000000..1c57b6851c --- /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 { useIntl } from 'react-intl'; +import FavouriteRouteContainer from '../../routepage/FavouriteRouteContainer'; +import { PREFIX_TIMETABLE, routePagePath } from '../../../util/path'; +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 = useIntl(); + + return ( +
+
+
+ +
+ +
+
+
+ {Object.entries(patterns).map(([patternCode, pattern]) => ( + + + + ))} +
+ {isMobile && ( + + )} +
+ + {!isMobile && ( +