From 4f8cafffce21e79dbaf2480d6a6d1409060251e4 Mon Sep 17 00:00:00 2001 From: Zaid Abdulameer Date: Tue, 16 Jun 2026 10:32:40 -0400 Subject: [PATCH] feat: add GTFS service alerts endpoint and sidebar panel Adds backend alerts fetching/parsing (backend/services/gtfs_alerts.py), Cache integration, GET /api/alerts endpoint, useAlerts hook, AlertsPanel component, and alerts tab in sidebar with count badge. Closes #75 --- backend/api/routes.py | 35 ++++++++ backend/services/cache.py | 20 ++++- backend/services/gtfs_alerts.py | 103 ++++++++++++++++++++++++ frontend/src/api/types.ts | 10 +++ frontend/src/components/AlertsPanel.tsx | 65 +++++++++++++++ frontend/src/components/Sidebar.tsx | 26 +++++- frontend/src/hooks/useAlerts.ts | 40 +++++++++ 7 files changed, 296 insertions(+), 3 deletions(-) create mode 100644 backend/services/gtfs_alerts.py create mode 100644 frontend/src/components/AlertsPanel.tsx create mode 100644 frontend/src/hooks/useAlerts.ts diff --git a/backend/api/routes.py b/backend/api/routes.py index 1fe78cd..8143a03 100644 --- a/backend/api/routes.py +++ b/backend/api/routes.py @@ -149,6 +149,26 @@ class NearbyStopsResponse(BaseModel): stops: List[StopItem] +class AlertItem(BaseModel): + """A single service alert.""" + + model_config = ConfigDict(extra="allow") + + alert_id: Optional[str] = None + header_text: Optional[str] = None + description_text: Optional[str] = None + route_ids: List[str] = [] + cause: Optional[str] = None + effect: Optional[str] = None + active_periods: List[Dict[str, int]] = [] + + +class AlertsResponse(BaseModel): + """Response with active service alerts.""" + + alerts: List[AlertItem] + + class VehicleArrivalsResponse(BaseModel): """Response with upcoming stop arrivals for a vehicle's active trip.""" @@ -288,6 +308,21 @@ async def search_stops(q: str, limit: int = 20) -> NearbyStopsResponse: ) +@router.get( + "/api/alerts", + response_model=AlertsResponse, + tags=["alerts"], + summary="List active service alerts", +) +async def list_alerts() -> AlertsResponse: + cache = get_cache() + alerts = await cache.get_alerts() + return JSONResponse( + content={"alerts": alerts}, + headers={"Cache-Control": "public, max-age=15, stale-while-revalidate=30"}, + ) + + @router.get( "/api/stops/{stop_id}/departures", response_model=StopDeparturesResponse, diff --git a/backend/services/cache.py b/backend/services/cache.py index 8c6b510..b0aed78 100644 --- a/backend/services/cache.py +++ b/backend/services/cache.py @@ -7,7 +7,7 @@ from typing import Any, Dict, List, Optional from core.config import Settings, load_settings -from services import gtfs_realtime, gtfs_static +from services import gtfs_alerts, gtfs_realtime, gtfs_static from services.departure_query import DepartureQuery from services.geo_query import GeoQuery @@ -36,6 +36,7 @@ def __init__(self, settings: Settings) -> None: self._task: Optional[asyncio.Task] = None self._geo = GeoQuery() self._departure = DepartureQuery() + self._alerts: List[Dict[str, Any]] = [] def _compute_bearing( self, @@ -186,6 +187,17 @@ async def refresh_once(self) -> None: logger.exception("Failed to fetch LRT trip updates") any_failure = True + if self._settings.GRT_ALERTS_URL: + try: + self._alerts = await gtfs_alerts.fetch_alerts( + self._settings.GRT_ALERTS_URL, + allow_weak_tls=self._settings.GRT_ALLOW_WEAK_TLS, + ) + self._feed_health["grt_alerts"] = "ok" + except Exception: + logger.exception("Failed to fetch GTFS-realtime alerts") + self._feed_health["grt_alerts"] = "error" + if not self._routes or not self._stops: try: bundle = await gtfs_static.fetch_static_bundle_cached( @@ -316,6 +328,11 @@ async def get_nearby_stops( stops_snapshot = {sid: dict(info) for sid, info in self._stops.items()} return self._geo.nearby_stops(stops_snapshot, lat, lon, radius_m, limit) + async def get_alerts(self) -> List[Dict[str, Any]]: + await self.ensure_fresh() + async with self._lock: + return [dict(alert) for alert in self._alerts] + async def get_trip_updates(self) -> List[Dict[str, Any]]: await self.ensure_fresh() async with self._lock: @@ -369,6 +386,7 @@ async def get_cache_sizes(self) -> Dict[str, int]: "stop_times": len(self._stop_times), "trip_routes": len(self._trip_routes), "trip_updates": len(self._trip_updates), + "alerts": len(self._alerts), } async def get_feed_health(self) -> Dict[str, str]: diff --git a/backend/services/gtfs_alerts.py b/backend/services/gtfs_alerts.py new file mode 100644 index 0000000..387eb41 --- /dev/null +++ b/backend/services/gtfs_alerts.py @@ -0,0 +1,103 @@ +"""Helpers for fetching and parsing GTFS-realtime service alerts.""" + +import logging +from typing import Any, Dict, List, Optional + +from google.transit import gtfs_realtime_pb2 + +from services.http_client import create_async_client + +logger = logging.getLogger(__name__) + + +def _extract_text(text) -> Optional[str]: + """Extract text from a TranslatedString, preferring English.""" + if text is None: + return None + for translation in text.translation: + if translation.language == "en" or not translation.language: + return translation.text + if text.translation: + return text.translation[0].text + return None + + +def parse_alerts(feed_message: gtfs_realtime_pb2.FeedMessage) -> List[Dict[str, Any]]: + """Convert a GTFS-realtime feed into a list of alert dictionaries.""" + alerts: List[Dict[str, Any]] = [] + + for entity in feed_message.entity: + if not entity.HasField("alert"): + continue + + alert = entity.alert + alert_id = entity.id + + header = _extract_text(alert.header_text) + description = _extract_text(alert.description_text) + + # Affected routes (informed entities of type route) + route_ids: List[str] = [] + for informed in alert.informed_entity: + if informed.route_id: + route_ids.append(informed.route_id) + + # Cause + cause = None + if alert.HasField("cause"): + cause = gtfs_realtime_pb2.Alert.Cause.Name(alert.cause) + + # Effect + effect = None + if alert.HasField("effect"): + effect = gtfs_realtime_pb2.Alert.Effect.Name(alert.effect) + + # Active periods + active_periods: List[Dict[str, int]] = [] + for period in alert.active_period: + entry = {} + if period.HasField("start"): + entry["start"] = period.start + if period.HasField("end"): + entry["end"] = period.end + if entry: + active_periods.append(entry) + + alerts.append({ + "alert_id": alert_id, + "header_text": header, + "description_text": description, + "route_ids": route_ids, + "cause": cause, + "effect": effect, + "active_periods": active_periods, + }) + + return alerts + + +async def fetch_alerts( + url: str, + timeout_s: float = 10.0, + allow_weak_tls: bool = False, +) -> List[Dict[str, Any]]: + """Fetch a GTFS-realtime alerts feed and return parsed alerts.""" + if not url: + raise ValueError("Alerts URL is required") + + try: + async with create_async_client(timeout_s, allow_weak_tls) as client: + response = await client.get(url) + response.raise_for_status() + except Exception: + logger.exception("Failed to fetch GTFS-realtime alerts from %s", url) + raise + + feed = gtfs_realtime_pb2.FeedMessage() + try: + feed.ParseFromString(response.content) + except Exception: + logger.exception("Failed to parse GTFS-realtime alerts feed from %s", url) + raise + + return parse_alerts(feed) diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index e738c5e..ee4d0c8 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -58,6 +58,16 @@ export interface Stop { transportType?: string; } +export interface Alert { + alertId?: string; + headerText?: string; + descriptionText?: string; + routeIds: string[]; + cause?: string; + effect?: string; + activePeriods: { start?: number; end?: number }[]; +} + export interface CacheStatus { lastUpdated: string; lastRefreshAgeSeconds: number | null; diff --git a/frontend/src/components/AlertsPanel.tsx b/frontend/src/components/AlertsPanel.tsx new file mode 100644 index 0000000..1cc724e --- /dev/null +++ b/frontend/src/components/AlertsPanel.tsx @@ -0,0 +1,65 @@ +import type { Alert } from "../api/types"; + +interface AlertsPanelProps { + alerts: Alert[]; + isLoading: boolean; +} + +const getSeverityColor = (effect?: string): string => { + if (!effect) return "border-l-amber-400"; + const severe = ["NO_SERVICE", "STOP_MOVE", "SIGNIFICANT_DELAYS", "DETOUR"]; + return severe.includes(effect) ? "border-l-red-400" : "border-l-amber-400"; +}; + +export default function AlertsPanel({ alerts, isLoading }: AlertsPanelProps) { + if (isLoading) { + return ( +
+
+
+
+
+
+
+ ); + } + + if (alerts.length === 0) { + return ( +
+

No active alerts.

+
+ ); + } + + return ( +
+ {alerts.map((alert, idx) => ( +
+
{alert.headerText}
+ {alert.descriptionText && ( +
{alert.descriptionText}
+ )} +
+ {alert.effect && ( + + {alert.effect.replace(/_/g, " ")} + + )} + {alert.routeIds.slice(0, 5).map((rid) => ( + + {rid} + + ))} +
+
+ ))} +
+ ); +} diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 9014b3f..7b6504d 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -1,5 +1,7 @@ import { useMemo, useState } from "react"; import type { Route, VehicleArrivalStop, VehiclePosition } from "../api/types"; +import { useAlerts } from "../hooks/useAlerts"; +import AlertsPanel from "./AlertsPanel"; import RouteListPanel from "./RouteListPanel"; import TripTimelinePanel from "./TripTimelinePanel"; @@ -17,7 +19,7 @@ interface SidebarProps { arrivalsLoading: boolean; } -type Tab = "stops" | "routes"; +type Tab = "stops" | "routes" | "alerts"; export default function Sidebar({ routes, @@ -32,6 +34,9 @@ export default function Sidebar({ arrivals, arrivalsLoading, }: SidebarProps) { + const { data: alerts, isLoading: alertsLoading } = useAlerts(); + const activeAlerts = alerts ?? []; + const [activeTab, setActiveTab] = useState("stops"); const selectedVehicleRoute = useMemo(() => { @@ -115,6 +120,21 @@ export default function Sidebar({ > Routes +
{/* Content */} @@ -133,12 +153,14 @@ export default function Sidebar({ ) : ( children ) - ) : ( + ) : activeTab === "routes" ? ( + ) : ( + )}
diff --git a/frontend/src/hooks/useAlerts.ts b/frontend/src/hooks/useAlerts.ts new file mode 100644 index 0000000..5e53797 --- /dev/null +++ b/frontend/src/hooks/useAlerts.ts @@ -0,0 +1,40 @@ +import { useQuery } from "@tanstack/react-query"; +import { apiGet } from "../api/client"; +import type { Alert } from "../api/types"; + +type AlertApi = { + alert_id?: string | null; + header_text?: string | null; + description_text?: string | null; + route_ids?: string[]; + cause?: string | null; + effect?: string | null; + active_periods?: { start?: number; end?: number }[]; +}; + +type AlertsResponse = { + alerts: AlertApi[]; +}; + +const toAlert = (raw: AlertApi): Alert => ({ + alertId: raw.alert_id ?? undefined, + headerText: raw.header_text ?? undefined, + descriptionText: raw.description_text ?? undefined, + routeIds: raw.route_ids ?? [], + cause: raw.cause ?? undefined, + effect: raw.effect ?? undefined, + activePeriods: raw.active_periods ?? [], +}); + +const fetchAlerts = async () => { + const response = await apiGet("/api/alerts"); + return response.alerts.map(toAlert); +}; + +export const useAlerts = () => + useQuery({ + queryKey: ["alerts"], + queryFn: fetchAlerts, + staleTime: 30000, + refetchInterval: 30000, + });