summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCopilot <198982749+Copilot@users.noreply.github.com>2026-03-24 20:32:17 +0100
committerGitHub <noreply@github.com>2026-03-24 20:32:17 +0100
commit695c7a65a1e9ab3b95beeaf02a1e3b10bb16996b (patch)
treef302b91a050e3ecfb295b5d16c6ab2962de1a713
parent757960525576038898d655b630cbaac44671f599 (diff)
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>
-rw-r--r--src/frontend/app/components/arrivals/ArrivalCard.tsx43
-rw-r--r--src/frontend/app/components/arrivals/ArrivalList.tsx11
-rw-r--r--src/frontend/app/components/journey/ActiveJourneyBanner.tsx136
-rw-r--r--src/frontend/app/components/layout/AppShell.tsx6
-rw-r--r--src/frontend/app/contexts/JourneyContext.tsx84
-rw-r--r--src/frontend/app/hooks/useJourneyTracker.ts84
-rw-r--r--src/frontend/app/i18n/locales/en-GB.json14
-rw-r--r--src/frontend/app/i18n/locales/es-ES.json14
-rw-r--r--src/frontend/app/i18n/locales/gl-ES.json14
-rw-r--r--src/frontend/app/root.tsx9
-rw-r--r--src/frontend/app/routes/stops-$id.tsx47
-rw-r--r--src/frontend/package-lock.json20
12 files changed, 456 insertions, 26 deletions
diff --git a/src/frontend/app/components/arrivals/ArrivalCard.tsx b/src/frontend/app/components/arrivals/ArrivalCard.tsx
index bdd20a5..9c68a97 100644
--- a/src/frontend/app/components/arrivals/ArrivalCard.tsx
+++ b/src/frontend/app/components/arrivals/ArrivalCard.tsx
@@ -1,4 +1,4 @@
-import { AlertTriangle, BusFront, LocateIcon } from "lucide-react";
+import { AlertTriangle, BusFront, LocateIcon, Navigation } from "lucide-react";
import React, { useEffect, useMemo, useRef, useState } from "react";
import Marquee from "react-fast-marquee";
import { useTranslation } from "react-i18next";
@@ -9,6 +9,8 @@ import "./ArrivalCard.css";
interface ArrivalCardProps {
arrival: Arrival;
onClick?: () => void;
+ onTrack?: () => void;
+ isTracked?: boolean;
}
const AutoMarquee = ({ text }: { text: string }) => {
@@ -57,6 +59,8 @@ const AutoMarquee = ({ text }: { text: string }) => {
export const ArrivalCard: React.FC<ArrivalCardProps> = ({
arrival,
onClick,
+ onTrack,
+ isTracked = false,
}) => {
const { t } = useTranslation();
const {
@@ -287,6 +291,43 @@ export const ArrivalCard: React.FC<ArrivalCardProps> = ({
</span>
);
})}
+
+ {onTrack && estimate.precision !== "past" && (
+ // Use a <span> instead of a <button> here because this element can
+ // be rendered inside a <button> (when isClickable=true), and nested
+ // <button> elements are invalid HTML.
+ <span
+ role="button"
+ tabIndex={0}
+ onClick={(e) => {
+ e.stopPropagation();
+ onTrack();
+ }}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ e.stopPropagation();
+ onTrack();
+ }
+ }}
+ aria-label={
+ isTracked
+ ? t("journey.stop_tracking", "Detener seguimiento")
+ : t("journey.track_bus", "Seguir este autobús")
+ }
+ aria-pressed={isTracked}
+ className={`ml-auto text-xs px-2.5 py-0.5 rounded-full flex items-center gap-1 shrink-0 font-medium tracking-wide transition-colors cursor-pointer select-none ${
+ isTracked
+ ? "bg-blue-600 text-white hover:bg-blue-700"
+ : "bg-black/[0.04] dark:bg-white/[0.08] text-slate-500 dark:text-slate-400 hover:bg-blue-100 dark:hover:bg-blue-900/30 hover:text-blue-600 dark:hover:text-blue-400"
+ }`}
+ >
+ <Navigation className="w-3 h-3" />
+ {isTracked
+ ? t("journey.tracking", "Siguiendo")
+ : t("journey.track", "Seguir")}
+ </span>
+ )}
</div>
</Tag>
);
diff --git a/src/frontend/app/components/arrivals/ArrivalList.tsx b/src/frontend/app/components/arrivals/ArrivalList.tsx
index 83eb4f0..18885c8 100644
--- a/src/frontend/app/components/arrivals/ArrivalList.tsx
+++ b/src/frontend/app/components/arrivals/ArrivalList.tsx
@@ -1,4 +1,5 @@
import React from "react";
+import { useTranslation } from "react-i18next";
import { type Arrival } from "../../api/schema";
import { ArrivalCard } from "./ArrivalCard";
import { ReducedArrivalCard } from "./ReducedArrivalCard";
@@ -7,21 +8,25 @@ interface ArrivalListProps {
arrivals: Arrival[];
reduced?: boolean;
onArrivalClick?: (arrival: Arrival) => void;
+ onTrackArrival?: (arrival: Arrival) => void;
+ trackedTripId?: string;
}
export const ArrivalList: React.FC<ArrivalListProps> = ({
arrivals,
reduced,
onArrivalClick,
+ onTrackArrival,
+ trackedTripId,
}) => {
+ const { t } = useTranslation();
const clickable = Boolean(onArrivalClick);
return (
<div className="flex flex-col flex-1 gap-3">
{arrivals.length === 0 && (
<div className="text-center text-muted mt-16">
- {/* TOOD i18n */}
- No hay llegadas próximas disponibles para esta parada.
+ {t("estimates.none", "No hay llegadas próximas disponibles para esta parada.")}
</div>
)}
{arrivals.map((arrival, index) =>
@@ -36,6 +41,8 @@ export const ArrivalList: React.FC<ArrivalListProps> = ({
key={`${arrival.tripId}-${index}`}
arrival={arrival}
onClick={clickable ? () => onArrivalClick?.(arrival) : undefined}
+ onTrack={onTrackArrival ? () => onTrackArrival(arrival) : undefined}
+ isTracked={trackedTripId === arrival.tripId}
/>
)
)}
diff --git a/src/frontend/app/components/journey/ActiveJourneyBanner.tsx b/src/frontend/app/components/journey/ActiveJourneyBanner.tsx
new file mode 100644
index 0000000..df853ca
--- /dev/null
+++ b/src/frontend/app/components/journey/ActiveJourneyBanner.tsx
@@ -0,0 +1,136 @@
+import { Bell, Map, X } from "lucide-react";
+import { useEffect, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { Link } from "react-router";
+import RouteIcon from "~/components/RouteIcon";
+import { useJourney } from "~/contexts/JourneyContext";
+import { useStopArrivals } from "~/hooks/useArrivals";
+
+/**
+ * A sticky banner rendered at the bottom of the AppShell (above the nav bar)
+ * while a journey is being tracked. Shows live minutes-remaining count and
+ * lets the user cancel tracking.
+ */
+export function ActiveJourneyBanner() {
+ const { t } = useTranslation();
+ const { activeJourney, stopJourney } = useJourney();
+
+ const { data } = useStopArrivals(
+ activeJourney?.stopId ?? "",
+ false,
+ !!activeJourney
+ );
+
+ const [permissionState, setPermissionState] = useState<
+ NotificationPermission | "unsupported"
+ >(() => {
+ if (typeof Notification === "undefined") return "unsupported";
+ return Notification.permission;
+ });
+
+ // Request notification permission the first time a journey starts
+ const hasRequestedRef = useRef(false);
+ useEffect(() => {
+ if (!activeJourney || hasRequestedRef.current) return;
+ if (
+ typeof Notification === "undefined" ||
+ Notification.permission !== "default"
+ )
+ return;
+
+ hasRequestedRef.current = true;
+ Notification.requestPermission().then((perm) => {
+ setPermissionState(perm);
+ });
+ }, [activeJourney]);
+
+ if (!activeJourney) return null;
+
+ const liveArrival = data?.arrivals.find(
+ (a) => a.tripId === activeJourney.tripId
+ );
+
+ const minutes = liveArrival?.estimate.minutes;
+ const precision = liveArrival?.estimate.precision;
+
+ const minutesLabel =
+ minutes == null
+ ? "–"
+ : minutes <= 0
+ ? t("journey.arriving_now", "¡Llegando!")
+ : t("journey.minutes_away", {
+ defaultValue: "{{minutes}} min",
+ minutes,
+ });
+
+ const isApproaching =
+ minutes != null &&
+ minutes <= activeJourney.notifyAtMinutes &&
+ precision !== "past";
+
+ return (
+ <div
+ role="status"
+ aria-live="polite"
+ className={`relative mx-3 mb-2 rounded-2xl shadow-lg overflow-hidden border transition-colors ${
+ isApproaching
+ ? "bg-primary-400 border-primary-500"
+ : "bg-primary-600 border-primary-700"
+ }`}
+ >
+ {/* Clickable body — navigates to the stop and opens the map for the tracked trip */}
+ <Link
+ to={`/stops/${activeJourney.stopId}`}
+ state={{
+ openMap: true,
+ selectedTripId: activeJourney.tripId,
+ }}
+ aria-label={t("journey.view_on_map", "View on map")}
+ className="flex items-center gap-3 px-4 py-2.5 pr-14 text-sm text-white w-full"
+ >
+ <RouteIcon
+ line={activeJourney.routeShortName}
+ colour={activeJourney.routeColour}
+ textColour={activeJourney.routeTextColour}
+ mode="pill"
+ />
+
+ <div className="flex-1 min-w-0">
+ <p className="font-semibold leading-tight truncate">
+ {activeJourney.headsignDestination ??
+ t("journey.tracking_bus", "Siguiendo autobús")}
+ </p>
+ <p className="text-xs opacity-80 truncate">
+ {activeJourney.stopName}
+ {" · "}
+ {minutesLabel}
+ </p>
+ </div>
+
+ {permissionState === "denied" && (
+ <span
+ title={t(
+ "journey.notifications_blocked",
+ "Notificaciones bloqueadas"
+ )}
+ className="opacity-60 shrink-0"
+ >
+ <Bell size={16} className="line-through" />
+ </span>
+ )}
+
+ <Map size={16} className="opacity-60 shrink-0" />
+ </Link>
+
+ {/* Cancel button — absolutely positioned so it doesn't nest inside the Link */}
+ <button
+ type="button"
+ onClick={stopJourney}
+ aria-label={t("journey.stop_tracking", "Detener seguimiento")}
+ className="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 rounded-full text-white hover:bg-white/20 transition-colors shrink-0"
+ >
+ <X size={18} />
+ </button>
+ </div>
+ );
+}
diff --git a/src/frontend/app/components/layout/AppShell.tsx b/src/frontend/app/components/layout/AppShell.tsx
index 50f5742..6bb5e34 100644
--- a/src/frontend/app/components/layout/AppShell.tsx
+++ b/src/frontend/app/components/layout/AppShell.tsx
@@ -4,6 +4,8 @@ import {
PageTitleProvider,
usePageTitleContext,
} from "~/contexts/PageTitleContext";
+import { ActiveJourneyBanner } from "~/components/journey/ActiveJourneyBanner";
+import { useJourneyTracker } from "~/hooks/useJourneyTracker";
import { ThemeColorManager } from "../ThemeColorManager";
import "./AppShell.css";
import { Drawer } from "./Drawer";
@@ -15,6 +17,9 @@ const AppShellContent: React.FC = () => {
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const location = useLocation();
+ // Mount journey tracker at shell level so tracking persists across navigation
+ useJourneyTracker();
+
return (
<div className="app-shell">
<ThemeColorManager />
@@ -29,6 +34,7 @@ const AppShellContent: React.FC = () => {
<Outlet key={location.pathname} />
</main>
</div>
+ <ActiveJourneyBanner />
<footer className="app-shell__bottom-nav">
<NavBar />
</footer>
diff --git a/src/frontend/app/contexts/JourneyContext.tsx b/src/frontend/app/contexts/JourneyContext.tsx
new file mode 100644
index 0000000..f513aa8
--- /dev/null
+++ b/src/frontend/app/contexts/JourneyContext.tsx
@@ -0,0 +1,84 @@
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useRef,
+ useState,
+ type ReactNode,
+} from "react";
+
+export interface ActiveJourney {
+ tripId: string;
+ stopId: string;
+ stopName: string;
+ routeShortName: string;
+ routeColour: string;
+ routeTextColour: string;
+ headsignDestination: string | null;
+ /** Minutes remaining when tracking was started (for display context) */
+ initialMinutes: number;
+ /** Send notification when this many minutes remain (default: 2) */
+ notifyAtMinutes: number;
+ /** Whether the "approaching" notification has already been sent */
+ hasNotified: boolean;
+}
+
+interface JourneyContextValue {
+ activeJourney: ActiveJourney | null;
+ startJourney: (
+ journey: Omit<ActiveJourney, "hasNotified">
+ ) => void;
+ stopJourney: () => void;
+ markNotified: () => void;
+}
+
+const JourneyContext = createContext<JourneyContextValue | null>(null);
+
+export function JourneyProvider({ children }: { children: ReactNode }) {
+ const [activeJourney, setActiveJourney] = useState<ActiveJourney | null>(
+ null
+ );
+ const notificationRef = useRef<Notification | null>(null);
+
+ const startJourney = useCallback(
+ (journey: Omit<ActiveJourney, "hasNotified">) => {
+ // Close any existing notification
+ if (notificationRef.current) {
+ notificationRef.current.close();
+ notificationRef.current = null;
+ }
+ setActiveJourney({ ...journey, hasNotified: false });
+ },
+ []
+ );
+
+ const stopJourney = useCallback(() => {
+ if (notificationRef.current) {
+ notificationRef.current.close();
+ notificationRef.current = null;
+ }
+ setActiveJourney(null);
+ }, []);
+
+ const markNotified = useCallback(() => {
+ setActiveJourney((prev) =>
+ prev ? { ...prev, hasNotified: true } : null
+ );
+ }, []);
+
+ return (
+ <JourneyContext.Provider
+ value={{ activeJourney, startJourney, stopJourney, markNotified }}
+ >
+ {children}
+ </JourneyContext.Provider>
+ );
+}
+
+export function useJourney() {
+ const ctx = useContext(JourneyContext);
+ if (!ctx) {
+ throw new Error("useJourney must be used within a JourneyProvider");
+ }
+ return ctx;
+}
diff --git a/src/frontend/app/hooks/useJourneyTracker.ts b/src/frontend/app/hooks/useJourneyTracker.ts
new file mode 100644
index 0000000..e9be393
--- /dev/null
+++ b/src/frontend/app/hooks/useJourneyTracker.ts
@@ -0,0 +1,84 @@
+import { useEffect, useRef } from "react";
+import { useTranslation } from "react-i18next";
+import { useStopArrivals } from "./useArrivals";
+import { useJourney } from "../contexts/JourneyContext";
+
+/**
+ * Polls the stop arrivals for the active journey and fires a browser
+ * notification when the tracked bus is approaching.
+ *
+ * Mount this hook once at the app-shell level so it continues tracking
+ * even when the user navigates away from the stop page.
+ */
+export function useJourneyTracker() {
+ const { t } = useTranslation();
+ const { activeJourney, stopJourney, markNotified } = useJourney();
+
+ const stopId = activeJourney?.stopId ?? "";
+ const enabled = !!activeJourney;
+
+ const { data } = useStopArrivals(stopId, false, enabled);
+
+ // Keep a stable ref so the effect below doesn't re-run on every render
+ const journeyRef = useRef(activeJourney);
+ useEffect(() => {
+ journeyRef.current = activeJourney;
+ }, [activeJourney]);
+
+ useEffect(() => {
+ if (!data || !activeJourney) return;
+
+ const journey = journeyRef.current;
+ if (!journey) return;
+
+ const arrival = data.arrivals.find((a) => a.tripId === journey.tripId);
+
+ if (!arrival) {
+ // Trip is no longer in the arrivals list — it has passed or expired
+ stopJourney();
+ return;
+ }
+
+ const { minutes, precision } = arrival.estimate;
+
+ // Trip already departed from this stop
+ if (precision === "past") {
+ stopJourney();
+ return;
+ }
+
+ // Fire approaching notification if not already sent
+ if (!journey.hasNotified && minutes <= journey.notifyAtMinutes) {
+ markNotified();
+
+ if (
+ typeof Notification !== "undefined" &&
+ Notification.permission === "granted"
+ ) {
+ const title =
+ minutes <= 0
+ ? t(
+ "journey.notification_now_title",
+ "¡Tu autobús está llegando!"
+ )
+ : t("journey.notification_approaching_title", {
+ defaultValue: "Tu autobús llega en {{minutes}} min",
+ minutes,
+ });
+
+ const body = t("journey.notification_body", {
+ defaultValue: "Línea {{line}} dirección {{destination}} — {{stop}}",
+ line: journey.routeShortName,
+ destination: journey.headsignDestination ?? "",
+ stop: journey.stopName,
+ });
+
+ new Notification(title, {
+ body,
+ icon: "/icon-512.png",
+ tag: `journey-${journey.tripId}`,
+ });
+ }
+ }
+ }, [data, activeJourney, markNotified, stopJourney, t]);
+}
diff --git a/src/frontend/app/i18n/locales/en-GB.json b/src/frontend/app/i18n/locales/en-GB.json
index 25a7e7b..152edb8 100644
--- a/src/frontend/app/i18n/locales/en-GB.json
+++ b/src/frontend/app/i18n/locales/en-GB.json
@@ -249,5 +249,19 @@
"usage_passengers": "pax",
"usage_disclaimer": "Based on average historical occupancy from recent months available at datos.vigo.org. Does not reflect real-time occupancy.",
"usage_scale_info": "Graph uses a non-linear scale to better highlight lower occupancy values."
+ },
+ "journey": {
+ "track": "Track",
+ "tracking": "Tracking",
+ "track_bus": "Track this bus",
+ "stop_tracking": "Stop tracking",
+ "tracking_bus": "Tracking bus",
+ "arriving_now": "Arriving now!",
+ "minutes_away": "{{minutes}} min",
+ "notifications_blocked": "Notifications blocked",
+ "notification_now_title": "Your bus is arriving!",
+ "notification_approaching_title": "Your bus arrives in {{minutes}} min",
+ "notification_body": "Line {{line}} towards {{destination}} — {{stop}}",
+ "view_on_map": "View on map"
}
}
diff --git a/src/frontend/app/i18n/locales/es-ES.json b/src/frontend/app/i18n/locales/es-ES.json
index a97534d..364cb5b 100644
--- a/src/frontend/app/i18n/locales/es-ES.json
+++ b/src/frontend/app/i18n/locales/es-ES.json
@@ -249,5 +249,19 @@
"usage_passengers": "pas.",
"usage_disclaimer": "Basado en la ocupación histórica promedio de los últimos meses disponible en datos.vigo.org. No refleja la ocupación en tiempo real.",
"usage_scale_info": "La escala del gráfico no es lineal para resaltar mejor los valores bajos."
+ },
+ "journey": {
+ "track": "Seguir",
+ "tracking": "Siguiendo",
+ "track_bus": "Seguir este autobús",
+ "stop_tracking": "Detener seguimiento",
+ "tracking_bus": "Siguiendo autobús",
+ "arriving_now": "¡Llegando!",
+ "minutes_away": "{{minutes}} min",
+ "notifications_blocked": "Notificaciones bloqueadas",
+ "notification_now_title": "¡Tu autobús está llegando!",
+ "notification_approaching_title": "Tu autobús llega en {{minutes}} min",
+ "notification_body": "Línea {{line}} dirección {{destination}} — {{stop}}",
+ "view_on_map": "Ver en el mapa"
}
}
diff --git a/src/frontend/app/i18n/locales/gl-ES.json b/src/frontend/app/i18n/locales/gl-ES.json
index 36a1c66..e66f18c 100644
--- a/src/frontend/app/i18n/locales/gl-ES.json
+++ b/src/frontend/app/i18n/locales/gl-ES.json
@@ -245,5 +245,19 @@
"usage_passengers": "pas.",
"usage_disclaimer": "Baseado na ocupación histórica media dos últimos meses dispoñible en datos.vigo.org. Non reflicte a ocupación en tempo real.",
"usage_scale_info": "A escala do gráfico non é lineal para resaltar mellor os valores baixos."
+ },
+ "journey": {
+ "track": "Seguir",
+ "tracking": "Seguindo",
+ "track_bus": "Seguir este autobús",
+ "stop_tracking": "Deter seguimento",
+ "tracking_bus": "Seguindo autobús",
+ "arriving_now": "¡Chegando!",
+ "minutes_away": "{{minutes}} min",
+ "notifications_blocked": "Notificacións bloqueadas",
+ "notification_now_title": "¡O teu autobús está chegando!",
+ "notification_approaching_title": "O teu autobús chega en {{minutes}} min",
+ "notification_body": "Liña {{line}} dirección {{destination}} — {{stop}}",
+ "view_on_map": "Ver no mapa"
}
}
diff --git a/src/frontend/app/root.tsx b/src/frontend/app/root.tsx
index 5faafd8..72daab9 100644
--- a/src/frontend/app/root.tsx
+++ b/src/frontend/app/root.tsx
@@ -22,6 +22,7 @@ maplibregl.addProtocol("pmtiles", pmtiles.tile);
//#endregion
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { JourneyProvider } from "./contexts/JourneyContext";
import { PlannerProvider } from "./contexts/PlannerContext";
import "./i18n";
@@ -342,9 +343,11 @@ export default function App() {
return (
<QueryClientProvider client={queryClient}>
<AppProvider>
- <PlannerProvider>
- <AppShell />
- </PlannerProvider>
+ <JourneyProvider>
+ <PlannerProvider>
+ <AppShell />
+ </PlannerProvider>
+ </JourneyProvider>
</AppProvider>
</QueryClientProvider>
);
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 && (
diff --git a/src/frontend/package-lock.json b/src/frontend/package-lock.json
index b1dccd6..5ee9784 100644
--- a/src/frontend/package-lock.json
+++ b/src/frontend/package-lock.json
@@ -91,7 +91,6 @@
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.3",
@@ -1479,7 +1478,6 @@
"integrity": "sha512-vh5lr41rioXLz/zNLTYo0zq4yh97AkgEkJK7bhPeXnNbLNtI36WCZ2AeBtSJ4sdx4gx5LZvcjP8zoWFfSbNupA==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@mjackson/node-fetch-server": "^0.2.0",
"@react-router/express": "7.13.1",
@@ -2151,7 +2149,6 @@
"integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==",
"devOptional": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"undici-types": "~7.18.0"
}
@@ -2162,7 +2159,6 @@
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -2232,7 +2228,6 @@
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.56.1",
"@typescript-eslint/types": "8.56.1",
@@ -2564,7 +2559,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2782,7 +2776,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.9",
"caniuse-lite": "^1.0.30001746",
@@ -3354,7 +3347,6 @@
"integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -3562,7 +3554,6 @@
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
@@ -4364,8 +4355,7 @@
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
- "license": "BSD-2-Clause",
- "peer": true
+ "license": "BSD-2-Clause"
},
"node_modules/leaflet.markercluster": {
"version": "1.5.3",
@@ -4703,7 +4693,6 @@
"integrity": "sha512-REhYUN8gNP3HlcIZS6QU2uy8iovl31cXsrNDkCcqWSQbCkcpdYLczqDz5PVIwNH42UQNyvukjes/RoHPDrOUmQ==",
"dev": true,
"license": "BSD-3-Clause",
- "peer": true,
"dependencies": {
"@mapbox/geojson-rewind": "^0.5.2",
"@mapbox/jsonlint-lines-primitives": "^2.0.2",
@@ -5176,7 +5165,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -5359,7 +5347,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -5369,7 +5356,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -5511,7 +5497,6 @@
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz",
"integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==",
"license": "MIT",
- "peer": true,
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
@@ -6154,7 +6139,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
- "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -6327,7 +6311,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"license": "MIT",
- "peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -6486,7 +6469,6 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"license": "MIT",
- "peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}