Show service alerts on the offering, where they can become queries

8d60c25 · Gabriel Morell · 2026-08-11 18:47

16 files +303 -0
Message
{commit_body(@commit)}

Files changed

modified apps/room_gtfs/lib/room_gtfs/gtfs_worker.ex
+79 −0
@@ -66,6 +66,23 @@ defmodule RoomGtfs.Worker do
66 66 end
67 67 end
68 68
69 + @doc """
70 + Every alert currently in force, unfiltered.
71 +
72 + query_alerts/3 answers "does this affect that stop"; this is the whole feed,
73 + for looking over what a source is reporting and deciding what is worth a
74 + query. Returns [] when the worker is not running or no alert feed is set.
75 + """
76 + def current_alerts(name) do
77 + try do
78 + "gtfs-rt#{name}"
79 + |> via_tuple()
80 + |> GenServer.call(:current_alerts, 10_000)
81 + catch
82 + :exit, _ -> []
83 + end
84 + end
85 +
69 86 def get_current_vehicle_positions(name) do
70 87 "gtfs-rt#{name}"
71 88 |> via_tuple
@@ -335,6 +352,68 @@ defmodule RoomGtfs.Worker.RT do
335 352 {:reply, alerts, state}
336 353 end
337 354
355 + def handle_call(:current_alerts, _from, state) do
356 + now = System.os_time(:second)
357 +
358 + alerts =
359 + case state.rt_sa do
360 + nil ->
361 + []
362 +
363 + feed ->
364 + feed.entity
365 + |> Enum.filter(& &1.alert)
366 + |> Enum.filter(fn entity -> alert_active?(entity.alert, now) end)
367 + |> Enum.map(&present_alert/1)
368 + end
369 +
370 + {:reply, alerts, state}
371 + end
372 +
373 + defp alert_active?(alert, now) do
374 + case alert.active_period do
375 + [] ->
376 + true
377 +
378 + periods ->
379 + Enum.any?(periods, fn p ->
380 + (p.start == nil or p.start <= now) and
381 + (Map.get(p, :end) == nil or Map.get(p, :end) >= now)
382 + end)
383 + end
384 + end
385 +
386 + # An alert names what it affects as a list of informed entities, each of
387 + # which may carry a route, a stop, both, or neither -- "neither" meaning the
388 + # whole agency. Those ids are the thing worth reading: they say whether an
389 + # alert touches anything you would want a query for.
390 + defp present_alert(entity) do
391 + alert = entity.alert
392 +
393 + %{
394 + id: entity.id,
395 + effect: alert.effect |> to_string(),
396 + cause: alert.cause |> to_string(),
397 + header: get_translation(alert.header_text),
398 + description: get_translation(alert.description_text),
399 + url: get_translation(alert.url),
400 + route_ids: informed(alert, :route_id),
401 + stop_ids: informed(alert, :stop_id),
402 + agency_wide?: Enum.any?(alert.informed_entity, fn e ->
403 + e.route_id in [nil, ""] and e.stop_id in [nil, ""] and e.trip == nil
404 + end),
405 + starts_at: alert.active_period |> Enum.map(& &1.start) |> Enum.reject(&is_nil/1) |> Enum.min(fn -> nil end),
406 + ends_at: alert.active_period |> Enum.map(&Map.get(&1, :end)) |> Enum.reject(&is_nil/1) |> Enum.max(fn -> nil end)
407 + }
408 + end
409 +
410 + defp informed(alert, key) do
411 + alert.informed_entity
412 + |> Enum.map(&Map.get(&1, key))
413 + |> Enum.reject(&(&1 in [nil, ""]))
414 + |> Enum.uniq()
415 + end
416 +
338 417 defp get_translation(nil), do: nil
339 418 defp get_translation(%{translation: []}), do: nil
340 419 defp get_translation(%{translation: translations}) do
modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_source_live/show.ex
+53 −0
@@ -41,6 +41,7 @@ defmodule RoomSanctumWeb.SourceLive.Show do
41 41 # show that stop's routes rather than the whole system's.
42 42 |> assign(:tester_route_ids, [])
43 43 |> assign(:aircraft, [])
44 + |> assign(:alerts, [])
44 45 |> assign(:view_mode, :system)
45 46 # Same palette the query form offers, so a tint picked here and one
46 47 # picked there are drawn from the same set. Every entry needs its
@@ -88,6 +89,9 @@ defmodule RoomSanctumWeb.SourceLive.Show do
88 89 _ -> []
89 90 end
90 91
92 + # Service alerts, for a GTFS source that has an alert feed configured.
93 + alerts = current_alerts(socket.assigns.source, queries)
94 +
91 95 # Whatever the ADS-B worker is currently holding across its watched areas.
92 96 aircraft = current_aircraft(socket.assigns.source.type, socket.assigns.source_id)
93 97
@@ -113,6 +117,7 @@ defmodule RoomSanctumWeb.SourceLive.Show do
113 117 |> assign(:station_statuses, station_statuses)
114 118 |> assign(:route_types, route_types)
115 119 |> assign(:aircraft, aircraft)
120 + |> assign(:alerts, alerts)
116 121 |> assign(:source_tint, source_tint)}
117 122 end
118 123
@@ -166,6 +171,54 @@ defmodule RoomSanctumWeb.SourceLive.Show do
166 171 }
167 172 end
168 173
174 + # Alerts are only worth fetching for a source that publishes them; a GTFS
175 + # source with no alert feed configured has nothing to say.
176 + defp current_alerts(%{type: :gtfs, config: %{url_rt_sa: url}} = source, queries)
177 + when is_binary(url) and url != "" do
178 + if Code.ensure_loaded?(RoomGtfs.Worker) do
179 + queried = queries |> Enum.map(&station_id_of/1) |> Enum.reject(&is_nil/1) |> MapSet.new()
180 +
181 + source.id
182 + |> RoomGtfs.Worker.current_alerts()
183 + |> Enum.map(fn alert ->
184 + touching = Enum.filter(alert.stop_ids, &MapSet.member?(queried, &1))
185 + Map.put(alert, :queried_stop_ids, touching)
186 + end)
187 + # What already affects something you watch comes first -- that is the
188 + # question the list is here to answer.
189 + |> Enum.sort_by(fn alert -> {alert.queried_stop_ids == [], alert.header || ""} end)
190 + else
191 + []
192 + end
193 + end
194 +
195 + defp current_alerts(_source, _queries), do: []
196 +
197 + # GTFS effects are SCREAMING_SNAKE; a person reads "Stop moved".
198 + def alert_effect_label(effect) do
199 + effect
200 + |> to_string()
201 + |> String.downcase()
202 + |> String.replace("_", " ")
203 + |> String.capitalize()
204 + end
205 +
206 + # Anything that stops or diverts service is worth noticing before an
207 + # accessibility notice or a construction hoarding.
208 + def alert_effect_class("NO_SERVICE"), do: "badge-error"
209 + def alert_effect_class("REDUCED_SERVICE"), do: "badge-warning"
210 + def alert_effect_class("SIGNIFICANT_DELAYS"), do: "badge-warning"
211 + def alert_effect_class("DETOUR"), do: "badge-warning"
212 + def alert_effect_class("STOP_MOVED"), do: "badge-warning"
213 + def alert_effect_class(_effect), do: "badge-ghost"
214 +
215 + defp stop_name(stations, stop_id) do
216 + case Enum.find(stations, &(&1.station_id == stop_id)) do
217 + nil -> stop_id
218 + station -> station.name || stop_id
219 + end
220 + end
221 +
169 222 # The worker is only running for an enabled source, and it holds nothing
170 223 # until its first sweep, so an absent process is an ordinary state rather
171 224 # than an error worth taking the page down for.
modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_source_live/show.html.heex
+69 −0
@@ -479,6 +479,75 @@ gl_available_projects={@gitlab_available_projects}
479 479 </div>
480 480 </div>
481 481 </div>
482 + <%= if @alerts != [] do %>
483 + <div class="flex">
484 + <div class="card card-compact w-full bg-base-100 shadow-xl m-2">
485 + <div class="card-body">
486 + <h3 class="card-title">
487 + Service Alerts
488 + <span class="text-sm font-normal opacity-60">
489 + <%= length(@alerts) %> in force<%= if Enum.any?(@alerts, &(&1.queried_stop_ids != [])) do %>,
490 + <%= Enum.count(@alerts, &(&1.queried_stop_ids != [])) %> touching a query<% end %>
491 + </span>
492 + </h3>
493 +
494 + <%!-- Tall feeds are the norm -- MBTA runs 80-odd at once -- so
495 + this scrolls rather than pushing the page down. --%>
496 + <div class="max-h-96 overflow-y-auto pr-1">
497 + <%= for alert <- @alerts do %>
498 + <div class={"border-l-4 pl-3 py-2 mb-2 #{if alert.queried_stop_ids != [], do: "border-accent bg-base-200", else: "border-base-300"}"}>
499 + <div class="flex items-start justify-between gap-2">
500 + <div class="font-semibold text-sm"><%= alert.header %></div>
501 + <span class={"badge badge-sm shrink-0 #{alert_effect_class(alert.effect)}"}>
502 + <%= alert_effect_label(alert.effect) %>
503 + </span>
504 + </div>
505 +
506 + <%= if alert.description not in [nil, ""] do %>
507 + <div class="text-xs opacity-70 mt-1"><%= alert.description %></div>
508 + <% end %>
509 +
510 + <div class="text-xs mt-1 flex flex-wrap gap-1 items-center">
511 + <%= if alert.agency_wide? do %>
512 + <span class="badge badge-xs badge-outline">whole agency</span>
513 + <% end %>
514 + <%= for route_id <- Enum.take(alert.route_ids, 8) do %>
515 + <span class="badge badge-xs badge-outline"><%= route_id %></span>
516 + <% end %>
517 + </div>
518 +
519 + <%= if alert.stop_ids != [] do %>
520 + <div class="text-xs mt-1 flex flex-wrap gap-2 items-center">
521 + <%= for stop_id <- Enum.take(alert.stop_ids, 8) do %>
522 + <%= if stop_id in alert.queried_stop_ids do %>
523 + <span class="text-accent">
524 + <i class="fa-solid fa-circle-check mr-1"></i><%= stop_name(@stations, stop_id) %>
525 + </span>
526 + <% else %>
527 + <span
528 + class="cursor-pointer opacity-70 hover:opacity-100"
529 + phx-click="map-add-query"
530 + phx-value-station-id={stop_id}
531 + phx-value-name={stop_name(@stations, stop_id)}
532 + title={"Add a query for #{stop_name(@stations, stop_id)}"}
533 + >
534 + <i class="fa-solid fa-plus mr-1"></i><%= stop_name(@stations, stop_id) %>
535 + </span>
536 + <% end %>
537 + <% end %>
538 + <%= if length(alert.stop_ids) > 8 do %>
539 + <span class="opacity-50">+<%= length(alert.stop_ids) - 8 %> more</span>
540 + <% end %>
541 + </div>
542 + <% end %>
543 + </div>
544 + <% end %>
545 + </div>
546 + </div>
547 + </div>
548 + </div>
549 + <% end %>
550 +
482 551 <%= if @tester do %>
483 552 <div class="flex">
484 553 <div class="card card-compact w-full bg-base-100 shadow-xl m-2">
added apps/room_sanctum/test/room_sanctum_web/live/service_alerts_test.exs
+102 −0
@@ -0,0 +1,102 @@
1 +defmodule RoomSanctumWeb.ServiceAlertsTest do
2 + @moduledoc """
3 + The service alerts pane on an offering.
4 +
5 + The alerts themselves come from the GTFS-RT worker, which lives in another
6 + app and cannot be loaded here, so these cover what this app decides: when the
7 + pane appears at all, and how an alert is labelled.
8 + """
9 + use RoomSanctumWeb.ConnCase
10 +
11 + import Phoenix.LiveViewTest
12 +
13 + alias RoomSanctum.{Accounts, Configuration}
14 + alias RoomSanctumWeb.SourceLive.Show
15 +
16 + setup %{conn: conn} do
17 + {:ok, user} =
18 + Accounts.register_user(%{
19 + email: "alert#{System.unique_integer([:positive])}@example.com",
20 + password: "hello world!hello world!"
21 + })
22 +
23 + %{conn: log_in_user(conn, user), user: user}
24 + end
25 +
26 + defp source(user, config) do
27 + {:ok, source} =
28 + Configuration.create_source(%{
29 + name: "T", notes: "", type: :gtfs, enabled: true,
30 + user_id: user.id, config: config
31 + })
32 +
33 + source
34 + end
35 +
36 + describe "when the pane is shown" do
37 + test "not for a source with no alert feed configured", ctx do
38 + src = source(ctx.user, %{"__type__" => "gtfs", "url" => "https://e.test/g.zip", "tz" => "UTC"})
39 +
40 + {:ok, live, _html} = live(ctx.conn, Routes.source_show_path(ctx.conn, :show, src))
41 + send(live.pid, :update_sec)
42 +
43 + refute render(live) =~ "Service Alerts"
44 + end
45 +
46 + test "not for a source of a type that has no alerts", ctx do
47 + {:ok, src} =
48 + Configuration.create_source(%{
49 + name: "Markets", notes: "", type: :bourse, enabled: true,
50 + user_id: ctx.user.id, config: %{"__type__" => "bourse"}
51 + })
52 +
53 + {:ok, live, _html} = live(ctx.conn, Routes.source_show_path(ctx.conn, :show, src))
54 + send(live.pid, :update_sec)
55 +
56 + refute render(live) =~ "Service Alerts"
57 + end
58 +
59 + test "an alert feed being configured does not by itself crash the page", ctx do
60 + # the worker is not running here, which is the same shape as a disabled
61 + # source or one that has not fetched yet
62 + src =
63 + source(ctx.user, %{
64 + "__type__" => "gtfs", "url" => "https://e.test/g.zip", "tz" => "UTC",
65 + "url_rt_sa" => "https://e.test/alerts.pb"
66 + })
67 +
68 + {:ok, live, _html} = live(ctx.conn, Routes.source_show_path(ctx.conn, :show, src))
69 + send(live.pid, :update_sec)
70 +
71 + assert Process.alive?(live.pid)
72 + # nothing to show, so no empty pane either
73 + refute render(live) =~ "Service Alerts"
74 + end
75 + end
76 +
77 + describe "labelling an alert" do
78 + test "reads as a sentence rather than a constant" do
79 + assert Show.alert_effect_label("STOP_MOVED") == "Stop moved"
80 + assert Show.alert_effect_label("ACCESSIBILITY_ISSUE") == "Accessibility issue"
81 + assert Show.alert_effect_label("NO_SERVICE") == "No service"
82 + end
83 +
84 + test "service disruptions stand out from notices" do
85 + assert Show.alert_effect_class("NO_SERVICE") == "badge-error"
86 +
87 + for effect <- ~w(REDUCED_SERVICE SIGNIFICANT_DELAYS DETOUR STOP_MOVED) do
88 + assert Show.alert_effect_class(effect) == "badge-warning", effect
89 + end
90 +
91 + # the two thirds of MBTA's feed that are notices should not shout
92 + for effect <- ~w(ACCESSIBILITY_ISSUE OTHER_EFFECT UNKNOWN_EFFECT) do
93 + assert Show.alert_effect_class(effect) == "badge-ghost", effect
94 + end
95 + end
96 +
97 + test "an effect nobody anticipated still renders" do
98 + assert Show.alert_effect_label("SOME_NEW_EFFECT") == "Some new effect"
99 + assert Show.alert_effect_class("SOME_NEW_EFFECT") == "badge-ghost"
100 + end
101 + end
102 +end

Parents: a953f33