aboutsummaryrefslogtreecommitdiff
path: root/src/frontend/app/hooks/useJourneyTracker.ts
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 /src/frontend/app/hooks/useJourneyTracker.ts
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>
Diffstat (limited to 'src/frontend/app/hooks/useJourneyTracker.ts')
-rw-r--r--src/frontend/app/hooks/useJourneyTracker.ts84
1 files changed, 84 insertions, 0 deletions
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]);
+}