From 5e9f1094a50bbcdd514e958dcd67d0f0a844589d Mon Sep 17 00:00:00 2001 From: Ariel Costas Guerrero Date: Fri, 12 Dec 2025 20:30:44 +0100 Subject: feat: implement favourites management, add recent places functionality, and enhance planner features --- src/frontend/app/components/PlaceListItem.tsx | 47 ++++ src/frontend/app/components/PlannerOverlay.tsx | 163 ++++++++++--- src/frontend/app/data/SpecialPlacesProvider.ts | 78 +++++++ src/frontend/app/i18n/locales/en-GB.json | 22 +- src/frontend/app/i18n/locales/es-ES.json | 22 +- src/frontend/app/i18n/locales/gl-ES.json | 22 +- src/frontend/app/routes/favourites.tsx | 309 ++++++++++++++++++++++++- src/frontend/app/routes/planner.tsx | 28 ++- 8 files changed, 646 insertions(+), 45 deletions(-) create mode 100644 src/frontend/app/components/PlaceListItem.tsx create mode 100644 src/frontend/app/data/SpecialPlacesProvider.ts (limited to 'src/frontend/app') diff --git a/src/frontend/app/components/PlaceListItem.tsx b/src/frontend/app/components/PlaceListItem.tsx new file mode 100644 index 0000000..6c4f4a7 --- /dev/null +++ b/src/frontend/app/components/PlaceListItem.tsx @@ -0,0 +1,47 @@ +import { Building2, MapPin } from "lucide-react"; +import type { PlannerSearchResult } from "~/data/PlannerApi"; + +function getIcon(layer?: string) { + switch ((layer || "").toLowerCase()) { + case "venue": + return ( + + ); + case "address": + case "street": + case "favourite-stop": + case "current-location": + default: + return ; + } +} + +export default function PlaceListItem({ + place, + onClick, +}: { + place: PlannerSearchResult; + onClick: (place: PlannerSearchResult) => void; +}) { + return ( +
  • + +
  • + ); +} diff --git a/src/frontend/app/components/PlannerOverlay.tsx b/src/frontend/app/components/PlannerOverlay.tsx index 8046ab2..12cfb0f 100644 --- a/src/frontend/app/components/PlannerOverlay.tsx +++ b/src/frontend/app/components/PlannerOverlay.tsx @@ -1,3 +1,4 @@ +import { MapPin } from "lucide-react"; import React, { useCallback, useEffect, @@ -6,6 +7,8 @@ import React, { useState, } from "react"; import { useTranslation } from "react-i18next"; +import PlaceListItem from "~/components/PlaceListItem"; +import { REGION_DATA } from "~/config/RegionConfig"; import { reverseGeocode, searchPlaces, @@ -55,6 +58,14 @@ export const PlannerOverlay: React.FC = ({ const [favouriteStops, setFavouriteStops] = useState( [] ); + const [recentPlaces, setRecentPlaces] = useState([]); + const RECENT_KEY = `recentPlaces_${REGION_DATA.id}`; + const clearRecentPlaces = useCallback(() => { + setRecentPlaces([]); + try { + localStorage.removeItem(RECENT_KEY); + } catch {} + }, []); const pickerInputRef = useRef(null); @@ -100,6 +111,43 @@ export const PlannerOverlay: React.FC = ({ .catch(() => setFavouriteStops([])); }, []); + // Load recent places from localStorage + useEffect(() => { + try { + const raw = localStorage.getItem(RECENT_KEY); + if (raw) { + const parsed = JSON.parse(raw) as PlannerSearchResult[]; + setRecentPlaces(parsed.slice(0, 20)); + } + } catch { + setRecentPlaces([]); + } + }, []); + + const addRecentPlace = useCallback( + (p: PlannerSearchResult) => { + const key = `${p.lat.toFixed(5)},${p.lon.toFixed(5)}`; + const existing = recentPlaces.filter( + (rp) => `${rp.lat.toFixed(5)},${rp.lon.toFixed(5)}` !== key + ); + const updated = [ + { + name: p.name, + label: p.label, + lat: p.lat, + lon: p.lon, + layer: p.layer, + }, + ...existing, + ].slice(0, 20); + setRecentPlaces(updated); + try { + localStorage.setItem(RECENT_KEY, JSON.stringify(updated)); + } catch {} + }, + [recentPlaces] + ); + const filteredFavouriteStops = useMemo(() => { const q = pickerQuery.trim().toLowerCase(); if (!q) return favouriteStops; @@ -110,6 +158,28 @@ export const PlannerOverlay: React.FC = ({ ); }, [favouriteStops, pickerQuery]); + const sortedRemoteResults = useMemo(() => { + const order: Record = { venue: 0, address: 1, street: 2 }; + const q = pickerQuery.trim().toLowerCase(); + const base = q + ? remoteResults.filter( + (s) => + (s.name || "").toLowerCase().includes(q) || + (s.label || "").toLowerCase().includes(q) + ) + : remoteResults; + return [...base].sort((a, b) => { + const oa = order[a.layer || ""] ?? 99; + const ob = order[b.layer || ""] ?? 99; + if (oa !== ob) return oa - ob; + // Secondary: shorter label first, then name alpha + const la = (a.label || "").length; + const lb = (b.label || "").length; + if (la !== lb) return la - lb; + return (a.name || "").localeCompare(b.name || ""); + }); + }, [remoteResults, pickerQuery]); + const openPicker = (field: PickerField) => { setPickerField(field); setPickerQuery( @@ -134,6 +204,7 @@ export const PlannerOverlay: React.FC = ({ setDestination(result); setDestQuery(result.name || ""); } + addRecentPlace(result); setPickerOpen(false); }; @@ -484,15 +555,17 @@ export const PlannerOverlay: React.FC = ({ /> @@ -506,60 +579,74 @@ export const PlannerOverlay: React.FC = ({ onClick={setOriginFromCurrentLocation} disabled={locationLoading} > -
    -
    - {t("planner.current_location")} -
    -
    - {t("planner.gps")} +
    + + + +
    +
    + {t("planner.current_location")} +
    +
    + {t("planner.gps")} +
    - {locationLoading ? "…" : "📍"} + {locationLoading ? "…" : ""}
    )} + {(remoteLoading || sortedRemoteResults.length > 0) && ( +
  • + {remoteLoading + ? t("planner.searching_ellipsis") + : t("planner.results", "Results")} +
  • + )} + + {sortedRemoteResults.map((r, i) => ( + + ))} + {filteredFavouriteStops.length > 0 && ( <>
  • {t("planner.favourite_stops")}
  • {filteredFavouriteStops.map((r, i) => ( -
  • - -
  • + place={r} + onClick={applyPickedResult} + /> ))} )} - {(remoteLoading || remoteResults.length > 0) && ( -
  • - {remoteLoading - ? t("planner.searching_ellipsis") - : t("planner.results", "Results")} + {recentPlaces.length > 0 && ( +
  • + + {t("planner.recent_locations", "Recent locations")} + +
  • )} - {remoteResults.map((r, i) => ( + {recentPlaces.map((r, i) => (
  • + + ) : ( + + )} +
  • +
    + + ); +} + +interface FavouriteStopItemProps { + stop: Stop; + onRemove: (stopId: string) => void; + removeLabel: string; + viewLabel: string; +} + +function FavouriteStopItem({ + stop, + onRemove, + removeLabel, + viewLabel, +}: FavouriteStopItemProps) { + const { t } = useTranslation(); + const confirmAndRemove = () => { + const ok = window.confirm( + t("favourites.confirm_remove", "Remove this favourite?") + ); + if (!ok) return; + onRemove(stop.stopId); + }; + + return ( +
  • +
    + +
    + + ★ + + + ({stop.stopId}) + +
    +
    + {StopDataProvider.getDisplayName(stop)} +
    +
    + {stop.lines?.slice(0, 6).map((line) => ( + + ))} + {stop.lines && stop.lines.length > 6 && ( + + +{stop.lines.length - 6} + + )} +
    + +
    + +
    +
    +
  • + ); +} diff --git a/src/frontend/app/routes/planner.tsx b/src/frontend/app/routes/planner.tsx index 5121dce..55d1553 100644 --- a/src/frontend/app/routes/planner.tsx +++ b/src/frontend/app/routes/planner.tsx @@ -754,6 +754,8 @@ export default function PlannerPage() { selectedItineraryIndex, selectItinerary, deselectItinerary, + setOrigin, + setDestination, } = usePlanner(); const [selectedItinerary, setSelectedItinerary] = useState( null @@ -839,7 +841,31 @@ export default function PlannerPage() {

    )} - -- cgit v1.3