diff options
| author | Copilot <198982749+Copilot@users.noreply.github.com> | 2026-03-24 20:32:17 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-03-24 20:32:17 +0100 |
| commit | 695c7a65a1e9ab3b95beeaf02a1e3b10bb16996b (patch) | |
| tree | f302b91a050e3ecfb295b5d16c6ab2962de1a713 /src/frontend/app/hooks | |
| parent | 757960525576038898d655b630cbaac44671f599 (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')
| -rw-r--r-- | src/frontend/app/hooks/useJourneyTracker.ts | 84 |
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]); +} |
