From b3f5bfad9c2d1ac92debb389fd7a774a6cdb5109 Mon Sep 17 00:00:00 2001
From: Ariel Costas Guerrero <ariel@costas.dev>
Date: Mon, 9 Mar 2026 00:12:27 +0100
Subject: Enhance Favourites and Routes components; add agency favorites
 handling and improve layout for favorites display

---
 src/frontend/app/routes/favourites.tsx | 287 ++++++++++++++++++++++++++-------
 1 file changed, 232 insertions(+), 55 deletions(-)

(limited to 'src/frontend/app/routes/favourites.tsx')

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>
   );
 }
-- 
cgit v1.3

