From ffb8ee87898bffe5fee706abb047133585bb5d0d Mon Sep 17 00:00:00 2001 From: Ariel Costas Guerrero Date: Fri, 12 Dec 2025 18:23:46 +0100 Subject: feat: enhance OTP service logic, improve planner overlay, and update NavBar styles --- .../Services/OtpService.cs | 13 +- src/frontend/app/components/PlannerOverlay.tsx | 120 ++++-- .../app/components/layout/NavBar.module.css | 1 - src/frontend/app/components/layout/NavBar.tsx | 19 +- src/frontend/app/root.tsx | 4 - src/frontend/app/routes/map.tsx | 30 +- src/frontend/app/routes/planner.tsx | 409 +++++++++++++++++---- 7 files changed, 449 insertions(+), 147 deletions(-) (limited to 'src') diff --git a/src/Costasdev.Busurbano.Backend/Services/OtpService.cs b/src/Costasdev.Busurbano.Backend/Services/OtpService.cs index 82c43e0..87895d3 100644 --- a/src/Costasdev.Busurbano.Backend/Services/OtpService.cs +++ b/src/Costasdev.Busurbano.Backend/Services/OtpService.cs @@ -161,13 +161,24 @@ public class OtpService int cardTicketsRequired = 0; DateTime? lastTicketPurchased = null; + int tripsPaidWithTicket = 0; foreach (var leg in busLegs) { - if (lastTicketPurchased == null || (leg.StartTime - lastTicketPurchased.Value).TotalMinutes > 45) + // If no ticket purchased, ticket expired (no free transfers after 45 mins), or max trips with ticket reached + if ( + lastTicketPurchased == null || + (leg.StartTime - lastTicketPurchased.Value).TotalMinutes > 45 || + tripsPaidWithTicket >= 3 + ) { cardTicketsRequired++; lastTicketPurchased = leg.StartTime; + tripsPaidWithTicket = 1; + } + else + { + tripsPaidWithTicket++; } } diff --git a/src/frontend/app/components/PlannerOverlay.tsx b/src/frontend/app/components/PlannerOverlay.tsx index 622884e..8046ab2 100644 --- a/src/frontend/app/components/PlannerOverlay.tsx +++ b/src/frontend/app/components/PlannerOverlay.tsx @@ -1,4 +1,10 @@ -import React, { useEffect, useMemo, useRef, useState } from "react"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { useTranslation } from "react-i18next"; import { reverseGeocode, @@ -20,6 +26,7 @@ interface PlannerOverlayProps { inline?: boolean; clearPickerOnOpen?: boolean; showLastDestinationWhenCollapsed?: boolean; + cardBackground?: string; } export const PlannerOverlay: React.FC = ({ @@ -29,6 +36,7 @@ export const PlannerOverlay: React.FC = ({ inline, clearPickerOnOpen = false, showLastDestinationWhenCollapsed = true, + cardBackground, }) => { const { t } = useTranslation(); const { origin, setOrigin, destination, setDestination, loading, error } = @@ -108,6 +116,14 @@ 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) => { @@ -121,34 +137,75 @@ export const PlannerOverlay: React.FC = ({ setPickerOpen(false); }; - const setOriginFromCurrentLocation = () => { - if (!navigator.geolocation) return; - setLocationLoading(true); - navigator.geolocation.getCurrentPosition( - async (pos) => { - try { - const rev = await reverseGeocode( + const setOriginFromCurrentLocation = useCallback( + (closePicker: boolean = true) => { + console.log( + "[PlannerOverlay] setOriginFromCurrentLocation called, closePicker:", + closePicker + ); + if (!navigator.geolocation) { + console.warn("[PlannerOverlay] Geolocation not available"); + return; + } + setLocationLoading(true); + navigator.geolocation.getCurrentPosition( + async (pos) => { + console.log( + "[PlannerOverlay] Geolocation success:", pos.coords.latitude, pos.coords.longitude ); - const picked: PlannerSearchResult = { - name: rev?.name || "Ubicación actual", - label: rev?.label || "GPS", - lat: pos.coords.latitude, - lon: pos.coords.longitude, - layer: "current-location", - }; - setOrigin(picked); - setOriginQuery(picked.name || ""); - setPickerOpen(false); - } finally { + try { + // Set immediately using raw coordinates; refine later if reverse geocode works. + const initial: PlannerSearchResult = { + name: t("planner.current_location"), + label: "GPS", + lat: pos.coords.latitude, + lon: pos.coords.longitude, + layer: "current-location", + }; + console.log("[PlannerOverlay] Setting initial origin:", initial); + setOrigin(initial); + setOriginQuery(initial.name || ""); + + try { + const rev = await reverseGeocode( + pos.coords.latitude, + pos.coords.longitude + ); + console.log("[PlannerOverlay] Reverse geocode result:", rev); + if (rev) { + const refined: PlannerSearchResult = { + ...initial, + name: rev.name || initial.name, + label: rev.label || initial.label, + layer: "current-location", + }; + console.log( + "[PlannerOverlay] Setting refined origin:", + refined + ); + setOrigin(refined); + setOriginQuery(refined.name || ""); + } + } catch (err) { + console.error("[PlannerOverlay] Reverse geocode failed:", err); + } + + if (closePicker) setPickerOpen(false); + } finally { + setLocationLoading(false); + } + }, + (err) => { + console.error("[PlannerOverlay] Geolocation error:", err); setLocationLoading(false); - } - }, - () => setLocationLoading(false), - { enableHighAccuracy: true, timeout: 10000 } - ); - }; + }, + { enableHighAccuracy: true, timeout: 10000 } + ); + }, + [setOrigin, t] + ); useEffect(() => { if (!pickerOpen) return; @@ -199,8 +256,8 @@ export const PlannerOverlay: React.FC = ({ : "pointer-events-none absolute left-0 right-0 top-0 z-20 flex justify-center"; const cardClass = inline - ? "pointer-events-auto w-full overflow-hidden rounded-xl bg-white dark:bg-slate-900 px-2 flex flex-col gap-3" - : "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 bg-white/95 dark:bg-slate-900/90 shadow-2xl backdrop-blur"; + ? `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"}`; return (
@@ -346,7 +403,12 @@ export const PlannerOverlay: React.FC = ({ time = targetDate; } - onSearch(origin, destination, time, timeMode === "arrive"); + await onSearch( + origin, + destination, + time, + timeMode === "arrive" + ); // After search, if origin was current location, switch to reverse-geocoded address if ( @@ -492,7 +554,7 @@ export const PlannerOverlay: React.FC = ({
  • {remoteLoading ? t("planner.searching_ellipsis") - : t("planner.results")} + : t("planner.results", "Results")}
  • )} {remoteResults.map((r, i) => ( diff --git a/src/frontend/app/components/layout/NavBar.module.css b/src/frontend/app/components/layout/NavBar.module.css index ddace40..19d0a93 100644 --- a/src/frontend/app/components/layout/NavBar.module.css +++ b/src/frontend/app/components/layout/NavBar.module.css @@ -5,7 +5,6 @@ padding: 0.5rem 0; background-color: var(--background-color); - border-top: 1px solid var(--border-color); max-width: 48rem; margin-inline: auto; diff --git a/src/frontend/app/components/layout/NavBar.tsx b/src/frontend/app/components/layout/NavBar.tsx index 69b3a63..9c42987 100644 --- a/src/frontend/app/components/layout/NavBar.tsx +++ b/src/frontend/app/components/layout/NavBar.tsx @@ -1,7 +1,8 @@ import { Home, Map, Navigation2, Route } from "lucide-react"; import type { LngLatLike } from "maplibre-gl"; import { useTranslation } from "react-i18next"; -import { Link, useLocation } from "react-router"; +import { Link, useLocation, useNavigate } from "react-router"; +import { usePlanner } from "~/hooks/usePlanner"; import { useApp } from "../../AppContext"; import styles from "./NavBar.module.css"; @@ -28,6 +29,8 @@ export default function NavBar({ orientation = "horizontal" }: NavBarProps) { const { t } = useTranslation(); const { mapState, updateMapState, mapPositionMode } = useApp(); const location = useLocation(); + const navigate = useNavigate(); + const { deselectItinerary } = usePlanner(); const navItems = [ { @@ -94,8 +97,18 @@ export default function NavBar({ orientation = "horizontal" }: NavBarProps) { { + if ( + item.path === "/planner" && + location.pathname === "/planner" + ) { + deselectItinerary(); + window.location.reload(); + } else if (item.callback) { + item.callback(); + } + }} title={item.name} aria-label={item.name} > diff --git a/src/frontend/app/root.tsx b/src/frontend/app/root.tsx index 9c105f0..49c9dc8 100644 --- a/src/frontend/app/root.tsx +++ b/src/frontend/app/root.tsx @@ -24,10 +24,6 @@ import "./i18n"; export const links: Route.LinksFunction = () => []; -export function HydrateFallback() { - return "Cargando..."; -} - export function Layout({ children }: { children: React.ReactNode }) { return ( diff --git a/src/frontend/app/routes/map.tsx b/src/frontend/app/routes/map.tsx index 3d59efb..39fc062 100644 --- a/src/frontend/app/routes/map.tsx +++ b/src/frontend/app/routes/map.tsx @@ -45,38 +45,11 @@ export default function StopMap() { const { mapState, updateMapState, theme } = useApp(); const mapRef = useRef(null); - const { searchRoute, origin, setOrigin } = usePlanner(); + const { searchRoute } = usePlanner(); // Style state for Map component const [mapStyle, setMapStyle] = useState(DEFAULT_STYLE); - // Set default origin to current location on first load (map page) - useEffect(() => { - // On the map page, always default to current location on load, - // overriding any previously used address. The user can change it after. - if (!navigator.geolocation) return; - navigator.geolocation.getCurrentPosition( - async (pos) => { - try { - // Keep display as "Current location" until a search is performed - setOrigin({ - name: t("planner.current_location"), - label: "GPS", - lat: pos.coords.latitude, - lon: pos.coords.longitude, - layer: "current-location", - }); - } catch (_) { - // ignore - } - }, - () => { - // ignore geolocation errors; user can set origin manually - }, - { enableHighAccuracy: true, timeout: 10000 } - ); - }, [setOrigin, t]); - // Handle click events on clusters and individual stops const onMapClick = (e: MapLayerMouseEvent) => { const features = e.features; @@ -222,6 +195,7 @@ export default function StopMap() { onNavigateToPlanner={() => navigate("/planner")} clearPickerOnOpen={true} showLastDestinationWhenCollapsed={false} + cardBackground="bg-white/95 dark:bg-slate-900/90" />
    -
    +
    {startTime} - {endTime}
    -
    {durationMinutes} min
    +
    + {durationMinutes} min +
    @@ -146,7 +174,7 @@ const ItinerarySummary = ({
    )} @@ -155,7 +183,7 @@ const ItinerarySummary = ({ })}
    -
    +
    {t("planner.walk")}: {formatDistance(walkTotals.meters)} {walkTotals.minutes @@ -163,11 +191,11 @@ const ItinerarySummary = ({ : ""} - + {formatCurrency(cashFare)} - + {t("planner.card_fare", { amount: cardFare })} @@ -187,6 +215,9 @@ const ItineraryDetail = ({ const { t } = useTranslation(); const mapRef = useRef(null); const { destination: userDestination } = usePlanner(); + const [nextArrivals, setNextArrivals] = useState< + Record + >({}); const routeGeoJson = { type: "FeatureCollection", @@ -208,18 +239,41 @@ const ItineraryDetail = ({ })), }; - // Collect unique stops with their roles (board, alight, transfer) - const stopMarkers = useMemo(() => { + // 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: "board" | "alight" | "transfer"; - } + { lat: number; lon: number; name: string; type: string } > = {}; - itinerary.legs.forEach((leg, idx) => { if (leg.mode !== "WALK") { // Boarding stop @@ -254,7 +308,30 @@ const ItineraryDetail = ({ } }); - return Object.values(stopsMap); + // 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 }, + }); + }); + + // Add intermediate stops + itinerary.legs.forEach((leg) => { + leg.intermediateStops?.forEach((stop) => { + features.push({ + type: "Feature", + geometry: { type: "Point", coordinates: [stop.lon, stop.lat] }, + properties: { + type: "intermediate", + name: stop.name || "Intermediate stop", + }, + }); + }); + }); + + return { type: "FeatureCollection", features }; }, [itinerary]); // Get origin and destination coordinates @@ -276,6 +353,17 @@ const ItineraryDetail = ({ ); }); + // Also include markers (origin, destination, transfers, intermediate) so all are visible + markersGeoJson.features.forEach((feature: any) => { + if ( + feature.geometry?.type === "Point" && + Array.isArray(feature.geometry.coordinates) + ) { + const [lng, lat] = feature.geometry.coordinates as [number, number]; + bounds.extend([lng, lat]); + } + }); + // Ensure bounds are valid before fitting if (!bounds.isEmpty()) { mapRef.current.fitBounds(bounds, { padding: 80, duration: 1000 }); @@ -284,18 +372,53 @@ const ItineraryDetail = ({ }, 100); return () => clearTimeout(timer); - }, [itinerary]); + }, [mapRef.current, itinerary]); const { theme } = useApp(); const [mapStyle, setMapStyle] = useState(DEFAULT_STYLE); useEffect(() => { const styleName = "openfreemap"; - loadStyle(styleName, theme) + loadStyle(styleName, theme, { includeTraffic: false }) .then((style) => setMapStyle(style)) .catch((error) => console.error("Failed to load map style:", error)); }, [theme]); + // Fetch next arrivals for bus legs + useEffect(() => { + const fetchArrivals = async () => { + const arrivalsByStop: 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]) { + try { + const resp = await fetch( + `${REGION_DATA.consolidatedCirculationsEndpoint}?stopId=${encodeURIComponent(leg.from.stopCode || leg.from.stopId)}`, + { headers: { Accept: "application/json" } } + ); + + if (resp.ok) { + const data: ConsolidatedCirculation[] = await resp.json(); + arrivalsByStop[stopKey] = data; + } + } catch (err) { + console.warn( + `Failed to fetch arrivals for ${leg.from.stopId}:`, + err + ); + } + } + } + } + + setNextArrivals(arrivalsByStop); + }; + + fetchArrivals(); + }, [itinerary]); + return (
    {/* Map Section */} @@ -320,7 +443,6 @@ const ItineraryDetail = ({ paint={{ "line-color": ["get", "color"], "line-width": 5, - // Dotted for walking segments, solid for bus segments "line-dasharray": [ "case", ["==", ["get", "mode"], "WALK"], @@ -331,55 +453,119 @@ const ItineraryDetail = ({ /> - {/* Origin marker (red) */} - {origin?.lat && origin?.lon && ( - -
    -
    -
    -
    - )} - - {/* Destination marker (green) */} - {destination?.lat && destination?.lon && ( - -
    -
    -
    -
    - )} - - {/* Stop markers (boarding, alighting, transfer) */} - {stopMarkers.map((stop, idx) => ( - -
    - - ))} - - {/* Intermediate stops (smaller white dots) */} - {itinerary.legs.map((leg, legIdx) => - leg.intermediateStops?.map((stop, stopIdx) => ( - -
    - - )) - )} + {/* All markers as GeoJSON layers */} + + {/* Outer circle for origin/destination markers */} + + {/* Inner circle for origin/destination markers */} + + {/* Stop markers (board, alight, transfer) */} + + {/* Intermediate stops (smaller white dots) */} + +