From c1ddd1b84016a3654787de77a11627807ae34a3c Mon Sep 17 00:00:00 2001 From: Ariel Costas Guerrero Date: Fri, 13 Mar 2026 14:19:43 +0100 Subject: feat: add next arrivals and intermediate stops to itinerary details in planner --- src/frontend/app/i18n/locales/en-GB.json | 6 +- src/frontend/app/i18n/locales/es-ES.json | 6 +- src/frontend/app/i18n/locales/gl-ES.json | 6 +- src/frontend/app/routes/planner.tsx | 597 ++++++++++++++++++++----------- 4 files changed, 407 insertions(+), 208 deletions(-) (limited to 'src/frontend') diff --git a/src/frontend/app/i18n/locales/en-GB.json b/src/frontend/app/i18n/locales/en-GB.json index 580aba3..25a7e7b 100644 --- a/src/frontend/app/i18n/locales/en-GB.json +++ b/src/frontend/app/i18n/locales/en-GB.json @@ -153,7 +153,11 @@ "fare": "€{{amount}}", "free": "Free", "urban_traffic_warning": "Possible transit restriction", - "urban_traffic_warning_desc": "Both stops on this leg are within {{municipality}}, which has its own urban transport. It's likely you are not allowed to do this trip with Xunta services." + "urban_traffic_warning_desc": "Both stops on this leg are within {{municipality}}, which has its own urban transport. It's likely you are not allowed to do this trip with Xunta services.", + "next_arrivals": "Next arrivals", + "next_arrival": "Next", + "intermediate_stops_one": "1 stop", + "intermediate_stops": "{{count}} stops" }, "common": { "loading": "Loading...", diff --git a/src/frontend/app/i18n/locales/es-ES.json b/src/frontend/app/i18n/locales/es-ES.json index 7863a19..a97534d 100644 --- a/src/frontend/app/i18n/locales/es-ES.json +++ b/src/frontend/app/i18n/locales/es-ES.json @@ -153,7 +153,11 @@ "fare": "{{amount}} €", "free": "Gratuito", "urban_traffic_warning": "Posible restricción de tráfico", - "urban_traffic_warning_desc": "Las dos paradas de este tramo están en {{municipality}}, que dispone de transporte urbano propio. Es probable que no puedas utilizar los servicios de la Xunta en este trayecto." + "urban_traffic_warning_desc": "Las dos paradas de este tramo están en {{municipality}}, que dispone de transporte urbano propio. Es probable que no puedas utilizar los servicios de la Xunta en este trayecto.", + "next_arrivals": "Próximas llegadas", + "next_arrival": "Próximo", + "intermediate_stops_one": "1 parada", + "intermediate_stops": "{{count}} paradas" }, "common": { "loading": "Cargando...", diff --git a/src/frontend/app/i18n/locales/gl-ES.json b/src/frontend/app/i18n/locales/gl-ES.json index 39e28de..36a1c66 100644 --- a/src/frontend/app/i18n/locales/gl-ES.json +++ b/src/frontend/app/i18n/locales/gl-ES.json @@ -153,7 +153,11 @@ "fare": "{{amount}} €", "free": "Gratuíto", "urban_traffic_warning": "Posible restrición de tráfico", - "urban_traffic_warning_desc": "As dúas paradas deste tramo están en {{municipality}}, que dispón de transporte urbano propio. É probable que non poidas utilizar os servizos da Xunta neste traxecto." + "urban_traffic_warning_desc": "As dúas paradas deste tramo están en {{municipality}}, que dispón de transporte urbano propio. É probable que non poidas utilizar os servizos da Xunta neste traxecto.", + "next_arrivals": "Próximas chegadas", + "next_arrival": "Próximo", + "intermediate_stops_one": "1 parada", + "intermediate_stops": "{{count}} paradas" }, "common": { "loading": "Cargando...", diff --git a/src/frontend/app/routes/planner.tsx b/src/frontend/app/routes/planner.tsx index 0cd5efb..eaa98ca 100644 --- a/src/frontend/app/routes/planner.tsx +++ b/src/frontend/app/routes/planner.tsx @@ -1,4 +1,12 @@ -import { AlertTriangle, Coins, CreditCard, Footprints } from "lucide-react"; +import { + AlertTriangle, + Coins, + CreditCard, + Footprints, + LayoutGrid, + List, + Map as MapIcon, +} from "lucide-react"; import maplibregl from "maplibre-gl"; import "maplibre-gl/dist/maplibre-gl.css"; import React, { useEffect, useMemo, useRef, useState } from "react"; @@ -6,7 +14,7 @@ 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 { type Arrival } from "~/api/schema"; import { PlannerOverlay } from "~/components/PlannerOverlay"; import RouteIcon from "~/components/RouteIcon"; import { AppMap } from "~/components/shared/AppMap"; @@ -167,8 +175,8 @@ const ItinerarySummary = ({ leg.routeShortName || leg.routeName || leg.mode || "" } mode="pill" - colour={leg.routeColor || undefined} - textColour={leg.routeTextColor || undefined} + colour={leg.routeColor || ""} + textColour={leg.routeTextColor || ""} /> )} @@ -216,9 +224,43 @@ const ItineraryDetail = ({ useBackButton({ onBack: onClose }); const mapRef = useRef(null); const { destination: userDestination } = usePlanner(); - const [nextArrivals, setNextArrivals] = useState< - Record - >({}); + const [nextArrivals, setNextArrivals] = useState>( + {} + ); + const [selectedLegIndex, setSelectedLegIndex] = useState(null); + const [layoutMode, setLayoutMode] = useState<"balanced" | "map" | "list">( + "balanced" + ); + + const focusLegOnMap = (leg: Itinerary["legs"][number]) => { + if (!mapRef.current) return; + + const bounds = new maplibregl.LngLatBounds(); + leg.geometry?.coordinates?.forEach((coord) => + bounds.extend([coord[0], coord[1]]) + ); + + if (leg.from?.lon && leg.from?.lat) { + bounds.extend([leg.from.lon, leg.from.lat]); + } + + if (leg.to?.lon && leg.to?.lat) { + bounds.extend([leg.to.lon, leg.to.lat]); + } + + if (!bounds.isEmpty()) { + mapRef.current.fitBounds(bounds, { padding: 90, duration: 800 }); + return; + } + + if (leg.from?.lon && leg.from?.lat) { + mapRef.current.flyTo({ + center: [leg.from.lon, leg.from.lat], + zoom: 15, + duration: 800, + }); + } + }; const routeGeoJson = { type: "FeatureCollection", @@ -291,12 +333,41 @@ const ItineraryDetail = ({ return { type: "FeatureCollection", features }; }, [itinerary]); - // Get origin and destination coordinates - const visibleLegs = itinerary.legs.filter((leg) => !shouldSkipWalkLeg(leg)); - const origin = itinerary.legs[0]?.from; const destination = itinerary.legs[itinerary.legs.length - 1]?.to; + const mapHeightClass = + layoutMode === "map" + ? "h-[78%]" + : layoutMode === "list" + ? "h-[35%]" + : "h-[50%]"; + + const detailHeightClass = + layoutMode === "map" + ? "h-[22%]" + : layoutMode === "list" + ? "h-[65%]" + : "h-[50%]"; + + const layoutOptions = [ + { + id: "map", + label: t("routes.layout_map", "Mapa"), + icon: MapIcon, + }, + { + id: "balanced", + label: t("routes.layout_balanced", "Equilibrada"), + icon: LayoutGrid, + }, + { + id: "list", + label: t("routes.layout_list", "Paradas"), + icon: List, + }, + ] as const; + useEffect(() => { if (!mapRef.current) return; @@ -336,22 +407,29 @@ const ItineraryDetail = ({ // Fetch next arrivals for bus legs useEffect(() => { const fetchArrivals = async () => { - const arrivalsByStop: Record = {}; + const arrivalsByStop: Record = {}; for (const leg of itinerary.legs) { if (leg.mode !== "WALK" && leg.from?.stopId) { - const stopKey = leg.from.name || leg.from.stopId; + const stopKey = leg.from.stopId; if (!arrivalsByStop[stopKey]) { try { //TODO: Allow multiple stops one request const resp = await fetch( - `/api/stops/arrivals?id=${encodeURIComponent(leg.from.stopId)}`, + `/api/stops/arrivals?id=${encodeURIComponent(leg.from.stopId)}&reduced=true`, { headers: { Accept: "application/json" } } ); if (resp.ok) { - arrivalsByStop[stopKey] = - (await resp.json()) satisfies ConsolidatedCirculation[]; + const payload = await resp.json(); + const normalizedArrivals: Arrival[] = Array.isArray( + payload?.arrivals + ) + ? payload.arrivals + : Array.isArray(payload) + ? payload + : []; + arrivalsByStop[stopKey] = normalizedArrivals; } } catch (err) { console.warn( @@ -372,7 +450,7 @@ const ItineraryDetail = ({ return (
{/* Map Section */} -
+
+ +
+ {layoutOptions.map((option) => { + const Icon = option.icon; + const isActive = layoutMode === option.id; + return ( + + ); + })} +
{/* Details Panel */} -
+

{t("planner.itinerary_details")}

- {visibleLegs.map((leg, idx) => ( -
-
- {leg.mode === "WALK" ? ( -
- -
- ) : ( - - )} - {idx < visibleLegs.length - 1 && ( -
- )} -
-
-
+ {itinerary.legs.map((leg, idx) => { + 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); + } + + const linesToShowLower = linesToShow + .filter(Boolean) + .map((l) => l!.trim().toLowerCase()); + const arrivalsForLeg = + leg.mode !== "WALK" && leg.from?.stopId + ? (Array.isArray(nextArrivals[leg.from.stopId]) + ? nextArrivals[leg.from.stopId] + : [] + ) + .filter( + (arrival) => + linesToShowLower.length === 0 || + linesToShowLower.includes( + arrival.route.shortName.trim().toLowerCase() + ) + ) + .map((arrival) => ({ + arrival, + minutes: arrival.estimate.minutes, + delay: arrival.delay, + })) + .slice(0, 4) + : []; + + const legDestinationLabel = (() => { + if (leg.mode !== "WALK") { + return ( + leg.to?.name || t("planner.unknown_stop", "Unknown stop") + ); + } + + const enteredDest = userDestination?.name || ""; + const finalDest = + enteredDest || + itinerary.legs[itinerary.legs.length - 1]?.to?.name || + ""; + const raw = leg.to?.name || finalDest || ""; + const cleaned = raw.trim(); + const placeholder = cleaned.toLowerCase(); + + if ( + placeholder === "destination" || + placeholder === "destino" || + placeholder === "destinación" || + placeholder === "destinatario" + ) { + return enteredDest || finalDest; + } + + return cleaned || finalDest; + })(); + + return ( +
+
{leg.mode === "WALK" ? ( - t("planner.walk") - ) : ( -
- - {t("planner.direction")} - - - {leg.headsign || - leg.routeLongName || - leg.routeName || - ""} - +
+
+ ) : ( + )} -
-
- - {new Date(leg.startTime).toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - timeZone: "Europe/Madrid", - })}{" "} - - - - {formatDuration( - Math.round( - (new Date(leg.endTime).getTime() - - new Date(leg.startTime).getTime()) / - 60000 - ), - t - )} - - - {formatDistance(leg.distanceMeters)} - {leg.agencyName && ( - <> - - {leg.agencyName} - + {idx < itinerary.legs.length - 1 && ( +
)}
- {leg.mode !== "WALK" && - leg.from?.stopId && - nextArrivals[leg.from.name || leg.from.stopId] && ( -
-
- {t("planner.next_arrivals", "Next arrivals")}: +
-
- ))} + ); + })}
-- cgit v1.3