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 ? (
+