diff --git a/src/components/line-chip/index.tsx b/src/components/line-chip/index.tsx new file mode 100644 index 00000000..d884ffd5 --- /dev/null +++ b/src/components/line-chip/index.tsx @@ -0,0 +1,47 @@ +import { + TransportIcon, + useTransportationThemeColor, +} from '@atb/components/transport-mode/transport-icon'; +import { + TransportModeType, + TransportSubmodeType, +} from '@atb/components/transport-mode/types'; +import { Typo } from '@atb/components/typography'; +import style from './line-chip.module.css'; + +export type LineChipProps = { + transportMode: TransportModeType; + transportSubmode?: TransportSubmodeType; + publicCode: string; +}; + +export default function LineChip({ + transportMode, + transportSubmode, + publicCode, +}: LineChipProps) { + const transportationColor = useTransportationThemeColor({ + mode: transportMode, + subMode: transportSubmode, + }); + + return ( +
+ + + {publicCode} + +
+ ); +} diff --git a/src/components/line-chip/line-chip.module.css b/src/components/line-chip/line-chip.module.css new file mode 100644 index 00000000..ab8ac258 --- /dev/null +++ b/src/components/line-chip/line-chip.module.css @@ -0,0 +1,14 @@ +.container { + min-width: 4.125rem; + + display: flex; + align-items: center; + justify-content: space-between; + + gap: var(--spacings-xSmall); + padding: var(--spacings-small); + border-radius: var(--spacings-small); +} +.publicCode { + text-align: right; +} diff --git a/src/modules/time-representation/index.ts b/src/modules/time-representation/index.ts new file mode 100644 index 00000000..85247fc7 --- /dev/null +++ b/src/modules/time-representation/index.ts @@ -0,0 +1,31 @@ +import { secondsBetween } from '@atb/utils/date'; + +const DEFAULT_THRESHOLD_AIMED_EXPECTED_IN_SECONDS = 60; + +type TimeValues = { + aimedTime: string; + expectedTime?: string; + missingRealTime?: boolean; +}; + +type TimeRepresentationType = + | 'no-realtime' + | 'no-significant-difference' + | 'significant-difference'; + +export function getTimeRepresentationType({ + missingRealTime, + aimedTime, + expectedTime, +}: TimeValues): TimeRepresentationType { + if (missingRealTime) { + return 'no-realtime'; + } + if (!expectedTime) { + return 'no-significant-difference'; + } + const secondsDifference = Math.abs(secondsBetween(aimedTime, expectedTime)); + return secondsDifference <= DEFAULT_THRESHOLD_AIMED_EXPECTED_IN_SECONDS + ? 'no-significant-difference' + : 'significant-difference'; +} diff --git a/src/page-modules/assistant/trip/trip-pattern/utils.ts b/src/page-modules/assistant/trip/trip-pattern/utils.ts index 319ccea3..a4157727 100644 --- a/src/page-modules/assistant/trip/trip-pattern/utils.ts +++ b/src/page-modules/assistant/trip/trip-pattern/utils.ts @@ -9,6 +9,7 @@ import { Language, TranslateFunction, PageText } from '@atb/translations'; import dictionary from '@atb/translations/dictionary'; import { screenReaderPause } from '@atb/components/typography/utils'; import { transportModeToTranslatedString } from '@atb/components/transport-mode'; +import { getTimeRepresentationType } from '@atb/modules/time-representation'; export const tripSummary = ( tripPattern: TripPattern, @@ -231,31 +232,3 @@ export function getFilteredLegsByWalkOrWaitTime(tripPattern: TripPattern) { function isLegFlexibleTransport(leg: Leg): boolean { return !!leg.line?.flexibleLineType; } - -const DEFAULT_THRESHOLD_AIMED_EXPECTED_IN_MINUTES = 1; - -type TimeValues = { - aimedTime: string; - expectedTime?: string; - missingRealTime?: boolean; -}; -type TimeRepresentationType = - | 'no-realtime' - | 'no-significant-difference' - | 'significant-difference'; -function getTimeRepresentationType({ - missingRealTime, - aimedTime, - expectedTime, -}: TimeValues): TimeRepresentationType { - if (missingRealTime) { - return 'no-realtime'; - } - if (!expectedTime) { - return 'no-significant-difference'; - } - const secondsDifference = Math.abs(secondsBetween(aimedTime, expectedTime)); - return secondsDifference <= DEFAULT_THRESHOLD_AIMED_EXPECTED_IN_MINUTES * 60 - ? 'no-significant-difference' - : 'significant-difference'; -} diff --git a/src/page-modules/departures/__tests__/departure-details.test.tsx b/src/page-modules/departures/__tests__/departure-details.test.tsx new file mode 100644 index 00000000..9d7e4b6b --- /dev/null +++ b/src/page-modules/departures/__tests__/departure-details.test.tsx @@ -0,0 +1,106 @@ +import { serviceJourneyFixture } from './service-journey-data.fixture'; +import { cleanup, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import { DeparturesDetails } from '../details'; + +afterEach(function () { + cleanup(); +}); + +const serviceJourneyId = 'ATB:ServiceJourney:22_230306097862461_113'; +const date = '2023-11-10'; +const fromQuayId = 'NSR:Quay:74990'; + +describe('departure details page', function () { + it('should render correct header', () => { + const output = render( + , + ); + expect( + output.getByText( + `${serviceJourneyFixture.line.publicCode} ${serviceJourneyFixture.estimatedCalls[0].destinationDisplay.frontText}`, + ), + ).toBeInTheDocument(); + }); + + it('should not render passed quays', () => { + const output = render( + , + ); + + expect( + output.queryByText( + `${serviceJourneyFixture.estimatedCalls[0].quay.name}`, + ), + ).toBeInTheDocument(); + + expect( + output.queryByText( + `${serviceJourneyFixture.estimatedCalls[2].quay.name}`, + ), + ).not.toBeInTheDocument(); + + const fromQuayName = + serviceJourneyFixture.estimatedCalls + .map((call) => call.quay) + .find((quay) => quay.id === fromQuayId)?.name || ''; + expect(output.queryByText(fromQuayName)).toBeInTheDocument(); + }); + + it('should render passed departures when collapse button is clicked', async () => { + const output = render( + , + ); + const button = screen.getByRole('button', { + name: /mellomstopp/i, + }); + + await userEvent.click(button); + + expect( + output.queryByText( + `${serviceJourneyFixture.estimatedCalls[2].quay.name}`, + ), + ).toBeInTheDocument(); + + await userEvent.click(button); + + expect( + output.queryByText( + `${serviceJourneyFixture.estimatedCalls[2].quay.name}`, + ), + ).not.toBeInTheDocument(); + }); + + it('should render all departures', async () => { + const fromQuayIndex = serviceJourneyFixture.estimatedCalls.findIndex( + (call) => call.quay.id === fromQuayId, + ); + const expectedDepartures = serviceJourneyFixture.estimatedCalls.slice( + fromQuayIndex, + serviceJourneyFixture.estimatedCalls.length, + ); + const output = render( + , + ); + + for (let i = 0; i < expectedDepartures.length; i++) { + expect( + output.getByText(`${expectedDepartures[i].quay.name}`), + ).toBeInTheDocument(); + } + }); +}); diff --git a/src/page-modules/departures/__tests__/departure.test.tsx b/src/page-modules/departures/__tests__/departure.test.tsx index 3b5ba322..b78c2075 100644 --- a/src/page-modules/departures/__tests__/departure.test.tsx +++ b/src/page-modules/departures/__tests__/departure.test.tsx @@ -63,6 +63,9 @@ describe('departure page', function () { estimatedCalls() { return {} as any; }, + serviceJourney() { + return {} as any; + }, client: null as any, }; diff --git a/src/page-modules/departures/__tests__/service-journey-data.fixture.ts b/src/page-modules/departures/__tests__/service-journey-data.fixture.ts new file mode 100644 index 00000000..91f59295 --- /dev/null +++ b/src/page-modules/departures/__tests__/service-journey-data.fixture.ts @@ -0,0 +1,234 @@ +import { ServiceJourneyData } from '../server/journey-planner/validators'; +import { + TransportModeType, + TransportSubmodeType, +} from '@atb/components/transport-mode/types'; + +export const serviceJourneyFixture: ServiceJourneyData = { + id: 'ATB:ServiceJourney:22_230306097862461_113', + transportMode: 'bus' as TransportModeType, + transportSubmode: 'localBus' as TransportSubmodeType, + line: { publicCode: '22' }, + estimatedCalls: [ + { + actualArrivalTime: '2023-11-10T14:59:24+01:00', + actualDepartureTime: '2023-11-10T14:59:24+01:00', + aimedArrivalTime: '2023-11-10T14:59:00+01:00', + aimedDepartureTime: '2023-11-10T14:59:00+01:00', + cancellation: false, + date: '2023-11-10', + destinationDisplay: { + frontText: 'Vestlia via sentrum-Othilienborg', + }, + expectedDepartureTime: '2023-11-10T14:59:24+01:00', + expectedArrivalTime: '2023-11-10T14:59:24+01:00', + forAlighting: false, + forBoarding: true, + realtime: true, + quay: { + publicCode: '', + name: 'Strinda vgs.', + id: 'NSR:Quay:73030', + stopPlace: { id: 'NSR:StopPlace:42623' }, + }, + }, + { + actualArrivalTime: '2023-11-10T15:01:22+01:00', + actualDepartureTime: '2023-11-10T15:01:40+01:00', + aimedArrivalTime: '2023-11-10T15:00:00+01:00', + aimedDepartureTime: '2023-11-10T15:00:00+01:00', + cancellation: false, + date: '2023-11-10', + destinationDisplay: { + frontText: 'Vestlia via sentrum-Othilienborg', + }, + expectedDepartureTime: '2023-11-10T15:01:40+01:00', + expectedArrivalTime: '2023-11-10T15:01:22+01:00', + forAlighting: true, + forBoarding: true, + realtime: true, + quay: { + publicCode: '', + name: 'Magnus Berrføtts veg', + id: 'NSR:Quay:72609', + stopPlace: { id: 'NSR:StopPlace:42400' }, + }, + }, + { + actualArrivalTime: '2023-11-10T15:02:11+01:00', + actualDepartureTime: '2023-11-10T15:02:50+01:00', + aimedArrivalTime: '2023-11-10T15:01:00+01:00', + aimedDepartureTime: '2023-11-10T15:01:00+01:00', + cancellation: false, + date: '2023-11-10', + destinationDisplay: { + frontText: 'Vestlia via sentrum-Othilienborg', + }, + expectedDepartureTime: '2023-11-10T15:02:50+01:00', + expectedArrivalTime: '2023-11-10T15:02:11+01:00', + forAlighting: true, + forBoarding: true, + realtime: true, + quay: { + publicCode: '', + name: 'Valentinlyst', + id: 'NSR:Quay:71898', + stopPlace: { id: 'NSR:StopPlace:42004' }, + }, + }, + { + actualArrivalTime: '2023-11-10T15:03:04+01:00', + actualDepartureTime: '2023-11-10T15:03:43+01:00', + aimedArrivalTime: '2023-11-10T15:02:00+01:00', + aimedDepartureTime: '2023-11-10T15:02:00+01:00', + cancellation: false, + date: '2023-11-10', + destinationDisplay: { + frontText: 'Vestlia via sentrum-Othilienborg', + }, + expectedDepartureTime: '2023-11-10T15:03:43+01:00', + expectedArrivalTime: '2023-11-10T15:03:04+01:00', + forAlighting: true, + forBoarding: true, + realtime: true, + quay: { + publicCode: '', + name: 'Tyholtveien', + id: 'NSR:Quay:71678', + stopPlace: { id: 'NSR:StopPlace:41882' }, + }, + }, + { + actualArrivalTime: '2023-11-10T15:03:54+01:00', + actualDepartureTime: '2023-11-10T15:04:09+01:00', + aimedArrivalTime: '2023-11-10T15:03:00+01:00', + aimedDepartureTime: '2023-11-10T15:03:00+01:00', + cancellation: false, + date: '2023-11-10', + destinationDisplay: { + frontText: 'Vestlia via sentrum-Othilienborg', + }, + expectedDepartureTime: '2023-11-10T15:04:09+01:00', + expectedArrivalTime: '2023-11-10T15:03:54+01:00', + forAlighting: true, + forBoarding: true, + realtime: true, + quay: { + publicCode: '', + name: 'Cecilie Thoresens veg', + id: 'NSR:Quay:75585', + stopPlace: { id: 'NSR:StopPlace:44015' }, + }, + }, + { + actualArrivalTime: '2023-11-10T15:04:42+01:00', + actualDepartureTime: '2023-11-10T15:05:07+01:00', + aimedArrivalTime: '2023-11-10T15:04:00+01:00', + aimedDepartureTime: '2023-11-10T15:04:00+01:00', + cancellation: false, + date: '2023-11-10', + destinationDisplay: { + frontText: 'Vestlia via sentrum-Othilienborg', + }, + expectedDepartureTime: '2023-11-10T15:05:07+01:00', + expectedArrivalTime: '2023-11-10T15:04:42+01:00', + forAlighting: true, + forBoarding: true, + realtime: true, + quay: { + publicCode: '', + name: 'Tyholt', + id: 'NSR:Quay:71659', + stopPlace: { id: 'NSR:StopPlace:41875' }, + }, + }, + { + actualArrivalTime: '2023-11-10T15:05:19+01:00', + actualDepartureTime: '2023-11-10T15:05:56+01:00', + aimedArrivalTime: '2023-11-10T15:05:00+01:00', + aimedDepartureTime: '2023-11-10T15:05:00+01:00', + cancellation: false, + date: '2023-11-10', + destinationDisplay: { + frontText: 'Vestlia via sentrum-Othilienborg', + }, + expectedDepartureTime: '2023-11-10T15:05:56+01:00', + expectedArrivalTime: '2023-11-10T15:05:19+01:00', + forAlighting: true, + forBoarding: true, + realtime: true, + quay: { + publicCode: '', + name: 'Clara Holsts veg', + id: 'NSR:Quay:75616', + stopPlace: { id: 'NSR:StopPlace:44034' }, + }, + }, + { + actualArrivalTime: '2023-11-10T15:06:34+01:00', + actualDepartureTime: '2023-11-10T15:07:14+01:00', + aimedArrivalTime: '2023-11-10T15:06:00+01:00', + aimedDepartureTime: '2023-11-10T15:06:00+01:00', + cancellation: false, + date: '2023-11-10', + destinationDisplay: { + frontText: 'Vestlia via sentrum-Othilienborg', + }, + expectedDepartureTime: '2023-11-10T15:07:14+01:00', + expectedArrivalTime: '2023-11-10T15:06:34+01:00', + forAlighting: true, + forBoarding: true, + realtime: true, + quay: { + publicCode: '', + name: 'Persaunet leir', + id: 'NSR:Quay:74990', + stopPlace: { id: 'NSR:StopPlace:43687' }, + }, + }, + { + actualArrivalTime: '2023-11-10T15:07:52+01:00', + actualDepartureTime: '2023-11-10T15:08:22+01:00', + aimedArrivalTime: '2023-11-10T15:07:00+01:00', + aimedDepartureTime: '2023-11-10T15:07:00+01:00', + cancellation: false, + date: '2023-11-10', + destinationDisplay: { + frontText: 'Vestlia via sentrum-Othilienborg', + }, + expectedDepartureTime: '2023-11-10T15:08:22+01:00', + expectedArrivalTime: '2023-11-10T15:07:52+01:00', + forAlighting: true, + forBoarding: true, + realtime: true, + quay: { + publicCode: '', + name: 'Brian Smiths gate', + id: 'NSR:Quay:75371', + stopPlace: { id: 'NSR:StopPlace:43896' }, + }, + }, + { + actualArrivalTime: '2023-11-10T15:09:56+01:00', + actualDepartureTime: '2023-11-10T15:10:33+01:00', + aimedArrivalTime: '2023-11-10T15:09:00+01:00', + aimedDepartureTime: '2023-11-10T15:09:00+01:00', + cancellation: false, + date: '2023-11-10', + destinationDisplay: { + frontText: 'Vestlia via sentrum-Othilienborg', + }, + expectedDepartureTime: '2023-11-10T15:10:33+01:00', + expectedArrivalTime: '2023-11-10T15:09:56+01:00', + forAlighting: true, + forBoarding: true, + realtime: true, + quay: { + publicCode: '', + name: 'Dalen Hageby', + id: 'NSR:Quay:74497', + stopPlace: { id: 'NSR:StopPlace:43418' }, + }, + }, + ], +}; diff --git a/src/page-modules/departures/details/decoration-line/decoration-line.module.css b/src/page-modules/departures/details/decoration-line/decoration-line.module.css new file mode 100644 index 00000000..6d720cd6 --- /dev/null +++ b/src/page-modules/departures/details/decoration-line/decoration-line.module.css @@ -0,0 +1,36 @@ +.decoration { + position: absolute; + height: 100%; + width: var(--decorationLineWidth); + display: flex; + flex: 1; + flex-direction: column; + justify-content: center; + align-items: flex-start; + left: calc(var(--spacings-large) / 2 + var(--labelWidth)); + transform: translateX(-50%); +} + +.decorationMarker { + width: var(--decorationLineEndWidth); + height: var(--decorationLineWidth); + left: -0.25rem; +} + +.decorationMarker__start { + position: absolute; + top: 0; +} +.decorationMarker__center { + position: absolute; + top: calc(var(--spacings-large) + var(--spacings-small)); +} +.decorationMarker__end { + position: absolute; + bottom: 0; +} + +.decorationLine { + height: 1.25rem; + flex-grow: 1; +} diff --git a/src/page-modules/departures/details/decoration-line/index.tsx b/src/page-modules/departures/details/decoration-line/index.tsx new file mode 100644 index 00000000..524f35dd --- /dev/null +++ b/src/page-modules/departures/details/decoration-line/index.tsx @@ -0,0 +1,43 @@ +import { and } from '@atb/utils/css'; +import style from './decoration-line.module.css'; + +type DecorationLineProps = { + hasStart?: boolean; + hasCenter?: boolean; + hasEnd?: boolean; + color: string; +}; + +export default function DecorationLine({ + color, + hasStart, + hasCenter, + hasEnd, +}: DecorationLineProps) { + const colorStyle = { backgroundColor: color }; + return ( +
+ {hasStart && ( +
+ )} + {hasCenter && ( +
+ )} + {hasEnd && ( +
+ )} +
+ ); +} diff --git a/src/page-modules/departures/details/departure-time/departure-time.module.css b/src/page-modules/departures/details/departure-time/departure-time.module.css new file mode 100644 index 00000000..1e9a620c --- /dev/null +++ b/src/page-modules/departures/details/departure-time/departure-time.module.css @@ -0,0 +1,13 @@ +.expectedContainer { + display: flex; + align-items: center; +} +.significantDifferenceContainer { + display: flex; + flex-direction: column; + align-items: flex-end; +} +.significantDifference { + display: flex; + align-items: center; +} diff --git a/src/page-modules/departures/details/departure-time/index.tsx b/src/page-modules/departures/details/departure-time/index.tsx new file mode 100644 index 00000000..f6498243 --- /dev/null +++ b/src/page-modules/departures/details/departure-time/index.tsx @@ -0,0 +1,71 @@ +import { PageText, useTranslation } from '@atb/translations'; +import { formatToClock } from '@atb/utils/date'; +import { Typo } from '@atb/components/typography'; +import { getTimeRepresentationType } from '@atb/modules/time-representation'; +import style from './departure-time.module.css'; + +type DepartureTimeProps = { + aimedDepartureTime: string; + expectedDepartureTime: string; + realtime: boolean; + isStartOfServiceJourney: boolean; +}; + +export default function DepartureTime({ + aimedDepartureTime, + expectedDepartureTime, + realtime, + isStartOfServiceJourney, +}: DepartureTimeProps) { + const { t, language } = useTranslation(); + + const representationType = getTimeRepresentationType({ + aimedTime: aimedDepartureTime, + expectedTime: expectedDepartureTime, + missingRealTime: !realtime && isStartOfServiceJourney, + }); + const scheduled = formatToClock(aimedDepartureTime, language, 'floor'); + + const expected = expectedDepartureTime + ? formatToClock(expectedDepartureTime, language, 'floor') + : ''; + + switch (representationType) { + case 'significant-difference': { + return ( +
+
+ + {expected} + +
+ + {scheduled} + +
+ ); + } + case 'no-realtime': { + return {scheduled}; + } + default: { + return ( +
+ {expected} +
+ ); + } + } +} diff --git a/src/page-modules/departures/details/details.module.css b/src/page-modules/departures/details/details.module.css new file mode 100644 index 00000000..0b75b057 --- /dev/null +++ b/src/page-modules/departures/details/details.module.css @@ -0,0 +1,105 @@ +.container { + margin: 0 auto; + max-width: var(--maxPageWidth); + padding: var(--spacings-xLarge); + + display: grid; + grid-template-columns: 2fr 3fr; + grid-template-areas: + 'header header' + 'serviceJourney map'; + gap: var(--spacings-small); +} +@media (max-width: 650px) { + .container { + grid-template-columns: 1fr; + grid-template-areas: + 'header' + 'serviceJourney' + 'map'; + } +} +.headerContainer { + grid-area: header; + display: flex; + flex-direction: column; + gap: var(--spacings-large); +} +.mapContainer { + grid-area: map; + max-height: 37.5rem; + width: 100%; + margin: 0 auto; +} +.serviceJourneyContainer { + grid-area: serviceJourney; +} + +.header { + display: flex; + align-items: center; + gap: var(--spacings-large); +} + +.realtimeText { + display: flex; + align-items: center; + gap: var(--spacings-small); + font-size: 0.875rem; + color: var(--text-colors-secondary); +} + +.callRows { + margin-top: var(--spacings-large); +} +.callRows__container { + padding: var(--spacings-medium); +} +.callRows__container:nth-of-type(odd) { + padding-bottom: var(--spacings-large); +} +.callRows__container:nth-of-type(even) { + padding-top: 0; +} +.rowContainer { + position: relative; + overflow: hidden; + --labelWidth: 5rem; + --decorationContainerWidth: 1.25rem; + --decorationLineWidth: 0.25rem; + --decorationLineEndWidth: 0.75rem; +} +.boardingInfo { + color: var(--text-colors-secondary); +} +.collapseButton { + padding: var(--spacings-xSmall); + margin-bottom: var(--spacings-medium); + margin-left: calc( + var(--labelWidth) + var(--decorationContainerWidth) - var(--spacings-xSmall) + ); + color: var(--text-colors-secondary); +} +.row { + display: flex; + flex: 1; + flex-direction: row; + justify-content: center; + padding: var(--spacings-small) 0; + text-decoration: none; + color: inherit; +} +.middleRow { + min-height: 3.75rem; +} +.leftColumn { + display: flex; + justify-content: flex-end; + min-width: var(--labelWidth); +} +.rightColumn { + flex: 1; +} +.decorationPlaceholder { + width: var(--spacings-large); +} diff --git a/src/page-modules/departures/details/estimated-call-rows.tsx b/src/page-modules/departures/details/estimated-call-rows.tsx new file mode 100644 index 00000000..50a53115 --- /dev/null +++ b/src/page-modules/departures/details/estimated-call-rows.tsx @@ -0,0 +1,199 @@ +import { TransportModeType } from '@atb/components/transport-mode/types'; +import { EstimatedCallWithMetadata } from '../types'; +import { PageText, useTranslation } from '@atb/translations'; +import { PropsWithChildren, useState } from 'react'; +import style from './details.module.css'; +import { motion } from 'framer-motion'; +import { useTransportationThemeColor } from '@atb/components/transport-mode/transport-icon'; +import { Typo } from '@atb/components/typography'; +import { MonoIcon } from '@atb/components/icon'; +import { Button } from '@atb/components/button'; +import Link from 'next/link'; +import DepartureTime from './departure-time'; +import DecorationLine from './decoration-line'; +import { and } from '@atb/utils/css'; + +export type EstimatedCallRowsProps = { + calls: EstimatedCallWithMetadata[]; + mode: TransportModeType; +}; + +export function EstimatedCallRows({ calls, mode }: EstimatedCallRowsProps) { + const { t } = useTranslation(); + const [collapsed, setCollapsed] = useState(true); + + const passedCalls = calls.filter((c) => c.metadata.group === 'passed'); + const showCollapsable = passedCalls.length > 1; + + const estimatedCallsToShow = calls.filter( + (c) => c.metadata.group !== 'passed', + ); + + const passedCallsToShow = collapsed ? [passedCalls[0]] : passedCalls; + + const collapseButton = showCollapsable ? ( +