aboutsummaryrefslogtreecommitdiff
path: root/src/frontend/app/routes
diff options
context:
space:
mode:
authorAriel Costas Guerrero <ariel@costas.dev>2025-12-28 15:59:32 +0100
committerAriel Costas Guerrero <ariel@costas.dev>2025-12-28 15:59:50 +0100
commit4fb2fe683b75464917dec4b1a0aaee63830f3b9a (patch)
tree40b48d9717061db2bc3434b5db085eeeaae6cd76 /src/frontend/app/routes
parent1fd17d4d07d25a810816e4e38ddc31ae72b8c91a (diff)
feat: Refactor NavBar and Planner components; update geocoding services
- Removed unused Navigation2 icon from NavBar. - Updated usePlanner hook to manage route history and improve local storage handling. - Enhanced PlannerApi with new fare properties and improved itinerary handling. - Added recent routes feature in StopList with navigation to planner. - Implemented NominatimGeocodingService for autocomplete and reverse geocoding. - Updated UI components for better user experience and accessibility. - Added translations for recent routes in multiple languages. - Improved CSS styles for map controls and overall layout.
Diffstat (limited to 'src/frontend/app/routes')
-rw-r--r--src/frontend/app/routes/home.tsx74
-rw-r--r--src/frontend/app/routes/map.tsx7
-rw-r--r--src/frontend/app/routes/planner.tsx256
3 files changed, 179 insertions, 158 deletions
diff --git a/src/frontend/app/routes/home.tsx b/src/frontend/app/routes/home.tsx
index a20ba64..b20a349 100644
--- a/src/frontend/app/routes/home.tsx
+++ b/src/frontend/app/routes/home.tsx
@@ -1,7 +1,11 @@
import Fuse from "fuse.js";
+import { History } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
+import { useNavigate } from "react-router";
+import { PlannerOverlay } from "~/components/PlannerOverlay";
import { usePageTitle } from "~/contexts/PageTitleContext";
+import { usePlanner } from "~/hooks/usePlanner";
import StopGallery from "../components/StopGallery";
import StopItem from "../components/StopItem";
import StopItemSkeleton from "../components/StopItemSkeleton";
@@ -11,6 +15,8 @@ import "../tailwind-full.css";
export default function StopList() {
const { t } = useTranslation();
usePageTitle(t("navbar.stops", "Paradas"));
+ const navigate = useNavigate();
+ const { history, searchRoute, loadRoute } = usePlanner({ autoLoad: false });
const [data, setData] = useState<Stop[] | null>(null);
const [loading, setLoading] = useState(true);
const [searchResults, setSearchResults] = useState<Stop[] | null>(null);
@@ -239,9 +245,73 @@ export default function StopList() {
return (
<div className="flex flex-col gap-4 py-4 pb-8">
+ {/* Planner Section */}
+ <div className="w-full px-4">
+ <details className="group bg-surface border border-slate-200 dark:border-slate-700 shadow-sm">
+ <summary className="list-none cursor-pointer focus:outline-none">
+ <div className="flex items-center justify-between p-3 rounded-xl group-open:mb-3 transition-all">
+ <div className="flex items-center gap-3">
+ <History className="w-5 h-5 text-primary-600 dark:text-primary-400" />
+ <span className="font-semibold text-text">
+ {t("planner.where_to", "¿A dónde quieres ir?")}
+ </span>
+ </div>
+ <div className="text-muted group-open:rotate-180 transition-transform">
+ ↓
+ </div>
+ </div>
+ </summary>
+
+ <PlannerOverlay
+ inline
+ forceExpanded
+ cardBackground="bg-transparent"
+ userLocation={userLocation}
+ autoLoad={false}
+ onSearch={(origin, destination, time, arriveBy) => {
+ searchRoute(origin, destination, time, arriveBy);
+ }}
+ onNavigateToPlanner={() => navigate("/planner")}
+ />
+ </details>
+
+ {history.length > 0 && (
+ <div className="mt-3 flex flex-col gap-2">
+ <h4 className="text-xs font-bold uppercase tracking-wider text-muted px-1">
+ {t("planner.recent_routes", "Rutas recientes")}
+ </h4>
+ <div className="flex flex-col gap-1">
+ {history.map((route, idx) => (
+ <button
+ key={idx}
+ onClick={() => {
+ loadRoute(route);
+ navigate("/planner");
+ }}
+ className="flex items-center gap-3 p-3 rounded-xl bg-surface border border-border hover:bg-surface/80 transition-colors text-left"
+ >
+ <History className="w-4 h-4 text-muted shrink-0" />
+ <div className="flex flex-col min-w-0">
+ <span className="text-sm font-semibold text-text truncate">
+ {route.destination.name}
+ </span>
+ <span className="text-xs text-muted truncate">
+ {t("planner.from_to", {
+ from: route.origin.name,
+ to: route.destination.name,
+ })}
+ </span>
+ </div>
+ </button>
+ ))}
+ </div>
+ </div>
+ )}
+ </div>
+
{/* Search Section */}
<div className="w-full px-4">
- <h3 className="text-lg font-semibold mb-2 text-text">
+ <h3 className="text-xs font-bold uppercase tracking-wider text-muted mb-2 px-1">
{t("stoplist.search_label", "Buscar paradas")}
</h3>
<input
@@ -249,7 +319,7 @@ export default function StopList() {
placeholder={randomPlaceholder}
onChange={handleStopSearch}
className="
- w-full px-4 py-3 text-base
+ w-full px-4 py-2 text-sm
border border-border rounded-xl
bg-surface
text-text
diff --git a/src/frontend/app/routes/map.tsx b/src/frontend/app/routes/map.tsx
index cccdaa3..b02c494 100644
--- a/src/frontend/app/routes/map.tsx
+++ b/src/frontend/app/routes/map.tsx
@@ -38,7 +38,7 @@ export default function StopMap() {
const [isSheetOpen, setIsSheetOpen] = useState(false);
const mapRef = useRef<MapRef>(null);
- const { searchRoute } = usePlanner();
+ const { searchRoute } = usePlanner({ autoLoad: false });
// Handle click events on clusters and individual stops
const onMapClick = (e: MapLayerMouseEvent) => {
@@ -58,7 +58,7 @@ export default function StopMap() {
};
const stopLayerFilter = useMemo(() => {
- const filter: FilterSpecification = ["any"];
+ const filter: any[] = ["any"];
if (showCitybusStops) {
filter.push(["==", ["get", "transitKind"], "bus"]);
}
@@ -68,7 +68,7 @@ export default function StopMap() {
if (showTrainStops) {
filter.push(["==", ["get", "transitKind"], "train"]);
}
- return filter;
+ return filter as FilterSpecification;
}, [showCitybusStops, showIntercityBusStops, showTrainStops]);
const getLatitude = (center: any) =>
@@ -119,6 +119,7 @@ export default function StopMap() {
clearPickerOnOpen={true}
showLastDestinationWhenCollapsed={false}
cardBackground="bg-white/95 dark:bg-slate-900/90"
+ autoLoad={false}
/>
<AppMap
diff --git a/src/frontend/app/routes/planner.tsx b/src/frontend/app/routes/planner.tsx
index 5968bc2..b71d211 100644
--- a/src/frontend/app/routes/planner.tsx
+++ b/src/frontend/app/routes/planner.tsx
@@ -6,12 +6,13 @@ import { useTranslation } from "react-i18next";
import { Layer, Source, type MapRef } from "react-map-gl/maplibre";
import { useLocation } from "react-router";
-import { type ConsolidatedCirculation, type Itinerary } from "~/api/schema";
+import { type ConsolidatedCirculation } from "~/api/schema";
import LineIcon from "~/components/LineIcon";
import { PlannerOverlay } from "~/components/PlannerOverlay";
import { AppMap } from "~/components/shared/AppMap";
import { APP_CONSTANTS } from "~/config/constants";
import { usePageTitle } from "~/contexts/PageTitleContext";
+import { type Itinerary } from "~/data/PlannerApi";
import { usePlanner } from "~/hooks/usePlanner";
import "../tailwind-full.css";
@@ -21,6 +22,14 @@ const formatDistance = (meters: number) => {
return `${rounded} m`;
};
+const formatDuration = (minutes: number, t: any) => {
+ if (minutes < 60) return `${minutes} ${t("estimates.minutes")}`;
+ const h = Math.floor(minutes / 60);
+ const m = minutes % 60;
+ if (m === 0) return `${h}h`;
+ return `${h}h ${m}min`;
+};
+
const haversineMeters = (a: [number, number], b: [number, number]) => {
const toRad = (v: number) => (v * Math.PI) / 180;
const R = 6371000;
@@ -84,11 +93,8 @@ const ItinerarySummary = ({
});
const walkTotals = sumWalkMetrics(itinerary.legs);
- const busLegsCount = itinerary.legs.filter(
- (leg) => leg.mode !== "WALK"
- ).length;
- const cashFare = (itinerary.cashFareEuro ?? 0).toFixed(2);
- const cardFare = (itinerary.cardFareEuro ?? 0).toFixed(2);
+ const cashFare = (itinerary.cashFare ?? 0).toFixed(2);
+ const cardFare = (itinerary.cardFare ?? 0).toFixed(2);
return (
<div
@@ -99,7 +105,7 @@ const ItinerarySummary = ({
<div className="font-bold text-lg text-text">
{startTime} - {endTime}
</div>
- <div className="text-muted">{durationMinutes} min</div>
+ <div className="text-muted">{formatDuration(durationMinutes, t)}</div>
</div>
<div className="flex items-center gap-2 overflow-x-auto pb-2">
@@ -125,7 +131,7 @@ const ItinerarySummary = ({
<div className="flex items-center gap-2 rounded-full bg-surface px-3 py-1.5 text-sm text-text whitespace-nowrap border border-border">
<Footprints className="w-4 h-4 text-muted" />
<span className="font-semibold">
- {legDurationMinutes} {t("estimates.minutes")}
+ {formatDuration(legDurationMinutes, t)}
</span>
</div>
) : (
@@ -147,7 +153,7 @@ const ItinerarySummary = ({
<span>
{t("planner.walk")}: {formatDistance(walkTotals.meters)}
{walkTotals.minutes
- ? ` • ${walkTotals.minutes} ${t("estimates.minutes")}`
+ ? ` • ${formatDuration(walkTotals.minutes, t)}`
: ""}
</span>
<span className="flex items-center gap-3">
@@ -156,12 +162,14 @@ const ItinerarySummary = ({
{cashFare === "0.00"
? t("planner.free")
: t("planner.fare", { amount: cashFare })}
+ {itinerary.cashFareIsTotal ? "" : "++"}
</span>
<span className="flex items-center gap-1 text-muted">
<CreditCard className="w-4 h-4" />
{cardFare === "0.00"
? t("planner.free")
: t("planner.fare", { amount: cardFare })}
+ {itinerary.cashFareIsTotal ? "" : "++"}
</span>
</span>
</div>
@@ -206,83 +214,39 @@ const ItineraryDetail = ({
// Create GeoJSON for all markers
const markersGeoJson = useMemo(() => {
const features: any[] = [];
- const origin = itinerary.legs[0]?.from;
- const destination = itinerary.legs[itinerary.legs.length - 1]?.to;
-
- // Origin marker (red)
- if (origin?.lat && origin?.lon) {
- features.push({
- type: "Feature",
- geometry: { type: "Point", coordinates: [origin.lon, origin.lat] },
- properties: { type: "origin", name: origin.name || "Origin" },
- });
- }
-
- // Destination marker (green)
- if (destination?.lat && destination?.lon) {
- features.push({
- type: "Feature",
- geometry: {
- type: "Point",
- coordinates: [destination.lon, destination.lat],
- },
- properties: {
- type: "destination",
- name: destination.name || "Destination",
- },
- });
- }
- // Collect unique stops with their roles (board, alight, transfer)
- const stopsMap: Record<
- string,
- { lat: number; lon: number; name: string; type: string }
- > = {};
+ // Add points for each leg transition
itinerary.legs.forEach((leg, idx) => {
- if (leg.mode !== "WALK") {
- // Boarding stop
- if (leg.from?.lat && leg.from?.lon) {
- const key = `${leg.from.lat},${leg.from.lon}`;
- if (!stopsMap[key]) {
- const isTransfer =
- idx > 0 && itinerary.legs[idx - 1].mode !== "WALK";
- stopsMap[key] = {
- lat: leg.from.lat,
- lon: leg.from.lon,
- name: leg.from.name || "",
- type: isTransfer ? "transfer" : "board",
- };
- }
- }
- // Alighting stop
- if (leg.to?.lat && leg.to?.lon) {
- const key = `${leg.to.lat},${leg.to.lon}`;
- if (!stopsMap[key]) {
- const isTransfer =
- idx < itinerary.legs.length - 1 &&
- itinerary.legs[idx + 1].mode !== "WALK";
- stopsMap[key] = {
- lat: leg.to.lat,
- lon: leg.to.lon,
- name: leg.to.name || "",
- type: isTransfer ? "transfer" : "alight",
- };
- }
- }
+ // Add "from" point of the leg
+ if (leg.from?.lat && leg.from?.lon) {
+ features.push({
+ type: "Feature",
+ geometry: {
+ type: "Point",
+ coordinates: [leg.from.lon, leg.from.lat],
+ },
+ properties: {
+ type: idx === 0 ? "origin" : "transfer",
+ name: leg.from.name || "",
+ index: idx.toString(),
+ },
+ });
}
- });
- // Add stop markers
- Object.values(stopsMap).forEach((stop) => {
- features.push({
- type: "Feature",
- geometry: { type: "Point", coordinates: [stop.lon, stop.lat] },
- properties: { type: stop.type, name: stop.name },
- });
- });
+ // If it's the last leg, also add the "to" point
+ if (idx === itinerary.legs.length - 1 && leg.to?.lat && leg.to?.lon) {
+ features.push({
+ type: "Feature",
+ geometry: { type: "Point", coordinates: [leg.to.lon, leg.to.lat] },
+ properties: {
+ type: "destination",
+ name: leg.to.name || "",
+ index: (idx + 1).toString(),
+ },
+ });
+ }
- // Add intermediate stops
- itinerary.legs.forEach((leg) => {
+ // Add intermediate stops
leg.intermediateStops?.forEach((stop) => {
features.push({
type: "Feature",
@@ -389,7 +353,9 @@ const ItineraryDetail = ({
zoom: 13,
}}
showTraffic={false}
- attributionControl={false}
+ showGeolocate={true}
+ showNavigation={true}
+ attributionControl={true}
>
<Source id="route" type="geojson" data={routeGeoJson as any}>
<Layer
@@ -411,69 +377,36 @@ const ItineraryDetail = ({
{/* All markers as GeoJSON layers */}
<Source id="markers" type="geojson" data={markersGeoJson as any}>
- {/* Outer circle for origin/destination markers */}
+ {/* Intermediate stops (smaller white dots) - rendered first to be at the bottom */}
<Layer
- id="markers-outer"
- type="circle"
- filter={[
- "in",
- ["get", "type"],
- ["literal", ["origin", "destination"]],
- ]}
- paint={{
- "circle-radius": [
- "interpolate",
- ["linear"],
- ["zoom"],
- 10,
- 6,
- 16,
- 8,
- 20,
- 10,
- ],
- "circle-color": [
- "case",
- ["==", ["get", "type"], "origin"],
- "#dc2626",
- "#16a34a",
- ],
- "circle-stroke-width": 2,
- "circle-stroke-color": "#ffffff",
- }}
- />
- {/* Inner circle for origin/destination markers */}
- <Layer
- id="markers-inner"
+ id="markers-intermediate"
type="circle"
- filter={[
- "in",
- ["get", "type"],
- ["literal", ["origin", "destination"]],
- ]}
+ filter={["==", ["get", "type"], "intermediate"]}
paint={{
"circle-radius": [
"interpolate",
["linear"],
["zoom"],
10,
- 2,
- 16,
3,
+ 16,
+ 5,
20,
- 4,
+ 7,
],
"circle-color": "#ffffff",
+ "circle-stroke-width": 1.5,
+ "circle-stroke-color": "#6b7280",
}}
/>
- {/* Stop markers (board, alight, transfer) */}
+ {/* Outer circle for all numbered markers */}
<Layer
- id="markers-stops"
+ id="markers-outer"
type="circle"
filter={[
"in",
["get", "type"],
- ["literal", ["board", "alight", "transfer"]],
+ ["literal", ["origin", "destination", "transfer"]],
]}
paint={{
"circle-radius": [
@@ -481,44 +414,51 @@ const ItineraryDetail = ({
["linear"],
["zoom"],
10,
- 4,
+ 8,
16,
- 6,
+ 10,
20,
- 7,
+ 12,
],
"circle-color": [
"case",
- ["==", ["get", "type"], "board"],
+ ["==", ["get", "type"], "origin"],
+ "#dc2626",
+ ["==", ["get", "type"], "destination"],
+ "#16a34a",
"#3b82f6",
- ["==", ["get", "type"], "alight"],
- "#a855f7",
- "#f97316",
],
"circle-stroke-width": 2,
"circle-stroke-color": "#ffffff",
}}
/>
- {/* Intermediate stops (smaller white dots) */}
+ {/* Numbers for markers */}
<Layer
- id="markers-intermediate"
- type="circle"
- filter={["==", ["get", "type"], "intermediate"]}
- paint={{
- "circle-radius": [
+ id="markers-labels"
+ type="symbol"
+ filter={[
+ "in",
+ ["get", "type"],
+ ["literal", ["origin", "destination", "transfer"]],
+ ]}
+ layout={{
+ "text-field": ["get", "index"],
+ "text-font": ["Open Sans Bold", "Arial Unicode MS Bold"],
+ "text-size": [
"interpolate",
["linear"],
["zoom"],
10,
- 2,
+ 8,
16,
- 3,
+ 10,
20,
- 4,
+ 12,
],
- "circle-color": "#ffffff",
- "circle-stroke-width": 1,
- "circle-stroke-color": "#9ca3af",
+ "text-allow-overlap": true,
+ }}
+ paint={{
+ "text-color": "#ffffff",
}}
/>
</Source>
@@ -590,12 +530,14 @@ const ItineraryDetail = ({
</span>
<span>•</span>
<span>
- {(
- (new Date(leg.endTime).getTime() -
- new Date(leg.startTime).getTime()) /
- 60000
- ).toFixed(0)}{" "}
- {t("estimates.minutes")}
+ {formatDuration(
+ Math.round(
+ (new Date(leg.endTime).getTime() -
+ new Date(leg.startTime).getTime()) /
+ 60000
+ ),
+ t
+ )}
</span>
<span>•</span>
<span>{formatDistance(leg.distanceMeters)}</span>
@@ -654,8 +596,8 @@ const ItineraryDetail = ({
<span className="flex-1 truncate">
{circ.route}
</span>
- <span className="font-semibold text-emerald-600 dark:text-emerald-400">
- {minutes} {t("estimates.minutes")}
+ <span className="font-semibold text-primary-600 dark:text-primary-400">
+ {formatDuration(minutes, t)}
{circ.realTime && " 🟢"}
</span>
</div>
@@ -735,6 +677,7 @@ export default function PlannerPage() {
const location = useLocation();
const {
plan,
+ loading,
searchRoute,
clearRoute,
searchTime,
@@ -815,6 +758,13 @@ export default function PlannerPage() {
cardBackground="bg-transparent"
/>
+ {loading && !plan && (
+ <div className="flex flex-col items-center justify-center py-12">
+ <div className="w-8 h-8 border-4 border-primary-500 border-t-transparent rounded-full animate-spin mb-4"></div>
+ <p className="text-muted">{t("planner.searching")}</p>
+ </div>
+ )}
+
{plan && (
<div>
<div className="flex justify-between items-center my-4">