Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/page-modules/assistant/details-body/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -32,6 +33,10 @@ export function AssistantDetailsBody({ tripPattern }: DetailsBodyProps) {
);
});

const liveVehicleServiceJourneyIds = useLiveVehicleServiceJourneyIds(
tripPattern.legs,
);

return (
<div className={style.bodyContainer}>
<GlobalMessages
Expand Down Expand Up @@ -61,6 +66,10 @@ export function AssistantDetailsBody({ tripPattern }: DetailsBodyProps) {
isFirst={index === 0}
isLast={index === tripPattern.legs.length - 1}
leg={leg as ExtendedLegType}
hasLiveVehicle={
!!leg.serviceJourney?.id &&
liveVehicleServiceJourneyIds.has(leg.serviceJourney.id)
}
interchangeDetails={getInterchangeDetails(
tripPattern.legs,
leg.interchangeTo?.toServiceJourney?.id,
Expand Down
27 changes: 26 additions & 1 deletion src/page-modules/assistant/details/trip-section/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,14 @@ 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 } from '@atb/components/icon';
import { ColorIcon, MonoIcon } from '@atb/components/icon';
import { ButtonLink } from '@atb/components/button';
import { useTheme } from '@atb/modules/theme';
import { MessageBox } from '@atb/components/message-box';
import {
SituationMessageBox,
Expand All @@ -32,17 +35,20 @@ export type TripSectionProps = {
isFirst: boolean;
isLast: boolean;
leg: ExtendedLegType;
hasLiveVehicle?: boolean;
interchangeDetails?: InterchangeDetails;
legWaitDetails?: LegWaitDetails;
};
export default function TripSection({
isFirst,
isLast,
leg,
hasLiveVehicle,
interchangeDetails,
legWaitDetails,
}: TripSectionProps) {
const { t } = useTranslation();
const { color } = useTheme();
const isWalkSection = leg.mode === 'foot';
const isFlexible = !!leg.line?.flexibleLineType;
const legColor = useTransportationThemeColor({
Expand Down Expand Up @@ -209,6 +215,25 @@ export default function TripSection({

{realtimeText && <RealtimeSection realtimeText={realtimeText} />}

{hasLiveVehicle && departureDetailsHref && (
<TripRow>
<ButtonLink
href={departureDetailsHref}
mode="secondary"
backgroundColor={color.background.neutral[0]}
size="pill"
radiusSize="circular"
display="inline"
title={t(
PageText.Assistant.details.tripSection.followVehicle(
t(transportModeToTranslatedString(leg.mode)).toLowerCase(),
),
)}
icon={{ left: <MonoIcon icon="map/Map" /> }}
/>
</TripRow>
)}

<EstimatedCallsSection
intermediateEstimatedCalls={leg.intermediateEstimatedCalls}
duration={leg.duration}
Expand Down
31 changes: 31 additions & 0 deletions src/page-modules/assistant/details/use-live-vehicle-ids.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { getShouldShowLiveVehicle } from '@atb/page-modules/departures/details/utils';
import { useServiceJourneyVehicles } from '@atb/page-modules/departures/client/vehicles';
import { ExtendedLegType } from '@atb/page-modules/assistant';

/**
* Returns the set of service journey ids among `legs` that currently have a
* live vehicle position, used to gate the "follow vehicle" button. Only the
* legs within the live-vehicle time window are queried, and only while
* `enabled` (e.g. when the trip is expanded).
*/
export function useLiveVehicleServiceJourneyIds(
legs: ExtendedLegType[],
enabled: boolean = true,
): Set<string> {
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),
);
}
10 changes: 10 additions & 0 deletions src/page-modules/assistant/trip/trip-pattern-collapse/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<VehicleWithPosition[]>(key, swrFetcher, {
refreshInterval: POLL_INTERVAL_MS,
});

return data ?? [];
}
1 change: 1 addition & 0 deletions src/page-modules/departures/client/vehicles/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './types';
export * from './use-live-vehicle-subscription';
export * from './get-service-journey-vehicles';
12 changes: 11 additions & 1 deletion src/page-modules/departures/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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<typeof composed>;

Expand Down
29 changes: 29 additions & 0 deletions src/page-modules/departures/server/vehicles/index.ts
Original file line number Diff line number Diff line change
@@ -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<VehicleWithPosition[]>;
};

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 ?? [];
},
};
}
32 changes: 32 additions & 0 deletions src/pages/api/departures/vehicles.ts
Original file line number Diff line number Diff line change
@@ -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<VehicleWithPosition[]>({
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));
});
},
});
6 changes: 6 additions & 0 deletions src/translations/pages/assistant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`),
Expand Down
Loading