From 7ae6777f29e51387d57b48a2113531448146c69f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B8ran=20Dalum?= Date: Fri, 17 Jul 2026 14:01:26 +0200 Subject: [PATCH 1/2] feat: follow bus button on trip search results --- .../assistant/details-body/index.tsx | 9 +++++ .../assistant/details/trip-section/index.tsx | 27 +++++++++++++- .../assistant/details/use-live-vehicle-ids.ts | 31 ++++++++++++++++ .../trip/trip-pattern-collapse/index.tsx | 10 ++++++ .../vehicles/get-service-journey-vehicles.ts | 35 +++++++++++++++++++ .../departures/client/vehicles/index.ts | 1 + src/page-modules/departures/server/index.ts | 12 ++++++- .../departures/server/vehicles/index.ts | 29 +++++++++++++++ src/pages/api/departures/vehicles.ts | 32 +++++++++++++++++ src/translations/pages/assistant.ts | 6 ++++ 10 files changed, 190 insertions(+), 2 deletions(-) create mode 100644 src/page-modules/assistant/details/use-live-vehicle-ids.ts create mode 100644 src/page-modules/departures/client/vehicles/get-service-journey-vehicles.ts create mode 100644 src/page-modules/departures/server/vehicles/index.ts create mode 100644 src/pages/api/departures/vehicles.ts diff --git a/src/page-modules/assistant/details-body/index.tsx b/src/page-modules/assistant/details-body/index.tsx index b1a4890cf..e8e702b8e 100644 --- a/src/page-modules/assistant/details-body/index.tsx +++ b/src/page-modules/assistant/details-body/index.tsx @@ -16,6 +16,7 @@ import { } from '@atb/page-modules/assistant'; import { isDefined } from '@atb/utils/presence'; import { withinZoneIds } from '../utils'; +import { useLiveVehicleServiceJourneyIds } from '@atb/page-modules/assistant/details/use-live-vehicle-ids'; type DetailsBodyProps = { tripPattern: ExtendedTripPatternWithDetailsType; @@ -32,6 +33,10 @@ export function AssistantDetailsBody({ tripPattern }: DetailsBodyProps) { ); }); + const liveVehicleServiceJourneyIds = useLiveVehicleServiceJourneyIds( + tripPattern.legs, + ); + return (
} + {hasLiveVehicle && departureDetailsHref && ( + + }} + /> + + )} + { + const candidateIds = legs + .filter( + (leg) => + leg.serviceJourney?.id && + getShouldShowLiveVehicle(leg.serviceJourneyEstimatedCalls), + ) + .map((leg) => leg.serviceJourney!.id); + + const vehicles = useServiceJourneyVehicles( + candidateIds, + enabled && candidateIds.length > 0, + ); + + return new Set( + vehicles.filter((v) => v.location).map((v) => v.serviceJourney.id), + ); +} diff --git a/src/page-modules/assistant/trip/trip-pattern-collapse/index.tsx b/src/page-modules/assistant/trip/trip-pattern-collapse/index.tsx index 8ef828eed..0e18fc73a 100644 --- a/src/page-modules/assistant/trip/trip-pattern-collapse/index.tsx +++ b/src/page-modules/assistant/trip/trip-pattern-collapse/index.tsx @@ -12,6 +12,7 @@ import { useTheme } from '@atb/modules/theme'; 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 { useLiveVehicleServiceJourneyIds } from '@atb/page-modules/assistant/details/use-live-vehicle-ids'; import { TripSummaryPanel } from '@atb/page-modules/assistant/trip-summary-panel'; import TravelCard from '@atb/page-modules/assistant/trip/travel-card'; import { tripSummary } from '../utils.ts'; @@ -45,6 +46,11 @@ export default function TripPatternCollapse({ const { refreshedTripPattern } = useRefreshedTripPattern(tripPattern, inView); const displayTripPattern = refreshedTripPattern ?? tripPattern; + const liveVehicleServiceJourneyIds = useLiveVehicleServiceJourneyIds( + displayTripPattern.legs, + isOpen, + ); + const filter = Array.isArray(router.query.filter) ? router.query.filter.join(',') : router.query.filter; @@ -100,6 +106,10 @@ export default function TripPatternCollapse({ isFirst={index === 0} isLast={index === displayTripPattern.legs.length - 1} leg={leg} + hasLiveVehicle={ + !!leg.serviceJourney?.id && + liveVehicleServiceJourneyIds.has(leg.serviceJourney.id) + } interchangeDetails={getInterchangeDetails( displayTripPattern.legs, leg.interchangeTo?.toServiceJourney?.id, diff --git a/src/page-modules/departures/client/vehicles/get-service-journey-vehicles.ts b/src/page-modules/departures/client/vehicles/get-service-journey-vehicles.ts new file mode 100644 index 000000000..5cdb45963 --- /dev/null +++ b/src/page-modules/departures/client/vehicles/get-service-journey-vehicles.ts @@ -0,0 +1,35 @@ +import { swrFetcher } from '@atb/modules/api-browser'; +import qs from 'query-string'; +import useSWR from 'swr'; +import type { VehicleWithPosition } from './types'; + +// Matches the mobile app's refetch interval for the trip-results vehicle +// snapshot. +const POLL_INTERVAL_MS = 20000; + +/** + * Polls the BFF for current vehicle positions for the given service journeys. + * Used to decide whether to show the "follow vehicle" button on trip results; + * the live map itself uses the WebSocket subscription. + * + * SWR keeps the last successful data on a failed revalidation (so the button + * doesn't flicker away on a transient error) and pauses polling while the tab + * is hidden. + */ +export function useServiceJourneyVehicles( + serviceJourneyIds: string[], + enabled: boolean, +): VehicleWithPosition[] { + // A `null` key disables fetching; otherwise the URL doubles as the cache key, + // so it stays stable as long as the ids do. + const key = + enabled && serviceJourneyIds.length > 0 + ? `/api/departures/vehicles?${qs.stringify({ serviceJourneyIds })}` + : null; + + const { data } = useSWR(key, swrFetcher, { + refreshInterval: POLL_INTERVAL_MS, + }); + + return data ?? []; +} diff --git a/src/page-modules/departures/client/vehicles/index.ts b/src/page-modules/departures/client/vehicles/index.ts index 345f33429..6d81e0159 100644 --- a/src/page-modules/departures/client/vehicles/index.ts +++ b/src/page-modules/departures/client/vehicles/index.ts @@ -1,2 +1,3 @@ export * from './types'; export * from './use-live-vehicle-subscription'; +export * from './get-service-journey-vehicles'; diff --git a/src/page-modules/departures/server/index.ts b/src/page-modules/departures/server/index.ts index ba8881afe..cb9ae7ca4 100644 --- a/src/page-modules/departures/server/index.ts +++ b/src/page-modules/departures/server/index.ts @@ -6,6 +6,7 @@ import { } from '@atb/modules/api-server'; import { createJourneyApi } from './journey-planner'; import { createBffGeocoderApi } from '@atb/page-modules/bff/server/geocoder'; +import { createBffVehiclesApi } from './vehicles'; const journeyClient = createExternalClient( 'graphql-journeyPlanner3', @@ -17,7 +18,16 @@ const bffGeocoderClient = createExternalClient( createBffGeocoderApi, ); -const composed = composeClientFactories(journeyClient, bffGeocoderClient); +const bffVehiclesClient = createExternalClient( + 'http-bff', + createBffVehiclesApi, +); + +const composed = composeClientFactories( + journeyClient, + bffGeocoderClient, + bffVehiclesClient, +); export const withDepartureClient = createWithExternalClientDecorator(composed); export type DepartureClient = ReturnType; diff --git a/src/page-modules/departures/server/vehicles/index.ts b/src/page-modules/departures/server/vehicles/index.ts new file mode 100644 index 000000000..abf68fffa --- /dev/null +++ b/src/page-modules/departures/server/vehicles/index.ts @@ -0,0 +1,29 @@ +import { HttpRequester } from '@atb/modules/api-server'; +import qs from 'query-string'; +import type { VehicleWithPosition } from '@atb/page-modules/departures/client/vehicles'; + +export type BffVehiclesApi = { + serviceJourneyVehicles( + serviceJourneyIds: string[], + ): Promise; +}; + +export function createBffVehiclesApi( + request: HttpRequester<'http-bff'>, +): BffVehiclesApi { + return { + async serviceJourneyVehicles(serviceJourneyIds) { + if (!serviceJourneyIds.length) return []; + + const url = '/v2/vehicles/service-journeys'; + const query = qs.stringify( + { serviceJourneyIds }, + { skipNull: true, skipEmptyString: true }, + ); + + const result = await request(`${url}?${query}`); + const data = (await result.json()) as VehicleWithPosition[] | null; + return data ?? []; + }, + }; +} diff --git a/src/pages/api/departures/vehicles.ts b/src/pages/api/departures/vehicles.ts new file mode 100644 index 000000000..18e75aa59 --- /dev/null +++ b/src/pages/api/departures/vehicles.ts @@ -0,0 +1,32 @@ +import { errorResultAsJson, tryResult } from '@atb/modules/api-server'; +import type { VehicleWithPosition } from '@atb/page-modules/departures/client'; +import { handlerWithDepartureClient } from '@atb/page-modules/departures/server'; +import { ServerText } from '@atb/translations'; +import { constants } from 'http2'; +import { z } from 'zod'; + +export default handlerWithDepartureClient({ + async GET(req, res, { client, ok }) { + const query = z + .object({ + serviceJourneyIds: z.union([z.string(), z.array(z.string())]), + }) + .safeParse(req.query); + + if (!query.success) { + return errorResultAsJson( + res, + constants.HTTP_STATUS_BAD_REQUEST, + ServerText.Endpoints.invalidMethod, + ); + } + + const serviceJourneyIds = Array.isArray(query.data.serviceJourneyIds) + ? query.data.serviceJourneyIds + : [query.data.serviceJourneyIds]; + + return tryResult(req, res, async () => { + return ok(await client.serviceJourneyVehicles(serviceJourneyIds)); + }); + }, +}); diff --git a/src/translations/pages/assistant.ts b/src/translations/pages/assistant.ts index e268d83c4..cdf04ada9 100644 --- a/src/translations/pages/assistant.ts +++ b/src/translations/pages/assistant.ts @@ -477,6 +477,12 @@ const AssistantInternal = { ), }, tripSection: { + followVehicle: (transportMode: string) => + _( + `Følg ${transportMode}`, + `Follow ${transportMode}`, + `Følg ${transportMode}`, + ), walk: { label: (duration: string) => _(`Gå i ${duration}`, `Walk for ${duration}`, `Gå i ${duration}`), From 0fbc7fba007d60abea208eba457979ef7c09962d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B8ran=20Dalum?= Date: Mon, 20 Jul 2026 08:08:04 +0200 Subject: [PATCH 2/2] import fix --- src/page-modules/assistant/details/trip-section/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/page-modules/assistant/details/trip-section/index.tsx b/src/page-modules/assistant/details/trip-section/index.tsx index 41419a6cf..d7fb0510d 100644 --- a/src/page-modules/assistant/details/trip-section/index.tsx +++ b/src/page-modules/assistant/details/trip-section/index.tsx @@ -6,13 +6,13 @@ import { import style from './trip-section.module.css'; import { TransportIconWithDuration, + transportModeToTranslatedString, useTransportationThemeColor, } from '@atb/modules/transport-mode'; import { Typo } from '@atb/components/typography'; import WalkSection from './walk-section'; import { ColorIcon, MonoIcon } from '@atb/components/icon'; import { ButtonLink } from '@atb/components/button'; -import { transportModeToTranslatedString } from '@atb/modules/transport-mode'; import { useTheme } from '@atb/modules/theme'; import { MessageBox } from '@atb/components/message-box'; import {