diff options
| author | Ariel Costas Guerrero <ariel@costas.dev> | 2026-03-09 00:12:27 +0100 |
|---|---|---|
| committer | Ariel Costas Guerrero <ariel@costas.dev> | 2026-03-09 00:12:27 +0100 |
| commit | b3f5bfad9c2d1ac92debb389fd7a774a6cdb5109 (patch) | |
| tree | 9cb2d1c200fd858dd232fb2f1d40b269092a08b2 | |
| parent | b08e4a3be983e497b774fdf02a56ff0d06bea5f9 (diff) | |
Enhance Favourites and Routes components; add agency favorites handling and improve layout for favorites display
| -rw-r--r-- | src/frontend/app/routes/favourites.tsx | 287 | ||||
| -rw-r--r-- | src/frontend/app/routes/routes.tsx | 82 |
2 files changed, 281 insertions, 88 deletions
diff --git a/src/frontend/app/routes/favourites.tsx b/src/frontend/app/routes/favourites.tsx index 6b57256..9632123 100644 --- a/src/frontend/app/routes/favourites.tsx +++ b/src/frontend/app/routes/favourites.tsx @@ -1,12 +1,18 @@ -import { useCallback, useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Clock, Star } from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; +import { fetchArrivals } from "~/api/arrivals"; +import { type Arrival } from "~/api/schema"; +import { fetchRoutes } from "~/api/transit"; import RouteIcon from "~/components/RouteIcon"; import { usePageTitle } from "~/contexts/PageTitleContext"; import SpecialPlacesProvider, { type SpecialPlace, } from "~/data/SpecialPlacesProvider"; import StopDataProvider, { type Stop } from "~/data/StopDataProvider"; +import { useFavorites } from "~/hooks/useFavorites"; export default function Favourites() { const { t } = useTranslation(); @@ -16,6 +22,27 @@ export default function Favourites() { const [work, setWork] = useState<SpecialPlace | null>(null); const [favouriteStops, setFavouriteStops] = useState<Stop[]>([]); const [loading, setLoading] = useState(true); + const [expandedAgencies, setExpandedAgencies] = useState< + Record<string, boolean> + >({}); + const { favorites: favouriteRouteIds, isFavorite: isFavoriteRoute } = + useFavorites("favouriteRoutes"); + const { favorites: favouriteAgencyIds, isFavorite: isFavoriteAgency } = + useFavorites("favouriteAgencies"); + + const orderedAgencies = [ + "vitrasa", + "tranvias", + "tussa", + "ourense", + "feve", + "shuttle", + ]; + + const { data: routes = [], isLoading: routesLoading } = useQuery({ + queryKey: ["routes", "favourites"], + queryFn: () => fetchRoutes(orderedAgencies), + }); const loadData = useCallback(async () => { try { @@ -59,9 +86,55 @@ export default function Favourites() { setWork(null); }; - const isEmpty = !home && !work && favouriteStops.length === 0; + const toggleAgencyExpanded = (agency: string) => { + setExpandedAgencies((prev) => ({ ...prev, [agency]: !prev[agency] })); + }; + + const favouriteRoutes = useMemo(() => { + return routes.filter((route) => isFavoriteRoute(route.id)); + }, [routes, isFavoriteRoute]); + + const favouriteAgencies = useMemo(() => { + return routes.reduce( + (acc, route) => { + const agency = route.agencyName || t("routes.unknown_agency", "Otros"); + if (!isFavoriteAgency(agency)) { + return acc; + } + + if (!acc[agency]) { + acc[agency] = []; + } + + acc[agency].push(route); + return acc; + }, + {} as Record<string, typeof routes> + ); + }, [routes, isFavoriteAgency, t]); + + const sortedFavouriteAgencyEntries = useMemo(() => { + return Object.entries(favouriteAgencies).sort(([a], [b]) => { + const indexA = orderedAgencies.indexOf(a.toLowerCase()); + const indexB = orderedAgencies.indexOf(b.toLowerCase()); + + if (indexA === -1 && indexB === -1) { + return a.localeCompare(b); + } + if (indexA === -1) return 1; + if (indexB === -1) return -1; + return indexA - indexB; + }); + }, [favouriteAgencies]); + + const isEmpty = + !home && + !work && + favouriteStops.length === 0 && + favouriteRouteIds.length === 0 && + favouriteAgencyIds.length === 0; - if (loading) { + if (loading || routesLoading) { return ( <div className="page-container"> <div className="flex items-center justify-center py-12"> @@ -105,13 +178,54 @@ export default function Favourites() { } return ( - <div className="page-container pb-8"> + <div className="flex flex-col gap-4 py-4 pb-8"> + {favouriteRoutes.length > 0 && ( + <div className="w-full px-4 flex flex-col gap-2"> + <div className="flex items-center gap-2 mb-1 pl-1"> + <Star className="text-yellow-500 w-4 h-4" /> + <h3 className="text-xs font-bold uppercase tracking-wider text-muted m-0"> + {t("routes.favorites", "Rutas favoritas")} + </h3> + </div> + <ul className="list-none p-0 m-0 flex flex-col gap-2 md:grid md:grid-cols-[repeat(auto-fill,minmax(300px,1fr))] lg:grid-cols-[repeat(auto-fill,minmax(320px,1fr))]"> + {favouriteRoutes.map((route) => ( + <li key={route.id}> + <Link + to={`/routes/${route.id}`} + className="flex items-center gap-x-4 gap-y-3 rounded-xl p-3 transition-all bg-slate-50 dark:bg-slate-800 border border-gray-200 dark:border-gray-700 shadow-sm hover:border-blue-400 dark:hover:border-blue-500 active:scale-[0.98] cursor-pointer" + > + <RouteIcon + line={route.shortName ?? "?"} + mode="pill" + colour={route.color ?? "#6b7280"} + textColour={route.textColor ?? "#ffffff"} + /> + <div className="min-w-0 flex-1 flex flex-col gap-1"> + <p className="truncate text-base font-bold leading-tight text-slate-900 dark:text-slate-100"> + {route.longName} + </p> + {route.agencyName && ( + <p className="text-xs text-slate-500 dark:text-slate-400"> + {route.agencyName} + </p> + )} + </div> + </Link> + </li> + ))} + </ul> + </div> + )} + {/* Special Places Section */} {(home || work) && ( - <div className="px-4 pt-4 pb-6"> - <h2 className="text-xl font-bold text-gray-900 dark:text-gray-100 mb-4"> - {t("favourites.special_places", "Special Places")} - </h2> + <div className="w-full px-4 flex flex-col gap-2 pb-2"> + <div className="flex items-center gap-2 mb-1 pl-1"> + <Star className="text-yellow-500 w-4 h-4 opacity-70" /> + <h3 className="text-xs font-bold uppercase tracking-wider text-muted m-0"> + {t("favourites.special_places", "Special Places")} + </h3> + </div> <div className="flex flex-col gap-3"> {/* Home */} <SpecialPlaceCard @@ -141,18 +255,22 @@ export default function Favourites() { {/* Favourite Stops Section */} {favouriteStops.length > 0 && ( - <div className="px-4 pt-4"> - <h2 className="text-xl font-bold text-gray-900 dark:text-gray-100 mb-4"> - {t("favourites.favourite_stops", "Favourite Stops")} - </h2> - <ul className="list-none p-0 m-0 flex flex-col gap-2"> - {favouriteStops.map((stop) => ( + <div className="w-full px-4 flex flex-col gap-2"> + <div className="flex items-center gap-2 mb-1 pl-1"> + <Star className="text-yellow-500 w-4 h-4" /> + <h3 className="text-xs font-bold uppercase tracking-wider text-muted m-0"> + {t("favourites.favourite_stops", "Favourite Stops")} + </h3> + </div> + <ul className="list-none p-0 m-0 flex flex-col gap-2 md:grid md:grid-cols-[repeat(auto-fill,minmax(300px,1fr))] lg:grid-cols-[repeat(auto-fill,minmax(320px,1fr))]"> + {favouriteStops.map((stop, index) => ( <FavouriteStopItem key={stop.stopId} stop={stop} onRemove={handleRemoveFavourite} removeLabel={t("favourites.remove", "Remove")} viewLabel={t("favourites.view_estimates", "View estimates")} + showArrivals={index < 3} /> ))} </ul> @@ -251,15 +369,35 @@ interface FavouriteStopItemProps { onRemove: (stopId: string) => void; removeLabel: string; viewLabel: string; + showArrivals?: boolean; } function FavouriteStopItem({ stop, onRemove, removeLabel, - viewLabel, + viewLabel: _viewLabel, + showArrivals, }: FavouriteStopItemProps) { const { t } = useTranslation(); + const [arrivals, setArrivals] = useState<Arrival[] | null>(null); + + useEffect(() => { + let mounted = true; + if (showArrivals) { + fetchArrivals(stop.stopId, true) + .then((res) => { + if (mounted) { + setArrivals(res.arrivals.slice(0, 3)); + } + }) + .catch(console.error); + } + return () => { + mounted = false; + }; + }, [showArrivals, stop.stopId]); + const confirmAndRemove = () => { const ok = window.confirm( t("favourites.confirm_remove", "Remove this favourite?") @@ -269,50 +407,89 @@ function FavouriteStopItem({ }; return ( - <li className="bg-surface border border-border rounded-lg"> - <div className="flex items-stretch justify-between gap-2"> - <Link - to={`/stops/${stop.stopId}`} - className="flex-1 min-w-0 p-3 no-underline hover:bg-surface/80 rounded-l-lg transition-colors" - > - <div className="flex items-center gap-2 mb-1"> - <span className="text-yellow-500 text-base" aria-label="Favourite"> - ★ - </span> - <span className="text-xs text-muted font-medium"> - ({stop.stopCode || stop.stopId}) + <li className="relative"> + <button + onClick={confirmAndRemove} + className="absolute right-3 top-3 z-10 rounded-full p-1 text-yellow-500 transition-colors hover:bg-yellow-500/10" + type="button" + aria-label={removeLabel} + title={removeLabel} + > + <Star size={14} className="fill-current" /> + </button> + + <Link + to={`/stops/${stop.stopId}`} + className="flex items-center gap-x-4 gap-y-3 rounded-xl border border-gray-200 bg-slate-50 p-3 shadow-sm transition-all hover:border-blue-400 active:scale-[0.98] cursor-pointer dark:border-gray-700 dark:bg-slate-800 dark:hover:border-blue-500" + > + <div className="flex-1 min-w-0 flex flex-col gap-1"> + <div className="flex justify-between items-start gap-2"> + <span className="pr-6 text-base font-bold overflow-hidden text-ellipsis line-clamp-2 leading-tight text-slate-900 dark:text-slate-100"> + {StopDataProvider.getDisplayName(stop)} </span> </div> - <div className="font-semibold text-text mb-2"> - {StopDataProvider.getDisplayName(stop)} - </div> - <div className="flex flex-wrap gap-1 items-center"> - {stop.lines?.slice(0, 6).map((lineObj) => ( - <RouteIcon - key={lineObj.line} - line={lineObj.line} - colour={lineObj.colour} - textColour={lineObj.textColour} - /> - ))} - {stop.lines && stop.lines.length > 6 && ( - <span className="text-xs text-gray-500 dark:text-gray-400 ml-1"> - +{stop.lines.length - 6} - </span> - )} + + <div className="text-xs flex items-center gap-1.5 text-slate-500 dark:text-slate-400 font-mono uppercase"> + <span className="px-1.5 py-0.5 rounded flex items-center justify-center bg-slate-200 dark:bg-slate-700 text-[10px] font-bold text-slate-700 dark:text-slate-300 leading-none"> + {stop.stopId.split(":")[0]} + </span> + <span> + {stop.stopCode || stop.stopId.split(":")[1] || stop.stopId} + </span> </div> - </Link> - <div className="flex items-center pr-3"> - <button - onClick={confirmAndRemove} - className="text-sm px-3 py-1 rounded-md border border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/20 hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors whitespace-nowrap" - type="button" - aria-label={removeLabel} - > - {removeLabel} - </button> + + {stop.lines && stop.lines.length > 0 && ( + <div className="flex flex-wrap gap-1 mt-1"> + {stop.lines.map((lineObj) => ( + <RouteIcon + key={lineObj.line} + line={lineObj.line} + colour={lineObj.colour} + textColour={lineObj.textColour} + /> + ))} + </div> + )} + + {showArrivals && arrivals && arrivals.length > 0 && ( + <div className="flex flex-col gap-1 mt-2 p-2 bg-slate-100 dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-800"> + <div className="flex items-center gap-1.5 mb-1 opacity-70"> + <Clock className="w-3 h-3" /> + <span className="text-[10px] font-bold uppercase tracking-wider"> + {t("estimates.next_arrivals", "Próximas llegadas")} + </span> + </div> + {arrivals.map((arr, i) => ( + <div key={i} className="flex items-center gap-2 text-sm"> + <div className="shrink-0"> + <RouteIcon + line={arr.route.shortName} + colour={arr.route.colour} + textColour={arr.route.textColour} + /> + </div> + <span className="flex-1 truncate text-xs font-medium text-slate-700 dark:text-slate-300"> + {arr.headsign.destination} + </span> + <span + className={`text-xs pr-1 font-bold ${ + arr.estimate.precision === "confident" + ? "text-green-600 dark:text-green-500" + : arr.estimate.precision === "unsure" + ? "text-orange-600 dark:text-orange-500" + : arr.estimate.precision === "past" + ? "text-gray-500 line-through" + : "text-blue-600 dark:text-blue-400" + }`} + > + {arr.estimate.minutes}' + </span> + </div> + ))} + </div> + )} </div> - </div> + </Link> </li> ); } diff --git a/src/frontend/app/routes/routes.tsx b/src/frontend/app/routes/routes.tsx index 128bbc4..6f07571 100644 --- a/src/frontend/app/routes/routes.tsx +++ b/src/frontend/app/routes/routes.tsx @@ -13,8 +13,8 @@ export default function RoutesPage() { const { t } = useTranslation(); usePageTitle(t("navbar.routes", "Rutas")); const [searchQuery, setSearchQuery] = useState(""); - const { toggleFavorite: toggleFavoriteRoute, isFavorite: isFavoriteRoute } = - useFavorites("favouriteRoutes"); + const [isFavoritesExpanded, setIsFavoritesExpanded] = useState(true); + const { isFavorite: isFavoriteRoute } = useFavorites("favouriteRoutes"); const { toggleFavorite: toggleFavoriteAgency, isFavorite: isFavoriteAgency } = useFavorites("favouriteAgencies"); @@ -105,36 +105,52 @@ export default function RoutesPage() { <div className="space-y-3"> {favoriteRoutes.length > 0 && !searchQuery && ( - <div className="mb-2"> - <h2 className="mb-3 flex items-center gap-2 border-b border-border pb-2 text-sm font-semibold uppercase tracking-wide text-muted"> + <div className="overflow-hidden rounded-xl border border-border bg-surface"> + <button + type="button" + onClick={() => setIsFavoritesExpanded((prev) => !prev)} + className={`flex w-full items-center gap-3 px-4 py-3 text-left ${isFavoritesExpanded ? "border-b border-border" : ""}`} + > + <div className="text-muted"> + {isFavoritesExpanded ? ( + <ChevronDown size={18} /> + ) : ( + <ChevronRight size={18} /> + )} + </div> <Star size={16} className="fill-yellow-500 text-yellow-500" /> - {t("routes.favorites", "Favoritas")} - </h2> - <div className="space-y-2"> - {favoriteRoutes.map((route) => ( - <div - key={`fav-${route.id}`} - className="rounded-xl border border-border bg-surface" - > - <Link - to={`/routes/${route.id}`} - className="flex items-center gap-3 px-4 py-3" - > - <RouteIcon - line={route.shortName ?? "?"} - mode="pill" - colour={route.color ?? undefined} - textColour={route.textColor ?? undefined} - /> - <div className="flex-1 min-w-0"> - <p className="truncate text-sm font-medium text-text"> - {route.longName} - </p> - </div> - </Link> - </div> - ))} - </div> + <h2 className="flex-1 text-base font-semibold text-text"> + {t("routes.favorites", "Favoritas")} + </h2> + <span className="rounded-full bg-background px-2 py-0.5 text-xs text-muted"> + {favoriteRoutes.length} + </span> + </button> + + {isFavoritesExpanded && ( + <div className="space-y-1 px-3 py-2"> + {favoriteRoutes.map((route) => ( + <div key={`fav-${route.id}`} className="rounded-lg"> + <Link + to={`/routes/${route.id}`} + className="flex items-center gap-3 rounded-lg px-3 py-2.5 hover:bg-background" + > + <RouteIcon + line={route.shortName ?? "?"} + mode="pill" + colour={route.color ?? "#6b7280"} + textColour={route.textColor ?? "#ffffff"} + /> + <div className="flex-1 min-w-0"> + <p className="truncate text-sm font-medium text-text"> + {route.longName} + </p> + </div> + </Link> + </div> + ))} + </div> + )} </div> )} @@ -199,8 +215,8 @@ export default function RoutesPage() { <RouteIcon line={route.shortName ?? "?"} mode="pill" - colour={route.color ?? undefined} - textColour={route.textColor ?? undefined} + colour={route.color ?? "#6b7280"} + textColour={route.textColor ?? "#ffffff"} /> <div className="flex-1 min-w-0"> <p className="truncate text-sm font-medium text-text"> |
