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}`),