From bc93421e45e16171f699d9cd4e6f281370e1fe41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5kon=20Strandlie?= Date: Mon, 6 Jul 2026 14:57:37 +0200 Subject: [PATCH 01/30] feat: travel-card foundation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Groundwork for the assistant trip-pattern card redesign that the following commits build on. Squashed from the pre-merge WIP commits ce700c41 ("feat: Initial implementation of new travel card") and 7a81de4a on strandlie/travel-card-backup — see that branch for the step-by-step history. Introduces: - Extracted TripPatternHeader with its own module.css, absorbing the title, time range, duration and status label from the old inline header markup. - New status-text component + module.css for the cancelled / impossible / stale states surfaced later. - Reworked trip-pattern.module.css grid layout. - transport-mode/icon module.css + index.tsx changes underlying the travel-card-shaped transport pills. - Translation keys for the new status labels. Behavior and visual polish, refreshed-pattern rendering, hover-based price fetch, notification-badge primitives, token cleanup and overflow hiding land in the follow-up commits. --- .../transport-mode/icon/icon.module.css | 23 +- src/modules/transport-mode/icon/index.tsx | 19 +- .../assistant/trip/trip-pattern/index.tsx | 310 +++++++++--------- .../__tests__/trip-pattern-header.test.tsx | 42 +-- .../trip-pattern-header/index.tsx | 230 +++++++------ .../status-text.module.css | 17 + .../trip-pattern-header/status-text.tsx | 34 ++ .../trip-pattern-header.module.css | 36 +- .../trip/trip-pattern/trip-pattern.module.css | 133 +++----- src/translations/pages/assistant.ts | 26 ++ 10 files changed, 488 insertions(+), 382 deletions(-) create mode 100644 src/page-modules/assistant/trip/trip-pattern/trip-pattern-header/status-text.module.css create mode 100644 src/page-modules/assistant/trip/trip-pattern/trip-pattern-header/status-text.tsx diff --git a/src/modules/transport-mode/icon/icon.module.css b/src/modules/transport-mode/icon/icon.module.css index 62bc03e78..0631db87e 100644 --- a/src/modules/transport-mode/icon/icon.module.css +++ b/src/modules/transport-mode/icon/icon.module.css @@ -3,20 +3,37 @@ gap: 0.125rem; } -.transportIcon, -.transportIconWithLabel { +.transportIcon { display: inline-block; - border-radius: token('border.radius.small'); + border-radius: token('border.radius.circle'); padding: token('spacing.xSmall'); } +.transportIconWithLabelContainer { + display: flex; + align-items: center; +} + .transportIconWithLabel { display: flex; justify-content: center; align-items: center; + border-radius: token('border.radius.circle'); padding: token('spacing.small') token('spacing.medium'); } +.transportIconNotification { + margin-left: -10px; + background-color: token('color.background.neutral.0.background'); + border-radius: 50%; + padding: 1px; + line-height: 0; + display: flex; + align-items: center; + justify-content: center; + z-index: 1; +} + .transportIcon img, .transportIconWithLabel img { display: block; diff --git a/src/modules/transport-mode/icon/index.tsx b/src/modules/transport-mode/icon/index.tsx index 341bbef12..1b4367ca2 100644 --- a/src/modules/transport-mode/icon/index.tsx +++ b/src/modules/transport-mode/icon/index.tsx @@ -1,10 +1,12 @@ import MonoIcon, { MonoIconProps } from '@atb/components/icon/mono-icon'; -import { ContrastColor, TransportColors, useTheme } from '@atb/modules/theme'; +import { ColorIcon } from '@atb/components/icon'; +import { ContrastColor, StatusColorName, TransportColors, useTheme } from '@atb/modules/theme'; import { useTranslation } from '@atb/translations'; import { isSubModeBoat, transportModeToTranslatedString } from '../utils'; import { colorToOverrideMode } from '@atb/utils/color'; import { Typo } from '@atb/components/typography'; import { secondsToMinutes } from '@atb/utils/date'; +import { messageTypeToColorIcon } from '@atb/modules/situations'; import style from './icon.module.css'; import { TransportSubmode } from '@atb/modules/graphql-types/journeyplanner-types_v3.generated.ts'; @@ -61,6 +63,7 @@ export type TransportIconWithDurationProps = { label?: string; duration?: number; isFlexible?: boolean; + notificationType?: StatusColorName; }; export function TransportIconWithDuration({ @@ -69,6 +72,7 @@ export function TransportIconWithDuration({ label, duration, isFlexible, + notificationType, }: TransportIconWithDurationProps) { const { t } = useTranslation(); const { @@ -91,7 +95,7 @@ export function TransportIconWithDuration({ }; } - return ( + const pill = ( ); + + if (!notificationType) return pill; + + return ( + + {pill} + + + ); } export function useTransportationThemeColor(props: { diff --git a/src/page-modules/assistant/trip/trip-pattern/index.tsx b/src/page-modules/assistant/trip/trip-pattern/index.tsx index 3eea88445..11e9f971e 100644 --- a/src/page-modules/assistant/trip/trip-pattern/index.tsx +++ b/src/page-modules/assistant/trip/trip-pattern/index.tsx @@ -1,10 +1,10 @@ -import { useClientWidth } from '@atb/utils/use-client-width'; import { AnimatePresence, motion } from 'framer-motion'; -import { Fragment, useEffect, useId, useState } from 'react'; +import { ColorIcon } from '@atb/components/icon'; +import { Fragment, useId, useRef, useState } from 'react'; import { getFilteredLegsByWalkOrWaitTime, tripSummary } from './utils'; import { PageText, useTranslation } from '@atb/translations'; import style from './trip-pattern.module.css'; -import { formatToClock, isInPast, secondsBetween } from '@atb/utils/date'; +import { isInPast, secondsBetween } from '@atb/utils/date'; import { TripPatternHeader } from './trip-pattern-header'; import { MonoIcon } from '@atb/components/icon'; import { Typo } from '@atb/components/typography'; @@ -20,13 +20,22 @@ import { ExtendedTripPatternWithDetailsType, } from '@atb/page-modules/assistant'; import { Button, ButtonLink } from '@atb/components/button'; -import { AssistantDetailsBody } from '@atb/page-modules/assistant/details-body'; +import TripSection from '@atb/page-modules/assistant/details/trip-section'; +import { getInterchangeDetails } from '@atb/page-modules/assistant/details/trip-section/interchange-section.tsx'; +import { getLegWaitDetails } from '@atb/page-modules/assistant/details/trip-section/wait-section.tsx'; import { Price } from './price'; import { useInView } from 'react-intersection-observer'; -import { Tag } from '@atb/components/tag'; +import useResizeObserver from '@react-hook/resize-observer'; +import { + getMsgTypeForMostCriticalSituationOrNotice, + messageTypeToColorIcon, +} from '@atb/modules/situations'; +import { getNoticesForLeg } from '@atb/page-modules/assistant/utils'; +import { StatusColorName } from '@atb/modules/theme'; +import { TransportSubmode } from '@atb/modules/graphql-types/journeyplanner-types_v3.generated.ts'; -const LAST_LEG_PADDING = 20; -const DEFAULT_THRESHOLD_AIMED_EXPECTED_IN_SECONDS = 60; +const SHORT_TRANSFER_SECONDS = 180; +const MIN_SIGNIFICANT_WAIT_SECONDS = 30; const ANIMATION_DURATION = 0.2; type TripPatternProps = { @@ -36,6 +45,73 @@ type TripPatternProps = { testId?: string; }; +/** + * Count how many leg elements overflow the container's visible width. + * Each leg item is a direct child with data-leg-index attribute. + */ +function countOverflowingLegs(container: HTMLElement | null): number { + if (!container) return 0; + const containerRight = container.getBoundingClientRect().right; + const legElements = Array.from(container.querySelectorAll('[data-leg-index]')); + let hiddenCount = 0; + for (const el of legElements) { + const elRect = el.getBoundingClientRect(); + if (elRect.right > containerRight + 1) { + hiddenCount++; + } + } + return hiddenCount; +} + +function toMostCriticalNotificationStatus( + a: StatusColorName | undefined, + b: StatusColorName | undefined, +): StatusColorName | undefined { + const priority: Record = { + error: 3, + warning: 2, + info: 1, + valid: 0, + }; + if (!a) return b; + if (!b) return a; + return (priority[a] ?? 0) >= (priority[b] ?? 0) ? a : b; +} + +function getLegNotificationType( + leg: ExtendedLegType, + previousLeg: ExtendedLegType | undefined, +): StatusColorName | undefined { + const situationsMsgType = getMsgTypeForMostCriticalSituationOrNotice( + leg.situations, + getNoticesForLeg(leg), + leg.fromEstimatedCall?.cancellation, + ); + const railReplacementMsgType = + leg.transportSubmode === TransportSubmode.RailReplacementBus + ? ('warning' as const) + : undefined; + const bookingMsgType = leg.bookingArrangements ? ('warning' as const) : undefined; + const shortTransferMsgType: StatusColorName | undefined = (() => { + if (!previousLeg) return undefined; + const waitSeconds = secondsBetween( + previousLeg.expectedEndTime, + leg.expectedStartTime, + ); + return waitSeconds > MIN_SIGNIFICANT_WAIT_SECONDS && + waitSeconds <= SHORT_TRANSFER_SECONDS + ? 'info' + : undefined; + })(); + + return [ + situationsMsgType, + railReplacementMsgType, + bookingMsgType, + shortTransferMsgType, + ].reduce(toMostCriticalNotificationStatus, undefined); +} + export default function TripPattern({ tripPattern, delay, @@ -46,31 +122,16 @@ export default function TripPattern({ const filteredLegs = getFilteredLegsByWalkOrWaitTime(tripPattern); - const [numberOfExpandedLegs, setNumberOfExpandedLegs] = useState( - filteredLegs.length, - ); const [isOpen, setIsOpen] = useState(false); const router = useRouter(); const id = useId(); - const expandedLegs = filteredLegs.slice(0, numberOfExpandedLegs); - const collapsedLegs = filteredLegs.slice( - numberOfExpandedLegs, - filteredLegs.length, - ); - - const [legsParentWidth, legsParentRef] = useClientWidth(); - const [legsContentWidth, legsContentRef] = useClientWidth(); + const legsContainerRef = useRef(null); + const [overflowCount, setOverflowCount] = useState(0); - // Dynamically collapse legs to fit horizontally - useEffect(() => { - if (legsParentWidth && legsContentWidth) { - if (legsContentWidth >= legsParentWidth - LAST_LEG_PADDING) { - setNumberOfExpandedLegs((val) => Math.max(val - 1, 1)); - } - // TODO: Increase expanded legs if there is space? - } - }, [legsParentWidth, legsContentWidth]); + useResizeObserver(legsContainerRef, () => { + setOverflowCount(countOverflowingLegs(legsContainerRef.current)); + }); const tripIsInPast = isInPast(tripPattern.legs[0].expectedStartTime); @@ -78,32 +139,33 @@ export default function TripPattern({ (leg) => leg.fromEstimatedCall?.cancellation, ); - const className = andIf({ - [style.tripPattern]: true, - }); - const staySeated = (idx: number) => { - const leg = expandedLegs[idx]; + const leg = filteredLegs[idx]; return leg && leg.interchangeTo?.staySeated === true; }; - const isNotLastLeg = (i: number) => { - return i < expandedLegs.length - 1 || collapsedLegs.length > 0; - }; - const { ref, inView } = useInView({ triggerOnce: true, rootMargin: '100px', threshold: 0, }); - const requireTicketBooking = tripPattern.legs.some((leg: ExtendedLegType) => { - if (!leg.bookingArrangements) return false; - return ( - getBookingStatus(leg.bookingArrangements, leg.aimedStartTime, 7) !== - 'none' - ); - }); + const overflowNotificationType: StatusColorName | undefined = + overflowCount > 0 + ? filteredLegs + .slice(-overflowCount) + .map((leg) => + getMsgTypeForMostCriticalSituationOrNotice( + leg.situations, + getNoticesForLeg(leg), + leg.fromEstimatedCall?.cancellation, + ), + ) + .reduce( + toMostCriticalNotificationStatus, + undefined, + ) + : undefined; return (
@@ -119,13 +181,13 @@ export default function TripPattern({ } }} aria-expanded={isOpen} - className={className} + className={style.tripPattern} data-testid={testId} initial={{ opacity: 0, x: -10 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -10 }} transition={{ - delay, // staggerChildren on parent only works first render + delay, }} aria-label={`${tripSummary( tripPattern, @@ -142,131 +204,65 @@ export default function TripPattern({ />
-
-
- {expandedLegs.map((leg, i) => ( +
+
+ {filteredLegs.map((leg, i) => ( {staySeated(i - 1) ? null : ( -
- {leg.mode ? ( +
+ {leg.mode === 'foot' ? ( +
+ +
+ ) : ( - ) : ( -
- -
)} - -
- {secondsBetween( - leg.aimedStartTime, - leg.expectedStartTime, - ) > DEFAULT_THRESHOLD_AIMED_EXPECTED_IN_SECONDS ? ( - <> - - {formatToClock( - leg.expectedStartTime, - language, - 'floor', - )} - - - {formatToClock( - leg.aimedStartTime, - language, - 'floor', - )} - - - ) : ( - - {formatToClock( - leg.aimedStartTime, - language, - 'floor', - )} - - )} -
-
- )} - - {isNotLastLeg(i) && !staySeated(i) && ( -
-
-
)} ))} +
- {collapsedLegs.length > 0 && ( + {overflowCount > 0 && ( +
- +{collapsedLegs.length} + +{overflowCount}
- )} -
- -
-
-
-
- -
-
- -
- - {formatToClock(tripPattern.expectedEndTime, language, 'ceil')} - + {overflowNotificationType && ( + + )} +
+ )}