From ee69c62adc5943a1dbd154df5142c0e726bdd317 Mon Sep 17 00:00:00 2001 From: Ariel Costas Guerrero Date: Fri, 13 Mar 2026 16:49:10 +0100 Subject: feat(routes): add realtime estimates panel with pattern-aware styling - New GET /api/stops/estimates endpoint (nano mode: tripId, patternId, estimate, delay only) - useStopEstimates hook wiring estimates to routes-$id stop panel - Pattern-aware styling: dim schedules and estimates from other patterns - Past scheduled departures shown with strikethrough instead of hidden - Persist selected pattern in URL hash (replace navigation, no history push) - Fix planner arrivals using new estimates endpoint --- src/frontend/app/api/arrivals.ts | 30 ++++ src/frontend/app/api/schema.ts | 14 +- src/frontend/app/components/stop/StopMapModal.tsx | 5 +- src/frontend/app/data/PlannerApi.ts | 2 + src/frontend/app/hooks/useArrivals.ts | 21 ++- src/frontend/app/routes/planner.tsx | 122 +++++++------- src/frontend/app/routes/routes-$id.tsx | 189 ++++++++++++---------- src/frontend/app/routes/stops-$id.tsx | 1 - 8 files changed, 219 insertions(+), 165 deletions(-) (limited to 'src/frontend') diff --git a/src/frontend/app/api/arrivals.ts b/src/frontend/app/api/arrivals.ts index 8ae6e78..ad99630 100644 --- a/src/frontend/app/api/arrivals.ts +++ b/src/frontend/app/api/arrivals.ts @@ -1,6 +1,8 @@ import { StopArrivalsResponseSchema, + StopEstimatesResponseSchema, type StopArrivalsResponse, + type StopEstimatesResponse, } from "./schema"; export const fetchArrivals = async ( @@ -29,3 +31,31 @@ export const fetchArrivals = async ( throw e; } }; + +export const fetchEstimates = async ( + stopId: string, + routeId: string, + viaStopId?: string +): Promise => { + let url = `/api/stops/estimates?stop=${encodeURIComponent(stopId)}&route=${encodeURIComponent(routeId)}`; + if (viaStopId) { + url += `&via=${encodeURIComponent(viaStopId)}`; + } + + const resp = await fetch(url, { + headers: { Accept: "application/json" }, + }); + + if (!resp.ok) { + throw new Error(`HTTP ${resp.status}: ${resp.statusText}`); + } + + const data = await resp.json(); + try { + return StopEstimatesResponseSchema.parse(data); + } catch (e) { + console.error("Zod parsing failed for estimates:", e); + console.log("Received data:", data); + throw e; + } +}; diff --git a/src/frontend/app/api/schema.ts b/src/frontend/app/api/schema.ts index 0c55969..f68d413 100644 --- a/src/frontend/app/api/schema.ts +++ b/src/frontend/app/api/schema.ts @@ -64,10 +64,16 @@ export const ArrivalSchema = z.object({ shift: ShiftBadgeSchema.optional().nullable(), shape: z.any().optional().nullable(), currentPosition: PositionSchema.optional().nullable(), - stopShapeIndex: z.number().optional().nullable(), vehicleInformation: VehicleInformationSchema.optional().nullable(), }); +export const ArrivalEstimateSchema = z.object({ + tripId: z.string(), + patternId: z.string().optional().nullable(), + estimate: ArrivalDetailsSchema, + delay: DelayBadgeSchema.optional().nullable(), +}); + export const StopArrivalsResponseSchema = z.object({ stopCode: z.string(), stopName: z.string(), @@ -77,6 +83,10 @@ export const StopArrivalsResponseSchema = z.object({ usage: z.array(BusStopUsagePointSchema).optional().nullable(), }); +export const StopEstimatesResponseSchema = z.object({ + arrivals: z.array(ArrivalEstimateSchema), +}); + export type RouteInfo = z.infer; export type HeadsignInfo = z.infer; export type ArrivalPrecision = z.infer; @@ -85,8 +95,10 @@ export type DelayBadge = z.infer; export type ShiftBadge = z.infer; export type Position = z.infer; export type Arrival = z.infer; +export type ArrivalEstimate = z.infer; export type BusStopUsagePoint = z.infer; export type StopArrivalsResponse = z.infer; +export type StopEstimatesResponse = z.infer; // Transit Routes export const RouteSchema = z.object({ diff --git a/src/frontend/app/components/stop/StopMapModal.tsx b/src/frontend/app/components/stop/StopMapModal.tsx index 30ac63f..8d3c6f8 100644 --- a/src/frontend/app/components/stop/StopMapModal.tsx +++ b/src/frontend/app/components/stop/StopMapModal.tsx @@ -15,14 +15,13 @@ import "./StopMapModal.css"; export interface Position { latitude: number; longitude: number; - orientationDegrees: number; - shapeIndex?: number; + orientationDegrees?: number | null; + shapeIndex?: number | null | undefined; } export interface ConsolidatedCirculationForMap { id: string; currentPosition?: Position; - stopShapeIndex?: number; colour: string; textColour: string; shape?: any; diff --git a/src/frontend/app/data/PlannerApi.ts b/src/frontend/app/data/PlannerApi.ts index 43c8ae1..6f39f50 100644 --- a/src/frontend/app/data/PlannerApi.ts +++ b/src/frontend/app/data/PlannerApi.ts @@ -31,6 +31,8 @@ export interface Itinerary { export interface Leg { mode?: string; feedId?: string; + routeId?: string; + tripId?: string; routeName?: string; routeShortName?: string; routeLongName?: string; diff --git a/src/frontend/app/hooks/useArrivals.ts b/src/frontend/app/hooks/useArrivals.ts index e86a0bf..530ebc4 100644 --- a/src/frontend/app/hooks/useArrivals.ts +++ b/src/frontend/app/hooks/useArrivals.ts @@ -1,5 +1,5 @@ import { useQuery } from "@tanstack/react-query"; -import { fetchArrivals } from "../api/arrivals"; +import { fetchArrivals, fetchEstimates } from "../api/arrivals"; export const useStopArrivals = ( stopId: string, @@ -10,7 +10,22 @@ export const useStopArrivals = ( queryKey: ["arrivals", stopId, reduced], queryFn: () => fetchArrivals(stopId, reduced), enabled: !!stopId && enabled, - refetchInterval: 15000, // Refresh every 15 seconds - retry: false, // Disable retries to see errors immediately + refetchInterval: 15000, + retry: false, + }); +}; + +export const useStopEstimates = ( + stopId: string, + routeId: string, + viaStopId?: string, + enabled: boolean = true +) => { + return useQuery({ + queryKey: ["estimates", stopId, routeId, viaStopId], + queryFn: () => fetchEstimates(stopId, routeId, viaStopId), + enabled: !!stopId && !!routeId && enabled, + refetchInterval: 15000, + retry: false, }); }; diff --git a/src/frontend/app/routes/planner.tsx b/src/frontend/app/routes/planner.tsx index b7ecaf9..4038ef7 100644 --- a/src/frontend/app/routes/planner.tsx +++ b/src/frontend/app/routes/planner.tsx @@ -6,7 +6,8 @@ import { useTranslation } from "react-i18next"; import { Layer, Source, type MapRef } from "react-map-gl/maplibre"; import { useLocation } from "react-router"; -import { type ConsolidatedCirculation } from "~/api/schema"; +import { fetchEstimates } from "~/api/arrivals"; +import { type StopEstimatesResponse } from "~/api/schema"; import { PlannerOverlay } from "~/components/PlannerOverlay"; import RouteIcon from "~/components/RouteIcon"; import { AppMap } from "~/components/shared/AppMap"; @@ -208,7 +209,7 @@ const ItineraryDetail = ({ const mapRef = useRef(null); const { destination: userDestination } = usePlanner(); const [nextArrivals, setNextArrivals] = useState< - Record + Record >({}); const routeGeoJson = { @@ -324,27 +325,27 @@ const ItineraryDetail = ({ // Fetch next arrivals for bus legs useEffect(() => { - const fetchArrivals = async () => { - const arrivalsByStop: Record = {}; + const fetchArrivalsForLegs = async () => { + const arrivalsByLeg: Record = {}; for (const leg of itinerary.legs) { - if (leg.mode !== "WALK" && leg.from?.stopId) { - const stopKey = leg.from.name || leg.from.stopId; - if (!arrivalsByStop[stopKey]) { + if ( + leg.mode !== "WALK" && + leg.from?.stopId && + leg.to?.stopId && + leg.routeId + ) { + const legKey = `${leg.from.stopId}::${leg.to.stopId}`; + if (!arrivalsByLeg[legKey]) { try { - //TODO: Allow multiple stops one request - const resp = await fetch( - `/api/stops/arrivals?id=${encodeURIComponent(leg.from.stopId)}`, - { headers: { Accept: "application/json" } } + arrivalsByLeg[legKey] = await fetchEstimates( + leg.from.stopId, + leg.routeId, + leg.to.stopId ); - - if (resp.ok) { - arrivalsByStop[stopKey] = - (await resp.json()) satisfies ConsolidatedCirculation[]; - } } catch (err) { console.warn( - `Failed to fetch arrivals for ${leg.from.stopId}:`, + `Failed to fetch estimates for leg ${leg.from.stopId} -> ${leg.to.stopId}:`, err ); } @@ -352,10 +353,10 @@ const ItineraryDetail = ({ } } - setNextArrivals(arrivalsByStop); + setNextArrivals(arrivalsByLeg); }; - fetchArrivals(); + fetchArrivalsForLegs(); }, [itinerary]); return ( @@ -564,60 +565,45 @@ const ItineraryDetail = ({ {leg.mode !== "WALK" && leg.from?.stopId && - nextArrivals[leg.from.name || leg.from.stopId] && ( + leg.to?.stopId && + nextArrivals[`${leg.from.stopId}::${leg.to.stopId}`] && (
{t("planner.next_arrivals", "Next arrivals")}:
- {(() => { - const currentLine = - leg.routeShortName || leg.routeName; - const previousLeg = - idx > 0 ? itinerary.legs[idx - 1] : null; - const previousLine = - previousLeg?.mode !== "WALK" - ? previousLeg?.routeShortName || - previousLeg?.routeName - : null; - - const linesToShow = [currentLine]; - if ( - previousLine && - previousLeg?.to?.stopId === leg.from?.stopId - ) { - linesToShow.push(previousLine); - } - - return nextArrivals[leg.from.stopId] - ?.filter((circ) => linesToShow.includes(circ.line)) - .slice(0, 3) - .map((circ, idx) => { - const minutes = - circ.realTime?.minutes ?? - circ.schedule?.minutes; - if (minutes === undefined) return null; - return ( -
- - {circ.line} - - - → - - - {circ.route} + {nextArrivals[ + `${leg.from.stopId}::${leg.to.stopId}` + ].arrivals + .slice(0, 3) + .map((arrival, i) => ( +
+ + {formatDuration(arrival.estimate.minutes, t)} + + {arrival.estimate.precision !== "scheduled" && ( + + 🟢 + + )} + {arrival.delay?.minutes !== undefined && + arrival.delay.minutes !== 0 && ( + 0 + ? "text-red-500" + : "text-green-500" + } + > + {arrival.delay.minutes > 0 + ? `+${arrival.delay.minutes}′` + : `${arrival.delay.minutes}′`} - - {formatDuration(minutes, t)} - {circ.realTime && " 🟢"} - -
- ); - }); - })()} + )} +
+ ))}
)}
diff --git a/src/frontend/app/routes/routes-$id.tsx b/src/frontend/app/routes/routes-$id.tsx index 2174244..bccaf56 100644 --- a/src/frontend/app/routes/routes-$id.tsx +++ b/src/frontend/app/routes/routes-$id.tsx @@ -19,7 +19,7 @@ import { Source, type MapRef, } from "react-map-gl/maplibre"; -import { Link, useParams } from "react-router"; +import { Link, useLocation, useNavigate, useParams } from "react-router"; import { fetchRouteDetails } from "~/api/transit"; import { AppMap } from "~/components/shared/AppMap"; import { @@ -28,7 +28,7 @@ import { usePageTitle, usePageTitleNode, } from "~/contexts/PageTitleContext"; -import { useStopArrivals } from "~/hooks/useArrivals"; +import { useStopEstimates } from "~/hooks/useArrivals"; import { useFavorites } from "~/hooks/useFavorites"; import { formatHex } from "~/utils/colours"; import "../tailwind-full.css"; @@ -59,9 +59,9 @@ function FavoriteStar({ id }: { id?: string }) { export default function RouteDetailsPage() { const { id } = useParams(); const { t, i18n } = useTranslation(); - const [selectedPatternId, setSelectedPatternId] = useState( - null - ); + const navigate = useNavigate(); + const location = useLocation(); + const selectedPatternId = location.hash ? location.hash.slice(1) : null; const [selectedStopId, setSelectedStopId] = useState(null); const [layoutMode, setLayoutMode] = useState<"balanced" | "map" | "list">( "balanced" @@ -103,34 +103,13 @@ export default function RouteDetailsPage() { queryFn: () => fetchRouteDetails(id!, selectedDateKey), enabled: !!id, }); - const { data: selectedStopRealtime, isLoading: isRealtimeLoading } = - useStopArrivals( + const { data: selectedStopEstimates, isLoading: isRealtimeLoading } = + useStopEstimates( selectedStopId ?? "", - true, - Boolean(selectedStopId) && isTodaySelectedDate + id ?? "", + undefined, + Boolean(selectedStopId) && Boolean(id) && isTodaySelectedDate ); - const filteredRealtimeArrivals = useMemo(() => { - const arrivals = selectedStopRealtime?.arrivals ?? []; - if (arrivals.length === 0) { - return []; - } - - const routeId = id?.trim(); - const routeShortName = route?.shortName?.trim().toLowerCase(); - - return arrivals.filter((arrival) => { - const arrivalGtfsId = arrival.route.gtfsId?.trim(); - if (routeId && arrivalGtfsId) { - return arrivalGtfsId === routeId; - } - - if (routeShortName) { - return arrival.route.shortName.trim().toLowerCase() === routeShortName; - } - - return true; - }); - }, [selectedStopRealtime?.arrivals, id, route?.shortName]); usePageTitle( route?.shortName @@ -589,7 +568,10 @@ export default function RouteDetailsPage() { key={pattern.id} type="button" onClick={() => { - setSelectedPatternId(pattern.id); + navigate( + { hash: "#" + pattern.id }, + { replace: true } + ); setSelectedStopId(null); setIsPatternPickerOpen(false); }} @@ -748,33 +730,31 @@ export default function RouteDetailsPage() { {selectedStopId === stop.id && (departuresByStop.get(stop.id)?.length ?? 0) > 0 && (
- {( - departuresByStop - .get(stop.id) - ?.filter((item) => - isTodaySelectedDate - ? item.departure >= - nowSeconds - ONE_HOUR_SECONDS - : true - ) ?? [] - ).map((item, i) => ( - - {Math.floor(item.departure / 3600) - .toString() - .padStart(2, "0")} - : - {Math.floor((item.departure % 3600) / 60) - .toString() - .padStart(2, "0")} - - ))} + {(departuresByStop.get(stop.id) ?? []).map( + (item, i) => { + const isPast = + isTodaySelectedDate && + item.departure < nowSeconds; + return ( + + {Math.floor(item.departure / 3600) + .toString() + .padStart(2, "0")} + : + {Math.floor((item.departure % 3600) / 60) + .toString() + .padStart(2, "0")} + + ); + } + )}
)} @@ -787,7 +767,8 @@ export default function RouteDetailsPage() {
{t("routes.loading_realtime", "Cargando...")}
- ) : filteredRealtimeArrivals.length === 0 ? ( + ) : (selectedStopEstimates?.arrivals.length ?? 0) === + 0 ? (
{t( "routes.realtime_no_route_estimates", @@ -796,37 +777,67 @@ export default function RouteDetailsPage() {
) : ( <> -
- - {t("routes.next_arrival", "Próximo")} - - - {filteredRealtimeArrivals[0].estimate.minutes}′ - {filteredRealtimeArrivals[0].delay?.minutes - ? formatDelayMinutes( - filteredRealtimeArrivals[0].delay.minutes - ) - : ""} - -
- - {filteredRealtimeArrivals.length > 1 && ( + {(() => { + const firstArrival = + selectedStopEstimates!.arrivals[0]; + const isFirstSelectedPattern = + firstArrival.patternId === selectedPattern?.id; + return ( +
+ + {t("routes.next_arrival", "Próximo")} + + + {firstArrival.estimate.minutes}′ + {firstArrival.delay?.minutes + ? formatDelayMinutes( + firstArrival.delay.minutes + ) + : ""} + +
+ ); + })()} + + {selectedStopEstimates!.arrivals.length > 1 && (
- {filteredRealtimeArrivals + {selectedStopEstimates!.arrivals .slice(1) - .map((arrival, i) => ( - - {arrival.estimate.minutes}′ - {arrival.delay?.minutes - ? formatDelayMinutes( - arrival.delay.minutes - ) - : ""} - - ))} + .map((arrival, i) => { + const isSelectedPattern = + arrival.patternId === selectedPattern?.id; + return ( + + {arrival.estimate.minutes}′ + {arrival.delay?.minutes + ? formatDelayMinutes( + arrival.delay.minutes + ) + : ""} + + ); + })}
)} diff --git a/src/frontend/app/routes/stops-$id.tsx b/src/frontend/app/routes/stops-$id.tsx index a61e925..4b32040 100644 --- a/src/frontend/app/routes/stops-$id.tsx +++ b/src/frontend/app/routes/stops-$id.tsx @@ -274,7 +274,6 @@ export default function Estimates() { circulations={(data?.arrivals ?? []).map((a) => ({ id: getArrivalId(a), currentPosition: a.currentPosition ?? undefined, - stopShapeIndex: a.stopShapeIndex ?? undefined, colour: formatHex(a.route.colour), textColour: formatHex(a.route.textColour), shape: a.shape, -- cgit v1.3