From d71f0ed16d175285f2e8cbde6091994c2aa1d962 Mon Sep 17 00:00:00 2001
From: Ariel Costas Guerrero <ariel@costas.dev>
Date: Mon, 9 Mar 2026 00:00:39 +0100
Subject: Enhance route details handling and add favorites functionality;
 improve error logging and response structure

---
 src/frontend/app/routes/routes.tsx | 222 ++++++++++++++++++++++++++++---------
 1 file changed, 168 insertions(+), 54 deletions(-)

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

diff --git a/src/frontend/app/routes/routes.tsx b/src/frontend/app/routes/routes.tsx
index b33fe58..128bbc4 100644
--- a/src/frontend/app/routes/routes.tsx
+++ b/src/frontend/app/routes/routes.tsx
@@ -1,16 +1,30 @@
 import { useQuery } from "@tanstack/react-query";
-import { useState } from "react";
+import { ChevronDown, ChevronRight, Star } from "lucide-react";
+import { useMemo, useState } from "react";
 import { useTranslation } from "react-i18next";
 import { Link } from "react-router";
 import { fetchRoutes } from "~/api/transit";
 import RouteIcon from "~/components/RouteIcon";
 import { usePageTitle } from "~/contexts/PageTitleContext";
+import { useFavorites } from "~/hooks/useFavorites";
 import "../tailwind-full.css";
 
 export default function RoutesPage() {
   const { t } = useTranslation();
   usePageTitle(t("navbar.routes", "Rutas"));
   const [searchQuery, setSearchQuery] = useState("");
+  const { toggleFavorite: toggleFavoriteRoute, isFavorite: isFavoriteRoute } =
+    useFavorites("favouriteRoutes");
+  const { toggleFavorite: toggleFavoriteAgency, isFavorite: isFavoriteAgency } =
+    useFavorites("favouriteAgencies");
+
+  const [expandedAgencies, setExpandedAgencies] = useState<
+    Record<string, boolean>
+  >({});
+
+  const toggleAgencyExpanded = (agency: string) => {
+    setExpandedAgencies((prev) => ({ ...prev, [agency]: !prev[agency] }));
+  };
 
   const orderedAgencies = [
     "vitrasa",
@@ -26,21 +40,50 @@ export default function RoutesPage() {
     queryFn: () => fetchRoutes(orderedAgencies),
   });
 
-  const filteredRoutes = routes?.filter(
-    (route) =>
-      route.shortName?.toLowerCase().includes(searchQuery.toLowerCase()) ||
-      route.longName?.toLowerCase().includes(searchQuery.toLowerCase())
-  );
+  const filteredRoutes = useMemo(() => {
+    return routes?.filter(
+      (route) =>
+        route.shortName?.toLowerCase().includes(searchQuery.toLowerCase()) ||
+        route.longName?.toLowerCase().includes(searchQuery.toLowerCase())
+    );
+  }, [routes, searchQuery]);
 
-  const routesByAgency = filteredRoutes?.reduce(
-    (acc, route) => {
-      const agency = route.agencyName || t("routes.unknown_agency", "Otros");
-      if (!acc[agency]) acc[agency] = [];
-      acc[agency].push(route);
-      return acc;
-    },
-    {} as Record<string, typeof routes>
-  );
+  const routesByAgency = useMemo(() => {
+    return filteredRoutes?.reduce(
+      (acc, route) => {
+        const agency = route.agencyName || t("routes.unknown_agency", "Otros");
+        if (!acc[agency]) acc[agency] = [];
+        acc[agency].push(route);
+        return acc;
+      },
+      {} as Record<string, typeof routes>
+    );
+  }, [filteredRoutes, t]);
+
+  const sortedAgencyEntries = useMemo(() => {
+    if (!routesByAgency) return [];
+    return Object.entries(routesByAgency).sort(([a], [b]) => {
+      // First, sort by favorite status
+      const isFavA = isFavoriteAgency(a);
+      const isFavB = isFavoriteAgency(b);
+      if (isFavA && !isFavB) return -1;
+      if (!isFavA && isFavB) return 1;
+
+      // Then by fixed order
+      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;
+    });
+  }, [routesByAgency, orderedAgencies, isFavoriteAgency]);
+
+  const favoriteRoutes = useMemo(() => {
+    return filteredRoutes?.filter((route) => isFavoriteRoute(route.id)) || [];
+  }, [filteredRoutes, isFavoriteRoute]);
 
   return (
     <div className="container mx-auto px-4 py-6">
@@ -48,7 +91,7 @@ export default function RoutesPage() {
         <input
           type="text"
           placeholder={t("routes.search_placeholder", "Buscar rutas...")}
-          className="w-full px-4 py-3 rounded-xl border border-border bg-surface text-text focus:outline-none focus:ring-2 focus:ring-primary shadow-sm placeholder-gray-500"
+          className="w-full rounded-xl border border-border bg-surface px-4 py-3 text-text placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-primary"
           value={searchQuery}
           onChange={(e) => setSearchQuery(e.target.value)}
         />
@@ -60,47 +103,118 @@ export default function RoutesPage() {
         </div>
       )}
 
-      <div className="space-y-8">
-        {routesByAgency &&
-          Object.entries(routesByAgency)
-            .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;
-            })
-            .map(([agency, agencyRoutes]) => (
-              <div key={agency}>
-                <h2 className="text-xl font-bold text-text mb-4 border-b border-border pb-2">
-                  {agency}
-                </h2>
-                <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
+      <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">
+              <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>
+          </div>
+        )}
+
+        {sortedAgencyEntries.map(([agency, agencyRoutes]) => {
+          const isFav = isFavoriteAgency(agency);
+          const isExpanded = searchQuery
+            ? true
+            : (expandedAgencies[agency] ?? false);
+
+          return (
+            <div
+              key={agency}
+              className="overflow-hidden rounded-xl border border-border bg-surface"
+            >
+              <div
+                className={`flex items-center justify-between px-4 py-3 select-none ${isExpanded ? "border-b border-border" : ""}`}
+              >
+                <button
+                  type="button"
+                  onClick={() => toggleAgencyExpanded(agency)}
+                  className="flex flex-1 items-center gap-3 text-left"
+                >
+                  <div className="text-muted">
+                    {isExpanded ? (
+                      <ChevronDown size={18} />
+                    ) : (
+                      <ChevronRight size={18} />
+                    )}
+                  </div>
+                  <h2 className="text-base font-semibold text-text">
+                    {agency}
+                  </h2>
+                  <span className="rounded-full bg-background px-2 py-0.5 text-xs text-muted">
+                    {agencyRoutes.length}
+                  </span>
+                </button>
+                <button
+                  type="button"
+                  onClick={() => toggleFavoriteAgency(agency)}
+                  className={`rounded-full p-2 transition-colors ${
+                    isFav
+                      ? "text-yellow-500"
+                      : "text-muted hover:text-yellow-500"
+                  }`}
+                  aria-label={t(
+                    "routes.toggle_favorite_agency",
+                    "Alternar agencia favorita"
+                  )}
+                >
+                  <Star size={16} className={isFav ? "fill-current" : ""} />
+                </button>
+              </div>
+
+              {isExpanded && (
+                <div className="space-y-1 px-3 py-2">
                   {agencyRoutes.map((route) => (
-                    <Link
-                      key={route.id}
-                      to={`/routes/${route.id}`}
-                      className="flex items-center gap-3 p-4 bg-surface rounded-lg shadow hover:shadow-lg transition-shadow border border-border"
-                    >
-                      <RouteIcon
-                        line={route.shortName ?? "?"}
-                        mode="pill"
-                        colour={route.color ?? undefined}
-                        textColour={route.textColor ?? undefined}
-                      />
-                      <div className="flex-1 min-w-0">
-                        <p className="text-sm md:text-md font-semibold text-text">
-                          {route.longName}
-                        </p>
-                      </div>
-                    </Link>
+                    <div key={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 ?? 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>
-              </div>
-            ))}
+              )}
+            </div>
+          );
+        })}
       </div>
     </div>
   );
-- 
cgit v1.3

