aboutsummaryrefslogtreecommitdiff
path: root/src/frontend/app/routes/routes-$id.tsx
diff options
context:
space:
mode:
authorAriel Costas Guerrero <ariel@costas.dev>2026-03-09 00:00:39 +0100
committerAriel Costas Guerrero <ariel@costas.dev>2026-03-09 00:00:50 +0100
commitd71f0ed16d175285f2e8cbde6091994c2aa1d962 (patch)
treee8b0bcc3f432fa9d5243dd4595af256511643151 /src/frontend/app/routes/routes-$id.tsx
parent5288cfbed34f94c4321b8d9dc497cfd0da3ffd26 (diff)
Enhance route details handling and add favorites functionality; improve error logging and response structure
Diffstat (limited to 'src/frontend/app/routes/routes-$id.tsx')
-rw-r--r--src/frontend/app/routes/routes-$id.tsx159
1 files changed, 133 insertions, 26 deletions
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) ?? [];
@@ -240,16 +299,16 @@ export default function RouteDetailsPage() {
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"),
icon: List,
@@ -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}`}