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-$id.tsx | 159 +++++++++++++++++++++++++++------
 1 file changed, 133 insertions(+), 26 deletions(-)

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

diff --git a/src/frontend/app/routes/routes-$id.tsx b/src/frontend/app/routes/routes-$id.tsx
index 6cc872d..2174244 100644
--- a/src/frontend/app/routes/routes-$id.tsx
+++ b/src/frontend/app/routes/routes-$id.tsx
@@ -1,11 +1,14 @@
 import { useQuery } from "@tanstack/react-query";
 import {
+  ArrowDownCircle,
+  ArrowUpCircle,
   Bus,
   ChevronDown,
   Clock,
   LayoutGrid,
   List,
   Map as MapIcon,
+  Star,
   X,
 } from "lucide-react";
 import { useMemo, useRef, useState } from "react";
@@ -21,13 +24,38 @@ import { fetchRouteDetails } from "~/api/transit";
 import { AppMap } from "~/components/shared/AppMap";
 import {
   useBackButton,
+  usePageRightNode,
   usePageTitle,
   usePageTitleNode,
 } from "~/contexts/PageTitleContext";
 import { useStopArrivals } from "~/hooks/useArrivals";
+import { useFavorites } from "~/hooks/useFavorites";
 import { formatHex } from "~/utils/colours";
 import "../tailwind-full.css";
 
+function FavoriteStar({ id }: { id?: string }) {
+  const { isFavorite, toggleFavorite } = useFavorites("favouriteRoutes");
+  const { t } = useTranslation();
+
+  if (!id) return null;
+
+  const isFav = isFavorite(id);
+
+  return (
+    <button
+      type="button"
+      onClick={() => toggleFavorite(id)}
+      className="p-2 rounded-full hover:bg-surface"
+      aria-label={t("routes.toggle_favorite", "Alternar favorita")}
+    >
+      <Star
+        size={20}
+        className={isFav ? "fill-yellow-500 text-yellow-500" : "text-muted"}
+      />
+    </button>
+  );
+}
+
 export default function RouteDetailsPage() {
   const { id } = useParams();
   const { t, i18n } = useTranslation();
@@ -45,6 +73,8 @@ export default function RouteDetailsPage() {
   const mapRef = useRef<MapRef>(null);
   const stopRefs = useRef<Record<string, HTMLDivElement | null>>({});
 
+  const { isFavorite, toggleFavorite } = useFavorites("favouriteRoutes");
+
   const formatDateKey = (value: Date) => {
     const year = value.getFullYear();
     const month = String(value.getMonth() + 1).padStart(2, "0");
@@ -138,6 +168,9 @@ export default function RouteDetailsPage() {
 
   usePageTitleNode(titleNode);
 
+  const rightNode = useMemo(() => <FavoriteStar id={id} />, [id]);
+  usePageRightNode(rightNode);
+
   useBackButton({ to: "/routes" });
 
   const weekDays = useMemo(() => {
@@ -169,6 +202,43 @@ export default function RouteDetailsPage() {
     });
   }, [i18n.language, t]);
 
+  const activePatterns = useMemo(() => {
+    return route?.patterns.filter((p) => p.tripCount > 0) ?? [];
+  }, [route?.patterns]);
+
+  const patternsByDirection = useMemo(() => {
+    return activePatterns.reduce(
+      (acc, pattern) => {
+        const dir = pattern.directionId;
+        if (!acc[dir]) acc[dir] = [];
+        acc[dir].push(pattern);
+        return acc;
+      },
+      {} as Record<number, typeof route.patterns>
+    );
+  }, [activePatterns, route?.patterns]);
+
+  const selectedPattern = useMemo(() => {
+    if (!route) return null;
+
+    if (selectedPatternId) {
+      const found = activePatterns.find((p) => p.id === selectedPatternId);
+      if (found) return found;
+    }
+
+    // Try to find the most frequent pattern in direction 0 (outbound)
+    const outboundPatterns = (patternsByDirection[0] ?? []).sort(
+      (a, b) => b.tripCount - a.tripCount
+    );
+    if (outboundPatterns.length > 0) return outboundPatterns[0];
+
+    // Fallback to any pattern with trips
+    const anyPatterns = [...activePatterns].sort(
+      (a, b) => b.tripCount - a.tripCount
+    );
+    return anyPatterns[0] || route.patterns[0];
+  }, [activePatterns, patternsByDirection, selectedPatternId, route]);
+
   if (isLoading) {
     return (
       <div className="flex justify-center py-12">
@@ -183,21 +253,6 @@ export default function RouteDetailsPage() {
     );
   }
 
-  const activePatterns = route.patterns.filter((p) => p.tripCount > 0);
-
-  const patternsByDirection = activePatterns.reduce(
-    (acc, pattern) => {
-      const dir = pattern.directionId;
-      if (!acc[dir]) acc[dir] = [];
-      acc[dir].push(pattern);
-      return acc;
-    },
-    {} as Record<number, typeof route.patterns>
-  );
-
-  const selectedPattern =
-    activePatterns.find((p) => p.id === selectedPatternId) || activePatterns[0];
-
   const selectedPatternLabel = selectedPattern
     ? selectedPattern.headsign || selectedPattern.name
     : t("routes.details", "Detalles de ruta");
@@ -210,6 +265,10 @@ export default function RouteDetailsPage() {
       { departure: number; patternId: string; tripId?: string | null }[]
     >();
 
+    if (selectedPattern?.tripCount === 0) {
+      return byStop;
+    }
+
     for (const pattern of sameDirectionPatterns) {
       for (const stop of pattern.stops) {
         const current = byStop.get(stop.id) ?? [];
@@ -239,16 +298,16 @@ export default function RouteDetailsPage() {
         : "h-[50%] md:h-[50%]";
 
   const layoutOptions = [
-    {
-      id: "balanced",
-      label: t("routes.layout_balanced", "Equilibrada"),
-      icon: LayoutGrid,
-    },
     {
       id: "map",
       label: t("routes.layout_map", "Mapa"),
       icon: MapIcon,
     },
+    {
+      id: "balanced",
+      label: t("routes.layout_balanced", "Equilibrada"),
+      icon: LayoutGrid,
+    },
     {
       id: "list",
       label: t("routes.layout_list", "Paradas"),
@@ -380,11 +439,19 @@ export default function RouteDetailsPage() {
                   type="circle"
                   paint={{
                     "circle-radius": 6,
-                    "circle-color": "#ffffff",
+                    "circle-color": [
+                      "case",
+                      ["==", ["get", "id"], selectedStopId ?? ""],
+                      route.color ? formatHex(route.color) : "#3b82f6",
+                      "#ffffff",
+                    ],
                     "circle-stroke-width": 2,
-                    "circle-stroke-color": route.color
-                      ? formatHex(route.color)
-                      : "#3b82f6",
+                    "circle-stroke-color": [
+                      "case",
+                      ["==", ["get", "id"], selectedStopId ?? ""],
+                      "#ffffff",
+                      route.color ? formatHex(route.color) : "#3b82f6",
+                    ],
                   }}
                 />
               </Source>
@@ -595,6 +662,24 @@ export default function RouteDetailsPage() {
             <h3 className="text-base font-semibold mb-3 text-text">
               {t("routes.stops", "Paradas")}
             </h3>
+
+            {selectedPattern?.tripCount === 0 && (
+              <div className="flex flex-col items-center justify-center py-12 px-4 text-center">
+                <div className="bg-surface p-4 rounded-full mb-4 border border-border">
+                  <Clock size={32} className="text-muted" />
+                </div>
+                <h4 className="text-lg font-bold text-text mb-1">
+                  {t("routes.no_service_today", "Sin servicio hoy")}
+                </h4>
+                <p className="text-sm text-muted max-w-xs">
+                  {t(
+                    "routes.no_service_today_desc",
+                    "Este trayecto no tiene viajes programados para la fecha seleccionada."
+                  )}
+                </p>
+              </div>
+            )}
+
             <div className="space-y-2">
               {selectedPattern?.stops.map((stop, idx) => (
                 <div
@@ -620,15 +705,37 @@ export default function RouteDetailsPage() {
                     )}
                   </div>
                   <div className="flex-1">
-                    <p className="font-semibold text-text text-sm">
+                    <p
+                      className={`font-semibold text-text text-sm ${selectedStopId === stop.id ? "text-primary" : ""}`}
+                    >
                       {stop.name}
                       {stop.code && (
-                        <span className="text-[11px] font-normal text-gray-500 ml-2">
+                        <span
+                          className={`text-[11px] font-normal ml-2 ${selectedStopId === stop.id ? "text-primary/70" : "text-gray-500"}`}
+                        >
                           {stop.code}
                         </span>
                       )}
                     </p>
 
+                    {(stop.pickupType === "NONE" ||
+                      stop.dropOffType === "NONE") && (
+                      <div className="flex items-center gap-1.5 mt-0.5">
+                        {stop.pickupType === "NONE" && (
+                          <span className="inline-flex items-center gap-1 text-[10px] font-medium text-amber-600 dark:text-amber-400">
+                            <ArrowDownCircle size={10} />
+                            {t("routes.drop_off_only", "Solo bajada")}
+                          </span>
+                        )}
+                        {stop.dropOffType === "NONE" && (
+                          <span className="inline-flex items-center gap-1 text-[10px] font-medium text-blue-600 dark:text-blue-400">
+                            <ArrowUpCircle size={10} />
+                            {t("routes.pickup_only", "Solo subida")}
+                          </span>
+                        )}
+                      </div>
+                    )}
+
                     {selectedStopId === stop.id && (
                       <Link
                         to={`/stops/${stop.id}`}
-- 
cgit v1.3

