From b2ddc0ef449ccbe7f0d33e539ccdfc1baef04e2c Mon Sep 17 00:00:00 2001 From: Ariel Costas Guerrero Date: Sun, 28 Dec 2025 18:21:17 +0100 Subject: Get favourite stops from OTP instead of pre-generated file --- src/frontend/app/components/PlannerOverlay.tsx | 9 +- src/frontend/app/components/StopGalleryItem.tsx | 11 +- src/frontend/app/components/StopItem.tsx | 11 +- src/frontend/app/components/stop/StopMapModal.tsx | 1 + src/frontend/app/config/constants.ts | 1 - src/frontend/app/data/StopDataProvider.ts | 262 +++++++++++++--------- src/frontend/app/routes/favourites.tsx | 28 +-- src/frontend/app/routes/home.tsx | 185 ++------------- src/frontend/app/routes/map.tsx | 44 ++++ 9 files changed, 255 insertions(+), 297 deletions(-) (limited to 'src/frontend/app') diff --git a/src/frontend/app/components/PlannerOverlay.tsx b/src/frontend/app/components/PlannerOverlay.tsx index 55e52d7..0320d45 100644 --- a/src/frontend/app/components/PlannerOverlay.tsx +++ b/src/frontend/app/components/PlannerOverlay.tsx @@ -110,10 +110,11 @@ export const PlannerOverlay: React.FC = ({ useEffect(() => { // Load favourites once; used as local suggestions in the picker. - StopDataProvider.getStops() - .then((stops) => - stops - .filter((s) => s.favourite && s.latitude && s.longitude) + const favouriteIds = StopDataProvider.getFavouriteIds(); + StopDataProvider.fetchStopsByIds(favouriteIds) + .then((stopsMap) => + Object.values(stopsMap) + .filter((s) => s.latitude && s.longitude) .map( (s) => ({ diff --git a/src/frontend/app/components/StopGalleryItem.tsx b/src/frontend/app/components/StopGalleryItem.tsx index bf60697..de369d8 100644 --- a/src/frontend/app/components/StopGalleryItem.tsx +++ b/src/frontend/app/components/StopGalleryItem.tsx @@ -26,7 +26,7 @@ const StopGalleryItem: React.FC = ({ stop }) => { )} - ({stop.stopId}) + ({stop.stopCode || stop.stopId})
= ({ stop }) => { {StopDataProvider.getDisplayName(stop)}
- {stop.lines?.slice(0, 5).map((line) => ( - + {stop.lines?.slice(0, 5).map((lineObj) => ( + ))} {stop.lines && stop.lines.length > 5 && ( diff --git a/src/frontend/app/components/StopItem.tsx b/src/frontend/app/components/StopItem.tsx index 9679b05..391e605 100644 --- a/src/frontend/app/components/StopItem.tsx +++ b/src/frontend/app/components/StopItem.tsx @@ -20,12 +20,17 @@ const StopItem: React.FC = ({ stop }) => { {StopDataProvider.getDisplayName(stop)} - ({stop.stopId}) + ({stop.stopCode || stop.stopId})
- {stop.lines?.map((line) => ( - + {stop.lines?.map((lineObj) => ( + ))}
diff --git a/src/frontend/app/components/stop/StopMapModal.tsx b/src/frontend/app/components/stop/StopMapModal.tsx index 757411e..688ec2e 100644 --- a/src/frontend/app/components/stop/StopMapModal.tsx +++ b/src/frontend/app/components/stop/StopMapModal.tsx @@ -1,3 +1,4 @@ +import maplibregl from "maplibre-gl"; import React, { useCallback, useEffect, diff --git a/src/frontend/app/config/constants.ts b/src/frontend/app/config/constants.ts index 38ebb0b..a130f87 100644 --- a/src/frontend/app/config/constants.ts +++ b/src/frontend/app/config/constants.ts @@ -5,7 +5,6 @@ export type RegionId = "vigo"; export const APP_CONSTANTS = { id: "vigo", - stopsEndpoint: "/stops/vigo.json", consolidatedCirculationsEndpoint: "/api/vigo/GetConsolidatedCirculations", shapeEndpoint: "/api/vigo/GetShape", defaultCenter: { diff --git a/src/frontend/app/data/StopDataProvider.ts b/src/frontend/app/data/StopDataProvider.ts index 697e171..76182c7 100644 --- a/src/frontend/app/data/StopDataProvider.ts +++ b/src/frontend/app/data/StopDataProvider.ts @@ -1,28 +1,31 @@ import { APP_CONSTANTS } from "~/config/constants"; -export interface CachedStopList { - timestamp: number; - data: Stop[]; -} - export interface Stop { stopId: string; + stopCode?: string; name: string; latitude?: number; longitude?: number; - lines: string[]; + lines: { + line: string; + colour: string; + textColour: string; + }[]; favourite?: boolean; - amenities?: string[]; + type?: "bus" | "coach" | "train" | "unknown"; +} - title?: string; - message?: string; - alert?: "info" | "warning" | "error"; - cancelled?: boolean; +interface CacheEntry { + stop: Stop; + timestamp: number; } -// In-memory cache and lookup map per region -const cachedStopsByRegion: Record = {}; -const stopsMapByRegion: Record> = {}; +const CACHE_KEY = `stops_cache_${APP_CONSTANTS.id}`; +const CACHE_DURATION = 24 * 60 * 60 * 1000; // 24 hours + +// In-memory cache for the current session +const memoryCache: Record = {}; + // Custom names loaded from localStorage per region const customNamesByRegion: Record> = {}; @@ -33,82 +36,105 @@ function normalizeId(id: number | string): string { return `vitrasa:${s}`; } -// Initialize cachedStops and customNames once per region -async function initStops() { - if (!cachedStopsByRegion[APP_CONSTANTS.id]) { - const response = await fetch(APP_CONSTANTS.stopsEndpoint); - const rawStops = (await response.json()) as any[]; - - // build array and map - stopsMapByRegion[APP_CONSTANTS.id] = {}; - cachedStopsByRegion[APP_CONSTANTS.id] = rawStops.map((raw) => { - const id = normalizeId(raw.stopId); - const entry = { - ...raw, - stopId: id, - type: raw.type || (id.startsWith("renfe:") ? "train" : "bus"), - favourite: false, - } as Stop; - stopsMapByRegion[APP_CONSTANTS.id][id] = entry; - return entry; - }); - - // load custom names - const rawCustom = localStorage.getItem( - `customStopNames_${APP_CONSTANTS.id}` - ); - if (rawCustom) { - const parsed = JSON.parse(rawCustom); - const normalized: Record = {}; - for (const [key, value] of Object.entries(parsed)) { - normalized[normalizeId(key)] = value as string; - } - customNamesByRegion[APP_CONSTANTS.id] = normalized; - } else { - customNamesByRegion[APP_CONSTANTS.id] = {}; - } +function getPersistentCache(): Record { + const raw = localStorage.getItem(CACHE_KEY); + if (!raw) return {}; + try { + return JSON.parse(raw); + } catch { + return {}; } } -async function getStops(): Promise { - await initStops(); - // update favourites - const rawFav = localStorage.getItem("favouriteStops"); - const favouriteStops = rawFav - ? (JSON.parse(rawFav) as (number | string)[]).map(normalizeId) - : []; +function savePersistentCache(cache: Record) { + localStorage.setItem(CACHE_KEY, JSON.stringify(cache)); +} - cachedStopsByRegion["vigo"]!.forEach( - (stop) => (stop.favourite = favouriteStops.includes(stop.stopId)) - ); - return cachedStopsByRegion["vigo"]!; +async function fetchStopsByIds(ids: string[]): Promise> { + if (ids.length === 0) return {}; + + const normalizedIds = ids.map(normalizeId); + const now = Date.now(); + const persistentCache = getPersistentCache(); + const result: Record = {}; + const toFetch: string[] = []; + + for (const id of normalizedIds) { + if (memoryCache[id]) { + result[id] = memoryCache[id]; + continue; + } + + const cached = persistentCache[id]; + if (cached && now - cached.timestamp < CACHE_DURATION) { + memoryCache[id] = cached.stop; + result[id] = cached.stop; + continue; + } + + toFetch.push(id); + } + + if (toFetch.length > 0) { + try { + const response = await fetch(`/api/stops?ids=${toFetch.join(",")}`); + if (!response.ok) throw new Error("Failed to fetch stops"); + + const data = await response.json(); + for (const [id, stopData] of Object.entries(data)) { + const stop: Stop = { + stopId: (stopData as any).id, + stopCode: (stopData as any).code, + name: (stopData as any).name, + lines: (stopData as any).routes.map((r: any) => ({ + line: r.shortName, + colour: r.colour, + textColour: r.textColour, + })), + type: (stopData as any).id.startsWith("renfe:") + ? "train" + : (stopData as any).id.startsWith("xunta:") + ? "coach" + : "bus", + }; + + memoryCache[id] = stop; + result[id] = stop; + persistentCache[id] = { stop, timestamp: now }; + } + savePersistentCache(persistentCache); + } catch (error) { + console.error("Error fetching stops:", error); + } + } + + return result; } -// New: get single stop by id async function getStopById(stopId: string | number): Promise { - await initStops(); const id = normalizeId(stopId); - const stop = stopsMapByRegion[APP_CONSTANTS.id]?.[id]; + const stops = await fetchStopsByIds([id]); + const stop = stops[id]; if (stop) { - const rawFav = localStorage.getItem(`favouriteStops_${APP_CONSTANTS.id}`); - const favouriteStops = rawFav - ? (JSON.parse(rawFav) as (number | string)[]).map(normalizeId) - : []; - stop.favourite = favouriteStops.includes(id); + stop.favourite = isFavourite(id); } return stop; } -// Updated display name to include custom names function getDisplayName(stop: Stop): string { - return stop.name; + const custom = getCustomName(stop.stopId); + return custom || stop.name; } -// New: set or remove custom names function setCustomName(stopId: string | number, label: string) { const id = normalizeId(stopId); if (!customNamesByRegion[APP_CONSTANTS.id]) { - customNamesByRegion[APP_CONSTANTS.id] = {}; + const rawCustom = localStorage.getItem( + `customStopNames_${APP_CONSTANTS.id}` + ); + customNamesByRegion[APP_CONSTANTS.id] = rawCustom + ? JSON.parse(rawCustom) + : {}; } customNamesByRegion[APP_CONSTANTS.id][id] = label; localStorage.setItem( @@ -119,7 +145,15 @@ function setCustomName(stopId: string | number, label: string) { function removeCustomName(stopId: string | number) { const id = normalizeId(stopId); - if (customNamesByRegion[APP_CONSTANTS.id]?.[id]) { + if (!customNamesByRegion[APP_CONSTANTS.id]) { + const rawCustom = localStorage.getItem( + `customStopNames_${APP_CONSTANTS.id}` + ); + customNamesByRegion[APP_CONSTANTS.id] = rawCustom + ? JSON.parse(rawCustom) + : {}; + } + if (customNamesByRegion[APP_CONSTANTS.id][id]) { delete customNamesByRegion[APP_CONSTANTS.id][id]; localStorage.setItem( `customStopNames_${APP_CONSTANTS.id}`, @@ -128,15 +162,24 @@ function removeCustomName(stopId: string | number) { } } -// New: get custom label for a stop function getCustomName(stopId: string | number): string | undefined { const id = normalizeId(stopId); - return customNamesByRegion[APP_CONSTANTS.id]?.[id]; + if (!customNamesByRegion[APP_CONSTANTS.id]) { + const rawCustom = localStorage.getItem( + `customStopNames_${APP_CONSTANTS.id}` + ); + customNamesByRegion[APP_CONSTANTS.id] = rawCustom + ? JSON.parse(rawCustom) + : {}; + } + return customNamesByRegion[APP_CONSTANTS.id][id]; } function addFavourite(stopId: string | number) { const id = normalizeId(stopId); - const rawFavouriteStops = localStorage.getItem(`favouriteStops`); + const rawFavouriteStops = localStorage.getItem( + `favouriteStops_${APP_CONSTANTS.id}` + ); let favouriteStops: string[] = []; if (rawFavouriteStops) { favouriteStops = (JSON.parse(rawFavouriteStops) as (number | string)[]).map( @@ -146,13 +189,18 @@ function addFavourite(stopId: string | number) { if (!favouriteStops.includes(id)) { favouriteStops.push(id); - localStorage.setItem(`favouriteStops`, JSON.stringify(favouriteStops)); + localStorage.setItem( + `favouriteStops_${APP_CONSTANTS.id}`, + JSON.stringify(favouriteStops) + ); } } function removeFavourite(stopId: string | number) { const id = normalizeId(stopId); - const rawFavouriteStops = localStorage.getItem(`favouriteStops`); + const rawFavouriteStops = localStorage.getItem( + `favouriteStops_${APP_CONSTANTS.id}` + ); let favouriteStops: string[] = []; if (rawFavouriteStops) { favouriteStops = (JSON.parse(rawFavouriteStops) as (number | string)[]).map( @@ -161,12 +209,17 @@ function removeFavourite(stopId: string | number) { } const newFavouriteStops = favouriteStops.filter((sid) => sid !== id); - localStorage.setItem(`favouriteStops`, JSON.stringify(newFavouriteStops)); + localStorage.setItem( + `favouriteStops_${APP_CONSTANTS.id}`, + JSON.stringify(newFavouriteStops) + ); } function isFavourite(stopId: string | number): boolean { const id = normalizeId(stopId); - const rawFavouriteStops = localStorage.getItem(`favouriteStops`); + const rawFavouriteStops = localStorage.getItem( + `favouriteStops_${APP_CONSTANTS.id}` + ); if (rawFavouriteStops) { const favouriteStops = ( JSON.parse(rawFavouriteStops) as (number | string)[] @@ -180,29 +233,34 @@ const RECENT_STOPS_LIMIT = 10; function pushRecent(stopId: string | number) { const id = normalizeId(stopId); - const rawRecentStops = localStorage.getItem(`recentStops_vigo`); - let recentStops: Set = new Set(); + const rawRecentStops = localStorage.getItem( + `recentStops_${APP_CONSTANTS.id}` + ); + let recentStops: string[] = []; if (rawRecentStops) { - recentStops = new Set( - (JSON.parse(rawRecentStops) as (number | string)[]).map(normalizeId) + recentStops = (JSON.parse(rawRecentStops) as (number | string)[]).map( + normalizeId ); } - recentStops.add(id); - if (recentStops.size > RECENT_STOPS_LIMIT) { - const iterator = recentStops.values(); - const val = iterator.next().value as string; - recentStops.delete(val); + // Remove if already exists to move to front + recentStops = recentStops.filter((sid) => sid !== id); + recentStops.unshift(id); + + if (recentStops.length > RECENT_STOPS_LIMIT) { + recentStops = recentStops.slice(0, RECENT_STOPS_LIMIT); } localStorage.setItem( - `recentStops_vigo`, - JSON.stringify(Array.from(recentStops)) + `recentStops_${APP_CONSTANTS.id}`, + JSON.stringify(recentStops) ); } function getRecent(): string[] { - const rawRecentStops = localStorage.getItem(`recentStops_vigo`); + const rawRecentStops = localStorage.getItem( + `recentStops_${APP_CONSTANTS.id}` + ); if (rawRecentStops) { return (JSON.parse(rawRecentStops) as (number | string)[]).map(normalizeId); } @@ -210,7 +268,9 @@ function getRecent(): string[] { } function getFavouriteIds(): string[] { - const rawFavouriteStops = localStorage.getItem(`favouriteStops`); + const rawFavouriteStops = localStorage.getItem( + `favouriteStops_${APP_CONSTANTS.id}` + ); if (rawFavouriteStops) { return (JSON.parse(rawFavouriteStops) as (number | string)[]).map( normalizeId @@ -219,28 +279,13 @@ function getFavouriteIds(): string[] { return []; } -// New function to load stops from network -async function loadStopsFromNetwork(): Promise { - const response = await fetch(APP_CONSTANTS.stopsEndpoint); - const rawStops = (await response.json()) as any[]; - return rawStops.map((raw) => { - const id = normalizeId(raw.stopId); - return { - ...raw, - stopId: id, - type: raw.type || (id.startsWith("renfe:") ? "train" : "bus"), - favourite: false, - } as Stop; - }); -} - function getTileUrlTemplate(): string { return window.location.origin + "/api/tiles/stops/{z}/{x}/{y}"; } export default { - getStops, getStopById, + fetchStopsByIds, getCustomName, getDisplayName, setCustomName, @@ -251,6 +296,5 @@ export default { pushRecent, getRecent, getFavouriteIds, - loadStopsFromNetwork, getTileUrlTemplate, }; diff --git a/src/frontend/app/routes/favourites.tsx b/src/frontend/app/routes/favourites.tsx index deb3629..c05ab11 100644 --- a/src/frontend/app/routes/favourites.tsx +++ b/src/frontend/app/routes/favourites.tsx @@ -27,10 +27,11 @@ export default function Favourites() { // Load favourite stops const favouriteIds = StopDataProvider.getFavouriteIds(); - const allStops = await StopDataProvider.getStops(); - const favStops = allStops.filter((stop) => - favouriteIds.includes(stop.stopId) - ); + const stopsMap = await StopDataProvider.fetchStopsByIds(favouriteIds); + const favStops = favouriteIds + .map((id) => stopsMap[id]) + .filter(Boolean) + .map((stop) => ({ ...stop, favourite: true })); setFavouriteStops(favStops); } catch (error) { console.error("Error loading favourites:", error); @@ -190,14 +191,10 @@ function SpecialPlaceCard({ {icon}
-

- {label} -

+

{label}

{place ? (
-

- {place.name} -

+

{place.name}

{place.type === "stop" && place.stopId && (

({place.stopId})

)} @@ -283,15 +280,20 @@ function FavouriteStopItem({ ★ - ({stop.stopId}) + ({stop.stopCode || stop.stopId})
{StopDataProvider.getDisplayName(stop)}
- {stop.lines?.slice(0, 6).map((line) => ( - + {stop.lines?.slice(0, 6).map((lineObj) => ( + ))} {stop.lines && stop.lines.length > 6 && ( diff --git a/src/frontend/app/routes/home.tsx b/src/frontend/app/routes/home.tsx index b20a349..3e7f12d 100644 --- a/src/frontend/app/routes/home.tsx +++ b/src/frontend/app/routes/home.tsx @@ -1,4 +1,3 @@ -import Fuse from "fuse.js"; import { History } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -8,7 +7,6 @@ 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"; import StopDataProvider, { type Stop } from "../data/StopDataProvider"; import "../tailwind-full.css"; @@ -33,15 +31,6 @@ export default function StopList() { [t] ); - const fuse = useMemo( - () => - new Fuse(data || [], { - threshold: 0.3, - keys: ["name", "stopId"], - }), - [data] - ); - const requestUserLocation = useCallback(() => { if (typeof window === "undefined" || !("geolocation" in navigator)) { return; @@ -103,90 +92,33 @@ export default function StopList() { }; }, [requestUserLocation]); - // Sort stops by proximity when we know where the user is located. - const sortedAllStops = useMemo(() => { - if (!data) { - return [] as Stop[]; - } - - if (!userLocation) { - return [...data].sort((a, b) => a.stopId.localeCompare(b.stopId)); - } - - const toRadians = (value: number) => (value * Math.PI) / 180; - const getDistance = ( - lat1: number, - lon1: number, - lat2: number, - lon2: number - ) => { - const R = 6371000; // meters - const dLat = toRadians(lat2 - lat1); - const dLon = toRadians(lon2 - lon1); - const a = - Math.sin(dLat / 2) * Math.sin(dLat / 2) + - Math.cos(toRadians(lat1)) * - Math.cos(toRadians(lat2)) * - Math.sin(dLon / 2) * - Math.sin(dLon / 2); - const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); - return R * c; - }; - - return data - .map((stop) => { - if ( - typeof stop.latitude !== "number" || - typeof stop.longitude !== "number" - ) { - return { stop, distance: Number.POSITIVE_INFINITY }; - } - - const distance = getDistance( - userLocation.latitude, - userLocation.longitude, - stop.latitude, - stop.longitude - ); - - return { stop, distance }; - }) - .sort((a, b) => { - if (a.distance === b.distance) { - return a.stop.stopId.localeCompare(b.stop.stopId); - } - return a.distance - b.distance; - }) - .map(({ stop }) => stop); - }, [data, userLocation]); - // Load stops from network const loadStops = useCallback(async () => { try { setLoading(true); - const stops = await StopDataProvider.loadStopsFromNetwork(); - - // Add favourite flags to stops - const favouriteStopsIds = StopDataProvider.getFavouriteIds(); - const stopsWithFavourites = stops.map((stop) => ({ - ...stop, - favourite: favouriteStopsIds.includes(stop.stopId), - })); + const favouriteIds = StopDataProvider.getFavouriteIds(); + const recentIds = StopDataProvider.getRecent(); + const allIds = Array.from(new Set([...favouriteIds, ...recentIds])); - setData(stopsWithFavourites); + const stopsMap = await StopDataProvider.fetchStopsByIds(allIds); - // Update favourite and recent stops with full data - const favStops = stopsWithFavourites.filter((stop) => - favouriteStopsIds.includes(stop.stopId) - ); + const favStops = favouriteIds + .map((id) => stopsMap[id]) + .filter(Boolean) + .map((stop) => ({ ...stop, favourite: true })); setFavouriteStops(favStops); - const recIds = StopDataProvider.getRecent(); - const recStops = recIds - .map((id) => stopsWithFavourites.find((stop) => stop.stopId === id)) - .filter(Boolean) as Stop[]; - setRecentStops(recStops.reverse()); + const recStops = recentIds + .map((id) => stopsMap[id]) + .filter(Boolean) + .map((stop) => ({ + ...stop, + favourite: favouriteIds.includes(stop.stopId), + })); + setRecentStops(recStops); + + setData(Object.values(stopsMap)); } catch (error) { console.error("Failed to load stops:", error); } finally { @@ -205,41 +137,14 @@ export default function StopList() { clearTimeout(searchTimeout.current); } - searchTimeout.current = setTimeout(() => { + searchTimeout.current = setTimeout(async () => { if (searchQuery.length === 0) { setSearchResults(null); return; } - if (!data) { - console.error("No data available for search"); - return; - } - - // Check if search query is a number (stop code search) - const isNumericSearch = /^\d+$/.test(searchQuery.trim()); - - let items: Stop[]; - if (isNumericSearch) { - // Direct match for stop codes - const stopId = searchQuery.trim(); - const exactMatch = data.filter( - (stop) => stop.stopId === stopId || stop.stopId.endsWith(`:${stopId}`) - ); - if (exactMatch.length > 0) { - items = exactMatch; - } else { - // Fuzzy search if no exact match - const results = fuse.search(searchQuery); - items = results.map((result) => result.item); - } - } else { - // Text search using Fuse.js - const results = fuse.search(searchQuery); - items = results.map((result) => result.item); - } - - setSearchResults(items); + // Placeholder for future backend search + setSearchResults([]); }, 300); }; @@ -370,54 +275,6 @@ export default function StopList() { )} {/**/} - - {/* All Stops / Nearby Stops */} -
-
- {userLocation && ( - - - - - )} -

- {userLocation - ? t("stoplist.nearby_stops", "Nearby stops") - : t("stoplist.all_stops", "Paradas")} -

-
- -
    - {loading && ( - <> - {Array.from({ length: 6 }, (_, index) => ( - - ))} - - )} - {!loading && data - ? (userLocation - ? sortedAllStops.slice(0, 6) - : sortedAllStops - ).map((stop) => ) - : null} -
-
)}
diff --git a/src/frontend/app/routes/map.tsx b/src/frontend/app/routes/map.tsx index b02c494..b8f179c 100644 --- a/src/frontend/app/routes/map.tsx +++ b/src/frontend/app/routes/map.tsx @@ -40,6 +40,13 @@ export default function StopMap() { const { searchRoute } = usePlanner({ autoLoad: false }); + const favouriteIds = useMemo(() => StopDataProvider.getFavouriteIds(), []); + + const favouriteFilter = useMemo(() => { + if (favouriteIds.length === 0) return ["boolean", false]; + return ["match", ["get", "id"], favouriteIds, true, false]; + }, [favouriteIds]); + // Handle click events on clusters and individual stops const onMapClick = (e: MapLayerMouseEvent) => { const features = e.features; @@ -139,6 +146,32 @@ export default function StopMap() { maxzoom={20} /> + + -- cgit v1.3