diff options
Diffstat (limited to 'src/frontend')
| -rw-r--r-- | src/frontend/app/api/schema.ts | 6 | ||||
| -rw-r--r-- | src/frontend/app/components/PlannerOverlay.tsx | 63 | ||||
| -rw-r--r-- | src/frontend/app/components/layout/NavBar.tsx | 9 | ||||
| -rw-r--r-- | src/frontend/app/data/PlannerApi.ts | 7 | ||||
| -rw-r--r-- | src/frontend/app/data/StopDataProvider.ts | 17 | ||||
| -rw-r--r-- | src/frontend/app/hooks/usePlanQuery.ts | 4 | ||||
| -rw-r--r-- | src/frontend/app/hooks/usePlanner.ts | 178 | ||||
| -rw-r--r-- | src/frontend/app/i18n/locales/en-GB.json | 1 | ||||
| -rw-r--r-- | src/frontend/app/i18n/locales/es-ES.json | 1 | ||||
| -rw-r--r-- | src/frontend/app/i18n/locales/gl-ES.json | 1 | ||||
| -rw-r--r-- | src/frontend/app/root.css | 7 | ||||
| -rw-r--r-- | src/frontend/app/routes/home.tsx | 74 | ||||
| -rw-r--r-- | src/frontend/app/routes/map.tsx | 7 | ||||
| -rw-r--r-- | src/frontend/app/routes/planner.tsx | 256 | ||||
| -rw-r--r-- | src/frontend/app/tailwind-full.css | 28 |
15 files changed, 414 insertions, 245 deletions
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<PlannerOverlayProps> = ({ @@ -39,10 +41,12 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({ 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<PlannerOverlayProps> = ({ : 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<PlannerOverlayProps> = ({ 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<PlannerOverlayProps> = ({ 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 ( <div className={wrapperClass}> @@ -349,10 +360,10 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({ </button> ) : ( <> - <div className="flex items-center gap-"> + <div className="flex items-center gap-2"> <button type="button" - className="w-full rounded-2xl bg-slate-100 dark:bg-slate-800 px-4 py-2.5 text-left text-sm text-slate-900 dark:text-slate-100 hover:bg-slate-200/80 dark:hover:bg-slate-700 transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-blue-500" + className="w-full rounded-lg bg-surface border border-slate-200 dark:border-slate-700 px-4 py-2.5 text-left text-sm text-slate-900 dark:text-slate-100 hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors duration-150 focus:outline-none focus:border-primary-500 shadow-sm" onClick={() => openPicker("origin")} > <span @@ -368,7 +379,7 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({ <div> <button type="button" - className="w-full rounded-2xl bg-slate-100 dark:bg-slate-800 px-4 py-2.5 text-left text-sm text-slate-900 dark:text-slate-100 hover:bg-slate-200/80 dark:hover:bg-slate-700 transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-blue-500" + className="w-full rounded-lg bg-surface border border-slate-200 dark:border-slate-700 px-4 py-2.5 text-left text-sm text-slate-900 dark:text-slate-100 hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors duration-150 focus:outline-none focus:border-primary-500 shadow-sm" onClick={() => openPicker("destination")} > <span @@ -383,13 +394,13 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({ <div className="flex flex-wrap items-center gap-2 text-sm text-slate-700 dark:text-slate-200"> <span className="font-semibold">{t("planner.when")}</span> - <div className="flex gap-1 rounded-2xl bg-slate-100 dark:bg-slate-800 p-1"> + <div className="flex gap-1 rounded-2xl bg-surface border border-slate-200 dark:border-slate-700 p-1 shadow-sm"> <button type="button" className={`px-3 py-1.5 rounded-xl text-xs font-semibold transition-colors duration-150 ${ timeMode === "now" - ? "bg-white dark:bg-slate-700 text-emerald-700 dark:text-emerald-300 shadow" - : "text-slate-700 dark:text-slate-300 hover:bg-slate-200/70 dark:hover:bg-slate-700" + ? "bg-primary-500 text-white shadow-sm" + : "text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800" }`} onClick={() => setTimeMode("now")} > @@ -399,8 +410,8 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({ type="button" className={`px-3 py-1.5 rounded-xl text-xs font-semibold transition-colors duration-150 ${ timeMode === "depart" - ? "bg-white dark:bg-slate-700 text-emerald-700 dark:text-emerald-300 shadow" - : "text-slate-700 dark:text-slate-300 hover:bg-slate-200/70 dark:hover:bg-slate-700" + ? "bg-primary-500 text-white shadow-sm" + : "text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800" }`} onClick={() => setTimeMode("depart")} > @@ -410,8 +421,8 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({ type="button" className={`px-3 py-1.5 rounded-xl text-xs font-semibold transition-colors duration-150 ${ timeMode === "arrive" - ? "bg-white dark:bg-slate-700 text-emerald-700 dark:text-emerald-300 shadow" - : "text-slate-700 dark:text-slate-300 hover:bg-slate-200/70 dark:hover:bg-slate-700" + ? "bg-primary-500 text-white shadow-sm" + : "text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800" }`} onClick={() => setTimeMode("arrive")} > @@ -421,7 +432,7 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({ {timeMode !== "now" && ( <div className="flex gap-2 w-full"> <select - className="rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 px-3 py-2 text-sm text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500 grow" + className="rounded-xl border border-slate-200 dark:border-slate-700 bg-surface px-3 py-2 text-sm text-slate-900 dark:text-slate-100 focus:outline-none focus:border-primary-500 grow shadow-sm" value={dateOffset} onChange={(e) => setDateOffset(Number(e.target.value))} > @@ -447,7 +458,7 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({ </select> <input type="time" - className="rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 px-3 py-2 text-sm text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500 grow" + className="rounded-xl border border-slate-200 dark:border-slate-700 bg-surface px-3 py-2 text-sm text-slate-900 dark:text-slate-100 focus:outline-none focus:border-primary-500 grow shadow-sm" value={timeValue} onChange={(e) => setTimeValue(e.target.value)} /> @@ -457,7 +468,7 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({ <div> <button - className="w-full rounded-lg bg-emerald-600 hover:bg-emerald-700 dark:bg-emerald-700 dark:hover:bg-emerald-800 px-2 py-2 text-sm font-semibold text-white disabled:bg-slate-300 dark:disabled:bg-slate-600 disabled:cursor-not-allowed transition-colors duration-200 focus:outline-none" + className="w-full rounded-xl bg-primary-600 hover:bg-primary-700 dark:bg-primary-700 dark:hover:bg-primary-800 px-2 py-2.5 text-sm font-semibold text-white disabled:bg-slate-300 dark:disabled:bg-slate-600 disabled:cursor-not-allowed transition-colors duration-200 focus:outline-none" disabled={!canSubmit} onClick={async () => { if (origin && destination) { @@ -543,7 +554,7 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({ <div className="relative"> <input ref={pickerInputRef} - className="w-full pr-12 px-4 py-3 text-base border border-slate-300 dark:border-slate-600 rounded-2xl bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 placeholder:text-slate-500 dark:placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200" + className="w-full pr-12 px-4 py-3 text-base border border-slate-200 dark:border-slate-700 rounded-2xl bg-surface text-slate-900 dark:text-slate-100 placeholder:text-slate-500 dark:placeholder:text-slate-400 focus:outline-none focus:border-primary-500 shadow-sm transition-all duration-200" placeholder={ pickerField === "origin" ? t("planner.search_origin") diff --git a/src/frontend/app/components/layout/NavBar.tsx b/src/frontend/app/components/layout/NavBar.tsx index 58228c7..fab47e0 100644 --- a/src/frontend/app/components/layout/NavBar.tsx +++ b/src/frontend/app/components/layout/NavBar.tsx @@ -1,4 +1,4 @@ -import { Home, Map, Navigation2, Route } from "lucide-react"; +import { Home, Map, Route } from "lucide-react"; import type { LngLatLike } from "maplibre-gl"; import { useTranslation } from "react-i18next"; import { Link, useLocation, useNavigate } from "react-router"; @@ -30,7 +30,7 @@ export default function NavBar({ orientation = "horizontal" }: NavBarProps) { const { mapState, updateMapState, mapPositionMode } = useApp(); const location = useLocation(); const navigate = useNavigate(); - const { deselectItinerary } = usePlanner(); + const { deselectItinerary } = usePlanner({ autoLoad: false }); const navItems = [ { @@ -70,11 +70,6 @@ export default function NavBar({ orientation = "horizontal" }: NavBarProps) { }, }, { - name: t("navbar.planner", "Planificador"), - icon: Navigation2, - path: "/planner", - }, - { name: t("navbar.lines", "Líneas"), icon: Route, path: "/lines", diff --git a/src/frontend/app/data/PlannerApi.ts b/src/frontend/app/data/PlannerApi.ts index be61d4b..4c78004 100644 --- a/src/frontend/app/data/PlannerApi.ts +++ b/src/frontend/app/data/PlannerApi.ts @@ -20,8 +20,10 @@ export interface Itinerary { transitTimeSeconds: number; waitingTimeSeconds: number; legs: Leg[]; - cashFareEuro?: number; - cardFareEuro?: number; + cashFare?: number; + cashFareIsTotal?: boolean; + cardFare?: number; + cardFareIsTotal?: boolean; } export interface Leg { @@ -30,6 +32,7 @@ export interface Leg { routeShortName?: string; routeLongName?: string; routeColor?: string; + routeTextColor?: string; headsign?: string; agencyName?: string; from?: PlannerPlace; diff --git a/src/frontend/app/data/StopDataProvider.ts b/src/frontend/app/data/StopDataProvider.ts index 7bab10c..697e171 100644 --- a/src/frontend/app/data/StopDataProvider.ts +++ b/src/frontend/app/data/StopDataProvider.ts @@ -73,7 +73,7 @@ async function initStops() { async function getStops(): Promise<Stop[]> { await initStops(); // update favourites - const rawFav = localStorage.getItem("favouriteStops_vigo"); + const rawFav = localStorage.getItem("favouriteStops"); const favouriteStops = rawFav ? (JSON.parse(rawFav) as (number | string)[]).map(normalizeId) : []; @@ -136,7 +136,7 @@ function getCustomName(stopId: string | number): string | undefined { function addFavourite(stopId: string | number) { const id = normalizeId(stopId); - const rawFavouriteStops = localStorage.getItem(`favouriteStops_vigo`); + const rawFavouriteStops = localStorage.getItem(`favouriteStops`); let favouriteStops: string[] = []; if (rawFavouriteStops) { favouriteStops = (JSON.parse(rawFavouriteStops) as (number | string)[]).map( @@ -146,13 +146,13 @@ function addFavourite(stopId: string | number) { if (!favouriteStops.includes(id)) { favouriteStops.push(id); - localStorage.setItem(`favouriteStops_vigo`, JSON.stringify(favouriteStops)); + localStorage.setItem(`favouriteStops`, JSON.stringify(favouriteStops)); } } function removeFavourite(stopId: string | number) { const id = normalizeId(stopId); - const rawFavouriteStops = localStorage.getItem(`favouriteStops_vigo`); + const rawFavouriteStops = localStorage.getItem(`favouriteStops`); let favouriteStops: string[] = []; if (rawFavouriteStops) { favouriteStops = (JSON.parse(rawFavouriteStops) as (number | string)[]).map( @@ -161,15 +161,12 @@ function removeFavourite(stopId: string | number) { } const newFavouriteStops = favouriteStops.filter((sid) => sid !== id); - localStorage.setItem( - `favouriteStops_vigo`, - JSON.stringify(newFavouriteStops) - ); + localStorage.setItem(`favouriteStops`, JSON.stringify(newFavouriteStops)); } function isFavourite(stopId: string | number): boolean { const id = normalizeId(stopId); - const rawFavouriteStops = localStorage.getItem(`favouriteStops_vigo`); + const rawFavouriteStops = localStorage.getItem(`favouriteStops`); if (rawFavouriteStops) { const favouriteStops = ( JSON.parse(rawFavouriteStops) as (number | string)[] @@ -213,7 +210,7 @@ function getRecent(): string[] { } function getFavouriteIds(): string[] { - const rawFavouriteStops = localStorage.getItem(`favouriteStops_vigo`); + const rawFavouriteStops = localStorage.getItem(`favouriteStops`); if (rawFavouriteStops) { return (JSON.parse(rawFavouriteStops) as (number | string)[]).map( normalizeId diff --git a/src/frontend/app/hooks/usePlanQuery.ts b/src/frontend/app/hooks/usePlanQuery.ts index 103f5f4..8c81073 100644 --- a/src/frontend/app/hooks/usePlanQuery.ts +++ b/src/frontend/app/hooks/usePlanQuery.ts @@ -8,7 +8,8 @@ export const usePlanQuery = ( toLon: number | undefined, time?: Date, arriveBy: boolean = false, - enabled: boolean = true + enabled: boolean = true, + initialData?: any ) => { return useQuery({ queryKey: [ @@ -25,5 +26,6 @@ export const usePlanQuery = ( enabled: !!(fromLat && fromLon && toLat && toLon) && enabled, staleTime: 60000, // 1 minute retry: false, + initialData, }); }; diff --git a/src/frontend/app/hooks/usePlanner.ts b/src/frontend/app/hooks/usePlanner.ts index a28167a..445a426 100644 --- a/src/frontend/app/hooks/usePlanner.ts +++ b/src/frontend/app/hooks/usePlanner.ts @@ -1,21 +1,23 @@ +import { useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useState } from "react"; import { type PlannerSearchResult, type RoutePlan } from "../data/PlannerApi"; import { usePlanQuery } from "./usePlanQuery"; -const STORAGE_KEY = "planner_last_route"; +const STORAGE_KEY = "planner_route_history"; const EXPIRY_MS = 2 * 60 * 60 * 1000; // 2 hours interface StoredRoute { timestamp: number; origin: PlannerSearchResult; destination: PlannerSearchResult; - plan: RoutePlan; + plan?: RoutePlan; searchTime?: Date; arriveBy?: boolean; selectedItineraryIndex?: number; } -export function usePlanner() { +export function usePlanner(options: { autoLoad?: boolean } = {}) { + const { autoLoad = true } = options; const [origin, setOrigin] = useState<PlannerSearchResult | null>(null); const [destination, setDestination] = useState<PlannerSearchResult | null>( null @@ -28,6 +30,8 @@ export function usePlanner() { const [selectedItineraryIndex, setSelectedItineraryIndex] = useState< number | null >(null); + const [history, setHistory] = useState<StoredRoute[]>([]); + const queryClient = useQueryClient(); const { data: queryPlan, @@ -41,13 +45,13 @@ export function usePlanner() { destination?.lon, searchTime ?? undefined, arriveBy, - !!(origin && destination) + !!(origin && destination && searchTime) ); // Sync query result to local state and storage useEffect(() => { if (queryPlan) { - setPlan(queryPlan as any); // Cast because of slight type differences if any, but they should match now + setPlan(queryPlan as any); if (origin && destination) { const toStore: StoredRoute = { @@ -59,7 +63,21 @@ export function usePlanner() { arriveBy, selectedItineraryIndex: selectedItineraryIndex ?? undefined, }; - localStorage.setItem(STORAGE_KEY, JSON.stringify(toStore)); + + setHistory((prev) => { + const filtered = prev.filter( + (r) => + !( + r.origin.lat === origin.lat && + r.origin.lon === origin.lon && + r.destination.lat === destination.lat && + r.destination.lon === destination.lon + ) + ); + const updated = [toStore, ...filtered].slice(0, 3); + localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); + return updated; + }); } } }, [ @@ -76,22 +94,40 @@ export function usePlanner() { const stored = localStorage.getItem(STORAGE_KEY); if (stored) { try { - const data: StoredRoute = JSON.parse(stored); - if (Date.now() - data.timestamp < EXPIRY_MS) { - setOrigin(data.origin); - setDestination(data.destination); - setPlan(data.plan); - setSearchTime(data.searchTime ? new Date(data.searchTime) : null); - setArriveBy(data.arriveBy ?? false); - setSelectedItineraryIndex(data.selectedItineraryIndex ?? null); - } else { - localStorage.removeItem(STORAGE_KEY); + const data: StoredRoute[] = JSON.parse(stored); + const valid = data.filter((r) => Date.now() - r.timestamp < EXPIRY_MS); + setHistory(valid); + + if (autoLoad && valid.length > 0) { + const last = valid[0]; + if (last.plan) { + queryClient.setQueryData( + [ + "plan", + last.origin.lat, + last.origin.lon, + last.destination.lat, + last.destination.lon, + last.searchTime + ? new Date(last.searchTime).toISOString() + : undefined, + last.arriveBy ?? false, + ], + last.plan + ); + setPlan(last.plan); + } + setOrigin(last.origin); + setDestination(last.destination); + setSearchTime(last.searchTime ? new Date(last.searchTime) : null); + setArriveBy(last.arriveBy ?? false); + setSelectedItineraryIndex(last.selectedItineraryIndex ?? null); } } catch (e) { localStorage.removeItem(STORAGE_KEY); } } - }, []); + }, [autoLoad]); const searchRoute = async ( from: PlannerSearchResult, @@ -101,9 +137,78 @@ export function usePlanner() { ) => { setOrigin(from); setDestination(to); - setSearchTime(time ?? new Date()); + const finalTime = time ?? new Date(); + setSearchTime(finalTime); setArriveBy(arriveByParam); setSelectedItineraryIndex(null); + + // Save to history immediately so other pages can pick it up + const toStore: StoredRoute = { + timestamp: Date.now(), + origin: from, + destination: to, + searchTime: finalTime, + arriveBy: arriveByParam, + }; + + setHistory((prev) => { + const filtered = prev.filter( + (r) => + !( + r.origin.lat === from.lat && + r.origin.lon === from.lon && + r.destination.lat === to.lat && + r.destination.lon === to.lon + ) + ); + const updated = [toStore, ...filtered].slice(0, 3); + localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); + return updated; + }); + }; + + const loadRoute = (route: StoredRoute) => { + if (route.plan) { + queryClient.setQueryData( + [ + "plan", + route.origin.lat, + route.origin.lon, + route.destination.lat, + route.destination.lon, + route.searchTime + ? new Date(route.searchTime).toISOString() + : undefined, + route.arriveBy ?? false, + ], + route.plan + ); + setPlan(route.plan); + } + setOrigin(route.origin); + setDestination(route.destination); + setSearchTime(route.searchTime ? new Date(route.searchTime) : null); + setArriveBy(route.arriveBy ?? false); + setSelectedItineraryIndex(route.selectedItineraryIndex ?? null); + + // Move to top of history + setHistory((prev) => { + const filtered = prev.filter( + (r) => + !( + r.origin.lat === route.origin.lat && + r.origin.lon === route.origin.lon && + r.destination.lat === route.destination.lat && + r.destination.lon === route.destination.lon + ) + ); + const updated = [{ ...route, timestamp: Date.now() }, ...filtered].slice( + 0, + 3 + ); + localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); + return updated; + }); }; const clearRoute = () => { @@ -113,6 +218,7 @@ export function usePlanner() { setSearchTime(null); setArriveBy(false); setSelectedItineraryIndex(null); + setHistory([]); localStorage.removeItem(STORAGE_KEY); }; @@ -120,32 +226,26 @@ export function usePlanner() { setSelectedItineraryIndex(index); // Update storage - const stored = localStorage.getItem(STORAGE_KEY); - if (stored) { - try { - const data: StoredRoute = JSON.parse(stored); - data.selectedItineraryIndex = index; - localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); - } catch (e) { - // Ignore - } - } + setHistory((prev) => { + if (prev.length === 0) return prev; + const updated = [...prev]; + updated[0] = { ...updated[0], selectedItineraryIndex: index }; + localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); + return updated; + }); }, []); const deselectItinerary = useCallback(() => { setSelectedItineraryIndex(null); // Update storage - const stored = localStorage.getItem(STORAGE_KEY); - if (stored) { - try { - const data: StoredRoute = JSON.parse(stored); - data.selectedItineraryIndex = undefined; - localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); - } catch (e) { - // Ignore - } - } + setHistory((prev) => { + if (prev.length === 0) return prev; + const updated = [...prev]; + updated[0] = { ...updated[0], selectedItineraryIndex: undefined }; + localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); + return updated; + }); }, []); return { @@ -159,7 +259,9 @@ export function usePlanner() { searchTime, arriveBy, selectedItineraryIndex, + history, searchRoute, + loadRoute, clearRoute, selectItinerary, deselectItinerary, diff --git a/src/frontend/app/i18n/locales/en-GB.json b/src/frontend/app/i18n/locales/en-GB.json index 2c58ebe..91c836a 100644 --- a/src/frontend/app/i18n/locales/en-GB.json +++ b/src/frontend/app/i18n/locales/en-GB.json @@ -128,6 +128,7 @@ "close": "Close", "results_title": "Results", "clear": "Clear", + "recent_routes": "Recent routes", "no_routes_found": "No routes found", "no_routes_message": "We couldn't find a route for your trip. Try changing the time or locations.", "walk": "Walk", diff --git a/src/frontend/app/i18n/locales/es-ES.json b/src/frontend/app/i18n/locales/es-ES.json index 298733e..526ab2f 100644 --- a/src/frontend/app/i18n/locales/es-ES.json +++ b/src/frontend/app/i18n/locales/es-ES.json @@ -128,6 +128,7 @@ "close": "Cerrar", "results_title": "Resultados", "clear": "Borrar", + "recent_routes": "Rutas recientes", "no_routes_found": "No se encontraron rutas", "no_routes_message": "No pudimos encontrar una ruta para tu viaje. Intenta cambiar la hora o las ubicaciones.", "walk": "Caminar", diff --git a/src/frontend/app/i18n/locales/gl-ES.json b/src/frontend/app/i18n/locales/gl-ES.json index 833279f..eec7ab9 100644 --- a/src/frontend/app/i18n/locales/gl-ES.json +++ b/src/frontend/app/i18n/locales/gl-ES.json @@ -128,6 +128,7 @@ "close": "Pechar", "results_title": "Resultados", "clear": "Limpar", + "recent_routes": "Rutas recentes", "no_routes_found": "Non se atoparon rutas", "no_routes_message": "Non puidemos atopar unha ruta para a túa viaxe. Intenta cambiar a hora ou as localizacións.", "walk": "Camiñar", diff --git a/src/frontend/app/root.css b/src/frontend/app/root.css index 9f79b08..3f41591 100644 --- a/src/frontend/app/root.css +++ b/src/frontend/app/root.css @@ -216,6 +216,11 @@ textarea { color: var(--ml-c-link-2); } -.maplibregl-ctrl button .maplibregl-ctrl-icon:before { +.maplibregl-ctrl button .maplibregl-ctrl-icon:before, +.maplibregl-ctrl-attrib-button::before { display: none !important; } + +.maplibregl-ctrl-attrib-inner { + line-height: 1rem !important; +} 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"> 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] *)); |
