Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions backend/api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down Expand Up @@ -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,
Expand Down
20 changes: 19 additions & 1 deletion backend/services/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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]:
Expand Down
103 changes: 103 additions & 0 deletions backend/services/gtfs_alerts.py
Original file line number Diff line number Diff line change
@@ -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)
10 changes: 10 additions & 0 deletions frontend/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
65 changes: 65 additions & 0 deletions frontend/src/components/AlertsPanel.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="px-4 py-3">
<div className="animate-pulse space-y-3">
<div className="h-4 bg-gray-200 rounded w-3/4" />
<div className="h-4 bg-gray-200 rounded w-1/2" />
<div className="h-4 bg-gray-200 rounded w-2/3" />
</div>
</div>
);
}

if (alerts.length === 0) {
return (
<div className="px-4 py-3">
<p className="text-gray-500 italic text-xs">No active alerts.</p>
</div>
);
}

return (
<div className="px-2 py-3 space-y-2">
{alerts.map((alert, idx) => (
<div
key={alert.alertId ?? idx}
className={`border-l-4 ${getSeverityColor(alert.effect)} bg-white rounded-r-md px-3 py-2 shadow-sm`}
>
<div className="text-sm font-medium text-gray-900">{alert.headerText}</div>
{alert.descriptionText && (
<div className="text-xs text-gray-600 mt-0.5">{alert.descriptionText}</div>
)}
<div className="flex items-center gap-1.5 mt-1.5 flex-wrap">
{alert.effect && (
<span className="text-[10px] font-medium text-gray-500 bg-gray-100 px-1.5 py-0.5 rounded">
{alert.effect.replace(/_/g, " ")}
</span>
)}
{alert.routeIds.slice(0, 5).map((rid) => (
<span
key={rid}
className="text-[10px] font-medium text-blue-600 bg-blue-50 px-1.5 py-0.5 rounded"
>
{rid}
</span>
))}
</div>
</div>
))}
</div>
);
}
26 changes: 24 additions & 2 deletions frontend/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -17,7 +19,7 @@ interface SidebarProps {
arrivalsLoading: boolean;
}

type Tab = "stops" | "routes";
type Tab = "stops" | "routes" | "alerts";

export default function Sidebar({
routes,
Expand All @@ -32,6 +34,9 @@ export default function Sidebar({
arrivals,
arrivalsLoading,
}: SidebarProps) {
const { data: alerts, isLoading: alertsLoading } = useAlerts();
const activeAlerts = alerts ?? [];

const [activeTab, setActiveTab] = useState<Tab>("stops");

const selectedVehicleRoute = useMemo(() => {
Expand Down Expand Up @@ -115,6 +120,21 @@ export default function Sidebar({
>
Routes
</button>
<button
onClick={() => setActiveTab("alerts")}
className={`flex-1 px-4 py-2 text-sm font-medium transition-colors relative ${
activeTab === "alerts"
? "text-blue-600 border-b-2 border-blue-500"
: "text-gray-500 hover:text-gray-700"
}`}
>
Alerts
{activeAlerts.length > 0 && (
<span className="absolute -top-0.5 right-1 bg-red-500 text-white text-[10px] font-bold px-1.5 py-0.5 rounded-full leading-none">
{activeAlerts.length}
</span>
)}
</button>
</div>

{/* Content */}
Expand All @@ -133,12 +153,14 @@ export default function Sidebar({
) : (
children
)
) : (
) : activeTab === "routes" ? (
<RouteListPanel
routes={routes}
selectedRouteId={selectedRouteId}
onSelectRoute={onSelectRoute}
/>
) : (
<AlertsPanel alerts={activeAlerts} isLoading={alertsLoading} />
)}
</div>

Expand Down
40 changes: 40 additions & 0 deletions frontend/src/hooks/useAlerts.ts
Original file line number Diff line number Diff line change
@@ -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<AlertsResponse>("/api/alerts");
return response.alerts.map(toAlert);
};

export const useAlerts = () =>
useQuery({
queryKey: ["alerts"],
queryFn: fetchAlerts,
staleTime: 30000,
refetchInterval: 30000,
});
Loading