aboutsummaryrefslogtreecommitdiff
path: root/src/frontend/app/routes/planner.tsx
diff options
context:
space:
mode:
authorAriel Costas Guerrero <ariel@costas.dev>2026-03-13 16:49:10 +0100
committerAriel Costas Guerrero <ariel@costas.dev>2026-03-13 16:49:30 +0100
commitee69c62adc5943a1dbd154df5142c0e726bdd317 (patch)
tree5874249173aa249d4d497733ef9fc410e64ab664 /src/frontend/app/routes/planner.tsx
parent90ad5395f6310da86fee9a29503e58ea74f3078b (diff)
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
Diffstat (limited to 'src/frontend/app/routes/planner.tsx')
-rw-r--r--src/frontend/app/routes/planner.tsx122
1 files changed, 54 insertions, 68 deletions
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<MapRef>(null);
const { destination: userDestination } = usePlanner();
const [nextArrivals, setNextArrivals] = useState<
- Record<string, ConsolidatedCirculation[]>
+ Record<string, StopEstimatesResponse>
>({});
const routeGeoJson = {
@@ -324,27 +325,27 @@ const ItineraryDetail = ({
// Fetch next arrivals for bus legs
useEffect(() => {
- const fetchArrivals = async () => {
- const arrivalsByStop: Record<string, ConsolidatedCirculation[]> = {};
+ const fetchArrivalsForLegs = async () => {
+ const arrivalsByLeg: Record<string, StopEstimatesResponse> = {};
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 = ({
</div>
{leg.mode !== "WALK" &&
leg.from?.stopId &&
- nextArrivals[leg.from.name || leg.from.stopId] && (
+ leg.to?.stopId &&
+ nextArrivals[`${leg.from.stopId}::${leg.to.stopId}`] && (
<div className="mt-2 text-xs text-gray-600 dark:text-gray-400">
<div className="font-semibold mb-1">
{t("planner.next_arrivals", "Next arrivals")}:
</div>
- {(() => {
- 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 (
- <div
- key={idx}
- className="flex items-center gap-2 py-0.5"
- >
- <span className="font-semibold">
- {circ.line}
- </span>
- <span className="text-gray-500 dark:text-gray-500">
- →
+ {nextArrivals[
+ `${leg.from.stopId}::${leg.to.stopId}`
+ ].arrivals
+ .slice(0, 3)
+ .map((arrival, i) => (
+ <div
+ key={`${arrival.tripId}-${i}`}
+ className="flex items-center gap-2 py-0.5"
+ >
+ <span className="font-semibold text-primary-600 dark:text-primary-400">
+ {formatDuration(arrival.estimate.minutes, t)}
+ </span>
+ {arrival.estimate.precision !== "scheduled" && (
+ <span className="text-green-600 dark:text-green-400">
+ 🟢
+ </span>
+ )}
+ {arrival.delay?.minutes !== undefined &&
+ arrival.delay.minutes !== 0 && (
+ <span
+ className={
+ arrival.delay.minutes > 0
+ ? "text-red-500"
+ : "text-green-500"
+ }
+ >
+ {arrival.delay.minutes > 0
+ ? `+${arrival.delay.minutes}′`
+ : `${arrival.delay.minutes}′`}
</span>
- <span className="flex-1 truncate">
- {circ.route}
- </span>
- <span className="font-semibold text-primary-600 dark:text-primary-400">
- {formatDuration(minutes, t)}
- {circ.realTime && " 🟢"}
- </span>
- </div>
- );
- });
- })()}
+ )}
+ </div>
+ ))}
</div>
)}
<div className="text-sm mt-1">