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/journey/ActiveJourneyBanner.tsx | 136 +++++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 src/frontend/app/components/journey/ActiveJourneyBanner.tsx (limited to 'src/frontend/app/components/journey') 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 ( +
+ {activeJourney.headsignDestination ?? + t("journey.tracking_bus", "Siguiendo autobús")} +
++ {activeJourney.stopName} + {" · "} + {minutesLabel} +
+