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/contexts/JourneyContext.tsx | 84 ++++++++++++++++++++++++++++
 1 file changed, 84 insertions(+)
 create mode 100644 src/frontend/app/contexts/JourneyContext.tsx

(limited to 'src/frontend/app/contexts/JourneyContext.tsx')

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;
+}
-- 
cgit v1.3

