From 6a258f297d0ba3e201bbc6943e4764a5057a75e9 Mon Sep 17 00:00:00 2001 From: Cristen Jones Date: Fri, 31 Jul 2026 13:02:43 -0400 Subject: [PATCH 1/5] feat(Alerts.Cache.Store): active stops for effect --- lib/alerts/cache/store.ex | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/lib/alerts/cache/store.ex b/lib/alerts/cache/store.ex index 221c7c0d1a..0bc612305c 100644 --- a/lib/alerts/cache/store.ex +++ b/lib/alerts/cache/store.ex @@ -18,6 +18,7 @@ defmodule Alerts.Cache.Store do """ alias Alerts.{Alert, Priority} + alias Stops.Stop use GenServer @@ -84,6 +85,32 @@ defmodule Alerts.Cache.Store do :ets.select(:stop_id_to_alert_ids, keys) end + @doc """ + Retrieves all active alerts for a given effect. + """ + @spec active_alerts_for_effect(Alert.effect()) :: [Alert.t()] + def active_alerts_for_effect(effect) do + :ets.select(:alert_id_to_alert, [ + {{:_, :"$1"}, [{:==, {:map_get, :effect, :"$1"}, effect}], [:"$1"]} + ]) + |> Enum.filter(&Dotcom.Alerts.in_effect_now?/1) + end + + @doc """ + Retrieves all stop ids associated with alerts for a given effect. + """ + @spec active_stop_ids_for_effect(Alert.effect()) :: [Stop.id_t()] + def active_stop_ids_for_effect(effect) do + effect + |> active_alerts_for_effect() + |> Enum.map(&{{:"$1", &1.id}, [], [:"$1"]}) + |> then(fn stop_ids_from_alert_ids_match_spec -> + :ets.select(:stop_id_to_alert_ids, stop_ids_from_alert_ids_match_spec) + end) + |> Enum.reject(&Kernel.is_nil/1) + |> Enum.uniq() + end + @doc """ Retrieves all the alerts of a given priority for the given list of alert ids. """ From ecae8dd9ce28a071686933693b9c51eda7fa6fdb Mon Sep 17 00:00:00 2001 From: Cristen Jones Date: Fri, 31 Jul 2026 13:05:29 -0400 Subject: [PATCH 2/5] refactor(Dotcom.Alerts): stops with access alerts --- lib/dotcom/alerts.ex | 49 ++++++++++--------- .../templates/page/_alerts.html.heex | 4 +- lib/dotcom_web/views/page_view.ex | 9 ---- test/dotcom/alerts_test.exs | 22 +++++---- 4 files changed, 42 insertions(+), 42 deletions(-) diff --git a/lib/dotcom/alerts.ex b/lib/dotcom/alerts.ex index 2011d13197..b1cdf57c8c 100644 --- a/lib/dotcom/alerts.ex +++ b/lib/dotcom/alerts.ex @@ -301,34 +301,39 @@ defmodule Dotcom.Alerts do Task.async_stream(ids, func, max_concurrency: 8, on_timeout: :kill_task, ordered: false) end - def stops_with_access_alerts_by_effect(alerts) do - access_effects = Alerts.Accessibility.effect_types() - empty_by_effect = Map.new(access_effects, &{&1, MapSet.new()}) - - stop_ids_by_effect = - alerts - |> Enum.reduce(empty_by_effect, fn alert, acc -> - if Map.has_key?(acc, alert.effect) do - stop_id = alert_stop_ids(alert) |> List.last() - Map.update!(acc, alert.effect, &MapSet.put(&1, stop_id)) - else - acc - end - end) - - Enum.map(access_effects, fn effect -> + def stops_with_access_alerts_by_effect() do + Alerts.Accessibility.effect_types() + |> Enum.map(fn effect -> stops = - stop_ids_by_effect - |> Map.fetch!(effect) - |> get_many(&@stops_repo_module.get_parent/1) - |> Stream.filter(&match?({:ok, %Stop{}}, &1)) - |> Stream.map(fn {:ok, stop} -> stop end) - |> Enum.sort_by(& &1.name) + effect + |> Alerts.Cache.Store.active_stop_ids_for_effect() + |> Enum.reduce(%{ids: [], stop_names: []}, &parent_stops/2) + |> Map.get(:stop_names) + |> Enum.sort_by(fn {_, name} -> name end) {effect, stops} end) end + # alerts reference both parent stop and all child stop ids, and we don't want + # to repeat fetches for the same parent stops, so we keep track of already + # evaluated stops while we traverse the list of IDs + defp parent_stops(stop_id, stop_data) do + if stop_id in stop_data.ids do + stop_data + else + case @stops_repo_module.get_parent(stop_id) do + %Stops.Stop{id: id, child_ids: child_ids, name: name} -> + new_ids = [id | child_ids] + all_ids = Enum.concat(new_ids, stop_data.ids) + %{ids: all_ids, stop_names: [{id, name} | stop_data.stop_names]} + + _ -> + stop_data + end + end + end + def alert_route_type(alert) do alert |> Alert.get_entity(:route_type) diff --git a/lib/dotcom_web/templates/page/_alerts.html.heex b/lib/dotcom_web/templates/page/_alerts.html.heex index 1d824f38f1..7a90956972 100644 --- a/lib/dotcom_web/templates/page/_alerts.html.heex +++ b/lib/dotcom_web/templates/page/_alerts.html.heex @@ -49,8 +49,8 @@

<% else %> <% end %> diff --git a/lib/dotcom_web/views/page_view.ex b/lib/dotcom_web/views/page_view.ex index 60d3973325..19e68387ab 100644 --- a/lib/dotcom_web/views/page_view.ex +++ b/lib/dotcom_web/views/page_view.ex @@ -80,15 +80,6 @@ defmodule DotcomWeb.PageView do ) end - @spec alerts_stop_url(Stops.Stop.t()) :: String.t() - defp alerts_stop_url(stop) do - DotcomWeb.Router.Helpers.stop_url( - DotcomWeb.Endpoint, - :show, - stop.id - ) - end - def shortcut_icons do [:commuter_rail, :subway, :bus, :ferry, :the_ride] |> Enum.map(&shortcut_icon/1) diff --git a/test/dotcom/alerts_test.exs b/test/dotcom/alerts_test.exs index 0b32e07883..6d07c38413 100644 --- a/test/dotcom/alerts_test.exs +++ b/test/dotcom/alerts_test.exs @@ -524,27 +524,31 @@ defmodule Dotcom.AlertsTest do describe "stops_with_access_alerts_by_effect/1" do setup do - {:ok, %{alerts: Factories.Alerts.Alert.build_list(50, :alert)}} + Factories.Alerts.Alert.build_list(50, :alert, + effect: fn -> Faker.Util.pick(Alerts.Accessibility.effect_types()) end, + priority: :high + ) + |> Enum.map(&Factories.Alerts.Alert.active_now/1) + |> Alerts.Cache.Store.update(nil) + + {:ok, %{}} end - test "builds list of stops by accessibility effect", %{alerts: alerts} do + test "builds list of stops by accessibility effect" do stub(Stops.Repo.Mock, :get_parent, fn _ -> Factories.Stops.Stop.build(:stop) end) - stops_by_effect = stops_with_access_alerts_by_effect(alerts) + stops_by_effect = stops_with_access_alerts_by_effect() for {effect, stops} <- stops_by_effect do assert effect in Alerts.Accessibility.effect_types() - - if stops != [] do - assert [%Stops.Stop{} | _] = stops - end + assert [{<<_stop_id::binary>>, <<_stop_name::binary>>} | _] = stops end end - test "doesn't error if nil stops", %{alerts: alerts} do + test "doesn't error if nil stops" do stub(Stops.Repo.Mock, :get_parent, fn _ -> nil end) - stops_by_effect = stops_with_access_alerts_by_effect(alerts) + stops_by_effect = stops_with_access_alerts_by_effect() assert stops_by_effect == [elevator_closure: [], escalator_closure: [], access_issue: []] end end From d153cc5c1f9c6e8bd9cd1c71c7fc981a9ce8a016 Mon Sep 17 00:00:00 2001 From: Cristen Jones Date: Fri, 31 Jul 2026 13:07:52 -0400 Subject: [PATCH 3/5] feat(Alerts.Cache.Store): route_ids for priority alerts for route type --- lib/alerts/cache/store.ex | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/lib/alerts/cache/store.ex b/lib/alerts/cache/store.ex index 0bc612305c..648fe8d16a 100644 --- a/lib/alerts/cache/store.ex +++ b/lib/alerts/cache/store.ex @@ -18,6 +18,7 @@ defmodule Alerts.Cache.Store do """ alias Alerts.{Alert, Priority} + alias Routes.Route alias Stops.Stop use GenServer @@ -111,6 +112,25 @@ defmodule Alerts.Cache.Store do |> Enum.uniq() end + @doc """ + Retrieves all the alerts of a given priority for the given list of alert ids. + """ + @spec route_ids_for_high_priority_alerts_for_route_types([Route.type_int()]) :: [Route.id_t()] + def route_ids_for_high_priority_alerts_for_route_types(route_types) do + route_types + |> Enum.map(&{{:_, &1, :"$1"}, [], [:"$1"]}) + |> then(fn alert_ids_from_route_type_match_spec -> + :ets.select(:route_id_and_type_to_alert_ids, alert_ids_from_route_type_match_spec) + end) + |> priority_alerts_from_alert_ids(:high) + |> Enum.filter(&Dotcom.Alerts.in_effect_now?/1) + |> Enum.map(&{{:"$1", :_, &1.id}, [], [:"$1"]}) + |> then(fn route_ids_from_alert_ids_match_spec -> + :ets.select(:route_id_and_type_to_alert_ids, route_ids_from_alert_ids_match_spec) + end) + |> Enum.uniq() + end + @doc """ Retrieves all the alerts of a given priority for the given list of alert ids. """ From 19c27977064b277456d54d8c5f6eaba9f9a6a694 Mon Sep 17 00:00:00 2001 From: Cristen Jones Date: Fri, 31 Jul 2026 13:08:37 -0400 Subject: [PATCH 4/5] refactor(Dotcom.Alerts): routes with high priority alerts --- lib/dotcom/alerts.ex | 34 ++++++++-------------------------- test/dotcom/alerts_test.exs | 14 +++++++++----- 2 files changed, 17 insertions(+), 31 deletions(-) diff --git a/lib/dotcom/alerts.ex b/lib/dotcom/alerts.ex index b1cdf57c8c..f22fab8ca5 100644 --- a/lib/dotcom/alerts.ex +++ b/lib/dotcom/alerts.ex @@ -263,33 +263,15 @@ defmodule Dotcom.Alerts do end) end - def routes_with_high_priority_alerts_by_mode(alerts) do - modes = [:subway, :bus, :commuter_rail, :ferry] - empty_by_mode = Map.new(modes, fn mode -> {mode, MapSet.new()} end) - - route_ids_by_mode = - alerts - |> Enum.filter(&(Alerts.Priority.priority(&1) == :high)) - |> Enum.reduce(empty_by_mode, fn alert, acc -> - route_ids = Alert.get_entity(alert, :route) |> MapSet.delete(nil) - - alert - |> alert_route_type() - |> Enum.map(&Route.type_atom/1) - |> Enum.reduce(acc, fn mode, acc2 -> - Map.update!(acc2, mode, &MapSet.union(&1, route_ids)) - end) - end) - - Enum.map(modes, fn mode_key -> - route_ids = - route_ids_by_mode - |> Map.fetch!(mode_key) - |> MapSet.to_list() - + def routes_with_high_priority_alerts_by_mode() do + [:subway, :bus, :commuter_rail, :ferry] + |> Enum.map(fn mode_key -> {mode_key, - get_many(route_ids, &@routes_repo_module.get/1) - |> Stream.filter(&match?({:ok, %Route{}}, &1)) + mode_key + |> Route.types_for_mode() + |> Alerts.Cache.Store.route_ids_for_high_priority_alerts_for_route_types() + |> get_many(&@routes_repo_module.get/1) + |> Stream.filter(&match?({:ok, %Route{listed?: true}}, &1)) |> Stream.map(fn {:ok, route} -> route end) |> Enum.sort_by(& &1.sort_order)} end) diff --git a/test/dotcom/alerts_test.exs b/test/dotcom/alerts_test.exs index 6d07c38413..9a5f6e78f1 100644 --- a/test/dotcom/alerts_test.exs +++ b/test/dotcom/alerts_test.exs @@ -494,13 +494,17 @@ defmodule Dotcom.AlertsTest do describe "routes_with_high_priority_alerts_by_mode/1" do setup do - {:ok, %{alerts: Factories.Alerts.Alert.build_list(50, :alert)}} + Factories.Alerts.Alert.build_list(50, :alert, priority: :high) + |> Enum.map(&Factories.Alerts.Alert.active_now/1) + |> Alerts.Cache.Store.update(nil) + + {:ok, %{}} end - test "builds list of routes by mode", %{alerts: alerts} do + test "builds list of routes by mode" do stub(Routes.Repo.Mock, :get, fn _ -> Factories.Routes.Route.build(:route) end) - for {mode, routes} <- routes_with_high_priority_alerts_by_mode(alerts) do + for {mode, routes} <- routes_with_high_priority_alerts_by_mode() do # valid mode assert Routes.Route.types_for_mode(mode) @@ -510,10 +514,10 @@ defmodule Dotcom.AlertsTest do end end - test "doesn't error if nil routes", %{alerts: alerts} do + test "doesn't error if nil routes" do stub(Routes.Repo.Mock, :get, fn _ -> nil end) - assert routes_with_high_priority_alerts_by_mode(alerts) == [ + assert routes_with_high_priority_alerts_by_mode() == [ subway: [], bus: [], commuter_rail: [], From 0aecec07f61c2a10e275d7c4fd7c970caba4c738 Mon Sep 17 00:00:00 2001 From: Cristen Jones Date: Fri, 31 Jul 2026 13:09:54 -0400 Subject: [PATCH 5/5] chore(Dotcom.PageController): remove unused alerts --- lib/dotcom_web/controllers/page_controller.ex | 10 --- .../templates/page/_tabbed_nav.html.heex | 7 +- lib/dotcom_web/views/page_view.ex | 16 ---- test/dotcom_web/views/page_view_test.exs | 75 ------------------- 4 files changed, 6 insertions(+), 102 deletions(-) diff --git a/lib/dotcom_web/controllers/page_controller.ex b/lib/dotcom_web/controllers/page_controller.ex index 11e21f1978..4863ceb6d4 100644 --- a/lib/dotcom_web/controllers/page_controller.ex +++ b/lib/dotcom_web/controllers/page_controller.ex @@ -27,7 +27,6 @@ defmodule DotcomWeb.PageController do {promoted, remainder} = whats_happening_items() banner = banner() date = conn.assigns.date - date_time = conn.assigns.date_time conn |> assign( @@ -39,15 +38,6 @@ defmodule DotcomWeb.PageController do |> assign(:whats_happening_items, remainder) |> async_assign_default(:news, &news/0, []) |> async_assign_default(:photo, &photo/0) - |> async_assign_default( - :alerts, - fn -> - date_time - |> Alerts.Repo.all() - |> Enum.filter(&Alerts.Match.any_time_match?(&1, date_time)) - end, - [] - ) |> async_assign_default( :event_teasers, fn -> CMS.Repo.next_n_event_teasers(date, 6) end, diff --git a/lib/dotcom_web/templates/page/_tabbed_nav.html.heex b/lib/dotcom_web/templates/page/_tabbed_nav.html.heex index 4762fbd83a..ccfa015d18 100644 --- a/lib/dotcom_web/templates/page/_tabbed_nav.html.heex +++ b/lib/dotcom_web/templates/page/_tabbed_nav.html.heex @@ -88,7 +88,12 @@ class="m-tabbed-nav__content-item" data-tab-content-type="alerts" > - {alerts(@conn.assigns.alerts)} + {render("_alerts.html", + routes_with_high_priority_alerts_by_mode: + Dotcom.Alerts.routes_with_high_priority_alerts_by_mode(), + stops_with_accessibility_alerts_by_issue: + Dotcom.Alerts.stops_with_access_alerts_by_effect() + )} diff --git a/lib/dotcom_web/views/page_view.ex b/lib/dotcom_web/views/page_view.ex index 19e68387ab..2b24049f45 100644 --- a/lib/dotcom_web/views/page_view.ex +++ b/lib/dotcom_web/views/page_view.ex @@ -11,22 +11,6 @@ defmodule DotcomWeb.PageView do alias CMS.Partial.Banner alias DotcomWeb.PartialView - @spec alerts([Alerts.Alert.t()]) :: Phoenix.HTML.Safe.t() - def alerts(alerts) do - [routes, stops] = - [ - &Dotcom.Alerts.routes_with_high_priority_alerts_by_mode/1, - &Dotcom.Alerts.stops_with_access_alerts_by_effect/1 - ] - |> Task.async_stream(& &1.(alerts), timeout: 10_000) - |> Enum.map(fn {:ok, result} -> result end) - - render("_alerts.html", - routes_with_high_priority_alerts_by_mode: routes, - stops_with_accessibility_alerts_by_issue: stops - ) - end - @spec alerts_mode_url(Routes.Route.gtfs_route_type()) :: String.t() defp alerts_mode_url(mode) do path = diff --git a/test/dotcom_web/views/page_view_test.exs b/test/dotcom_web/views/page_view_test.exs index 296f868153..cfe964616b 100644 --- a/test/dotcom_web/views/page_view_test.exs +++ b/test/dotcom_web/views/page_view_test.exs @@ -82,79 +82,4 @@ defmodule DotcomWeb.PageViewTest do assert document |> Floki.find(".c-news-entry--small") |> Enum.count() == 6 end end - - describe "alerts/1" do - alias Alerts.Alert - alias Alerts.InformedEntitySet, as: IESet - - @tag :external - test "renders properly" do - alerts = [ - # Filtered - %Alert{ - effect: :lifecycle - }, - # High Priority - %Alert{ - effect: :suspension, - informed_entity: %IESet{ - route: MapSet.new(["Green", "Blue"]) - } - }, - %Alert{ - effect: :suspension, - informed_entity: %IESet{ - route: MapSet.new(["CR-Greenbush"]) - } - }, - %Alert{ - effect: :suspension, - informed_entity: %IESet{ - route: MapSet.new(["505", "708"]) - } - }, - # Accessibility - %Alert{ - effect: :escalator_closure, - informed_entity: %IESet{ - stop: MapSet.new(["place-sstat"]) - } - } - ] - - rendered = - PageView.alerts(alerts) - |> Phoenix.HTML.Safe.to_iodata() - |> IO.iodata_to_binary() - - # Section Headers - assert rendered =~ "Routes With High Priority Alerts" - assert rendered =~ "Subway" - assert rendered =~ "Bus" - assert rendered =~ "Commuter Rail" - assert rendered =~ "Ferry" - assert rendered =~ "Station Accessibility" - assert rendered =~ "Access Issues" - assert rendered =~ "Elevator Closures" - assert rendered =~ "Escalator Closures" - - # Buttons - assert rendered =~ "href=\"/alerts\"" - assert rendered =~ "See all service alerts" - assert rendered =~ "https://alerts.mbta.com" - assert rendered =~ "Sign up for alert notifications" - - # Section Content - assert rendered =~ "/schedules/Green/alerts" - assert rendered =~ "/schedules/Blue/alerts" - assert rendered =~ "/schedules/505/alerts" - assert rendered =~ "/schedules/708/alerts" - assert rendered =~ "/schedules/CR-Greenbush/alerts" - assert Regex.match?(~r/There are no high priority.*ferry.*alerts at this time/s, rendered) - assert rendered =~ "/stops/place-sstat" - assert rendered =~ "South Station" - assert Regex.match?(~r/There are no.*elevator closures.*at this time/s, rendered) - assert Regex.match?(~r/There are no.*access issues.*at this time/s, rendered) - end - end end