From 695c7a65a1e9ab3b95beeaf02a1e3b10bb16996b Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Tue, 24 Mar 2026 20:32:17 +0100
Subject: feat: client-side trip tracking with browser notifications (#151)

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: arielcostas <94913521+arielcostas@users.noreply.github.com>
---
 src/frontend/app/routes/stops-$id.tsx | 47 ++++++++++++++++++++++++++++++++++-
 1 file changed, 46 insertions(+), 1 deletion(-)

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

diff --git a/src/frontend/app/routes/stops-$id.tsx b/src/frontend/app/routes/stops-$id.tsx
index 4b32040..b3d7e86 100644
--- a/src/frontend/app/routes/stops-$id.tsx
+++ b/src/frontend/app/routes/stops-$id.tsx
@@ -1,7 +1,7 @@
 import { CircleHelp, Eye, EyeClosed, Star } from "lucide-react";
 import { useCallback, useEffect, useMemo, useState } from "react";
 import { useTranslation } from "react-i18next";
-import { useParams } from "react-router";
+import { useLocation, useParams } from "react-router";
 import { fetchArrivals } from "~/api/arrivals";
 import {
   type Arrival,
@@ -17,6 +17,7 @@ import { StopHelpModal } from "~/components/stop/StopHelpModal";
 import { StopMapModal } from "~/components/stop/StopMapModal";
 import { StopUsageChart } from "~/components/stop/StopUsageChart";
 import { usePageRightNode, usePageTitle } from "~/contexts/PageTitleContext";
+import { useJourney } from "~/contexts/JourneyContext";
 import { formatHex } from "~/utils/colours";
 import StopDataProvider from "../data/StopDataProvider";
 import "../tailwind-full.css";
@@ -66,6 +67,7 @@ interface ErrorInfo {
 export default function Estimates() {
   const { t } = useTranslation();
   const params = useParams();
+  const location = useLocation();
   const stopId = params.id ?? "";
   const stopFeedId = stopId.split(":")[0] || stopId;
   const fallbackStopCode = stopId.split(":")[1] || stopId;
@@ -89,6 +91,47 @@ export default function Estimates() {
     string | undefined
   >(undefined);
 
+  // Journey tracking
+  const { activeJourney, startJourney, stopJourney } = useJourney();
+  const trackedTripId =
+    activeJourney?.stopId === stopId ? activeJourney.tripId : undefined;
+
+  // If navigated from the journey banner, open the map for the tracked trip.
+  // Empty dependency array is intentional: we only consume the navigation state
+  // once on mount (location.state is fixed for the lifetime of this component
+  // instance; setters from useState are stable and don't need to be listed).
+  useEffect(() => {
+    const state = location.state as
+      | { openMap?: boolean; selectedTripId?: string }
+      | null
+      | undefined;
+    if (state?.openMap && state?.selectedTripId) {
+      setSelectedArrivalId(state.selectedTripId);
+      setIsMapModalOpen(true);
+    }
+  }, []); // mount-only: see comment above
+
+  const handleTrackArrival = useCallback(
+    (arrival: Arrival) => {
+      if (activeJourney?.tripId === arrival.tripId) {
+        stopJourney();
+        return;
+      }
+      startJourney({
+        tripId: arrival.tripId,
+        stopId,
+        stopName: stopName ?? stopId,
+        routeShortName: arrival.route.shortName,
+        routeColour: arrival.route.colour,
+        routeTextColour: arrival.route.textColour,
+        headsignDestination: arrival.headsign.destination,
+        initialMinutes: arrival.estimate.minutes,
+        notifyAtMinutes: 2,
+      });
+    },
+    [activeJourney, startJourney, stopJourney, stopId, stopName]
+  );
+
   // Helper function to get the display name for the stop
   const getStopDisplayName = useCallback(() => {
     if (stopName) return stopName;
@@ -251,6 +294,8 @@ export default function Estimates() {
                   setSelectedArrivalId(getArrivalId(arrival));
                   setIsMapModalOpen(true);
                 }}
+                onTrackArrival={handleTrackArrival}
+                trackedTripId={trackedTripId}
               />
 
               {data.usage && data.usage.length > 0 && (
-- 
cgit v1.3

