From 4fb2fe683b75464917dec4b1a0aaee63830f3b9a Mon Sep 17 00:00:00 2001 From: Ariel Costas Guerrero Date: Sun, 28 Dec 2025 15:59:32 +0100 Subject: 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. --- src/frontend/app/api/schema.ts | 6 +- src/frontend/app/components/PlannerOverlay.tsx | 63 +++--- src/frontend/app/components/layout/NavBar.tsx | 9 +- src/frontend/app/data/PlannerApi.ts | 7 +- src/frontend/app/data/StopDataProvider.ts | 17 +- src/frontend/app/hooks/usePlanQuery.ts | 4 +- src/frontend/app/hooks/usePlanner.ts | 178 +++++++++++++---- src/frontend/app/i18n/locales/en-GB.json | 1 + src/frontend/app/i18n/locales/es-ES.json | 1 + src/frontend/app/i18n/locales/gl-ES.json | 1 + src/frontend/app/root.css | 7 +- src/frontend/app/routes/home.tsx | 74 ++++++- src/frontend/app/routes/map.tsx | 7 +- src/frontend/app/routes/planner.tsx | 256 ++++++++++--------------- src/frontend/app/tailwind-full.css | 28 +++ 15 files changed, 414 insertions(+), 245 deletions(-) (limited to 'src/frontend/app') diff --git a/src/frontend/app/api/schema.ts b/src/frontend/app/api/schema.ts index bb2fbcc..63f4368 100644 --- a/src/frontend/app/api/schema.ts +++ b/src/frontend/app/api/schema.ts @@ -163,8 +163,10 @@ export const ItinerarySchema = z.object({ transitTimeSeconds: z.number(), waitingTimeSeconds: z.number(), legs: z.array(PlannerLegSchema), - cashFareEuro: z.number().optional().nullable(), - cardFareEuro: z.number().optional().nullable(), + cashFare: z.number().optional().nullable(), + cashFareIsTotal: z.boolean().optional().nullable(), + cardFare: z.number().optional().nullable(), + cardFareIsTotal: z.boolean().optional().nullable(), }); export const RoutePlanSchema = z.object({ diff --git a/src/frontend/app/components/PlannerOverlay.tsx b/src/frontend/app/components/PlannerOverlay.tsx index af71e48..55e52d7 100644 --- a/src/frontend/app/components/PlannerOverlay.tsx +++ b/src/frontend/app/components/PlannerOverlay.tsx @@ -29,6 +29,8 @@ interface PlannerOverlayProps { clearPickerOnOpen?: boolean; showLastDestinationWhenCollapsed?: boolean; cardBackground?: string; + userLocation?: { latitude: number; longitude: number } | null; + autoLoad?: boolean; } export const PlannerOverlay: React.FC = ({ @@ -39,10 +41,12 @@ export const PlannerOverlay: React.FC = ({ clearPickerOnOpen = false, showLastDestinationWhenCollapsed = true, cardBackground, + userLocation, + autoLoad = true, }) => { const { t } = useTranslation(); const { origin, setOrigin, destination, setDestination, loading, error } = - usePlanner(); + usePlanner({ autoLoad }); const [isExpanded, setIsExpanded] = useState(false); const [originQuery, setOriginQuery] = useState(origin?.name || ""); const [destQuery, setDestQuery] = useState(""); @@ -85,6 +89,21 @@ export const PlannerOverlay: React.FC = ({ : origin?.name || "" ); }, [origin, t]); + + useEffect(() => { + if (userLocation && !origin) { + const initial: PlannerSearchResult = { + name: t("planner.current_location"), + label: "GPS", + lat: userLocation.latitude, + lon: userLocation.longitude, + layer: "current-location", + }; + setOrigin(initial); + setOriginQuery(initial.name || ""); + } + }, [userLocation, origin, t, setOrigin]); + useEffect(() => { setDestQuery(destination?.name || ""); }, [destination]); @@ -185,14 +204,6 @@ export const PlannerOverlay: React.FC = ({ clearPickerOnOpen ? "" : field === "origin" ? originQuery : destQuery ); setPickerOpen(true); - - // When opening destination picker, auto-fill origin from current location if not set - if (field === "destination" && !origin) { - console.log( - "[PlannerOverlay] Destination picker opened with no origin, requesting geolocation" - ); - setOriginFromCurrentLocation(false); - } }; const applyPickedResult = (result: PlannerSearchResult) => { @@ -323,11 +334,11 @@ export const PlannerOverlay: React.FC = ({ const wrapperClass = inline ? "w-full" - : "pointer-events-none absolute left-0 right-0 top-0 z-20 flex justify-center"; + : "pointer-events-none absolute left-0 right-0 top-0 z-20 flex justify-center mb-3"; const cardClass = inline - ? `pointer-events-auto w-full overflow-hidden rounded-xl px-2 flex flex-col gap-3 ${cardBackground || "bg-white dark:bg-slate-900"}` - : `pointer-events-auto w-[min(640px,calc(100%-16px))] px-2 py-1 flex flex-col gap-3 m-4 overflow-hidden rounded-xl border border-slate-200/80 dark:border-slate-700/70 shadow-2xl backdrop-blur ${cardBackground || "bg-white/95 dark:bg-slate-900/90"}`; + ? `pointer-events-auto w-full overflow-hidden rounded-xl px-2 flex flex-col gap-4 ${cardBackground || "bg-white dark:bg-slate-900"} mb-3` + : `pointer-events-auto w-[min(640px,calc(100%-16px))] px-2 py-1 flex flex-col gap-4 m-4 overflow-hidden rounded-xl border border-slate-200/80 dark:border-slate-700/70 shadow-2xl backdrop-blur ${cardBackground || "bg-white/95 dark:bg-slate-900/90"} mb-3`; return (
@@ -349,10 +360,10 @@ export const PlannerOverlay: React.FC = ({ ) : ( <> -
+
+ ))} +
+
+ )} +
+ {/* Search Section */}
-

+

{t("stoplist.search_label", "Buscar paradas")}

@@ -119,6 +119,7 @@ export default function StopMap() { clearPickerOnOpen={true} showLastDestinationWhenCollapsed={false} cardBackground="bg-white/95 dark:bg-slate-900/90" + autoLoad={false} /> { 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 (
{startTime} - {endTime}
-
{durationMinutes} min
+
{formatDuration(durationMinutes, t)}
@@ -125,7 +131,7 @@ const ItinerarySummary = ({
- {legDurationMinutes} {t("estimates.minutes")} + {formatDuration(legDurationMinutes, t)}
) : ( @@ -147,7 +153,7 @@ const ItinerarySummary = ({ {t("planner.walk")}: {formatDistance(walkTotals.meters)} {walkTotals.minutes - ? ` • ${walkTotals.minutes} ${t("estimates.minutes")}` + ? ` • ${formatDuration(walkTotals.minutes, t)}` : ""} @@ -156,12 +162,14 @@ const ItinerarySummary = ({ {cashFare === "0.00" ? t("planner.free") : t("planner.fare", { amount: cashFare })} + {itinerary.cashFareIsTotal ? "" : "++"} {cardFare === "0.00" ? t("planner.free") : t("planner.fare", { amount: cardFare })} + {itinerary.cashFareIsTotal ? "" : "++"}
@@ -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} > - {/* Outer circle for origin/destination markers */} - - {/* Inner circle for origin/destination markers */} + {/* Intermediate stops (smaller white dots) - rendered first to be at the bottom */} - {/* Stop markers (board, alight, transfer) */} + {/* Outer circle for all numbered markers */} - {/* Intermediate stops (smaller white dots) */} + {/* Numbers for markers */} @@ -590,12 +530,14 @@ const ItineraryDetail = ({ - {( - (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 + )} {formatDistance(leg.distanceMeters)} @@ -654,8 +596,8 @@ const ItineraryDetail = ({ {circ.route} - - {minutes} {t("estimates.minutes")} + + {formatDuration(minutes, t)} {circ.realTime && " 🟢"} @@ -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 && ( +
+
+

{t("planner.searching")}

+
+ )} + {plan && (
diff --git a/src/frontend/app/tailwind-full.css b/src/frontend/app/tailwind-full.css index 1767d61..e7c4dd3 100644 --- a/src/frontend/app/tailwind-full.css +++ b/src/frontend/app/tailwind-full.css @@ -1,3 +1,31 @@ @import "tailwindcss"; +@theme { + --color-primary: var(--button-background-color); + --color-background: var(--background-color); + --color-text: var(--text-color); + --color-subtitle: var(--subtitle-color); + --color-border: var(--border-color); + --color-surface: var(--message-background-color); + + --font-display: var(--font-display); + --font-sans: var(--font-ui); + + /* Semantic colors for easier migration from slate/gray */ + --color-muted: var(--subtitle-color); + --color-accent: var(--button-background-color); + + /* Generated-like palette using color-mix for flexibility */ + --color-primary-50: color-mix(in oklab, var(--button-background-color) 5%, white); + --color-primary-100: color-mix(in oklab, var(--button-background-color) 10%, white); + --color-primary-200: color-mix(in oklab, var(--button-background-color) 20%, white); + --color-primary-300: color-mix(in oklab, var(--button-background-color) 40%, white); + --color-primary-400: color-mix(in oklab, var(--button-background-color) 60%, white); + --color-primary-500: var(--button-background-color); + --color-primary-600: color-mix(in oklab, var(--button-background-color) 80%, black); + --color-primary-700: color-mix(in oklab, var(--button-background-color) 60%, black); + --color-primary-800: color-mix(in oklab, var(--button-background-color) 40%, black); + --color-primary-900: color-mix(in oklab, var(--button-background-color) 20%, black); +} + @custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *)); -- cgit v1.3