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> --- .../app/components/arrivals/ArrivalCard.tsx | 43 ++++++- .../app/components/arrivals/ArrivalList.tsx | 11 +- .../app/components/journey/ActiveJourneyBanner.tsx | 136 +++++++++++++++++++++ src/frontend/app/components/layout/AppShell.tsx | 6 + src/frontend/app/contexts/JourneyContext.tsx | 84 +++++++++++++ src/frontend/app/hooks/useJourneyTracker.ts | 84 +++++++++++++ src/frontend/app/i18n/locales/en-GB.json | 14 +++ src/frontend/app/i18n/locales/es-ES.json | 14 +++ src/frontend/app/i18n/locales/gl-ES.json | 14 +++ src/frontend/app/root.tsx | 9 +- src/frontend/app/routes/stops-$id.tsx | 47 ++++++- 11 files changed, 455 insertions(+), 7 deletions(-) create mode 100644 src/frontend/app/components/journey/ActiveJourneyBanner.tsx create mode 100644 src/frontend/app/contexts/JourneyContext.tsx create mode 100644 src/frontend/app/hooks/useJourneyTracker.ts (limited to 'src/frontend/app') 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 = ({ arrival, onClick, + onTrack, + isTracked = false, }) => { const { t } = useTranslation(); const { @@ -287,6 +291,43 @@ export const ArrivalCard: React.FC = ({ ); })} + + {onTrack && estimate.precision !== "past" && ( + // Use a instead of a + + ); +} 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 (
@@ -29,6 +34,7 @@ const AppShellContent: React.FC = () => {
+
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 + ) => void; + stopJourney: () => void; + markNotified: () => void; +} + +const JourneyContext = createContext(null); + +export function JourneyProvider({ children }: { children: ReactNode }) { + const [activeJourney, setActiveJourney] = useState( + null + ); + const notificationRef = useRef(null); + + const startJourney = useCallback( + (journey: Omit) => { + // 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 ( + + {children} + + ); +} + +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 ( - - - + + + + + ); 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