summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAriel Costas Guerrero <ariel@costas.dev>2026-03-09 00:12:27 +0100
committerAriel Costas Guerrero <ariel@costas.dev>2026-03-09 00:12:27 +0100
commitb3f5bfad9c2d1ac92debb389fd7a774a6cdb5109 (patch)
tree9cb2d1c200fd858dd232fb2f1d40b269092a08b2
parentb08e4a3be983e497b774fdf02a56ff0d06bea5f9 (diff)
Enhance Favourites and Routes components; add agency favorites handling and improve layout for favorites display
-rw-r--r--src/frontend/app/routes/favourites.tsx287
-rw-r--r--src/frontend/app/routes/routes.tsx82
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}&apos;
+ </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">