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 | |
| 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>
| -rw-r--r-- | src/frontend/app/components/arrivals/ArrivalCard.tsx | 43 | ||||
| -rw-r--r-- | src/frontend/app/components/arrivals/ArrivalList.tsx | 11 | ||||
| -rw-r--r-- | src/frontend/app/components/journey/ActiveJourneyBanner.tsx | 136 | ||||
| -rw-r--r-- | src/frontend/app/components/layout/AppShell.tsx | 6 | ||||
| -rw-r--r-- | src/frontend/app/contexts/JourneyContext.tsx | 84 | ||||
| -rw-r--r-- | src/frontend/app/hooks/useJourneyTracker.ts | 84 | ||||
| -rw-r--r-- | src/frontend/app/i18n/locales/en-GB.json | 14 | ||||
| -rw-r--r-- | src/frontend/app/i18n/locales/es-ES.json | 14 | ||||
| -rw-r--r-- | src/frontend/app/i18n/locales/gl-ES.json | 14 | ||||
| -rw-r--r-- | src/frontend/app/root.tsx | 9 | ||||
| -rw-r--r-- | src/frontend/app/routes/stops-$id.tsx | 47 | ||||
| -rw-r--r-- | src/frontend/package-lock.json | 20 |
12 files changed, 456 insertions, 26 deletions
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<ArrivalCardProps> = ({ arrival, onClick, + onTrack, + isTracked = false, }) => { const { t } = useTranslation(); const { @@ -287,6 +291,43 @@ export const ArrivalCard: React.FC<ArrivalCardProps> = ({ </span> ); })} + + {onTrack && estimate.precision !== "past" && ( + // Use a <span> instead of a <button> here because this element can + // be rendered inside a <button> (when isClickable=true), and nested + // <button> elements are invalid HTML. + <span + role="button" + tabIndex={0} + onClick={(e) => { + e.stopPropagation(); + onTrack(); + }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + e.stopPropagation(); + onTrack(); + } + }} + aria-label={ + isTracked + ? t("journey.stop_tracking", "Detener seguimiento") + : t("journey.track_bus", "Seguir este autobús") + } + aria-pressed={isTracked} + className={`ml-auto text-xs px-2.5 py-0.5 rounded-full flex items-center gap-1 shrink-0 font-medium tracking-wide transition-colors cursor-pointer select-none ${ + isTracked + ? "bg-blue-600 text-white hover:bg-blue-700" + : "bg-black/[0.04] dark:bg-white/[0.08] text-slate-500 dark:text-slate-400 hover:bg-blue-100 dark:hover:bg-blue-900/30 hover:text-blue-600 dark:hover:text-blue-400" + }`} + > + <Navigation className="w-3 h-3" /> + {isTracked + ? t("journey.tracking", "Siguiendo") + : t("journey.track", "Seguir")} + </span> + )} </div> </Tag> ); diff --git a/src/frontend/app/components/arrivals/ArrivalList.tsx b/src/frontend/app/components/arrivals/ArrivalList.tsx index 83eb4f0..18885c8 100644 --- a/src/frontend/app/components/arrivals/ArrivalList.tsx +++ b/src/frontend/app/components/arrivals/ArrivalList.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { useTranslation } from "react-i18next"; import { type Arrival } from "../../api/schema"; import { ArrivalCard } from "./ArrivalCard"; import { ReducedArrivalCard } from "./ReducedArrivalCard"; @@ -7,21 +8,25 @@ interface ArrivalListProps { arrivals: Arrival[]; reduced?: boolean; onArrivalClick?: (arrival: Arrival) => void; + onTrackArrival?: (arrival: Arrival) => void; + trackedTripId?: string; } export const ArrivalList: React.FC<ArrivalListProps> = ({ arrivals, reduced, onArrivalClick, + onTrackArrival, + trackedTripId, }) => { + const { t } = useTranslation(); const clickable = Boolean(onArrivalClick); return ( <div className="flex flex-col flex-1 gap-3"> {arrivals.length === 0 && ( <div className="text-center text-muted mt-16"> - {/* TOOD i18n */} - No hay llegadas próximas disponibles para esta parada. + {t("estimates.none", "No hay llegadas próximas disponibles para esta parada.")} </div> )} {arrivals.map((arrival, index) => @@ -36,6 +41,8 @@ export const ArrivalList: React.FC<ArrivalListProps> = ({ key={`${arrival.tripId}-${index}`} arrival={arrival} onClick={clickable ? () => onArrivalClick?.(arrival) : undefined} + onTrack={onTrackArrival ? () => onTrackArrival(arrival) : undefined} + isTracked={trackedTripId === arrival.tripId} /> ) )} 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 ( + <div + role="status" + aria-live="polite" + className={`relative mx-3 mb-2 rounded-2xl shadow-lg overflow-hidden border transition-colors ${ + isApproaching + ? "bg-primary-400 border-primary-500" + : "bg-primary-600 border-primary-700" + }`} + > + {/* Clickable body — navigates to the stop and opens the map for the tracked trip */} + <Link + to={`/stops/${activeJourney.stopId}`} + state={{ + openMap: true, + selectedTripId: activeJourney.tripId, + }} + aria-label={t("journey.view_on_map", "View on map")} + className="flex items-center gap-3 px-4 py-2.5 pr-14 text-sm text-white w-full" + > + <RouteIcon + line={activeJourney.routeShortName} + colour={activeJourney.routeColour} + textColour={activeJourney.routeTextColour} + mode="pill" + /> + + <div className="flex-1 min-w-0"> + <p className="font-semibold leading-tight truncate"> + {activeJourney.headsignDestination ?? + t("journey.tracking_bus", "Siguiendo autobús")} + </p> + <p className="text-xs opacity-80 truncate"> + {activeJourney.stopName} + {" · "} + {minutesLabel} + </p> + </div> + + {permissionState === "denied" && ( + <span + title={t( + "journey.notifications_blocked", + "Notificaciones bloqueadas" + )} + className="opacity-60 shrink-0" + > + <Bell size={16} className="line-through" /> + </span> + )} + + <Map size={16} className="opacity-60 shrink-0" /> + </Link> + + {/* Cancel button — absolutely positioned so it doesn't nest inside the Link */} + <button + type="button" + onClick={stopJourney} + aria-label={t("journey.stop_tracking", "Detener seguimiento")} + className="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 rounded-full text-white hover:bg-white/20 transition-colors shrink-0" + > + <X size={18} /> + </button> + </div> + ); +} 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 ( <div className="app-shell"> <ThemeColorManager /> @@ -29,6 +34,7 @@ const AppShellContent: React.FC = () => { <Outlet key={location.pathname} /> </main> </div> + <ActiveJourneyBanner /> <footer className="app-shell__bottom-nav"> <NavBar /> </footer> 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; +} 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 ( <QueryClientProvider client={queryClient}> <AppProvider> - <PlannerProvider> - <AppShell /> - </PlannerProvider> + <JourneyProvider> + <PlannerProvider> + <AppShell /> + </PlannerProvider> + </JourneyProvider> </AppProvider> </QueryClientProvider> ); 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 && ( diff --git a/src/frontend/package-lock.json b/src/frontend/package-lock.json index b1dccd6..5ee9784 100644 --- a/src/frontend/package-lock.json +++ b/src/frontend/package-lock.json @@ -91,7 +91,6 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -1479,7 +1478,6 @@ "integrity": "sha512-vh5lr41rioXLz/zNLTYo0zq4yh97AkgEkJK7bhPeXnNbLNtI36WCZ2AeBtSJ4sdx4gx5LZvcjP8zoWFfSbNupA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@mjackson/node-fetch-server": "^0.2.0", "@react-router/express": "7.13.1", @@ -2151,7 +2149,6 @@ "integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -2162,7 +2159,6 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2232,7 +2228,6 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -2564,7 +2559,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2782,7 +2776,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", @@ -3354,7 +3347,6 @@ "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3562,7 +3554,6 @@ "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -4364,8 +4355,7 @@ "version": "1.9.4", "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", - "license": "BSD-2-Clause", - "peer": true + "license": "BSD-2-Clause" }, "node_modules/leaflet.markercluster": { "version": "1.5.3", @@ -4703,7 +4693,6 @@ "integrity": "sha512-REhYUN8gNP3HlcIZS6QU2uy8iovl31cXsrNDkCcqWSQbCkcpdYLczqDz5PVIwNH42UQNyvukjes/RoHPDrOUmQ==", "dev": true, "license": "BSD-3-Clause", - "peer": true, "dependencies": { "@mapbox/geojson-rewind": "^0.5.2", "@mapbox/jsonlint-lines-primitives": "^2.0.2", @@ -5176,7 +5165,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -5359,7 +5347,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -5369,7 +5356,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -5511,7 +5497,6 @@ "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz", "integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==", "license": "MIT", - "peer": true, "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" @@ -6154,7 +6139,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6327,7 +6311,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -6486,7 +6469,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } |
