From cb13f0d8dacffa05d991848e937d0f16bd348f1a Mon Sep 17 00:00:00 2001 From: Josh Larson Date: Wed, 25 Feb 2026 12:37:43 -0500 Subject: [PATCH 01/32] SSES Playground --- .../live/predictions_stream_live.ex | 395 ++++++++++++++++++ lib/dotcom_web/live/preview_live.ex | 8 + lib/dotcom_web/router.ex | 1 + 3 files changed, 404 insertions(+) create mode 100644 lib/dotcom_web/live/predictions_stream_live.ex diff --git a/lib/dotcom_web/live/predictions_stream_live.ex b/lib/dotcom_web/live/predictions_stream_live.ex new file mode 100644 index 0000000000..6e8f2aeab0 --- /dev/null +++ b/lib/dotcom_web/live/predictions_stream_live.ex @@ -0,0 +1,395 @@ +defmodule DotcomWeb.PredictionsStreamLive do + @moduledoc """ + A page that shows stuff about predictions streaming + """ + + use DotcomWeb, :live_view + + alias DotcomWeb.PredictionsStreamLive.PredictionsConsumerStage + alias Phoenix.LiveView + + @impl LiveView + def mount(_params, _session, socket) do + {:ok, + socket + |> assign(:routes, Routes.Repo.all()) + |> assign_route(nil) + |> assign_direction(nil) + |> assign(:stops, nil) + |> assign(:subscribed?, false)} + end + + @impl LiveView + def handle_params(params, _uri, socket) do + route_id = Map.get(params, "route_id") + + direction_id = + params + |> Map.get("direction_id") + |> case do + nil -> nil + str when is_binary(str) -> String.to_integer(str) + end + + stop_id = Map.get(params, "stop_id") + + {:noreply, + socket + |> assign_route(route_id) + |> assign_direction(direction_id) + |> assign_stop(stop_id) + |> subscribe_or_unsubscribe_to_predictions()} + end + + defp assign_route(socket, nil) do + socket + |> assign(:route_id, nil) + |> assign(:route, nil) + end + + defp assign_route(socket, route_id) do + socket + |> assign(:route_id, route_id) + |> assign(:route, Routes.Repo.get(route_id)) + end + + defp assign_direction(socket, nil) do + socket + |> assign(:direction_id, nil) + |> assign(:stops, nil) + end + + defp assign_direction(socket, direction_id) do + stops = + Stops.Repo.by_route(socket.assigns.route_id, direction_id) + + socket + |> assign(:direction_id, direction_id) + |> assign(:stops, stops) + end + + defp assign_stop(socket, nil) do + socket + |> assign(:stop_id, nil) + |> assign(:stop, nil) + end + + defp assign_stop(socket, stop_id) do + socket + |> assign(:stop_id, stop_id) + |> assign(:stop, Stops.Repo.get(stop_id)) + end + + @impl LiveView + def render(assigns) do + ~H""" + <.route_picker_or_route_page + routes={@routes} + route={@route} + direction_id={@direction_id} + stop={@stop} + stops={@stops} + /> +
+
+ Connection Status + <.icon :if={@subscribed?} name="check" class="size-5" /> + <.icon :if={!@subscribed?} name="xmark" class="size-5" /> +
+
+ """ + end + + defp route_picker_or_route_page(%{route: route} = assigns) when route != nil do + ~H""" + <.route_page route={@route} direction_id={@direction_id} stop={@stop} stops={@stops} /> + """ + end + + defp route_picker_or_route_page(assigns) do + ~H""" +
<.route_picker routes={@routes} />
+ """ + end + + defp route_picker(assigns) do + ~H""" +
+ +
+ """ + end + + defp route_page(assigns) do + ~H""" +
+
+
+ {@route.name} +
+ <.icon class="size-5" name="circle-xmark" /> +
+
+
+
+ <.direction_picker_or_route_direction_page + route={@route} + direction_id={@direction_id} + stop={@stop} + stops={@stops} + /> + """ + end + + defp direction_picker_or_route_direction_page(%{direction_id: direction_id} = assigns) + when direction_id != nil do + ~H""" + <.route_direction_page route={@route} direction_id={@direction_id} stop={@stop} stops={@stops} /> + """ + end + + defp direction_picker_or_route_direction_page(assigns) do + ~H""" +
+ <.direction_picker route={@route} /> +
+ """ + end + + defp direction_picker(assigns) do + ~H""" +
+ +
+ """ + end + + defp route_direction_page(assigns) do + ~H""" +
+
+
+ {direction_description(@route, @direction_id)} +
+ <.icon class="size-5" name="circle-xmark" /> +
+
+
+
+ <.stop_picker_or_route_direction_stop_page stop={@stop} stops={@stops} /> + """ + end + + defp stop_picker_or_route_direction_stop_page(%{stop: stop} = assigns) when stop != nil do + ~H""" +
+
+
+
+ {@stop.name} + {@stop.id} +
+
+ <.icon class="size-5" name="circle-xmark" /> +
+
+
+
+ """ + end + + defp stop_picker_or_route_direction_stop_page(assigns) do + ~H""" + <.stop_picker stops={@stops} /> + """ + end + + defp stop_picker(assigns) do + ~H""" +
+
+ +
+
+ """ + end + + @impl LiveView + def handle_event("clear-route", _params, socket) do + {:noreply, socket |> push_patch(to: ~p"/preview/predictions-stream")} + end + + def handle_event("clear-direction", _params, socket) do + route_id = socket.assigns.route_id + + params = %{route_id: route_id} + {:noreply, socket |> push_patch(to: ~p"/preview/predictions-stream?#{params}")} + end + + def handle_event("clear-stop", _params, socket) do + route_id = socket.assigns.route_id + direction_id = socket.assigns.direction_id + + params = %{route_id: route_id, direction_id: direction_id} + {:noreply, socket |> push_patch(to: ~p"/preview/predictions-stream?#{params}")} + end + + def handle_event("select-direction", %{"direction-id" => direction_id}, socket) do + route_id = socket.assigns.route_id + + params = %{route_id: route_id, direction_id: direction_id} + {:noreply, socket |> push_patch(to: ~p"/preview/predictions-stream?#{params}")} + end + + def handle_event("select-route", %{"route-id" => route_id}, socket) do + params = %{route_id: route_id} + {:noreply, socket |> push_patch(to: ~p"/preview/predictions-stream?#{params}")} + end + + def handle_event("select-stop", %{"stop-id" => stop_id}, socket) do + route_id = socket.assigns.route_id + direction_id = socket.assigns.direction_id + + params = %{route_id: route_id, direction_id: direction_id, stop_id: stop_id} + {:noreply, socket |> push_patch(to: ~p"/preview/predictions-stream?#{params}")} + end + + @impl LiveView + def terminate(_reason, _socket) do + :ok + end + + defp direction_description(route, direction_id) do + "#{route.direction_names[direction_id]} towards #{route.direction_destinations[direction_id]}" + end + + defp text_color(route) do + if(route.type == 3 and not String.contains?(route.name, "SL"), do: "black", else: "white") + end + + defp subscribe_or_unsubscribe_to_predictions( + %{ + assigns: %{ + direction_id: direction_id, + route_id: route_id, + stop_id: stop_id + } + } = socket + ) + when direction_id != nil and route_id != nil and stop_id != nil do + if connected?(socket) do + subscribe_to_predictions(socket) + else + socket + end + end + + defp subscribe_or_unsubscribe_to_predictions(socket) do + unsubscribe_from_predictions(socket) + end + + defp subscribe_to_predictions(%{assigns: %{sses_pid: sses_pid}} = socket) + when sses_pid != nil do + socket + end + + defp subscribe_to_predictions(socket) do + direction_id = socket.assigns.direction_id + route_id = socket.assigns.route_id + stop_id = socket.assigns.stop_id + + url = + "#{base_url()}/predictions?route=#{route_id}&direction_id=#{direction_id}&stop=#{stop_id}" + + {:ok, sses_pid} = ServerSentEventStage.start_link(url: url, headers: headers()) + {:ok, consumer_stage_pid} = PredictionsConsumerStage.start_link() + + GenStage.sync_subscribe(consumer_stage_pid, to: sses_pid) + + socket + |> assign(:subscribed?, true) + |> assign(:sses_pid, sses_pid) + |> assign(:consumer_stage_pid, consumer_stage_pid) + end + + defp unsubscribe_from_predictions( + %{assigns: %{sses_pid: sses_pid, consumer_stage_pid: consumer_stage_pid}} = socket + ) + when sses_pid != nil do + PredictionsConsumerStage.stop(consumer_stage_pid) + GenStage.stop(sses_pid) + + socket + |> assign(:subscribed?, false) + |> assign(:sses_pid, nil) + |> assign(:consumer_stage_pid, nil) + end + + defp unsubscribe_from_predictions(socket) do + socket + end + + defp base_url() do + Application.get_env(:dotcom, :mbta_api)[:base_url] + end + + defp headers() do + Application.get_env(:dotcom, :mbta_api)[:headers] + end + + defmodule PredictionsConsumerStage do + use GenStage + + def start_link() do + GenStage.start_link(__MODULE__, :ok) + end + + def stop(pid) do + GenStage.stop(pid) + end + + def terminate(_reason, _state), do: :ok + + def init(state) do + {:consumer, state} + end + + def handle_events(events, _from, state) do + dbg(events) + {:noreply, [], state} + end + end +end diff --git a/lib/dotcom_web/live/preview_live.ex b/lib/dotcom_web/live/preview_live.ex index 0fc7a7b2a9..9dc9e9d398 100644 --- a/lib/dotcom_web/live/preview_live.ex +++ b/lib/dotcom_web/live/preview_live.ex @@ -6,12 +6,20 @@ defmodule DotcomWeb.PreviewLive do alias DotcomWeb.WorldCupTimetableLive use DotcomWeb, :live_view + alias DotcomWeb.PredictionsStreamLive alias DotcomWeb.Router.Helpers alias DotcomWeb.ScheduleFinderLive alias DotcomWeb.StopMapLive alias Phoenix.LiveView @pages [ + %{ + arguments: [], + icon_name: "faucet-drip", + icon_type: "solid", + module: PredictionsStreamLive, + title: "Prediction Streaming" + }, %{ arguments: [[route_id: "Red", direction_id: "0"]], icon_name: "icon-realtime-tracking", diff --git a/lib/dotcom_web/router.ex b/lib/dotcom_web/router.ex index 2ff9d472c1..6acda368fb 100644 --- a/lib/dotcom_web/router.ex +++ b/lib/dotcom_web/router.ex @@ -327,6 +327,7 @@ defmodule DotcomWeb.Router do live_session :default, layout: {DotcomWeb.LayoutView, :preview} do live "/", PreviewLive + live "/predictions-stream", PredictionsStreamLive live "/schedules/bostonstadium", WorldCupTimetableLive live "/stop-map", StopMapLive end From 503745b03a6ca9064852f960f9efccf9cff1f20f Mon Sep 17 00:00:00 2001 From: Josh Larson Date: Tue, 21 Apr 2026 19:38:55 -0400 Subject: [PATCH 02/32] Move `PredictionsConsumerStage` to a separate file --- .../playground/predictions_consumer_stage.ex | 22 +++ .../live/predictions_stream_live.ex | 157 ++++++++++++++---- 2 files changed, 145 insertions(+), 34 deletions(-) create mode 100644 lib/dotcom/playground/predictions_consumer_stage.ex diff --git a/lib/dotcom/playground/predictions_consumer_stage.ex b/lib/dotcom/playground/predictions_consumer_stage.ex new file mode 100644 index 0000000000..5c17e425ce --- /dev/null +++ b/lib/dotcom/playground/predictions_consumer_stage.ex @@ -0,0 +1,22 @@ +defmodule Dotcom.Playground.PredictionsConsumerStage do + use GenStage + + def start_link(caller_pid) do + GenStage.start_link(__MODULE__, %{caller_pid: caller_pid}) + end + + def stop(pid) do + GenStage.stop(pid) + end + + def terminate(_reason, _state), do: :ok + + def init(state) do + {:consumer, state} + end + + def handle_events(events, _from, %{caller_pid: caller_pid} = state) do + send(caller_pid, {:prediction_events, events}) + {:noreply, [], state} + end +end diff --git a/lib/dotcom_web/live/predictions_stream_live.ex b/lib/dotcom_web/live/predictions_stream_live.ex index 6e8f2aeab0..f647075ac7 100644 --- a/lib/dotcom_web/live/predictions_stream_live.ex +++ b/lib/dotcom_web/live/predictions_stream_live.ex @@ -5,18 +5,18 @@ defmodule DotcomWeb.PredictionsStreamLive do use DotcomWeb, :live_view - alias DotcomWeb.PredictionsStreamLive.PredictionsConsumerStage + alias Dotcom.Playground.PredictionsConsumerStage alias Phoenix.LiveView + alias ServerSentEventStage.Event @impl LiveView def mount(_params, _session, socket) do {:ok, socket |> assign(:routes, Routes.Repo.all()) - |> assign_route(nil) - |> assign_direction(nil) - |> assign(:stops, nil) - |> assign(:subscribed?, false)} + |> assign(:subscribed?, false) + |> assign(:predictions, %{}) + |> assign(:predictions_list, [])} end @impl LiveView @@ -87,6 +87,7 @@ defmodule DotcomWeb.PredictionsStreamLive do routes={@routes} route={@route} direction_id={@direction_id} + predictions={@predictions} stop={@stop} stops={@stops} /> @@ -102,7 +103,13 @@ defmodule DotcomWeb.PredictionsStreamLive do defp route_picker_or_route_page(%{route: route} = assigns) when route != nil do ~H""" - <.route_page route={@route} direction_id={@direction_id} stop={@stop} stops={@stops} /> + <.route_page + route={@route} + direction_id={@direction_id} + stop={@stop} + stops={@stops} + predictions={@predictions} + /> """ end @@ -148,6 +155,7 @@ defmodule DotcomWeb.PredictionsStreamLive do direction_id={@direction_id} stop={@stop} stops={@stops} + predictions={@predictions} /> """ end @@ -155,7 +163,13 @@ defmodule DotcomWeb.PredictionsStreamLive do defp direction_picker_or_route_direction_page(%{direction_id: direction_id} = assigns) when direction_id != nil do ~H""" - <.route_direction_page route={@route} direction_id={@direction_id} stop={@stop} stops={@stops} /> + <.route_direction_page + route={@route} + direction_id={@direction_id} + stop={@stop} + stops={@stops} + predictions={@predictions} + /> """ end @@ -199,7 +213,7 @@ defmodule DotcomWeb.PredictionsStreamLive do - <.stop_picker_or_route_direction_stop_page stop={@stop} stops={@stops} /> + <.stop_picker_or_route_direction_stop_page stop={@stop} stops={@stops} predictions={@predictions} /> """ end @@ -221,6 +235,21 @@ defmodule DotcomWeb.PredictionsStreamLive do + +
+
+
Map.values() |> Enum.sort_by(& &1.arrival_time)} + class="p-2 border-t-xs border-gray-lightest" + > + {prediction.arrival_time} +
+ Raw +
{inspect prediction, pretty: true}
+
+
+
+
""" end @@ -287,6 +316,88 @@ defmodule DotcomWeb.PredictionsStreamLive do {:noreply, socket |> push_patch(to: ~p"/preview/predictions-stream?#{params}")} end + @impl LiveView + def handle_info({:prediction_events, events}, socket) do + {:noreply, socket |> update_assigned_predictions(events)} + end + + def update_assigned_predictions(socket, events) do + new_predictions = Enum.reduce(events, socket.assigns.predictions, &handle_prediction_event/2) + + socket |> assign(:predictions, new_predictions) + end + + def handle_prediction_event(%Event{event: event_type, data: data}, predictions) do + data + |> JSON.decode() + |> case do + {:ok, parsed_data} -> handle_prediction_event(event_type, parsed_data, predictions) + _ -> predictions + end + end + + def handle_prediction_event("reset", data, _predictions) do + dbg("reset") + + data + |> Stream.map(&to_prediction/1) + |> Enum.group_by(& &1.id) + |> Map.new(fn {id, [pred]} -> {id, pred} end) + end + + def handle_prediction_event(event_type, data, predictions) + when event_type in ["update", "add"] do + prediction = to_prediction(data) + predictions |> Map.put(prediction.id, prediction) + end + + def handle_prediction_event("remove", data, predictions) do + %{"id" => prediction_id, "type" => "prediction"} = data + + dbg("remove #{prediction_id}") + + predictions |> Map.delete(prediction_id) + end + + defp to_prediction(data) do + %{ + "attributes" => %{ + "arrival_time" => arrival_time, + "arrival_uncertainty" => _arrival_uncertainty, + "departure_time" => _departure_time, + "departure_uncertainty" => _departure_uncertainty, + "direction_id" => _direction_id, + "last_trip" => _last_trip, + "revenue" => _revenue, + "schedule_relationship" => _schedule_relationship, + "status" => _status, + "stop_sequence" => stop_sequence, + "trip_headsign" => _trip_headsign, + "update_type" => _update_type + }, + "id" => id, + "relationships" => %{ + "route" => %{"data" => %{"id" => _route_id, "type" => "route"}}, + "stop" => %{"data" => %{"id" => stop_id, "type" => "stop"}}, + "trip" => %{"data" => %{"id" => trip_id, "type" => "trip"}}, + "vehicle" => %{"data" => %{"id" => _vehicle_id, "type" => "vehicle"}} + }, + "type" => "prediction" + } = data + + stop = Stops.Repo.get(stop_id) + + %{ + arrival_time: arrival_time, + id: id, + raw: data, + stop_id: stop.id, + stop_name: stop.name, + stop_sequence: stop_sequence, + trip_id: trip_id + } + end + @impl LiveView def terminate(_reason, _socket) do :ok @@ -331,11 +442,12 @@ defmodule DotcomWeb.PredictionsStreamLive do route_id = socket.assigns.route_id stop_id = socket.assigns.stop_id - url = - "#{base_url()}/predictions?route=#{route_id}&direction_id=#{direction_id}&stop=#{stop_id}" + query = URI.encode_query(%{route: route_id, direction_id: direction_id, stop: stop_id}) + + url = "#{base_url()}/predictions?#{query}" {:ok, sses_pid} = ServerSentEventStage.start_link(url: url, headers: headers()) - {:ok, consumer_stage_pid} = PredictionsConsumerStage.start_link() + {:ok, consumer_stage_pid} = PredictionsConsumerStage.start_link(self()) GenStage.sync_subscribe(consumer_stage_pid, to: sses_pid) @@ -369,27 +481,4 @@ defmodule DotcomWeb.PredictionsStreamLive do defp headers() do Application.get_env(:dotcom, :mbta_api)[:headers] end - - defmodule PredictionsConsumerStage do - use GenStage - - def start_link() do - GenStage.start_link(__MODULE__, :ok) - end - - def stop(pid) do - GenStage.stop(pid) - end - - def terminate(_reason, _state), do: :ok - - def init(state) do - {:consumer, state} - end - - def handle_events(events, _from, state) do - dbg(events) - {:noreply, [], state} - end - end end From b643015e478b94bcc59202324c0481b3862d0f84 Mon Sep 17 00:00:00 2001 From: Josh Larson Date: Wed, 22 Apr 2026 09:54:05 -0400 Subject: [PATCH 03/32] Add UpcomingDeparturesPubSub GenServer --- lib/dotcom/application.ex | 3 +- .../playground/upcoming_departures_pub_sub.ex | 85 +++++++++++++++++++ .../live/predictions_stream_live.ex | 14 ++- 3 files changed, 99 insertions(+), 3 deletions(-) create mode 100644 lib/dotcom/playground/upcoming_departures_pub_sub.ex diff --git a/lib/dotcom/application.ex b/lib/dotcom/application.ex index 2ac0a03f89..10cf46c3e5 100644 --- a/lib/dotcom/application.ex +++ b/lib/dotcom/application.ex @@ -58,7 +58,8 @@ defmodule Dotcom.Application do [ Dotcom.ViaFairmount, {Dotcom.SystemStatus.CommuterRailCache, []}, - {Dotcom.SystemStatus.SubwayCache, []} + {Dotcom.SystemStatus.SubwayCache, []}, + Dotcom.Playground.UpcomingDeparturesPubsub ] else [] diff --git a/lib/dotcom/playground/upcoming_departures_pub_sub.ex b/lib/dotcom/playground/upcoming_departures_pub_sub.ex new file mode 100644 index 0000000000..197784ca41 --- /dev/null +++ b/lib/dotcom/playground/upcoming_departures_pub_sub.ex @@ -0,0 +1,85 @@ +defmodule Dotcom.Playground.UpcomingDeparturesPubsub do + alias Dotcom.Playground.UpcomingDeparturesPubsub.SubscriptionRegister + use GenServer + + # alias Dotcom.Playground.PredictionsConsumerStage + + def start_link(_) do + GenServer.start_link(__MODULE__, SubscriptionRegister.new(), name: __MODULE__) + end + + def subscribe(params) do + GenServer.cast(__MODULE__, {:subscribe, self(), params}) + end + + def unsubscribe() do + GenServer.cast(__MODULE__, {:unsubscribe, self()}) + end + + @impl GenServer + def init(initial_state) do + {:ok, initial_state} + end + + @impl GenServer + def handle_cast({:subscribe, caller_pid, params}, state) do + new_state = SubscriptionRegister.add_subscription(state, caller_pid, params) + dbg(new_state) + {:noreply, new_state} + end + + @impl GenServer + def handle_cast({:unsubscribe, caller_pid}, state) do + new_state = SubscriptionRegister.remove_subscription(state, caller_pid) + dbg(new_state) + {:noreply, new_state} + end + + defmodule SubscriptionRegister do + defstruct pids_by_params: %{}, params_by_pid: %{} + + def new() do + %__MODULE__{} + end + + def add_subscription( + %__MODULE__{pids_by_params: pids_by_params, params_by_pid: params_by_pid}, + pid, + params + ) do + new_pids_by_params = + pids_by_params |> Map.update(params, [pid], fn pids -> [pid | pids] end) + + new_params_by_pid = params_by_pid |> Map.put(pid, params) + + %__MODULE__{ + pids_by_params: new_pids_by_params, + params_by_pid: new_params_by_pid + } + end + + def remove_subscription( + %__MODULE__{pids_by_params: pids_by_params, params_by_pid: params_by_pid} = state, + pid + ) do + case params_by_pid do + %{^pid => params} -> + new_pids_by_params = + pids_by_params + |> Map.get(params) + |> List.delete(pid) + |> case do + [] -> pids_by_params |> Map.delete(params) + new_pids -> pids_by_params |> Map.put(params, new_pids) + end + + new_params_by_pid = params_by_pid |> Map.delete(pid) + + %__MODULE__{pids_by_params: new_pids_by_params, params_by_pid: new_params_by_pid} + + _ -> + state + end + end + end +end diff --git a/lib/dotcom_web/live/predictions_stream_live.ex b/lib/dotcom_web/live/predictions_stream_live.ex index f647075ac7..4a4f4916b9 100644 --- a/lib/dotcom_web/live/predictions_stream_live.ex +++ b/lib/dotcom_web/live/predictions_stream_live.ex @@ -3,6 +3,7 @@ defmodule DotcomWeb.PredictionsStreamLive do A page that shows stuff about predictions streaming """ + alias Dotcom.Playground.UpcomingDeparturesPubsub use DotcomWeb, :live_view alias Dotcom.Playground.PredictionsConsumerStage @@ -399,7 +400,9 @@ defmodule DotcomWeb.PredictionsStreamLive do end @impl LiveView - def terminate(_reason, _socket) do + def terminate(reason, socket) do + dbg("Terminating #{inspect(reason)}") + unsubscribe_from_predictions(socket) :ok end @@ -442,7 +445,12 @@ defmodule DotcomWeb.PredictionsStreamLive do route_id = socket.assigns.route_id stop_id = socket.assigns.stop_id - query = URI.encode_query(%{route: route_id, direction_id: direction_id, stop: stop_id}) + params = %{route: route_id, direction_id: direction_id, stop: stop_id} + pid = self() + dbg(pid) + UpcomingDeparturesPubsub.subscribe(params) + + query = URI.encode_query(params) url = "#{base_url()}/predictions?#{query}" @@ -461,6 +469,8 @@ defmodule DotcomWeb.PredictionsStreamLive do %{assigns: %{sses_pid: sses_pid, consumer_stage_pid: consumer_stage_pid}} = socket ) when sses_pid != nil do + UpcomingDeparturesPubsub.unsubscribe() + PredictionsConsumerStage.stop(consumer_stage_pid) GenStage.stop(sses_pid) From 0f83595da288cb9855dea18f9a5a269abeff4523 Mon Sep 17 00:00:00 2001 From: Josh Larson Date: Wed, 22 Apr 2026 17:11:52 -0400 Subject: [PATCH 04/32] Add live view to inspect the UpcomingDeparturesPubSub --- .../playground/upcoming_departures_pub_sub.ex | 9 +++++++ .../live/predictions_stream_live.ex | 4 +-- lib/dotcom_web/live/preview_live.ex | 8 ++++++ .../live/upcoming_departures_pub_sub_live.ex | 25 +++++++++++++++++++ lib/dotcom_web/router.ex | 1 + 5 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 lib/dotcom_web/live/upcoming_departures_pub_sub_live.ex diff --git a/lib/dotcom/playground/upcoming_departures_pub_sub.ex b/lib/dotcom/playground/upcoming_departures_pub_sub.ex index 197784ca41..e892e2a4f3 100644 --- a/lib/dotcom/playground/upcoming_departures_pub_sub.ex +++ b/lib/dotcom/playground/upcoming_departures_pub_sub.ex @@ -16,6 +16,10 @@ defmodule Dotcom.Playground.UpcomingDeparturesPubsub do GenServer.cast(__MODULE__, {:unsubscribe, self()}) end + def state() do + GenServer.call(__MODULE__, :get_state) + end + @impl GenServer def init(initial_state) do {:ok, initial_state} @@ -35,6 +39,11 @@ defmodule Dotcom.Playground.UpcomingDeparturesPubsub do {:noreply, new_state} end + @impl GenServer + def handle_call(:get_state, _from, state) do + {:reply, state, state} + end + defmodule SubscriptionRegister do defstruct pids_by_params: %{}, params_by_pid: %{} diff --git a/lib/dotcom_web/live/predictions_stream_live.ex b/lib/dotcom_web/live/predictions_stream_live.ex index 4a4f4916b9..fbe04fb296 100644 --- a/lib/dotcom_web/live/predictions_stream_live.ex +++ b/lib/dotcom_web/live/predictions_stream_live.ex @@ -380,8 +380,8 @@ defmodule DotcomWeb.PredictionsStreamLive do "relationships" => %{ "route" => %{"data" => %{"id" => _route_id, "type" => "route"}}, "stop" => %{"data" => %{"id" => stop_id, "type" => "stop"}}, - "trip" => %{"data" => %{"id" => trip_id, "type" => "trip"}}, - "vehicle" => %{"data" => %{"id" => _vehicle_id, "type" => "vehicle"}} + "trip" => %{"data" => %{"id" => trip_id, "type" => "trip"}} + # "vehicle" => %{"data" => %{"id" => _vehicle_id, "type" => "vehicle"}} }, "type" => "prediction" } = data diff --git a/lib/dotcom_web/live/preview_live.ex b/lib/dotcom_web/live/preview_live.ex index 9dc9e9d398..c50d36cad7 100644 --- a/lib/dotcom_web/live/preview_live.ex +++ b/lib/dotcom_web/live/preview_live.ex @@ -6,6 +6,7 @@ defmodule DotcomWeb.PreviewLive do alias DotcomWeb.WorldCupTimetableLive use DotcomWeb, :live_view + alias DotcomWeb.UpcomingDeparturesPubSubStateLive alias DotcomWeb.PredictionsStreamLive alias DotcomWeb.Router.Helpers alias DotcomWeb.ScheduleFinderLive @@ -13,6 +14,13 @@ defmodule DotcomWeb.PreviewLive do alias Phoenix.LiveView @pages [ + %{ + arguments: [], + icon_name: "magnifying-glass", + icon_type: "solid", + module: UpcomingDeparturesPubSubStateLive, + title: "Prediction Consumers" + }, %{ arguments: [], icon_name: "faucet-drip", diff --git a/lib/dotcom_web/live/upcoming_departures_pub_sub_live.ex b/lib/dotcom_web/live/upcoming_departures_pub_sub_live.ex new file mode 100644 index 0000000000..99cb6a317a --- /dev/null +++ b/lib/dotcom_web/live/upcoming_departures_pub_sub_live.ex @@ -0,0 +1,25 @@ +defmodule DotcomWeb.UpcomingDeparturesPubSubStateLive do + @moduledoc """ + A page that shows some stuff about who is looking at predictions + """ + + use DotcomWeb, :live_view + + alias Dotcom.Playground.UpcomingDeparturesPubsub + + def mount(_params, _session, socket) do + {:ok, + socket + |> assign(:pub_sub_state, UpcomingDeparturesPubsub.state())} + end + + def render(assigns) do + ~H""" +
+

Note: This page does not live update. You'll have to refresh to get updated info

+ +
{inspect @pub_sub_state, pretty: true}
+
+ """ + end +end diff --git a/lib/dotcom_web/router.ex b/lib/dotcom_web/router.ex index 6acda368fb..992d0f454f 100644 --- a/lib/dotcom_web/router.ex +++ b/lib/dotcom_web/router.ex @@ -328,6 +328,7 @@ defmodule DotcomWeb.Router do live_session :default, layout: {DotcomWeb.LayoutView, :preview} do live "/", PreviewLive live "/predictions-stream", PredictionsStreamLive + live "/upcoming-departures-pub-sub-state", UpcomingDeparturesPubSubStateLive live "/schedules/bostonstadium", WorldCupTimetableLive live "/stop-map", StopMapLive end From b71eb7a14343963ff797a4306b85fad80056056a Mon Sep 17 00:00:00 2001 From: Josh Larson Date: Wed, 22 Apr 2026 17:12:11 -0400 Subject: [PATCH 05/32] Fix alias order --- lib/dotcom_web/live/predictions_stream_live.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/dotcom_web/live/predictions_stream_live.ex b/lib/dotcom_web/live/predictions_stream_live.ex index fbe04fb296..0071d182fe 100644 --- a/lib/dotcom_web/live/predictions_stream_live.ex +++ b/lib/dotcom_web/live/predictions_stream_live.ex @@ -3,10 +3,10 @@ defmodule DotcomWeb.PredictionsStreamLive do A page that shows stuff about predictions streaming """ - alias Dotcom.Playground.UpcomingDeparturesPubsub use DotcomWeb, :live_view alias Dotcom.Playground.PredictionsConsumerStage + alias Dotcom.Playground.UpcomingDeparturesPubsub alias Phoenix.LiveView alias ServerSentEventStage.Event From 1e0aa3989b27ea361302165055b262046d64a13f Mon Sep 17 00:00:00 2001 From: Josh Larson Date: Thu, 23 Apr 2026 10:05:37 -0400 Subject: [PATCH 06/32] Add a subscribeable supervisor (and shut it down when it's not needed!) --- .../playground/prediction_aggregator_stage.ex | 39 ++++++++++++++ .../playground/upcoming_departures_pub_sub.ex | 53 +++++++++++++++---- .../upcoming_departures_supervisor.ex | 45 ++++++++++++++++ .../live/predictions_stream_live.ex | 7 +-- 4 files changed, 128 insertions(+), 16 deletions(-) create mode 100644 lib/dotcom/playground/prediction_aggregator_stage.ex create mode 100644 lib/dotcom/playground/upcoming_departures_supervisor.ex diff --git a/lib/dotcom/playground/prediction_aggregator_stage.ex b/lib/dotcom/playground/prediction_aggregator_stage.ex new file mode 100644 index 0000000000..9c8ce13532 --- /dev/null +++ b/lib/dotcom/playground/prediction_aggregator_stage.ex @@ -0,0 +1,39 @@ +defmodule Dotcom.Playground.PredictionAggregatorStage do + use GenStage + + def start_link(opts) do + dbg("Start aggregator") + name = opts |> Keyword.get(:name) + dbg(name) + + dbg(opts) + + result = GenStage.start_link(__MODULE__, opts, name: name) + dbg(result) + result + end + + # def stop(pid) do + # GenStage.stop(pid) + # end + + def terminate(reason, _state) do + dbg("Terminate") + dbg(reason) + :ok + end + + def init(opts) do + dbg("Aggregator begins #{inspect(opts)}") + + subscribe_to = opts |> Keyword.get(:subscribe_to) + + {:consumer, %{}, subscribe_to: [subscribe_to]} + end + + def handle_events(events, _from, state) do + dbg("Aggregate!") + dbg(events) + {:noreply, [], state} + end +end diff --git a/lib/dotcom/playground/upcoming_departures_pub_sub.ex b/lib/dotcom/playground/upcoming_departures_pub_sub.ex index e892e2a4f3..d3eb33e37d 100644 --- a/lib/dotcom/playground/upcoming_departures_pub_sub.ex +++ b/lib/dotcom/playground/upcoming_departures_pub_sub.ex @@ -1,7 +1,13 @@ defmodule Dotcom.Playground.UpcomingDeparturesPubsub do - alias Dotcom.Playground.UpcomingDeparturesPubsub.SubscriptionRegister + @moduledoc """ + A GenServer that tracks which consumers are subscribed to which + sets of upcoming departures/predictions streams + """ + use GenServer + alias Dotcom.Playground.UpcomingDeparturesSupervisor + alias Dotcom.Playground.UpcomingDeparturesPubsub.SubscriptionRegister # alias Dotcom.Playground.PredictionsConsumerStage def start_link(_) do @@ -27,16 +33,29 @@ defmodule Dotcom.Playground.UpcomingDeparturesPubsub do @impl GenServer def handle_cast({:subscribe, caller_pid, params}, state) do - new_state = SubscriptionRegister.add_subscription(state, caller_pid, params) + new_state = + SubscriptionRegister.add_subscription( + state, + caller_pid, + params, + &UpcomingDeparturesSupervisor.start_link/1 + ) + + dbg("HELLOWEWE") + dbg(new_state) + {:noreply, new_state} end @impl GenServer def handle_cast({:unsubscribe, caller_pid}, state) do - new_state = SubscriptionRegister.remove_subscription(state, caller_pid) - dbg(new_state) - {:noreply, new_state} + {:noreply, + SubscriptionRegister.remove_subscription( + state, + caller_pid, + &UpcomingDeparturesSupervisor.stop/1 + )} end @impl GenServer @@ -54,10 +73,19 @@ defmodule Dotcom.Playground.UpcomingDeparturesPubsub do def add_subscription( %__MODULE__{pids_by_params: pids_by_params, params_by_pid: params_by_pid}, pid, - params + params, + on_new_subscription ) do new_pids_by_params = - pids_by_params |> Map.update(params, [pid], fn pids -> [pid | pids] end) + case pids_by_params do + %{^params => pids} -> + pids_by_params |> Map.put(params, [pid | pids]) + + _ -> + on_new_subscription.(params) + dbg("Hello3") + pids_by_params |> Map.put(params, [pid]) + end new_params_by_pid = params_by_pid |> Map.put(pid, params) @@ -69,7 +97,8 @@ defmodule Dotcom.Playground.UpcomingDeparturesPubsub do def remove_subscription( %__MODULE__{pids_by_params: pids_by_params, params_by_pid: params_by_pid} = state, - pid + pid, + on_empty_subscription ) do case params_by_pid do %{^pid => params} -> @@ -78,8 +107,12 @@ defmodule Dotcom.Playground.UpcomingDeparturesPubsub do |> Map.get(params) |> List.delete(pid) |> case do - [] -> pids_by_params |> Map.delete(params) - new_pids -> pids_by_params |> Map.put(params, new_pids) + [] -> + on_empty_subscription.(params) + pids_by_params |> Map.delete(params) + + new_pids -> + pids_by_params |> Map.put(params, new_pids) end new_params_by_pid = params_by_pid |> Map.delete(pid) diff --git a/lib/dotcom/playground/upcoming_departures_supervisor.ex b/lib/dotcom/playground/upcoming_departures_supervisor.ex new file mode 100644 index 0000000000..b5246bacad --- /dev/null +++ b/lib/dotcom/playground/upcoming_departures_supervisor.ex @@ -0,0 +1,45 @@ +defmodule Dotcom.Playground.UpcomingDeparturesSupervisor do + use Supervisor + + alias Dotcom.Playground.PredictionAggregatorStage + + def start_link(params) do + Supervisor.start_link(__MODULE__, params, name: process_name(params)) + end + + def stop(params) do + dbg("Stopping the supervisor") + Supervisor.stop(process_name(params)) + end + + def init(params) do + dbg("INIT") + dbg(params) + query = URI.encode_query(params) + + dbg(query) + url = "#{base_url()}/predictions?#{query}" + + children = [ + {ServerSentEventStage, url: url, headers: headers(), name: process_name({:sses, params})}, + {PredictionAggregatorStage, + subscribe_to: process_name({:sses, params}), name: process_name({:aggregate, params})} + ] + + children + |> Supervisor.init(strategy: :one_for_all) + |> dbg() + end + + defp base_url() do + Application.get_env(:dotcom, :mbta_api)[:base_url] + end + + defp headers() do + Application.get_env(:dotcom, :mbta_api)[:headers] + end + + defp process_name(args) do + {:via, :global, args} + end +end diff --git a/lib/dotcom_web/live/predictions_stream_live.ex b/lib/dotcom_web/live/predictions_stream_live.ex index 0071d182fe..9a9c80e710 100644 --- a/lib/dotcom_web/live/predictions_stream_live.ex +++ b/lib/dotcom_web/live/predictions_stream_live.ex @@ -338,8 +338,6 @@ defmodule DotcomWeb.PredictionsStreamLive do end def handle_prediction_event("reset", data, _predictions) do - dbg("reset") - data |> Stream.map(&to_prediction/1) |> Enum.group_by(& &1.id) @@ -355,8 +353,6 @@ defmodule DotcomWeb.PredictionsStreamLive do def handle_prediction_event("remove", data, predictions) do %{"id" => prediction_id, "type" => "prediction"} = data - dbg("remove #{prediction_id}") - predictions |> Map.delete(prediction_id) end @@ -401,7 +397,6 @@ defmodule DotcomWeb.PredictionsStreamLive do @impl LiveView def terminate(reason, socket) do - dbg("Terminating #{inspect(reason)}") unsubscribe_from_predictions(socket) :ok end @@ -447,7 +442,7 @@ defmodule DotcomWeb.PredictionsStreamLive do params = %{route: route_id, direction_id: direction_id, stop: stop_id} pid = self() - dbg(pid) + UpcomingDeparturesPubsub.subscribe(params) query = URI.encode_query(params) From e343164e909d3e183e748242517c8806eb637ec2 Mon Sep 17 00:00:00 2001 From: Josh Larson Date: Sat, 25 Apr 2026 16:25:34 -0400 Subject: [PATCH 07/32] Add a broadcaster (that doesn't broadcast) --- .../playground/prediction_aggregator_stage.ex | 72 +++++++++++++++---- .../playground/prediction_broadcaster.ex | 20 ++++++ .../playground/upcoming_departures_pub_sub.ex | 23 +++--- .../upcoming_departures_supervisor.ex | 9 +-- .../live/predictions_stream_live.ex | 2 +- 5 files changed, 92 insertions(+), 34 deletions(-) create mode 100644 lib/dotcom/playground/prediction_broadcaster.ex diff --git a/lib/dotcom/playground/prediction_aggregator_stage.ex b/lib/dotcom/playground/prediction_aggregator_stage.ex index 9c8ce13532..cfbda8760d 100644 --- a/lib/dotcom/playground/prediction_aggregator_stage.ex +++ b/lib/dotcom/playground/prediction_aggregator_stage.ex @@ -1,6 +1,10 @@ defmodule Dotcom.Playground.PredictionAggregatorStage do use GenStage + alias __MODULE__.Store + alias Predictions.StreamParser + alias ServerSentEventStage.Event + def start_link(opts) do dbg("Start aggregator") name = opts |> Keyword.get(:name) @@ -13,27 +17,67 @@ defmodule Dotcom.Playground.PredictionAggregatorStage do result end - # def stop(pid) do - # GenStage.stop(pid) - # end - - def terminate(reason, _state) do - dbg("Terminate") - dbg(reason) - :ok - end + def terminate(_reason, _state), do: :ok def init(opts) do dbg("Aggregator begins #{inspect(opts)}") subscribe_to = opts |> Keyword.get(:subscribe_to) + publish_to = opts |> Keyword.get(:publish_to) - {:consumer, %{}, subscribe_to: [subscribe_to]} + {:consumer, %{publish_to: publish_to, predictions_store: Store.new()}, + subscribe_to: [subscribe_to]} end - def handle_events(events, _from, state) do - dbg("Aggregate!") - dbg(events) - {:noreply, [], state} + def handle_events( + events, + _from, + %{publish_to: publish_to, predictions_store: predictions_store} = state + ) do + new_predictions_store = Enum.reduce(events, predictions_store, &handle_prediction_event/2) + + publish_to_pid = GenServer.whereis(publish_to) + + send(publish_to_pid, {:predictions, new_predictions_store}) + + {:noreply, [], %{state | predictions_store: new_predictions_store}} + end + + defp handle_prediction_event(%Event{event: event_type, data: data}, predictions_store) do + dbg(event_type) + parsed_data = JsonApi.parse(data) + + handle_parsed_prediction_event(event_type, parsed_data, predictions_store) + end + + defp handle_parsed_prediction_event("reset", %JsonApi{data: data}, _predictions_store) do + data + |> Enum.map(&StreamParser.parse/1) + |> Enum.reduce(Store.new(), &Store.add_prediction(&2, &1)) + end + + defp handle_parsed_prediction_event(event_type, _data, predictions_store) do + dbg(event_type) + predictions_store + end + + defmodule Store do + defstruct by_id: %{}, by_trip_id_and_stop_seq: %{} + + def new() do + %__MODULE__{} + end + + def add_prediction( + %__MODULE__{by_id: by_id, by_trip_id_and_stop_seq: by_trip_id_and_stop_seq}, + prediction + ) do + %__MODULE__{ + by_id: by_id |> Map.put(prediction.id, prediction), + by_trip_id_and_stop_seq: + by_trip_id_and_stop_seq + |> Map.put({prediction.trip.id, prediction.stop_sequence}, prediction) + } + end end end diff --git a/lib/dotcom/playground/prediction_broadcaster.ex b/lib/dotcom/playground/prediction_broadcaster.ex new file mode 100644 index 0000000000..6a1a3d96fc --- /dev/null +++ b/lib/dotcom/playground/prediction_broadcaster.ex @@ -0,0 +1,20 @@ +defmodule Dotcom.Playground.PredictionBroadcaster do + use GenServer + + def start_link(opts) do + GenServer.start_link(__MODULE__, opts, name: opts |> Keyword.get(:name)) + end + + def init(opts) do + dbg(opts) + {:ok, %{}} + end + + def handle_info( + {:predictions, predictions}, + state + ) do + dbg(predictions) + {:noreply, state} + end +end diff --git a/lib/dotcom/playground/upcoming_departures_pub_sub.ex b/lib/dotcom/playground/upcoming_departures_pub_sub.ex index d3eb33e37d..977855665a 100644 --- a/lib/dotcom/playground/upcoming_departures_pub_sub.ex +++ b/lib/dotcom/playground/upcoming_departures_pub_sub.ex @@ -8,7 +8,6 @@ defmodule Dotcom.Playground.UpcomingDeparturesPubsub do alias Dotcom.Playground.UpcomingDeparturesSupervisor alias Dotcom.Playground.UpcomingDeparturesPubsub.SubscriptionRegister - # alias Dotcom.Playground.PredictionsConsumerStage def start_link(_) do GenServer.start_link(__MODULE__, SubscriptionRegister.new(), name: __MODULE__) @@ -33,19 +32,13 @@ defmodule Dotcom.Playground.UpcomingDeparturesPubsub do @impl GenServer def handle_cast({:subscribe, caller_pid, params}, state) do - new_state = - SubscriptionRegister.add_subscription( - state, - caller_pid, - params, - &UpcomingDeparturesSupervisor.start_link/1 - ) - - dbg("HELLOWEWE") - - dbg(new_state) - - {:noreply, new_state} + {:noreply, + SubscriptionRegister.add_subscription( + state, + caller_pid, + params, + &UpcomingDeparturesSupervisor.start_link/1 + )} end @impl GenServer @@ -83,7 +76,7 @@ defmodule Dotcom.Playground.UpcomingDeparturesPubsub do _ -> on_new_subscription.(params) - dbg("Hello3") + pids_by_params |> Map.put(params, [pid]) end diff --git a/lib/dotcom/playground/upcoming_departures_supervisor.ex b/lib/dotcom/playground/upcoming_departures_supervisor.ex index b5246bacad..25d0cc987a 100644 --- a/lib/dotcom/playground/upcoming_departures_supervisor.ex +++ b/lib/dotcom/playground/upcoming_departures_supervisor.ex @@ -2,6 +2,7 @@ defmodule Dotcom.Playground.UpcomingDeparturesSupervisor do use Supervisor alias Dotcom.Playground.PredictionAggregatorStage + alias Dotcom.Playground.PredictionBroadcaster def start_link(params) do Supervisor.start_link(__MODULE__, params, name: process_name(params)) @@ -13,17 +14,17 @@ defmodule Dotcom.Playground.UpcomingDeparturesSupervisor do end def init(params) do - dbg("INIT") - dbg(params) query = URI.encode_query(params) - dbg(query) url = "#{base_url()}/predictions?#{query}" children = [ {ServerSentEventStage, url: url, headers: headers(), name: process_name({:sses, params})}, {PredictionAggregatorStage, - subscribe_to: process_name({:sses, params}), name: process_name({:aggregate, params})} + publish_to: process_name({:broadcast, params}), + subscribe_to: process_name({:sses, params}), + name: process_name({:aggregate, params})}, + {PredictionBroadcaster, name: process_name({:broadcast, params})} ] children diff --git a/lib/dotcom_web/live/predictions_stream_live.ex b/lib/dotcom_web/live/predictions_stream_live.ex index 9a9c80e710..3c2bce9467 100644 --- a/lib/dotcom_web/live/predictions_stream_live.ex +++ b/lib/dotcom_web/live/predictions_stream_live.ex @@ -396,7 +396,7 @@ defmodule DotcomWeb.PredictionsStreamLive do end @impl LiveView - def terminate(reason, socket) do + def terminate(_reason, socket) do unsubscribe_from_predictions(socket) :ok end From 2490fd4748aaa585e96e54979ededdb5eca4060a Mon Sep 17 00:00:00 2001 From: Josh Larson Date: Mon, 27 Apr 2026 14:05:32 -0400 Subject: [PATCH 08/32] Add `PredictionsWorker` and `PredictionsManager` --- lib/dotcom/application.ex | 3 +- lib/dotcom/playground/predictions_manager.ex | 38 +++++++++++ lib/dotcom/playground/predictions_worker.ex | 67 +++++++++++++++++++ .../live/predictions_stream_live.ex | 7 +- 4 files changed, 110 insertions(+), 5 deletions(-) create mode 100644 lib/dotcom/playground/predictions_manager.ex create mode 100644 lib/dotcom/playground/predictions_worker.ex diff --git a/lib/dotcom/application.ex b/lib/dotcom/application.ex index 10cf46c3e5..b35696b04a 100644 --- a/lib/dotcom/application.ex +++ b/lib/dotcom/application.ex @@ -59,7 +59,8 @@ defmodule Dotcom.Application do Dotcom.ViaFairmount, {Dotcom.SystemStatus.CommuterRailCache, []}, {Dotcom.SystemStatus.SubwayCache, []}, - Dotcom.Playground.UpcomingDeparturesPubsub + Dotcom.Playground.UpcomingDeparturesPubsub, + Dotcom.Playground.PredictionsManager ] else [] diff --git a/lib/dotcom/playground/predictions_manager.ex b/lib/dotcom/playground/predictions_manager.ex new file mode 100644 index 0000000000..41db42022f --- /dev/null +++ b/lib/dotcom/playground/predictions_manager.ex @@ -0,0 +1,38 @@ +defmodule Dotcom.Playground.PredictionsManager do + use GenServer + + alias Dotcom.Playground.PredictionsWorker + + # Client + def start_link(_) do + GenServer.start_link(__MODULE__, %{}, name: __MODULE__) + end + + def subscribe(params) do + GenServer.cast(__MODULE__, {:subscribe, self(), params}) + end + + def unsubscribe() do + GenServer.cast(__MODULE__, {:unsubscribe, self()}) + end + + # Server + def init(opts) do + {:ok, opts} + end + + def handle_cast({:subscribe, pid, params}, state) do + PredictionsWorker.subscribe(pid, params) + + {:noreply, state |> Map.put(pid, params)} + end + + def handle_cast({:unsubscribe, pid}, state) do + case state do + %{^pid => params} -> PredictionsWorker.unsubscribe(pid, params) + _ -> nil + end + + {:noreply, state |> Map.delete(pid)} + end +end diff --git a/lib/dotcom/playground/predictions_worker.ex b/lib/dotcom/playground/predictions_worker.ex new file mode 100644 index 0000000000..5e449e2774 --- /dev/null +++ b/lib/dotcom/playground/predictions_worker.ex @@ -0,0 +1,67 @@ +defmodule Dotcom.Playground.PredictionsWorker do + use GenServer + + # Client + def subscribe(caller_pid, params) do + GenServer.start_link(__MODULE__, params, name: process_name(params)) + |> case do + {:ok, pid} -> + dbg("New Subscription #{inspect(pid)}") + pid + + {:error, {:already_started, pid}} -> + dbg("Existing Subscription #{inspect(pid)}") + pid + end + |> GenServer.cast({:subscribe, caller_pid}) + end + + def unsubscribe(caller_pid, params) do + params + |> process_name() + |> GenServer.whereis() + |> GenServer.cast({:unsubscribe, caller_pid}) + end + + # Server + def init(params) do + {:ok, %{params: params, subscribers: MapSet.new()}} + end + + def terminate(_reason, state) do + dbg("TERMINATE #{inspect(state)}") + :ok + end + + def handle_cast({:subscribe, pid}, %{params: params, subscribers: subscribers} = state) do + dbg("SUBSCRIBE #{inspect(pid)} TO #{inspect(params)}") + + new_subscribers = + subscribers + |> MapSet.put(pid) + |> dbg() + + {:noreply, %{state | subscribers: new_subscribers}} + end + + def handle_cast({:unsubscribe, pid}, %{params: params, subscribers: subscribers} = state) do + dbg("UNSUBSCRIBE #{inspect(pid)} FROM #{inspect(params)}") + + new_subscribers = + subscribers + |> MapSet.delete(pid) + |> dbg() + + new_state = %{state | subscribers: new_subscribers} + + if Enum.empty?(new_subscribers) do + {:stop, :normal, new_state} + else + {:noreply, new_state} + end + end + + defp process_name(params) do + {:global, {:predictions, params}} + end +end diff --git a/lib/dotcom_web/live/predictions_stream_live.ex b/lib/dotcom_web/live/predictions_stream_live.ex index 3c2bce9467..171a279c31 100644 --- a/lib/dotcom_web/live/predictions_stream_live.ex +++ b/lib/dotcom_web/live/predictions_stream_live.ex @@ -6,7 +6,7 @@ defmodule DotcomWeb.PredictionsStreamLive do use DotcomWeb, :live_view alias Dotcom.Playground.PredictionsConsumerStage - alias Dotcom.Playground.UpcomingDeparturesPubsub + alias Dotcom.Playground.PredictionsManager alias Phoenix.LiveView alias ServerSentEventStage.Event @@ -441,9 +441,8 @@ defmodule DotcomWeb.PredictionsStreamLive do stop_id = socket.assigns.stop_id params = %{route: route_id, direction_id: direction_id, stop: stop_id} - pid = self() - UpcomingDeparturesPubsub.subscribe(params) + PredictionsManager.subscribe(params) query = URI.encode_query(params) @@ -464,7 +463,7 @@ defmodule DotcomWeb.PredictionsStreamLive do %{assigns: %{sses_pid: sses_pid, consumer_stage_pid: consumer_stage_pid}} = socket ) when sses_pid != nil do - UpcomingDeparturesPubsub.unsubscribe() + PredictionsManager.unsubscribe() PredictionsConsumerStage.stop(consumer_stage_pid) GenStage.stop(sses_pid) From 0c9832fa91c2b408de017a17f3e2e31987ccb8b1 Mon Sep 17 00:00:00 2001 From: Josh Larson Date: Mon, 27 Apr 2026 14:06:03 -0400 Subject: [PATCH 09/32] Add `PredictionsSupervisor` and `PredictionsBroadcasterStage` --- .../predictions_broadcaster_stage.ex | 88 +++++++++++++++++++ .../playground/predictions_supervisor.ex | 45 ++++++++++ lib/dotcom/playground/predictions_worker.ex | 22 ++--- .../live/predictions_stream_live.ex | 5 ++ 4 files changed, 149 insertions(+), 11 deletions(-) create mode 100644 lib/dotcom/playground/predictions_broadcaster_stage.ex create mode 100644 lib/dotcom/playground/predictions_supervisor.ex diff --git a/lib/dotcom/playground/predictions_broadcaster_stage.ex b/lib/dotcom/playground/predictions_broadcaster_stage.ex new file mode 100644 index 0000000000..c6c9290cba --- /dev/null +++ b/lib/dotcom/playground/predictions_broadcaster_stage.ex @@ -0,0 +1,88 @@ +defmodule Dotcom.Playground.PredictionsBroadcasterStage do + use GenStage + + alias Predictions.StreamParser + alias ServerSentEventStage.Event + + def start_link(opts) do + GenStage.start_link(__MODULE__, opts, name: opts |> Keyword.get(:name)) + end + + def terminate(_reason, _state), do: :ok + + def init(opts) do + subscribe_to = opts |> Keyword.get(:subscribe_to) + publish_to = opts |> Keyword.get(:publish_to) + + {:consumer, %{publish_to: publish_to, predictions: %{}}, subscribe_to: [subscribe_to]} + end + + def handle_events( + events, + _from, + %{publish_to: publish_to, predictions: predictions} = state + ) do + parsed_events = + events + |> Enum.map(&parse_event/1) + + # |> dbg() + + new_predictions = + Enum.reduce(parsed_events, predictions, fn + {:reset, predictions}, _predictions -> + predictions |> index_by(& &1.id) + + {event_type, prediction}, predictions when event_type in [:add, :update] -> + predictions |> Map.put(prediction.id, prediction) + + {:remove, prediction}, predictions -> + predictions |> Map.delete(prediction.id) + + _, _ -> + predictions + end) + + send( + publish_to, + {:predictions_update, %{predictions: new_predictions, events: parsed_events}} + ) + + {:noreply, [], %{state | predictions: new_predictions}} + end + + defp parse_event(%Event{event: "reset", data: data}) do + {:reset, + data + |> JsonApi.parse() + |> then(fn %JsonApi{data: data} -> data end) + |> Enum.map(&StreamParser.parse/1)} + end + + defp parse_event(%Event{event: "add", data: data}), do: {:add, to_prediction(data)} + defp parse_event(%Event{event: "update", data: data}), do: {:update, to_prediction(data)} + + defp parse_event(%Event{event: "remove", data: data}) do + {:update, to_prediction(data)} + end + + defp parse_event(%Event{event: event_type, data: _data}) do + {:unknown, event_type} + end + + defp parse_event(%Event{event: event_type}), do: {:unknown, event_type} + + defp to_prediction(data) do + data + |> JsonApi.parse() + |> then(fn %JsonApi{data: data} -> data end) + |> Enum.map(&StreamParser.parse/1) + |> then(fn [prediction] -> prediction end) + end + + defp index_by(enum, fun) do + enum + |> Enum.group_by(fun) + |> Map.new(fn {key, [value | _]} -> {key, value} end) + end +end diff --git a/lib/dotcom/playground/predictions_supervisor.ex b/lib/dotcom/playground/predictions_supervisor.ex new file mode 100644 index 0000000000..e784f5d8fa --- /dev/null +++ b/lib/dotcom/playground/predictions_supervisor.ex @@ -0,0 +1,45 @@ +defmodule Dotcom.Playground.PredictionsSupervisor do + use Supervisor + + alias Dotcom.Playground.PredictionsBroadcasterStage + + # Client + def start_link(%{params: params} = args) do + Supervisor.start_link(__MODULE__, args, name: process_name(params)) + end + + def stop(%{params: params}) do + Supervisor.stop(process_name(params)) + end + + # Server + @impl Supervisor + def init(%{params: params, publish_to: publish_to}) do + query = URI.encode_query(params) + + url = "#{base_url()}/predictions?#{query}" + + Supervisor.init( + [ + {ServerSentEventStage, url: url, headers: headers(), name: process_name({:sses, params})}, + {PredictionsBroadcasterStage, + publish_to: publish_to, + subscribe_to: process_name({:sses, params}), + name: process_name({:broadcast, params})} + ], + strategy: :one_for_all + ) + end + + defp process_name(args) do + {:global, {:predictions_supervisor, args}} + end + + defp base_url() do + Application.get_env(:dotcom, :mbta_api)[:base_url] + end + + defp headers() do + Application.get_env(:dotcom, :mbta_api)[:headers] + end +end diff --git a/lib/dotcom/playground/predictions_worker.ex b/lib/dotcom/playground/predictions_worker.ex index 5e449e2774..25950fab50 100644 --- a/lib/dotcom/playground/predictions_worker.ex +++ b/lib/dotcom/playground/predictions_worker.ex @@ -1,4 +1,5 @@ defmodule Dotcom.Playground.PredictionsWorker do + alias Dotcom.Playground.PredictionsSupervisor use GenServer # Client @@ -6,11 +7,9 @@ defmodule Dotcom.Playground.PredictionsWorker do GenServer.start_link(__MODULE__, params, name: process_name(params)) |> case do {:ok, pid} -> - dbg("New Subscription #{inspect(pid)}") pid {:error, {:already_started, pid}} -> - dbg("Existing Subscription #{inspect(pid)}") pid end |> GenServer.cast({:subscribe, caller_pid}) @@ -25,32 +24,27 @@ defmodule Dotcom.Playground.PredictionsWorker do # Server def init(params) do + PredictionsSupervisor.start_link(%{params: params, publish_to: self()}) + {:ok, %{params: params, subscribers: MapSet.new()}} end - def terminate(_reason, state) do - dbg("TERMINATE #{inspect(state)}") - :ok + def terminate(_reason, %{params: params}) do + PredictionsSupervisor.stop(params) end def handle_cast({:subscribe, pid}, %{params: params, subscribers: subscribers} = state) do - dbg("SUBSCRIBE #{inspect(pid)} TO #{inspect(params)}") - new_subscribers = subscribers |> MapSet.put(pid) - |> dbg() {:noreply, %{state | subscribers: new_subscribers}} end def handle_cast({:unsubscribe, pid}, %{params: params, subscribers: subscribers} = state) do - dbg("UNSUBSCRIBE #{inspect(pid)} FROM #{inspect(params)}") - new_subscribers = subscribers |> MapSet.delete(pid) - |> dbg() new_state = %{state | subscribers: new_subscribers} @@ -61,6 +55,12 @@ defmodule Dotcom.Playground.PredictionsWorker do end end + def handle_info({:predictions_update, data}, %{subscribers: subscribers} = state) do + subscribers |> Enum.each(&send(&1, {:predictions_update, data})) + + {:noreply, state} + end + defp process_name(params) do {:global, {:predictions, params}} end diff --git a/lib/dotcom_web/live/predictions_stream_live.ex b/lib/dotcom_web/live/predictions_stream_live.ex index 171a279c31..90b77862d9 100644 --- a/lib/dotcom_web/live/predictions_stream_live.ex +++ b/lib/dotcom_web/live/predictions_stream_live.ex @@ -322,6 +322,11 @@ defmodule DotcomWeb.PredictionsStreamLive do {:noreply, socket |> update_assigned_predictions(events)} end + def handle_info({:predictions_update, data}, socket) do + dbg("Predictions Update") + {:noreply, socket} + end + def update_assigned_predictions(socket, events) do new_predictions = Enum.reduce(events, socket.assigns.predictions, &handle_prediction_event/2) From b91729ecab8a9073140f71f9b4d12bfe9089976a Mon Sep 17 00:00:00 2001 From: Josh Larson Date: Tue, 28 Apr 2026 09:52:05 -0400 Subject: [PATCH 10/32] Refine `PredictionBroadcasterStage` --- .../predictions_broadcaster_stage.ex | 106 ++++++++++-------- lib/dotcom/playground/predictions_worker.ex | 2 +- .../live/predictions_stream_live.ex | 65 ++++++++--- 3 files changed, 107 insertions(+), 66 deletions(-) diff --git a/lib/dotcom/playground/predictions_broadcaster_stage.ex b/lib/dotcom/playground/predictions_broadcaster_stage.ex index c6c9290cba..59d0908a49 100644 --- a/lib/dotcom/playground/predictions_broadcaster_stage.ex +++ b/lib/dotcom/playground/predictions_broadcaster_stage.ex @@ -22,67 +22,77 @@ defmodule Dotcom.Playground.PredictionsBroadcasterStage do _from, %{publish_to: publish_to, predictions: predictions} = state ) do - parsed_events = - events - |> Enum.map(&parse_event/1) - - # |> dbg() - - new_predictions = - Enum.reduce(parsed_events, predictions, fn - {:reset, predictions}, _predictions -> - predictions |> index_by(& &1.id) - - {event_type, prediction}, predictions when event_type in [:add, :update] -> - predictions |> Map.put(prediction.id, prediction) - - {:remove, prediction}, predictions -> - predictions |> Map.delete(prediction.id) - - _, _ -> - predictions - end) + %{parsed_events: parsed_events, predictions: new_predictions} = + events |> Enum.reduce(%{predictions: predictions, parsed_events: []}, &handle_event/2) send( publish_to, - {:predictions_update, %{predictions: new_predictions, events: parsed_events}} + {:predictions_update, + %{ + predictions: new_predictions |> Map.values(), + events: parsed_events |> Enum.reverse() + }} ) {:noreply, [], %{state | predictions: new_predictions}} end - defp parse_event(%Event{event: "reset", data: data}) do - {:reset, - data - |> JsonApi.parse() - |> then(fn %JsonApi{data: data} -> data end) - |> Enum.map(&StreamParser.parse/1)} - end - - defp parse_event(%Event{event: "add", data: data}), do: {:add, to_prediction(data)} - defp parse_event(%Event{event: "update", data: data}), do: {:update, to_prediction(data)} - - defp parse_event(%Event{event: "remove", data: data}) do - {:update, to_prediction(data)} + defp handle_event(%Event{event: "reset", data: data}, %{ + predictions: _predictions, + parsed_events: parsed_events + }) do + new_predictions = + data + |> JsonApi.parse() + |> then(fn %JsonApi{data: data} -> data end) + |> Map.new(fn %JsonApi.Item{id: id} = item -> {id, StreamParser.parse(item)} end) + + %{ + parsed_events: [{"reset", Map.values(new_predictions)} | parsed_events], + predictions: new_predictions + } end - defp parse_event(%Event{event: event_type, data: _data}) do - {:unknown, event_type} + defp handle_event(%Event{event: event_type, data: data}, %{ + predictions: predictions, + parsed_events: parsed_events + }) + when event_type in ["add", "update"] do + {id, prediction} = + data + |> JsonApi.parse() + |> then(fn %JsonApi{data: data} -> data end) + |> then(fn [%JsonApi.Item{id: id} = item] -> {id, StreamParser.parse(item)} end) + + %{ + parsed_events: [{event_type, prediction} | parsed_events], + predictions: Map.put(predictions, id, prediction) + } end - defp parse_event(%Event{event: event_type}), do: {:unknown, event_type} - - defp to_prediction(data) do - data - |> JsonApi.parse() - |> then(fn %JsonApi{data: data} -> data end) - |> Enum.map(&StreamParser.parse/1) - |> then(fn [prediction] -> prediction end) + defp handle_event(%Event{event: "remove", data: data}, %{ + predictions: predictions, + parsed_events: parsed_events + }) do + id = + data + |> JsonApi.parse() + |> then(fn %JsonApi{data: data} -> data end) + |> then(fn [%JsonApi.Item{id: id}] -> id end) + + %{ + parsed_events: [{"remove", Map.get(predictions, id)} | parsed_events], + predictions: Map.delete(predictions, id) + } end - defp index_by(enum, fun) do - enum - |> Enum.group_by(fun) - |> Map.new(fn {key, [value | _]} -> {key, value} end) + defp handle_event(%Event{event: event_type, data: data}, %{ + predictions: predictions, + parsed_events: parsed_events + }) do + %{ + parsed_events: [{event_type, data |> JsonApi.parse()} | parsed_events], + predictions: predictions + } end end diff --git a/lib/dotcom/playground/predictions_worker.ex b/lib/dotcom/playground/predictions_worker.ex index 25950fab50..04109eaa9f 100644 --- a/lib/dotcom/playground/predictions_worker.ex +++ b/lib/dotcom/playground/predictions_worker.ex @@ -30,7 +30,7 @@ defmodule Dotcom.Playground.PredictionsWorker do end def terminate(_reason, %{params: params}) do - PredictionsSupervisor.stop(params) + PredictionsSupervisor.stop(%{params: params}) end def handle_cast({:subscribe, pid}, %{params: params, subscribers: subscribers} = state) do diff --git a/lib/dotcom_web/live/predictions_stream_live.ex b/lib/dotcom_web/live/predictions_stream_live.ex index 90b77862d9..61879d6d1a 100644 --- a/lib/dotcom_web/live/predictions_stream_live.ex +++ b/lib/dotcom_web/live/predictions_stream_live.ex @@ -17,7 +17,8 @@ defmodule DotcomWeb.PredictionsStreamLive do |> assign(:routes, Routes.Repo.all()) |> assign(:subscribed?, false) |> assign(:predictions, %{}) - |> assign(:predictions_list, [])} + |> assign(:predictions_list, []) + |> assign(:prediction_events, [])} end @impl LiveView @@ -89,6 +90,7 @@ defmodule DotcomWeb.PredictionsStreamLive do route={@route} direction_id={@direction_id} predictions={@predictions} + prediction_events={@prediction_events} stop={@stop} stops={@stops} /> @@ -110,6 +112,7 @@ defmodule DotcomWeb.PredictionsStreamLive do stop={@stop} stops={@stops} predictions={@predictions} + prediction_events={@prediction_events} /> """ end @@ -157,6 +160,7 @@ defmodule DotcomWeb.PredictionsStreamLive do stop={@stop} stops={@stops} predictions={@predictions} + prediction_events={@prediction_events} /> """ end @@ -170,6 +174,7 @@ defmodule DotcomWeb.PredictionsStreamLive do stop={@stop} stops={@stops} predictions={@predictions} + prediction_events={@prediction_events} /> """ end @@ -214,7 +219,12 @@ defmodule DotcomWeb.PredictionsStreamLive do - <.stop_picker_or_route_direction_stop_page stop={@stop} stops={@stops} predictions={@predictions} /> + <.stop_picker_or_route_direction_stop_page + stop={@stop} + stops={@stops} + predictions={@predictions} + prediction_events={@prediction_events} + /> """ end @@ -238,16 +248,33 @@ defmodule DotcomWeb.PredictionsStreamLive do
-
-
Map.values() |> Enum.sort_by(& &1.arrival_time)} - class="p-2 border-t-xs border-gray-lightest" - > - {prediction.arrival_time} -
- Raw -
{inspect prediction, pretty: true}
-
+
+
+

Predictions

+
+
+ {prediction.arrival_time} +
+ Raw +
{inspect prediction, pretty: true}
+
+
+
+
+ +
+

Events

+
+
+
+ Event +
{inspect event, pretty: true}
+
+
+
@@ -318,13 +345,17 @@ defmodule DotcomWeb.PredictionsStreamLive do end @impl LiveView - def handle_info({:prediction_events, events}, socket) do - {:noreply, socket |> update_assigned_predictions(events)} + def handle_info({:prediction_events, _events}, socket) do + {:noreply, socket} end - def handle_info({:predictions_update, data}, socket) do - dbg("Predictions Update") - {:noreply, socket} + def handle_info({:predictions_update, %{events: events, predictions: predictions}}, socket) do + prediction_events = socket.assigns.prediction_events + + {:noreply, + socket + |> assign(:prediction_events, [events | prediction_events]) + |> assign(:predictions, predictions |> Enum.sort_by(&(&1.arrival_time || &1.departure_time)))} end def update_assigned_predictions(socket, events) do From 0ee56f17a2c256fa0e3a23cfd8fe148b79533da3 Mon Sep 17 00:00:00 2001 From: Josh Larson Date: Tue, 28 Apr 2026 10:02:38 -0400 Subject: [PATCH 11/32] Remove unnecessary params from `PredictionsWorker` --- lib/dotcom/playground/predictions_worker.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/dotcom/playground/predictions_worker.ex b/lib/dotcom/playground/predictions_worker.ex index 04109eaa9f..ca1c670085 100644 --- a/lib/dotcom/playground/predictions_worker.ex +++ b/lib/dotcom/playground/predictions_worker.ex @@ -33,7 +33,7 @@ defmodule Dotcom.Playground.PredictionsWorker do PredictionsSupervisor.stop(%{params: params}) end - def handle_cast({:subscribe, pid}, %{params: params, subscribers: subscribers} = state) do + def handle_cast({:subscribe, pid}, %{subscribers: subscribers} = state) do new_subscribers = subscribers |> MapSet.put(pid) @@ -41,7 +41,7 @@ defmodule Dotcom.Playground.PredictionsWorker do {:noreply, %{state | subscribers: new_subscribers}} end - def handle_cast({:unsubscribe, pid}, %{params: params, subscribers: subscribers} = state) do + def handle_cast({:unsubscribe, pid}, %{subscribers: subscribers} = state) do new_subscribers = subscribers |> MapSet.delete(pid) From 9dea4730148b39f231efacce23840b16dac0bbb5 Mon Sep 17 00:00:00 2001 From: Josh Larson Date: Tue, 28 Apr 2026 10:03:07 -0400 Subject: [PATCH 12/32] Cleanup PredictionsStreamLive --- .../live/predictions_stream_live.ex | 126 +----------------- 1 file changed, 4 insertions(+), 122 deletions(-) diff --git a/lib/dotcom_web/live/predictions_stream_live.ex b/lib/dotcom_web/live/predictions_stream_live.ex index 61879d6d1a..0eb61da431 100644 --- a/lib/dotcom_web/live/predictions_stream_live.ex +++ b/lib/dotcom_web/live/predictions_stream_live.ex @@ -5,10 +5,8 @@ defmodule DotcomWeb.PredictionsStreamLive do use DotcomWeb, :live_view - alias Dotcom.Playground.PredictionsConsumerStage alias Dotcom.Playground.PredictionsManager alias Phoenix.LiveView - alias ServerSentEventStage.Event @impl LiveView def mount(_params, _session, socket) do @@ -16,8 +14,7 @@ defmodule DotcomWeb.PredictionsStreamLive do socket |> assign(:routes, Routes.Repo.all()) |> assign(:subscribed?, false) - |> assign(:predictions, %{}) - |> assign(:predictions_list, []) + |> assign(:predictions, []) |> assign(:prediction_events, [])} end @@ -345,10 +342,6 @@ defmodule DotcomWeb.PredictionsStreamLive do end @impl LiveView - def handle_info({:prediction_events, _events}, socket) do - {:noreply, socket} - end - def handle_info({:predictions_update, %{events: events, predictions: predictions}}, socket) do prediction_events = socket.assigns.prediction_events @@ -358,79 +351,6 @@ defmodule DotcomWeb.PredictionsStreamLive do |> assign(:predictions, predictions |> Enum.sort_by(&(&1.arrival_time || &1.departure_time)))} end - def update_assigned_predictions(socket, events) do - new_predictions = Enum.reduce(events, socket.assigns.predictions, &handle_prediction_event/2) - - socket |> assign(:predictions, new_predictions) - end - - def handle_prediction_event(%Event{event: event_type, data: data}, predictions) do - data - |> JSON.decode() - |> case do - {:ok, parsed_data} -> handle_prediction_event(event_type, parsed_data, predictions) - _ -> predictions - end - end - - def handle_prediction_event("reset", data, _predictions) do - data - |> Stream.map(&to_prediction/1) - |> Enum.group_by(& &1.id) - |> Map.new(fn {id, [pred]} -> {id, pred} end) - end - - def handle_prediction_event(event_type, data, predictions) - when event_type in ["update", "add"] do - prediction = to_prediction(data) - predictions |> Map.put(prediction.id, prediction) - end - - def handle_prediction_event("remove", data, predictions) do - %{"id" => prediction_id, "type" => "prediction"} = data - - predictions |> Map.delete(prediction_id) - end - - defp to_prediction(data) do - %{ - "attributes" => %{ - "arrival_time" => arrival_time, - "arrival_uncertainty" => _arrival_uncertainty, - "departure_time" => _departure_time, - "departure_uncertainty" => _departure_uncertainty, - "direction_id" => _direction_id, - "last_trip" => _last_trip, - "revenue" => _revenue, - "schedule_relationship" => _schedule_relationship, - "status" => _status, - "stop_sequence" => stop_sequence, - "trip_headsign" => _trip_headsign, - "update_type" => _update_type - }, - "id" => id, - "relationships" => %{ - "route" => %{"data" => %{"id" => _route_id, "type" => "route"}}, - "stop" => %{"data" => %{"id" => stop_id, "type" => "stop"}}, - "trip" => %{"data" => %{"id" => trip_id, "type" => "trip"}} - # "vehicle" => %{"data" => %{"id" => _vehicle_id, "type" => "vehicle"}} - }, - "type" => "prediction" - } = data - - stop = Stops.Repo.get(stop_id) - - %{ - arrival_time: arrival_time, - id: id, - raw: data, - stop_id: stop.id, - stop_name: stop.name, - stop_sequence: stop_sequence, - trip_id: trip_id - } - end - @impl LiveView def terminate(_reason, socket) do unsubscribe_from_predictions(socket) @@ -466,11 +386,6 @@ defmodule DotcomWeb.PredictionsStreamLive do unsubscribe_from_predictions(socket) end - defp subscribe_to_predictions(%{assigns: %{sses_pid: sses_pid}} = socket) - when sses_pid != nil do - socket - end - defp subscribe_to_predictions(socket) do direction_id = socket.assigns.direction_id route_id = socket.assigns.route_id @@ -480,45 +395,12 @@ defmodule DotcomWeb.PredictionsStreamLive do PredictionsManager.subscribe(params) - query = URI.encode_query(params) - - url = "#{base_url()}/predictions?#{query}" - - {:ok, sses_pid} = ServerSentEventStage.start_link(url: url, headers: headers()) - {:ok, consumer_stage_pid} = PredictionsConsumerStage.start_link(self()) - - GenStage.sync_subscribe(consumer_stage_pid, to: sses_pid) - - socket - |> assign(:subscribed?, true) - |> assign(:sses_pid, sses_pid) - |> assign(:consumer_stage_pid, consumer_stage_pid) - end - - defp unsubscribe_from_predictions( - %{assigns: %{sses_pid: sses_pid, consumer_stage_pid: consumer_stage_pid}} = socket - ) - when sses_pid != nil do - PredictionsManager.unsubscribe() - - PredictionsConsumerStage.stop(consumer_stage_pid) - GenStage.stop(sses_pid) - - socket - |> assign(:subscribed?, false) - |> assign(:sses_pid, nil) - |> assign(:consumer_stage_pid, nil) + socket |> assign(:subscribed?, true) end defp unsubscribe_from_predictions(socket) do - socket - end - - defp base_url() do - Application.get_env(:dotcom, :mbta_api)[:base_url] - end + PredictionsManager.unsubscribe() - defp headers() do - Application.get_env(:dotcom, :mbta_api)[:headers] + socket |> assign(:subscribed?, false) end end From 0d341afa705c36f9f0efeaaa1830208438dbf852 Mon Sep 17 00:00:00 2001 From: Josh Larson Date: Tue, 28 Apr 2026 14:01:07 -0400 Subject: [PATCH 13/32] Refactor PredictionsStreamLive --- .../live/predictions_stream_live.ex | 285 ++++++++---------- 1 file changed, 124 insertions(+), 161 deletions(-) diff --git a/lib/dotcom_web/live/predictions_stream_live.ex b/lib/dotcom_web/live/predictions_stream_live.ex index 0eb61da431..aeea01904f 100644 --- a/lib/dotcom_web/live/predictions_stream_live.ex +++ b/lib/dotcom_web/live/predictions_stream_live.ex @@ -82,15 +82,47 @@ defmodule DotcomWeb.PredictionsStreamLive do @impl LiveView def render(assigns) do ~H""" - <.route_picker_or_route_page - routes={@routes} - route={@route} - direction_id={@direction_id} - predictions={@predictions} - prediction_events={@prediction_events} - stop={@stop} - stops={@stops} - /> + <.route_picker_or route={@route} routes={@routes}> + <.banner + style={"background-color: ##{@route.color}; color: #{text_color(@route)}; fill: #{text_color(@route)};"} + clear_button_click="clear-route" + > + {@route.name} + + + <.direction_picker_or route={@route} direction_id={@direction_id}> + <.banner clear_button_click="clear-direction" class="bg-gray-lightest"> + {direction_description(@route, @direction_id)} + + + <.stop_picker_or + stop={@stop} + stops={@stops} + > + <.banner clear_button_click="clear-stop" class="bg-charcoal-10 text-white fill-white"> +
+ {@stop.name} + {@stop.id} +
+ + +
+
+
+

Predictions

+ <.predictions_panel predictions={@predictions} /> +
+ +
+

Events

+ <.events_panel prediction_events={@prediction_events} /> +
+
+
+ + + +
Connection Status @@ -101,203 +133,134 @@ defmodule DotcomWeb.PredictionsStreamLive do """ end - defp route_picker_or_route_page(%{route: route} = assigns) when route != nil do + defp predictions_panel(assigns) do ~H""" - <.route_page - route={@route} - direction_id={@direction_id} - stop={@stop} - stops={@stops} - predictions={@predictions} - prediction_events={@prediction_events} - /> - """ - end - - defp route_picker_or_route_page(assigns) do - ~H""" -
<.route_picker routes={@routes} />
+
+
+ {prediction.arrival_time} +
+ Raw +
{inspect prediction, pretty: true}
+
+
+
""" end - defp route_picker(assigns) do + defp events_panel(assigns) do ~H""" -
- +
+
+
+ Event +
{inspect event, pretty: true}
+
+
""" end - defp route_page(assigns) do + defp route_picker_or(%{route: nil} = assigns) do ~H""" -
-
-
- {@route.name} -
- <.icon class="size-5" name="circle-xmark" /> -
-
+
+
+
- <.direction_picker_or_route_direction_page - route={@route} - direction_id={@direction_id} - stop={@stop} - stops={@stops} - predictions={@predictions} - prediction_events={@prediction_events} - /> """ end - defp direction_picker_or_route_direction_page(%{direction_id: direction_id} = assigns) - when direction_id != nil do + defp route_picker_or(assigns) do ~H""" - <.route_direction_page - route={@route} - direction_id={@direction_id} - stop={@stop} - stops={@stops} - predictions={@predictions} - prediction_events={@prediction_events} - /> + {render_slot(@inner_block)} """ end - defp direction_picker_or_route_direction_page(assigns) do + defp direction_picker_or(%{direction_id: nil} = assigns) do ~H"""
- <.direction_picker route={@route} /> +
+ +
""" end - defp direction_picker(assigns) do + defp direction_picker_or(assigns) do ~H""" -
- -
+ {render_slot(@inner_block)} """ end - defp route_direction_page(assigns) do + defp stop_picker_or(%{stop: nil} = assigns) do ~H""" -
-
-
- {direction_description(@route, @direction_id)} -
- <.icon class="size-5" name="circle-xmark" /> -
-
+
+
+
- <.stop_picker_or_route_direction_stop_page - stop={@stop} - stops={@stops} - predictions={@predictions} - prediction_events={@prediction_events} - /> """ end - defp stop_picker_or_route_direction_stop_page(%{stop: stop} = assigns) when stop != nil do + defp stop_picker_or(assigns) do ~H""" -
+ {render_slot(@inner_block)} + """ + end + + attr :clear_button_click, :string, required: true + attr :class, :string, default: "" + attr :style, :string, default: "" + slot :inner_block + + defp banner(assigns) do + ~H""" +
-
- {@stop.name} - {@stop.id} -
+ {render_slot(@inner_block)}
<.icon class="size-5" name="circle-xmark" />
- -
-
-
-

Predictions

-
-
- {prediction.arrival_time} -
- Raw -
{inspect prediction, pretty: true}
-
-
-
-
- -
-

Events

-
-
-
- Event -
{inspect event, pretty: true}
-
-
-
-
-
-
- """ - end - - defp stop_picker_or_route_direction_stop_page(assigns) do - ~H""" - <.stop_picker stops={@stops} /> - """ - end - - defp stop_picker(assigns) do - ~H""" -
-
- -
-
""" end From 14f4ff79d5d10b4c8a6693fb9efeb8c4e391f79a Mon Sep 17 00:00:00 2001 From: Josh Larson Date: Wed, 29 Apr 2026 09:41:16 -0400 Subject: [PATCH 14/32] Restructure how predictions and events are shown in `PredictionsStreamLive` --- .../live/predictions_stream_live.ex | 47 +++++++++++++++---- 1 file changed, 39 insertions(+), 8 deletions(-) diff --git a/lib/dotcom_web/live/predictions_stream_live.ex b/lib/dotcom_web/live/predictions_stream_live.ex index aeea01904f..5c29fb3935 100644 --- a/lib/dotcom_web/live/predictions_stream_live.ex +++ b/lib/dotcom_web/live/predictions_stream_live.ex @@ -110,12 +110,16 @@ defmodule DotcomWeb.PredictionsStreamLive do

Predictions

- <.predictions_panel predictions={@predictions} /> +
+ <.predictions_panel predictions={@predictions} /> +

Events

- <.events_panel prediction_events={@prediction_events} /> +
+ <.events_panel prediction_events={@prediction_events} /> +
@@ -140,10 +144,19 @@ defmodule DotcomWeb.PredictionsStreamLive do :for={prediction <- @predictions} class="p-2 border-t-xs border-gray-lightest" > - {prediction.arrival_time} +
+ Trip ID + {prediction.trip.id} + + Arrival Time + {format(prediction.arrival_time)} + + Schedule Relationship + {prediction.schedule_relationship} +
Raw -
{inspect prediction, pretty: true}
+
{inspect prediction, pretty: true, limit: :infinity}
@@ -154,9 +167,18 @@ defmodule DotcomWeb.PredictionsStreamLive do ~H"""
-
- Event -
{inspect event, pretty: true}
+
+ + Event + + {event_type} + + + +
+ {event_type} + {item.trip.id} +
@@ -364,6 +386,15 @@ defmodule DotcomWeb.PredictionsStreamLive do defp unsubscribe_from_predictions(socket) do PredictionsManager.unsubscribe() - socket |> assign(:subscribed?, false) + socket + |> assign(:subscribed?, false) + |> assign(:predictions, []) + |> assign(:prediction_events, []) + end + + defp format(nil), do: "" + + defp format(time) do + Dotcom.Utils.Time.format!(time, :hour_12_minutes) end end From 46e8638a890a2abc33772c675de60b2669b1b68b Mon Sep 17 00:00:00 2001 From: Josh Larson Date: Wed, 29 Apr 2026 09:45:35 -0400 Subject: [PATCH 15/32] fix: Include timezone info in `Prediction.StreamParser` datetimes --- lib/predictions/stream_parser.ex | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/lib/predictions/stream_parser.ex b/lib/predictions/stream_parser.ex index 4be4cf3cc2..94c7b8d49b 100644 --- a/lib/predictions/stream_parser.ex +++ b/lib/predictions/stream_parser.ex @@ -64,9 +64,14 @@ defmodule Predictions.StreamParser do defp departure_time(_), do: nil @spec parse_time(String.t()) :: DateTime.t() - defp parse_time(time) do - {:ok, dt, _} = DateTime.from_iso8601(time) - dt + defp parse_time(prediction_time) do + case Timex.parse(prediction_time, "{ISO:Extended}") do + {:ok, time} -> + time + + _ -> + nil + end end @spec vehicle_id(Item.t()) :: Vehicles.Vehicle.id_t() | nil From 09eba169d47e3b79a8f360e84647d4ee4b5c1246 Mon Sep 17 00:00:00 2001 From: Josh Larson Date: Wed, 29 Apr 2026 11:35:46 -0400 Subject: [PATCH 16/32] Show departure time on PredictionsStreamLive --- lib/dotcom_web/live/predictions_stream_live.ex | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/dotcom_web/live/predictions_stream_live.ex b/lib/dotcom_web/live/predictions_stream_live.ex index 5c29fb3935..4bb1d2f090 100644 --- a/lib/dotcom_web/live/predictions_stream_live.ex +++ b/lib/dotcom_web/live/predictions_stream_live.ex @@ -144,13 +144,16 @@ defmodule DotcomWeb.PredictionsStreamLive do :for={prediction <- @predictions} class="p-2 border-t-xs border-gray-lightest" > -
+
Trip ID {prediction.trip.id} Arrival Time {format(prediction.arrival_time)} + Departure Time + {format(prediction.departure_time)} + Schedule Relationship {prediction.schedule_relationship}
From 7fb18e1c51038b59c2b78338fdff2cd8fb313490 Mon Sep 17 00:00:00 2001 From: Josh Larson Date: Wed, 29 Apr 2026 11:36:08 -0400 Subject: [PATCH 17/32] Send a fake `"reset"` event on subscribe --- lib/dotcom/playground/predictions_worker.ex | 26 +++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/lib/dotcom/playground/predictions_worker.ex b/lib/dotcom/playground/predictions_worker.ex index ca1c670085..2737615778 100644 --- a/lib/dotcom/playground/predictions_worker.ex +++ b/lib/dotcom/playground/predictions_worker.ex @@ -26,18 +26,23 @@ defmodule Dotcom.Playground.PredictionsWorker do def init(params) do PredictionsSupervisor.start_link(%{params: params, publish_to: self()}) - {:ok, %{params: params, subscribers: MapSet.new()}} + {:ok, %{params: params, predictions: :loading, subscribers: MapSet.new()}} end def terminate(_reason, %{params: params}) do PredictionsSupervisor.stop(%{params: params}) end - def handle_cast({:subscribe, pid}, %{subscribers: subscribers} = state) do + def handle_cast( + {:subscribe, pid}, + %{predictions: predictions, subscribers: subscribers} = state + ) do new_subscribers = subscribers |> MapSet.put(pid) + publish_predictions_if_any(pid, predictions) + {:noreply, %{state | subscribers: new_subscribers}} end @@ -55,13 +60,26 @@ defmodule Dotcom.Playground.PredictionsWorker do end end - def handle_info({:predictions_update, data}, %{subscribers: subscribers} = state) do + def handle_info( + {:predictions_update, %{predictions: predictions} = data}, + %{subscribers: subscribers} = state + ) do subscribers |> Enum.each(&send(&1, {:predictions_update, data})) - {:noreply, state} + {:noreply, %{state | predictions: {:ok, predictions}}} end defp process_name(params) do {:global, {:predictions, params}} end + + defp publish_predictions_if_any(_pid, :loading) do + end + + defp publish_predictions_if_any(pid, {:ok, predictions}) do + send( + pid, + {:predictions_update, %{predictions: predictions, events: [{"reset", predictions}]}} + ) + end end From 4f45c962bdf3658bb9b358153627a407d4185443 Mon Sep 17 00:00:00 2001 From: Josh Larson Date: Thu, 30 Apr 2026 09:11:12 -0400 Subject: [PATCH 18/32] Show predictions snapshot and derived predictions --- .../live/predictions_stream_live.ex | 84 +++++++++++++------ 1 file changed, 58 insertions(+), 26 deletions(-) diff --git a/lib/dotcom_web/live/predictions_stream_live.ex b/lib/dotcom_web/live/predictions_stream_live.ex index 4bb1d2f090..d1d79f0a16 100644 --- a/lib/dotcom_web/live/predictions_stream_live.ex +++ b/lib/dotcom_web/live/predictions_stream_live.ex @@ -14,7 +14,9 @@ defmodule DotcomWeb.PredictionsStreamLive do socket |> assign(:routes, Routes.Repo.all()) |> assign(:subscribed?, false) - |> assign(:predictions, []) + |> assign(:predictions_list_by_snapshot, []) + |> assign(:predictions_map_by_events, %{}) + |> assign(:predictions_list_by_events, []) |> assign(:prediction_events, [])} end @@ -107,11 +109,18 @@ defmodule DotcomWeb.PredictionsStreamLive do
-
+
-

Predictions

+

Predictions by Snapshot

- <.predictions_panel predictions={@predictions} /> + <.predictions_panel predictions={@predictions_list_by_snapshot} /> +
+
+ +
+

Predictions by Events

+
+ <.predictions_panel predictions={@predictions_list_by_events} />
@@ -144,17 +153,11 @@ defmodule DotcomWeb.PredictionsStreamLive do :for={prediction <- @predictions} class="p-2 border-t-xs border-gray-lightest" > -
- Trip ID - {prediction.trip.id} +
+ {prediction.trip.id} - Arrival Time - {format(prediction.arrival_time)} + {format(prediction.arrival_time)} / {format(prediction.departure_time)} - Departure Time - {format(prediction.departure_time)} - - Schedule Relationship {prediction.schedule_relationship}
@@ -170,19 +173,12 @@ defmodule DotcomWeb.PredictionsStreamLive do ~H"""
-
- - Event - - {event_type} - - - +
{event_type} {item.trip.id}
-
+
""" @@ -336,7 +332,11 @@ defmodule DotcomWeb.PredictionsStreamLive do {:noreply, socket |> assign(:prediction_events, [events | prediction_events]) - |> assign(:predictions, predictions |> Enum.sort_by(&(&1.arrival_time || &1.departure_time)))} + |> assign( + :predictions_list_by_snapshot, + predictions |> Enum.sort_by(&(&1.arrival_time || &1.departure_time)) + ) + |> apply_prediction_events(events)} end @impl LiveView @@ -345,6 +345,34 @@ defmodule DotcomWeb.PredictionsStreamLive do :ok end + defp apply_prediction_events(socket, events) do + new_predictions = + events |> Enum.reduce(socket.assigns.predictions_map_by_events, &apply_prediction_event/2) + + socket + |> assign(:predictions_map_by_events, new_predictions) + |> assign( + :predictions_list_by_events, + new_predictions |> Map.values() |> Enum.sort_by(&(&1.arrival_time || &1.departure_time)) + ) + end + + defp apply_prediction_event({"reset", predictions}, _predictions_map) do + predictions + |> Enum.group_by(&{&1.trip.id, &1.stop_sequence}) + |> Map.new(fn {key, [value | _]} -> {key, value} end) + end + + defp apply_prediction_event({event_type, prediction}, predictions_map) + when event_type in ["add", "update"] do + predictions_map + |> Map.put({prediction.trip.id, prediction.stop_sequence}, prediction) + end + + defp apply_prediction_event({"remove", prediction}, predictions_map) do + predictions_map |> Map.delete({prediction.trip.id, prediction.stop_sequence}) + end + defp direction_description(route, direction_id) do "#{route.direction_names[direction_id]} towards #{route.direction_destinations[direction_id]}" end @@ -391,13 +419,17 @@ defmodule DotcomWeb.PredictionsStreamLive do socket |> assign(:subscribed?, false) - |> assign(:predictions, []) + |> assign(:predictions_list_by_snapshot, []) + |> assign(:predictions_map_by_events, []) + |> assign(:predictions_list_by_events, %{}) |> assign(:prediction_events, []) end defp format(nil), do: "" - defp format(time) do - Dotcom.Utils.Time.format!(time, :hour_12_minutes) + defp format(datetime) do + {:ok, string} = Cldr.DateTime.to_string(datetime, Dotcom.Cldr, format: "h:mm:ss a") + + string end end From b5c27552bae066123fe982e45eb4a7bf3efb191c Mon Sep 17 00:00:00 2001 From: Josh Larson Date: Thu, 30 Apr 2026 10:45:21 -0400 Subject: [PATCH 19/32] Cleanup old modules that we're not using anymore --- lib/dotcom/application.ex | 1 - .../playground/prediction_aggregator_stage.ex | 83 ------------ .../playground/prediction_broadcaster.ex | 20 --- .../playground/predictions_consumer_stage.ex | 22 ---- .../playground/upcoming_departures_pub_sub.ex | 120 ------------------ .../upcoming_departures_supervisor.ex | 46 ------- 6 files changed, 292 deletions(-) delete mode 100644 lib/dotcom/playground/prediction_aggregator_stage.ex delete mode 100644 lib/dotcom/playground/prediction_broadcaster.ex delete mode 100644 lib/dotcom/playground/predictions_consumer_stage.ex delete mode 100644 lib/dotcom/playground/upcoming_departures_pub_sub.ex delete mode 100644 lib/dotcom/playground/upcoming_departures_supervisor.ex diff --git a/lib/dotcom/application.ex b/lib/dotcom/application.ex index b35696b04a..af74b172ba 100644 --- a/lib/dotcom/application.ex +++ b/lib/dotcom/application.ex @@ -59,7 +59,6 @@ defmodule Dotcom.Application do Dotcom.ViaFairmount, {Dotcom.SystemStatus.CommuterRailCache, []}, {Dotcom.SystemStatus.SubwayCache, []}, - Dotcom.Playground.UpcomingDeparturesPubsub, Dotcom.Playground.PredictionsManager ] else diff --git a/lib/dotcom/playground/prediction_aggregator_stage.ex b/lib/dotcom/playground/prediction_aggregator_stage.ex deleted file mode 100644 index cfbda8760d..0000000000 --- a/lib/dotcom/playground/prediction_aggregator_stage.ex +++ /dev/null @@ -1,83 +0,0 @@ -defmodule Dotcom.Playground.PredictionAggregatorStage do - use GenStage - - alias __MODULE__.Store - alias Predictions.StreamParser - alias ServerSentEventStage.Event - - def start_link(opts) do - dbg("Start aggregator") - name = opts |> Keyword.get(:name) - dbg(name) - - dbg(opts) - - result = GenStage.start_link(__MODULE__, opts, name: name) - dbg(result) - result - end - - def terminate(_reason, _state), do: :ok - - def init(opts) do - dbg("Aggregator begins #{inspect(opts)}") - - subscribe_to = opts |> Keyword.get(:subscribe_to) - publish_to = opts |> Keyword.get(:publish_to) - - {:consumer, %{publish_to: publish_to, predictions_store: Store.new()}, - subscribe_to: [subscribe_to]} - end - - def handle_events( - events, - _from, - %{publish_to: publish_to, predictions_store: predictions_store} = state - ) do - new_predictions_store = Enum.reduce(events, predictions_store, &handle_prediction_event/2) - - publish_to_pid = GenServer.whereis(publish_to) - - send(publish_to_pid, {:predictions, new_predictions_store}) - - {:noreply, [], %{state | predictions_store: new_predictions_store}} - end - - defp handle_prediction_event(%Event{event: event_type, data: data}, predictions_store) do - dbg(event_type) - parsed_data = JsonApi.parse(data) - - handle_parsed_prediction_event(event_type, parsed_data, predictions_store) - end - - defp handle_parsed_prediction_event("reset", %JsonApi{data: data}, _predictions_store) do - data - |> Enum.map(&StreamParser.parse/1) - |> Enum.reduce(Store.new(), &Store.add_prediction(&2, &1)) - end - - defp handle_parsed_prediction_event(event_type, _data, predictions_store) do - dbg(event_type) - predictions_store - end - - defmodule Store do - defstruct by_id: %{}, by_trip_id_and_stop_seq: %{} - - def new() do - %__MODULE__{} - end - - def add_prediction( - %__MODULE__{by_id: by_id, by_trip_id_and_stop_seq: by_trip_id_and_stop_seq}, - prediction - ) do - %__MODULE__{ - by_id: by_id |> Map.put(prediction.id, prediction), - by_trip_id_and_stop_seq: - by_trip_id_and_stop_seq - |> Map.put({prediction.trip.id, prediction.stop_sequence}, prediction) - } - end - end -end diff --git a/lib/dotcom/playground/prediction_broadcaster.ex b/lib/dotcom/playground/prediction_broadcaster.ex deleted file mode 100644 index 6a1a3d96fc..0000000000 --- a/lib/dotcom/playground/prediction_broadcaster.ex +++ /dev/null @@ -1,20 +0,0 @@ -defmodule Dotcom.Playground.PredictionBroadcaster do - use GenServer - - def start_link(opts) do - GenServer.start_link(__MODULE__, opts, name: opts |> Keyword.get(:name)) - end - - def init(opts) do - dbg(opts) - {:ok, %{}} - end - - def handle_info( - {:predictions, predictions}, - state - ) do - dbg(predictions) - {:noreply, state} - end -end diff --git a/lib/dotcom/playground/predictions_consumer_stage.ex b/lib/dotcom/playground/predictions_consumer_stage.ex deleted file mode 100644 index 5c17e425ce..0000000000 --- a/lib/dotcom/playground/predictions_consumer_stage.ex +++ /dev/null @@ -1,22 +0,0 @@ -defmodule Dotcom.Playground.PredictionsConsumerStage do - use GenStage - - def start_link(caller_pid) do - GenStage.start_link(__MODULE__, %{caller_pid: caller_pid}) - end - - def stop(pid) do - GenStage.stop(pid) - end - - def terminate(_reason, _state), do: :ok - - def init(state) do - {:consumer, state} - end - - def handle_events(events, _from, %{caller_pid: caller_pid} = state) do - send(caller_pid, {:prediction_events, events}) - {:noreply, [], state} - end -end diff --git a/lib/dotcom/playground/upcoming_departures_pub_sub.ex b/lib/dotcom/playground/upcoming_departures_pub_sub.ex deleted file mode 100644 index 977855665a..0000000000 --- a/lib/dotcom/playground/upcoming_departures_pub_sub.ex +++ /dev/null @@ -1,120 +0,0 @@ -defmodule Dotcom.Playground.UpcomingDeparturesPubsub do - @moduledoc """ - A GenServer that tracks which consumers are subscribed to which - sets of upcoming departures/predictions streams - """ - - use GenServer - - alias Dotcom.Playground.UpcomingDeparturesSupervisor - alias Dotcom.Playground.UpcomingDeparturesPubsub.SubscriptionRegister - - def start_link(_) do - GenServer.start_link(__MODULE__, SubscriptionRegister.new(), name: __MODULE__) - end - - def subscribe(params) do - GenServer.cast(__MODULE__, {:subscribe, self(), params}) - end - - def unsubscribe() do - GenServer.cast(__MODULE__, {:unsubscribe, self()}) - end - - def state() do - GenServer.call(__MODULE__, :get_state) - end - - @impl GenServer - def init(initial_state) do - {:ok, initial_state} - end - - @impl GenServer - def handle_cast({:subscribe, caller_pid, params}, state) do - {:noreply, - SubscriptionRegister.add_subscription( - state, - caller_pid, - params, - &UpcomingDeparturesSupervisor.start_link/1 - )} - end - - @impl GenServer - def handle_cast({:unsubscribe, caller_pid}, state) do - {:noreply, - SubscriptionRegister.remove_subscription( - state, - caller_pid, - &UpcomingDeparturesSupervisor.stop/1 - )} - end - - @impl GenServer - def handle_call(:get_state, _from, state) do - {:reply, state, state} - end - - defmodule SubscriptionRegister do - defstruct pids_by_params: %{}, params_by_pid: %{} - - def new() do - %__MODULE__{} - end - - def add_subscription( - %__MODULE__{pids_by_params: pids_by_params, params_by_pid: params_by_pid}, - pid, - params, - on_new_subscription - ) do - new_pids_by_params = - case pids_by_params do - %{^params => pids} -> - pids_by_params |> Map.put(params, [pid | pids]) - - _ -> - on_new_subscription.(params) - - pids_by_params |> Map.put(params, [pid]) - end - - new_params_by_pid = params_by_pid |> Map.put(pid, params) - - %__MODULE__{ - pids_by_params: new_pids_by_params, - params_by_pid: new_params_by_pid - } - end - - def remove_subscription( - %__MODULE__{pids_by_params: pids_by_params, params_by_pid: params_by_pid} = state, - pid, - on_empty_subscription - ) do - case params_by_pid do - %{^pid => params} -> - new_pids_by_params = - pids_by_params - |> Map.get(params) - |> List.delete(pid) - |> case do - [] -> - on_empty_subscription.(params) - pids_by_params |> Map.delete(params) - - new_pids -> - pids_by_params |> Map.put(params, new_pids) - end - - new_params_by_pid = params_by_pid |> Map.delete(pid) - - %__MODULE__{pids_by_params: new_pids_by_params, params_by_pid: new_params_by_pid} - - _ -> - state - end - end - end -end diff --git a/lib/dotcom/playground/upcoming_departures_supervisor.ex b/lib/dotcom/playground/upcoming_departures_supervisor.ex deleted file mode 100644 index 25d0cc987a..0000000000 --- a/lib/dotcom/playground/upcoming_departures_supervisor.ex +++ /dev/null @@ -1,46 +0,0 @@ -defmodule Dotcom.Playground.UpcomingDeparturesSupervisor do - use Supervisor - - alias Dotcom.Playground.PredictionAggregatorStage - alias Dotcom.Playground.PredictionBroadcaster - - def start_link(params) do - Supervisor.start_link(__MODULE__, params, name: process_name(params)) - end - - def stop(params) do - dbg("Stopping the supervisor") - Supervisor.stop(process_name(params)) - end - - def init(params) do - query = URI.encode_query(params) - - url = "#{base_url()}/predictions?#{query}" - - children = [ - {ServerSentEventStage, url: url, headers: headers(), name: process_name({:sses, params})}, - {PredictionAggregatorStage, - publish_to: process_name({:broadcast, params}), - subscribe_to: process_name({:sses, params}), - name: process_name({:aggregate, params})}, - {PredictionBroadcaster, name: process_name({:broadcast, params})} - ] - - children - |> Supervisor.init(strategy: :one_for_all) - |> dbg() - end - - defp base_url() do - Application.get_env(:dotcom, :mbta_api)[:base_url] - end - - defp headers() do - Application.get_env(:dotcom, :mbta_api)[:headers] - end - - defp process_name(args) do - {:via, :global, args} - end -end From db908c3f26313c3fe0fce0b451a94c56bd18b78d Mon Sep 17 00:00:00 2001 From: Josh Larson Date: Thu, 30 Apr 2026 11:19:22 -0400 Subject: [PATCH 20/32] Extract shareable components from PredictionsStreamLive --- .../components/playground_components.ex | 165 ++++++++++++++++++ .../live/predictions_stream_live.ex | 137 ++------------- 2 files changed, 178 insertions(+), 124 deletions(-) create mode 100644 lib/dotcom_web/components/playground_components.ex diff --git a/lib/dotcom_web/components/playground_components.ex b/lib/dotcom_web/components/playground_components.ex new file mode 100644 index 0000000000..d9078d9874 --- /dev/null +++ b/lib/dotcom_web/components/playground_components.ex @@ -0,0 +1,165 @@ +defmodule DotcomWeb.PlaygroundComponents do + @moduledoc """ + Some silly components to be used in non-rider-facing playground explorations + """ + + use DotcomWeb, :component + + attr :clear_button_click, :string, required: true + attr :class, :string, default: "" + attr :style, :string, default: "" + slot :inner_block + + defp banner(assigns) do + ~H""" +
+
+
+ {render_slot(@inner_block)} +
+ <.icon class="size-5" name="circle-xmark" /> +
+
+
+
+ """ + end + + attr :route, Routes.Route, required: true + + def route_banner(assigns) do + ~H""" + <.banner + style={"background-color: ##{@route.color}; color: #{text_color(@route)}; fill: #{text_color(@route)};"} + clear_button_click="clear-route" + > + {@route.name} + + """ + end + + attr :direction_id, :string, required: true + attr :route, Routes.Route, required: true + + def direction_banner(assigns) do + ~H""" + <.banner clear_button_click="clear-direction" class="bg-gray-lightest"> + {direction_description(@route, @direction_id)} + + """ + end + + attr :stop, Stops.Stop, required: true + + def stop_banner(assigns) do + ~H""" + <.banner clear_button_click="clear-stop" class="bg-charcoal-10 text-white fill-white"> +
+ {@stop.name} + {@stop.id} +
+ + """ + end + + attr :route, Routes.Route, default: nil + attr :routes, :list, required: true + slot :inner_block + + def route_picker_or(%{route: nil} = assigns) do + ~H""" +
+
+ +
+
+ """ + end + + def route_picker_or(assigns) do + ~H""" + {render_slot(@inner_block)} + """ + end + + attr :route, Routes.Route, required: true + attr :direction_id, :string, required: true + slot :inner_block + + def direction_picker_or(%{direction_id: nil} = assigns) do + ~H""" +
+
+ +
+
+ """ + end + + def direction_picker_or(assigns) do + ~H""" + {render_slot(@inner_block)} + """ + end + + attr :stop, Stops.Stop, default: nil + attr :stops, :list, required: true + slot :inner_block + + def stop_picker_or(%{stop: nil} = assigns) do + ~H""" +
+
+ +
+
+ """ + end + + def stop_picker_or(assigns) do + ~H""" + {render_slot(@inner_block)} + """ + end + + defp direction_description(route, direction_id) do + "#{route.direction_names[direction_id]} towards #{route.direction_destinations[direction_id]}" + end + + defp text_color(route) do + if(route.type == 3 and not String.contains?(route.name, "SL"), do: "black", else: "white") + end +end diff --git a/lib/dotcom_web/live/predictions_stream_live.ex b/lib/dotcom_web/live/predictions_stream_live.ex index d1d79f0a16..fe40c844fd 100644 --- a/lib/dotcom_web/live/predictions_stream_live.ex +++ b/lib/dotcom_web/live/predictions_stream_live.ex @@ -5,6 +5,16 @@ defmodule DotcomWeb.PredictionsStreamLive do use DotcomWeb, :live_view + import DotcomWeb.PlaygroundComponents, + only: [ + direction_banner: 1, + direction_picker_or: 1, + route_banner: 1, + route_picker_or: 1, + stop_banner: 1, + stop_picker_or: 1 + ] + alias Dotcom.Playground.PredictionsManager alias Phoenix.LiveView @@ -85,28 +95,16 @@ defmodule DotcomWeb.PredictionsStreamLive do def render(assigns) do ~H""" <.route_picker_or route={@route} routes={@routes}> - <.banner - style={"background-color: ##{@route.color}; color: #{text_color(@route)}; fill: #{text_color(@route)};"} - clear_button_click="clear-route" - > - {@route.name} - + <.route_banner route={@route} /> <.direction_picker_or route={@route} direction_id={@direction_id}> - <.banner clear_button_click="clear-direction" class="bg-gray-lightest"> - {direction_description(@route, @direction_id)} - + <.direction_banner route={@route} direction_id={@direction_id} /> <.stop_picker_or stop={@stop} stops={@stops} > - <.banner clear_button_click="clear-stop" class="bg-charcoal-10 text-white fill-white"> -
- {@stop.name} - {@stop.id} -
- + <.stop_banner stop={@stop} />
@@ -184,107 +182,6 @@ defmodule DotcomWeb.PredictionsStreamLive do """ end - defp route_picker_or(%{route: nil} = assigns) do - ~H""" -
-
- -
-
- """ - end - - defp route_picker_or(assigns) do - ~H""" - {render_slot(@inner_block)} - """ - end - - defp direction_picker_or(%{direction_id: nil} = assigns) do - ~H""" -
-
- -
-
- """ - end - - defp direction_picker_or(assigns) do - ~H""" - {render_slot(@inner_block)} - """ - end - - defp stop_picker_or(%{stop: nil} = assigns) do - ~H""" -
-
- -
-
- """ - end - - defp stop_picker_or(assigns) do - ~H""" - {render_slot(@inner_block)} - """ - end - - attr :clear_button_click, :string, required: true - attr :class, :string, default: "" - attr :style, :string, default: "" - slot :inner_block - - defp banner(assigns) do - ~H""" -
-
-
- {render_slot(@inner_block)} -
- <.icon class="size-5" name="circle-xmark" /> -
-
-
-
- """ - end - @impl LiveView def handle_event("clear-route", _params, socket) do {:noreply, socket |> push_patch(to: ~p"/preview/predictions-stream")} @@ -373,14 +270,6 @@ defmodule DotcomWeb.PredictionsStreamLive do predictions_map |> Map.delete({prediction.trip.id, prediction.stop_sequence}) end - defp direction_description(route, direction_id) do - "#{route.direction_names[direction_id]} towards #{route.direction_destinations[direction_id]}" - end - - defp text_color(route) do - if(route.type == 3 and not String.contains?(route.name, "SL"), do: "black", else: "white") - end - defp subscribe_or_unsubscribe_to_predictions( %{ assigns: %{ From f1faeaa53b25db0c403617217d93a17dc71129d0 Mon Sep 17 00:00:00 2001 From: Josh Larson Date: Thu, 30 Apr 2026 11:25:54 -0400 Subject: [PATCH 21/32] Remove defunct `UpcomingDeparturesPubSubStateLive` --- lib/dotcom_web/live/preview_live.ex | 8 ------ .../live/upcoming_departures_pub_sub_live.ex | 25 ------------------- lib/dotcom_web/router.ex | 1 - 3 files changed, 34 deletions(-) delete mode 100644 lib/dotcom_web/live/upcoming_departures_pub_sub_live.ex diff --git a/lib/dotcom_web/live/preview_live.ex b/lib/dotcom_web/live/preview_live.ex index c50d36cad7..9dc9e9d398 100644 --- a/lib/dotcom_web/live/preview_live.ex +++ b/lib/dotcom_web/live/preview_live.ex @@ -6,7 +6,6 @@ defmodule DotcomWeb.PreviewLive do alias DotcomWeb.WorldCupTimetableLive use DotcomWeb, :live_view - alias DotcomWeb.UpcomingDeparturesPubSubStateLive alias DotcomWeb.PredictionsStreamLive alias DotcomWeb.Router.Helpers alias DotcomWeb.ScheduleFinderLive @@ -14,13 +13,6 @@ defmodule DotcomWeb.PreviewLive do alias Phoenix.LiveView @pages [ - %{ - arguments: [], - icon_name: "magnifying-glass", - icon_type: "solid", - module: UpcomingDeparturesPubSubStateLive, - title: "Prediction Consumers" - }, %{ arguments: [], icon_name: "faucet-drip", diff --git a/lib/dotcom_web/live/upcoming_departures_pub_sub_live.ex b/lib/dotcom_web/live/upcoming_departures_pub_sub_live.ex deleted file mode 100644 index 99cb6a317a..0000000000 --- a/lib/dotcom_web/live/upcoming_departures_pub_sub_live.ex +++ /dev/null @@ -1,25 +0,0 @@ -defmodule DotcomWeb.UpcomingDeparturesPubSubStateLive do - @moduledoc """ - A page that shows some stuff about who is looking at predictions - """ - - use DotcomWeb, :live_view - - alias Dotcom.Playground.UpcomingDeparturesPubsub - - def mount(_params, _session, socket) do - {:ok, - socket - |> assign(:pub_sub_state, UpcomingDeparturesPubsub.state())} - end - - def render(assigns) do - ~H""" -
-

Note: This page does not live update. You'll have to refresh to get updated info

- -
{inspect @pub_sub_state, pretty: true}
-
- """ - end -end diff --git a/lib/dotcom_web/router.ex b/lib/dotcom_web/router.ex index 992d0f454f..6acda368fb 100644 --- a/lib/dotcom_web/router.ex +++ b/lib/dotcom_web/router.ex @@ -328,7 +328,6 @@ defmodule DotcomWeb.Router do live_session :default, layout: {DotcomWeb.LayoutView, :preview} do live "/", PreviewLive live "/predictions-stream", PredictionsStreamLive - live "/upcoming-departures-pub-sub-state", UpcomingDeparturesPubSubStateLive live "/schedules/bostonstadium", WorldCupTimetableLive live "/stop-map", StopMapLive end From 9fc7e03d8876b040d08ed916f6060c91ff02a3fa Mon Sep 17 00:00:00 2001 From: Josh Larson Date: Thu, 30 Apr 2026 12:19:47 -0400 Subject: [PATCH 22/32] Add new UpcomingDeparturesStreamLive --- .../playground/upcoming_departures_manager.ex | 12 ++ lib/dotcom_web/live/preview_live.ex | 8 + .../live/upcoming_departures_stream_live.ex | 203 ++++++++++++++++++ lib/dotcom_web/router.ex | 1 + 4 files changed, 224 insertions(+) create mode 100644 lib/dotcom/playground/upcoming_departures_manager.ex create mode 100644 lib/dotcom_web/live/upcoming_departures_stream_live.ex diff --git a/lib/dotcom/playground/upcoming_departures_manager.ex b/lib/dotcom/playground/upcoming_departures_manager.ex new file mode 100644 index 0000000000..b61dc25702 --- /dev/null +++ b/lib/dotcom/playground/upcoming_departures_manager.ex @@ -0,0 +1,12 @@ +defmodule Dotcom.Playground.UpcomingDeparturesManager do + def subscribe(params) do + dbg("SUBSCRIBE") + dbg(params) + dbg(self()) + end + + def unsubscribe() do + dbg("UNSUBSCRIBE") + dbg(self()) + end +end diff --git a/lib/dotcom_web/live/preview_live.ex b/lib/dotcom_web/live/preview_live.ex index 9dc9e9d398..93a5f6940f 100644 --- a/lib/dotcom_web/live/preview_live.ex +++ b/lib/dotcom_web/live/preview_live.ex @@ -10,6 +10,7 @@ defmodule DotcomWeb.PreviewLive do alias DotcomWeb.Router.Helpers alias DotcomWeb.ScheduleFinderLive alias DotcomWeb.StopMapLive + alias DotcomWeb.UpcomingDeparturesStreamLive alias Phoenix.LiveView @pages [ @@ -32,6 +33,13 @@ defmodule DotcomWeb.PreviewLive do module: StopMapLive, title: "Stop Page Map" }, + %{ + arguments: [], + icon_name: "cable-car", + icon_type: "solid", + module: UpcomingDeparturesStreamLive, + title: "Upcoming Departures Streaming" + }, %{ arguments: [], icon_name: "football", diff --git a/lib/dotcom_web/live/upcoming_departures_stream_live.ex b/lib/dotcom_web/live/upcoming_departures_stream_live.ex new file mode 100644 index 0000000000..33925aeb37 --- /dev/null +++ b/lib/dotcom_web/live/upcoming_departures_stream_live.ex @@ -0,0 +1,203 @@ +defmodule DotcomWeb.UpcomingDeparturesStreamLive do + @moduledoc """ + A page that shows stuff about predictions streaming + """ + + use DotcomWeb, :live_view + + import DotcomWeb.PlaygroundComponents, + only: [ + direction_banner: 1, + direction_picker_or: 1, + route_banner: 1, + route_picker_or: 1, + stop_banner: 1, + stop_picker_or: 1 + ] + + alias Dotcom.Playground.UpcomingDeparturesManager + alias Phoenix.LiveView + + @impl LiveView + def mount(_params, _session, socket) do + {:ok, + socket + |> assign(:routes, Routes.Repo.all()) + |> assign(:subscribed?, false)} + end + + @impl LiveView + def handle_params(params, _uri, socket) do + route_id = Map.get(params, "route_id") + + direction_id = + params + |> Map.get("direction_id") + |> case do + nil -> nil + str when is_binary(str) -> String.to_integer(str) + end + + stop_id = Map.get(params, "stop_id") + + {:noreply, + socket + |> assign_route(route_id) + |> assign_direction(direction_id) + |> assign_stop(stop_id) + |> subscribe_or_unsubscribe_to_upcoming_departures()} + end + + defp assign_route(socket, nil) do + socket + |> assign(:route_id, nil) + |> assign(:route, nil) + end + + defp assign_route(socket, route_id) do + socket + |> assign(:route_id, route_id) + |> assign(:route, Routes.Repo.get(route_id)) + end + + defp assign_direction(socket, nil) do + socket + |> assign(:direction_id, nil) + |> assign(:stops, nil) + end + + defp assign_direction(socket, direction_id) do + stops = + Stops.Repo.by_route(socket.assigns.route_id, direction_id) + + socket + |> assign(:direction_id, direction_id) + |> assign(:stops, stops) + end + + defp assign_stop(socket, nil) do + socket + |> assign(:stop_id, nil) + |> assign(:stop, nil) + end + + defp assign_stop(socket, stop_id) do + socket + |> assign(:stop_id, stop_id) + |> assign(:stop, Stops.Repo.get(stop_id)) + end + + @impl LiveView + def render(assigns) do + ~H""" + <.route_picker_or route={@route} routes={@routes}> + <.route_banner route={@route} /> + + <.direction_picker_or route={@route} direction_id={@direction_id}> + <.direction_banner route={@route} direction_id={@direction_id} /> + + <.stop_picker_or stop={@stop} stops={@stops}> + <.stop_banner stop={@stop} /> + +
+ Much like your departures, this page is upcoming! +
+ + + + +
+
+ Connection Status + <.icon :if={@subscribed?} name="check" class="size-5" /> + <.icon :if={!@subscribed?} name="xmark" class="size-5" /> +
+
+ """ + end + + @impl LiveView + def handle_event("clear-route", _params, socket) do + {:noreply, socket |> push_patch(to: ~p"/preview/upcoming-departures-stream")} + end + + def handle_event("clear-direction", _params, socket) do + route_id = socket.assigns.route_id + + params = %{route_id: route_id} + {:noreply, socket |> push_patch(to: ~p"/preview/upcoming-departures-stream?#{params}")} + end + + def handle_event("clear-stop", _params, socket) do + route_id = socket.assigns.route_id + direction_id = socket.assigns.direction_id + + params = %{route_id: route_id, direction_id: direction_id} + {:noreply, socket |> push_patch(to: ~p"/preview/upcoming-departures-stream?#{params}")} + end + + def handle_event("select-direction", %{"direction-id" => direction_id}, socket) do + route_id = socket.assigns.route_id + + params = %{route_id: route_id, direction_id: direction_id} + {:noreply, socket |> push_patch(to: ~p"/preview/upcoming-departures-stream?#{params}")} + end + + def handle_event("select-route", %{"route-id" => route_id}, socket) do + params = %{route_id: route_id} + {:noreply, socket |> push_patch(to: ~p"/preview/upcoming-departures-stream?#{params}")} + end + + def handle_event("select-stop", %{"stop-id" => stop_id}, socket) do + route_id = socket.assigns.route_id + direction_id = socket.assigns.direction_id + + params = %{route_id: route_id, direction_id: direction_id, stop_id: stop_id} + {:noreply, socket |> push_patch(to: ~p"/preview/upcoming-departures-stream?#{params}")} + end + + @impl LiveView + def terminate(_reason, socket) do + unsubscribe_from_upcoming_departures(socket) + :ok + end + + defp subscribe_or_unsubscribe_to_upcoming_departures( + %{ + assigns: %{ + direction_id: direction_id, + route_id: route_id, + stop_id: stop_id + } + } = socket + ) + when direction_id != nil and route_id != nil and stop_id != nil do + if connected?(socket) do + subscribe_to_upcoming_departures(socket) + else + socket + end + end + + defp subscribe_or_unsubscribe_to_upcoming_departures(socket) do + unsubscribe_from_upcoming_departures(socket) + end + + defp subscribe_to_upcoming_departures(socket) do + direction_id = socket.assigns.direction_id + route_id = socket.assigns.route_id + stop_id = socket.assigns.stop_id + + params = %{route: route_id, direction_id: direction_id, stop: stop_id} + + UpcomingDeparturesManager.subscribe(params) + + socket |> assign(:subscribed?, true) + end + + defp unsubscribe_from_upcoming_departures(socket) do + UpcomingDeparturesManager.unsubscribe() + + socket |> assign(:subscribed?, false) + end +end diff --git a/lib/dotcom_web/router.ex b/lib/dotcom_web/router.ex index 6acda368fb..eee2087656 100644 --- a/lib/dotcom_web/router.ex +++ b/lib/dotcom_web/router.ex @@ -330,6 +330,7 @@ defmodule DotcomWeb.Router do live "/predictions-stream", PredictionsStreamLive live "/schedules/bostonstadium", WorldCupTimetableLive live "/stop-map", StopMapLive + live "/upcoming-departures-stream", UpcomingDeparturesStreamLive end end From fc30698fd8e45c86609d7a0602e4986118a37331 Mon Sep 17 00:00:00 2001 From: Josh Larson Date: Thu, 30 Apr 2026 14:57:53 -0400 Subject: [PATCH 23/32] Add UpcomingDeparturesManager and UpcomingDeparturesWorker --- lib/dotcom/application.ex | 3 +- .../playground/upcoming_departures_manager.ex | 36 +++- .../playground/upcoming_departures_worker.ex | 167 ++++++++++++++++++ .../live/upcoming_departures_stream_live.ex | 63 ++++++- 4 files changed, 262 insertions(+), 7 deletions(-) create mode 100644 lib/dotcom/playground/upcoming_departures_worker.ex diff --git a/lib/dotcom/application.ex b/lib/dotcom/application.ex index af74b172ba..ae86a28fd4 100644 --- a/lib/dotcom/application.ex +++ b/lib/dotcom/application.ex @@ -59,7 +59,8 @@ defmodule Dotcom.Application do Dotcom.ViaFairmount, {Dotcom.SystemStatus.CommuterRailCache, []}, {Dotcom.SystemStatus.SubwayCache, []}, - Dotcom.Playground.PredictionsManager + Dotcom.Playground.PredictionsManager, + Dotcom.Playground.UpcomingDeparturesManager ] else [] diff --git a/lib/dotcom/playground/upcoming_departures_manager.ex b/lib/dotcom/playground/upcoming_departures_manager.ex index b61dc25702..8e41d438e7 100644 --- a/lib/dotcom/playground/upcoming_departures_manager.ex +++ b/lib/dotcom/playground/upcoming_departures_manager.ex @@ -1,12 +1,38 @@ defmodule Dotcom.Playground.UpcomingDeparturesManager do + use GenServer + + alias Dotcom.Playground.UpcomingDeparturesWorker + + # Client + def start_link(_) do + GenServer.start_link(__MODULE__, %{}, name: __MODULE__) + end + def subscribe(params) do - dbg("SUBSCRIBE") - dbg(params) - dbg(self()) + GenServer.cast(__MODULE__, {:subscribe, self(), params}) end def unsubscribe() do - dbg("UNSUBSCRIBE") - dbg(self()) + GenServer.cast(__MODULE__, {:unsubscribe, self()}) + end + + # Server + def init(opts) do + {:ok, opts} + end + + def handle_cast({:subscribe, pid, params}, state) do + UpcomingDeparturesWorker.subscribe(pid, params) + + {:noreply, state |> Map.put(pid, params)} + end + + def handle_cast({:unsubscribe, pid}, state) do + case state do + %{^pid => params} -> UpcomingDeparturesWorker.unsubscribe(pid, params) + _ -> nil + end + + {:noreply, state |> Map.delete(pid)} end end diff --git a/lib/dotcom/playground/upcoming_departures_worker.ex b/lib/dotcom/playground/upcoming_departures_worker.ex new file mode 100644 index 0000000000..43c1981564 --- /dev/null +++ b/lib/dotcom/playground/upcoming_departures_worker.ex @@ -0,0 +1,167 @@ +defmodule Dotcom.Playground.UpcomingDeparturesWorker do + use GenServer + + alias Dotcom.Playground.PredictionsManager + + # Client + def subscribe(caller_pid, params) do + GenServer.start_link(__MODULE__, params, name: process_name(params)) + |> case do + {:ok, pid} -> + pid + + {:error, {:already_started, pid}} -> + pid + end + |> GenServer.cast({:subscribe, caller_pid}) + end + + def unsubscribe(caller_pid, params) do + params + |> process_name() + |> GenServer.whereis() + |> GenServer.cast({:unsubscribe, caller_pid}) + end + + # Server + def init(%{route: route, stop: stop, direction_id: direction_id} = params) do + PredictionsManager.subscribe(params) + + predicted_schedules = + Schedules.Repo.by_route_ids([route], + direction_id: direction_id, + stop_ids: [stop] + ) + |> Map.new(fn s -> + {{s.trip.id, s.stop_sequence}, %PredictedSchedule{schedule: s, prediction: nil}} + end) + + {:ok, + %{ + params: params, + predictions: :loading, + predicted_schedules_init: predicted_schedules, + predicted_schedules: :loading, + subscribers: MapSet.new() + }} + end + + def terminate(_reason, %{params: _params}) do + PredictionsManager.unsubscribe() + end + + def handle_cast( + {:subscribe, pid}, + %{subscribers: subscribers} = state + ) do + new_subscribers = subscribers |> MapSet.put(pid) + + publish(%{state | subscribers: [pid]}) + + {:noreply, %{state | subscribers: new_subscribers}} + end + + def handle_cast({:unsubscribe, pid}, %{subscribers: subscribers} = state) do + new_subscribers = subscribers |> MapSet.delete(pid) + + new_state = %{state | subscribers: new_subscribers} + + if Enum.empty?(new_subscribers) do + {:stop, :normal, new_state} + else + {:noreply, new_state} + end + end + + def handle_info( + {:predictions_update, %{events: events}}, + state + ) do + new_state = + events + |> Enum.reduce(state, &apply_prediction_event/2) + |> publish() + + {:noreply, new_state} + end + + defp apply_prediction_event( + {"reset", predictions}, + %{predicted_schedules_init: predicted_schedules} = state + ) do + new_predicted_schedules = + predictions + |> Enum.reduce(predicted_schedules, &add_prediction_to_predicted_schedules/2) + + %{state | predicted_schedules: {:ok, new_predicted_schedules}} + end + + defp apply_prediction_event( + {event_type, prediction}, + %{predicted_schedules: {:ok, predicted_schedules}} = state + ) + when event_type in ["add", "update"] do + new_predicted_schedules = + add_prediction_to_predicted_schedules(prediction, predicted_schedules) + + %{state | predicted_schedules: {:ok, new_predicted_schedules}} + end + + defp apply_prediction_event( + {"remove", prediction}, + %{predicted_schedules: {:ok, predicted_schedules}} = state + ) do + new_predicted_schedules = + remove_prediction_from_predicted_schedules(prediction, predicted_schedules) + + %{state | predicted_schedules: {:ok, new_predicted_schedules}} + end + + defp add_prediction_to_predicted_schedules(prediction, predicted_schedules) do + predicted_schedules + |> Map.update( + {prediction.trip.id, prediction.stop_sequence}, + %PredictedSchedule{prediction: prediction}, + fn %PredictedSchedule{} = ps -> + %PredictedSchedule{ps | prediction: prediction} + end + ) + end + + defp remove_prediction_from_predicted_schedules(prediction, predicted_schedules) do + key = {prediction.trip.id, prediction.stop_sequence} + ps = predicted_schedules |> Map.get(key) + + if ps.schedule do + predicted_schedules |> Map.put(key, %{ps | prediction: nil}) + else + predicted_schedules |> Map.delete(key) + end + end + + defp publish( + %{subscribers: subscribers, predicted_schedules: {:ok, predicted_schedules}} = state + ) do + subscribers + |> Enum.each(fn pid -> + send( + pid, + {:upcoming_departures_update, + %{ + predicted_schedules: + predicted_schedules + |> Map.values() + |> Enum.sort_by(&PredictedSchedule.display_time/1, DateTime) + }} + ) + end) + + state + end + + defp publish(state), do: state + + defp process_name(params) do + {:global, {:upcoming_departures, params}} + end +end diff --git a/lib/dotcom_web/live/upcoming_departures_stream_live.ex b/lib/dotcom_web/live/upcoming_departures_stream_live.ex index 33925aeb37..5c63e8ac4a 100644 --- a/lib/dotcom_web/live/upcoming_departures_stream_live.ex +++ b/lib/dotcom_web/live/upcoming_departures_stream_live.ex @@ -17,6 +17,7 @@ defmodule DotcomWeb.UpcomingDeparturesStreamLive do alias Dotcom.Playground.UpcomingDeparturesManager alias Phoenix.LiveView + alias Phoenix.LiveView.AsyncResult @impl LiveView def mount(_params, _session, socket) do @@ -45,6 +46,7 @@ defmodule DotcomWeb.UpcomingDeparturesStreamLive do |> assign_route(route_id) |> assign_direction(direction_id) |> assign_stop(stop_id) + |> assign(:predicted_schedules, AsyncResult.loading()) |> subscribe_or_unsubscribe_to_upcoming_departures()} end @@ -100,7 +102,16 @@ defmodule DotcomWeb.UpcomingDeparturesStreamLive do <.stop_banner stop={@stop} />
- Much like your departures, this page is upcoming! + <.async_result :let={predicted_schedules} assign={@predicted_schedules}> + <:loading><.spinner aria_label="Loading Predicted Schedules" /> + +
+ <.predicted_schedule + :for={predicted_schedule <- predicted_schedules} + predicted_schedule={predicted_schedule} + /> +
+
@@ -116,6 +127,41 @@ defmodule DotcomWeb.UpcomingDeparturesStreamLive do """ end + defp predicted_schedule(assigns) do + ~H""" +
+
+
+ {PredictedSchedule.trip(@predicted_schedule).headsign} + {PredictedSchedule.trip(@predicted_schedule).id} +
+ +
+
+ <.icon + :if={@predicted_schedule.prediction} + type="icon-svg" + name="icon-realtime-tracking" + class="size-3" + /> + + {format(PredictedSchedule.display_time(@predicted_schedule))} +
+ +
+ {format( + @predicted_schedule.schedule.arrival_time || @predicted_schedule.schedule.departure_time + )} +
+
+
+
+ """ + end + @impl LiveView def handle_event("clear-route", _params, socket) do {:noreply, socket |> push_patch(to: ~p"/preview/upcoming-departures-stream")} @@ -156,6 +202,13 @@ defmodule DotcomWeb.UpcomingDeparturesStreamLive do {:noreply, socket |> push_patch(to: ~p"/preview/upcoming-departures-stream?#{params}")} end + def handle_info( + {:upcoming_departures_update, %{predicted_schedules: predicted_schedules}}, + socket + ) do + {:noreply, socket |> assign(:predicted_schedules, AsyncResult.ok(predicted_schedules))} + end + @impl LiveView def terminate(_reason, socket) do unsubscribe_from_upcoming_departures(socket) @@ -200,4 +253,12 @@ defmodule DotcomWeb.UpcomingDeparturesStreamLive do socket |> assign(:subscribed?, false) end + + defp format(nil), do: "" + + defp format(datetime) do + {:ok, string} = Cldr.DateTime.to_string(datetime, Dotcom.Cldr, format: "h:mm:ss a") + + string + end end From c227163d17782157b18c317e59228b125a0ed6ef Mon Sep 17 00:00:00 2001 From: Josh Larson Date: Mon, 4 May 2026 11:17:41 -0400 Subject: [PATCH 24/32] Don't load schedules for subway --- .../playground/upcoming_departures_worker.ex | 26 ++++++++++++++----- .../live/upcoming_departures_stream_live.ex | 5 ++-- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/lib/dotcom/playground/upcoming_departures_worker.ex b/lib/dotcom/playground/upcoming_departures_worker.ex index 43c1981564..8747b2f141 100644 --- a/lib/dotcom/playground/upcoming_departures_worker.ex +++ b/lib/dotcom/playground/upcoming_departures_worker.ex @@ -24,14 +24,12 @@ defmodule Dotcom.Playground.UpcomingDeparturesWorker do end # Server - def init(%{route: route, stop: stop, direction_id: direction_id} = params) do - PredictionsManager.subscribe(params) + def init(%{route: route, stop_id: stop_id, direction_id: direction_id} = params) do + PredictionsManager.subscribe(%{route: route.id, stop: stop_id, direction_id: direction_id}) predicted_schedules = - Schedules.Repo.by_route_ids([route], - direction_id: direction_id, - stop_ids: [stop] - ) + params + |> fetch_schedules() |> Map.new(fn s -> {{s.trip.id, s.stop_sequence}, %PredictedSchedule{schedule: s, prediction: nil}} end) @@ -46,7 +44,21 @@ defmodule Dotcom.Playground.UpcomingDeparturesWorker do }} end - def terminate(_reason, %{params: _params}) do + defp fetch_schedules(%{ + route: %Routes.Route{id: route_id, type: route_type}, + direction_id: direction_id, + stop_id: stop_id + }) + when route_type in [2, 3, 4] do + Schedules.Repo.by_route_ids([route_id], + direction_id: direction_id, + stop_ids: [stop_id] + ) + end + + defp fetch_schedules(_), do: [] + + def(terminate(_reason, %{params: _params})) do PredictionsManager.unsubscribe() end diff --git a/lib/dotcom_web/live/upcoming_departures_stream_live.ex b/lib/dotcom_web/live/upcoming_departures_stream_live.ex index 5c63e8ac4a..05088d0bc4 100644 --- a/lib/dotcom_web/live/upcoming_departures_stream_live.ex +++ b/lib/dotcom_web/live/upcoming_departures_stream_live.ex @@ -202,6 +202,7 @@ defmodule DotcomWeb.UpcomingDeparturesStreamLive do {:noreply, socket |> push_patch(to: ~p"/preview/upcoming-departures-stream?#{params}")} end + @impl LiveView def handle_info( {:upcoming_departures_update, %{predicted_schedules: predicted_schedules}}, socket @@ -238,10 +239,10 @@ defmodule DotcomWeb.UpcomingDeparturesStreamLive do defp subscribe_to_upcoming_departures(socket) do direction_id = socket.assigns.direction_id - route_id = socket.assigns.route_id + route = socket.assigns.route stop_id = socket.assigns.stop_id - params = %{route: route_id, direction_id: direction_id, stop: stop_id} + params = %{route: route, direction_id: direction_id, stop_id: stop_id} UpcomingDeparturesManager.subscribe(params) From d07379a6019298008038c316e3f639998ea4a665 Mon Sep 17 00:00:00 2001 From: Josh Larson Date: Mon, 4 May 2026 15:13:07 -0400 Subject: [PATCH 25/32] Convert predicted_schedules to upcoming_departures --- .../playground/upcoming_departures_worker.ex | 58 +++++++++++++++++- lib/dotcom_web/live/schedule_finder_live.ex | 2 +- .../live/upcoming_departures_stream_live.ex | 59 +++++++++++++++---- 3 files changed, 104 insertions(+), 15 deletions(-) diff --git a/lib/dotcom/playground/upcoming_departures_worker.ex b/lib/dotcom/playground/upcoming_departures_worker.ex index 8747b2f141..4aed6c1fd0 100644 --- a/lib/dotcom/playground/upcoming_departures_worker.ex +++ b/lib/dotcom/playground/upcoming_departures_worker.ex @@ -1,6 +1,7 @@ defmodule Dotcom.Playground.UpcomingDeparturesWorker do use GenServer + alias Dotcom.ScheduleFinder.UpcomingDepartures alias Dotcom.Playground.PredictionsManager # Client @@ -27,6 +28,8 @@ defmodule Dotcom.Playground.UpcomingDeparturesWorker do def init(%{route: route, stop_id: stop_id, direction_id: direction_id} = params) do PredictionsManager.subscribe(%{route: route.id, stop: stop_id, direction_id: direction_id}) + schedule_refresh() + predicted_schedules = params |> fetch_schedules() @@ -40,10 +43,16 @@ defmodule Dotcom.Playground.UpcomingDeparturesWorker do predictions: :loading, predicted_schedules_init: predicted_schedules, predicted_schedules: :loading, - subscribers: MapSet.new() + route: route, + subscribers: MapSet.new(), + upcoming_departures: :loading }} end + defp schedule_refresh() do + Process.send_after(self(), :refresh, 1000) + end + defp fetch_schedules(%{ route: %Routes.Route{id: route_id, type: route_type}, direction_id: direction_id, @@ -92,8 +101,20 @@ defmodule Dotcom.Playground.UpcomingDeparturesWorker do new_state = events |> Enum.reduce(state, &apply_prediction_event/2) + |> update_upcoming_departures() + |> publish() + + {:noreply, new_state} + end + + def handle_info(:refresh, state) do + new_state = + state + |> update_upcoming_departures() |> publish() + schedule_refresh() + {:noreply, new_state} end @@ -151,8 +172,38 @@ defmodule Dotcom.Playground.UpcomingDeparturesWorker do end end + defp update_upcoming_departures( + %{ + route: route, + predicted_schedules: {:ok, predicted_schedules} + } = + state + ) do + %{ + state + | upcoming_departures: + {:ok, + predicted_schedules + |> Map.values() + |> Enum.sort_by(&PredictedSchedule.display_time/1, DateTime) + |> Enum.map(fn ps -> + UpcomingDepartures.to_upcoming_departure(%{ + now: Dotcom.Utils.DateTime.now(), + predicted_schedule: ps, + route_type: Routes.Route.type_atom(route) + }) + end)} + } + end + + defp update_upcoming_departures(state), do: state + defp publish( - %{subscribers: subscribers, predicted_schedules: {:ok, predicted_schedules}} = state + %{ + subscribers: subscribers, + predicted_schedules: {:ok, predicted_schedules}, + upcoming_departures: {:ok, upcoming_departures} + } = state ) do subscribers |> Enum.each(fn pid -> @@ -163,7 +214,8 @@ defmodule Dotcom.Playground.UpcomingDeparturesWorker do predicted_schedules: predicted_schedules |> Map.values() - |> Enum.sort_by(&PredictedSchedule.display_time/1, DateTime) + |> Enum.sort_by(&PredictedSchedule.display_time/1, DateTime), + upcoming_departures: upcoming_departures }} ) end) diff --git a/lib/dotcom_web/live/schedule_finder_live.ex b/lib/dotcom_web/live/schedule_finder_live.ex index 4c82fadb6f..ccf92ba28c 100644 --- a/lib/dotcom_web/live/schedule_finder_live.ex +++ b/lib/dotcom_web/live/schedule_finder_live.ex @@ -869,7 +869,7 @@ defmodule DotcomWeb.ScheduleFinderLive do """ end - defp upcoming_departure_heading(assigns) do + def upcoming_departure_heading(assigns) do ~H""" <.departure_heading route={@upcoming_departure.route}> <:headsign> diff --git a/lib/dotcom_web/live/upcoming_departures_stream_live.ex b/lib/dotcom_web/live/upcoming_departures_stream_live.ex index 05088d0bc4..d6475069fa 100644 --- a/lib/dotcom_web/live/upcoming_departures_stream_live.ex +++ b/lib/dotcom_web/live/upcoming_departures_stream_live.ex @@ -15,6 +15,8 @@ defmodule DotcomWeb.UpcomingDeparturesStreamLive do stop_picker_or: 1 ] + import DotcomWeb.ScheduleFinderLive, only: [upcoming_departure_heading: 1] + alias Dotcom.Playground.UpcomingDeparturesManager alias Phoenix.LiveView alias Phoenix.LiveView.AsyncResult @@ -47,6 +49,7 @@ defmodule DotcomWeb.UpcomingDeparturesStreamLive do |> assign_direction(direction_id) |> assign_stop(stop_id) |> assign(:predicted_schedules, AsyncResult.loading()) + |> assign(:upcoming_departures, AsyncResult.loading()) |> subscribe_or_unsubscribe_to_upcoming_departures()} end @@ -102,16 +105,33 @@ defmodule DotcomWeb.UpcomingDeparturesStreamLive do <.stop_banner stop={@stop} />
- <.async_result :let={predicted_schedules} assign={@predicted_schedules}> - <:loading><.spinner aria_label="Loading Predicted Schedules" /> - -
- <.predicted_schedule - :for={predicted_schedule <- predicted_schedules} - predicted_schedule={predicted_schedule} - /> +
+
+ <.async_result :let={predicted_schedules} assign={@predicted_schedules}> + <:loading><.spinner aria_label="Loading Predicted Schedules" /> + +
+ <.predicted_schedule + :for={predicted_schedule <- predicted_schedules} + predicted_schedule={predicted_schedule} + /> +
+ +
+ +
+ <.async_result :let={upcoming_departures} assign={@upcoming_departures}> + <:loading><.spinner aria_label="Loading Predicted Schedules" /> + +
+ <.upcoming_departure + :for={upcoming_departure <- upcoming_departures} + upcoming_departure={upcoming_departure} + /> +
+
- +
@@ -127,6 +147,19 @@ defmodule DotcomWeb.UpcomingDeparturesStreamLive do """ end + defp upcoming_departure(assigns) do + ~H""" +
+ + <.upcoming_departure_heading upcoming_departure={@upcoming_departure} /> + {@upcoming_departure.trip_id} + + +
{inspect @upcoming_departure, pretty: true}
+
+ """ + end + defp predicted_schedule(assigns) do ~H"""
@@ -204,10 +237,14 @@ defmodule DotcomWeb.UpcomingDeparturesStreamLive do @impl LiveView def handle_info( - {:upcoming_departures_update, %{predicted_schedules: predicted_schedules}}, + {:upcoming_departures_update, + %{predicted_schedules: predicted_schedules, upcoming_departures: upcoming_departures}}, socket ) do - {:noreply, socket |> assign(:predicted_schedules, AsyncResult.ok(predicted_schedules))} + {:noreply, + socket + |> assign(:predicted_schedules, AsyncResult.ok(predicted_schedules)) + |> assign(:upcoming_departures, AsyncResult.ok(upcoming_departures))} end @impl LiveView From 98cf84a27bd4a76f266e2e104c0342c975effa4f Mon Sep 17 00:00:00 2001 From: Josh Larson Date: Mon, 4 May 2026 15:13:22 -0400 Subject: [PATCH 26/32] Strange formatting --- lib/dotcom/playground/upcoming_departures_worker.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/dotcom/playground/upcoming_departures_worker.ex b/lib/dotcom/playground/upcoming_departures_worker.ex index 4aed6c1fd0..d20c1a06e7 100644 --- a/lib/dotcom/playground/upcoming_departures_worker.ex +++ b/lib/dotcom/playground/upcoming_departures_worker.ex @@ -67,7 +67,7 @@ defmodule Dotcom.Playground.UpcomingDeparturesWorker do defp fetch_schedules(_), do: [] - def(terminate(_reason, %{params: _params})) do + def terminate(_reason, %{params: _params}) do PredictionsManager.unsubscribe() end From 14fcd634d65da9ddf3f4dda5ec7b5589ee1259b6 Mon Sep 17 00:00:00 2001 From: Josh Larson Date: Mon, 4 May 2026 15:20:03 -0400 Subject: [PATCH 27/32] Use `:hidden` for all upcoming_departures filtering --- .../playground/upcoming_departures_worker.ex | 6 ++-- .../schedule_finder/upcoming_departures.ex | 28 ++++++++++++++++--- .../upcoming_departures_test.exs | 21 ++++++++++---- 3 files changed, 43 insertions(+), 12 deletions(-) diff --git a/lib/dotcom/playground/upcoming_departures_worker.ex b/lib/dotcom/playground/upcoming_departures_worker.ex index d20c1a06e7..c9d34d9d5d 100644 --- a/lib/dotcom/playground/upcoming_departures_worker.ex +++ b/lib/dotcom/playground/upcoming_departures_worker.ex @@ -186,13 +186,15 @@ defmodule Dotcom.Playground.UpcomingDeparturesWorker do predicted_schedules |> Map.values() |> Enum.sort_by(&PredictedSchedule.display_time/1, DateTime) - |> Enum.map(fn ps -> + |> Stream.map(fn ps -> UpcomingDepartures.to_upcoming_departure(%{ now: Dotcom.Utils.DateTime.now(), predicted_schedule: ps, route_type: Routes.Route.type_atom(route) }) - end)} + end) + |> Stream.reject(&(&1.arrival_status == :hidden)) + |> Enum.to_list()} } end diff --git a/lib/dotcom/schedule_finder/upcoming_departures.ex b/lib/dotcom/schedule_finder/upcoming_departures.ex index c277742131..297a2283d9 100644 --- a/lib/dotcom/schedule_finder/upcoming_departures.ex +++ b/lib/dotcom/schedule_finder/upcoming_departures.ex @@ -154,7 +154,6 @@ defmodule Dotcom.ScheduleFinder.UpcomingDepartures do upcoming_predicted_schedules_at_stop = predicted_schedules_at_stop - |> Enum.reject(&past_schedule?(&1, now)) cond do Enum.empty?(predicted_schedules_at_stop) -> @@ -450,6 +449,7 @@ defmodule Dotcom.ScheduleFinder.UpcomingDepartures do do: {:status, status} defp arrival_status(%{ + now: now, predicted_schedule: %PredictedSchedule{ prediction: %Prediction{ schedule_relationship: schedule_relationship, @@ -461,7 +461,13 @@ defmodule Dotcom.ScheduleFinder.UpcomingDepartures do }) when schedule_relationship in [:cancelled, :skipped] and route_type != :subway do - {:cancelled, schedule.departure_time} + time = schedule.departure_time + + if DateTime.before?(now, time) do + {:cancelled, time} + else + :hidden + end end defp arrival_status(%{ @@ -518,18 +524,32 @@ defmodule Dotcom.ScheduleFinder.UpcomingDepartures do end defp arrival_status(%{ + now: now, predicted_schedule: %PredictedSchedule{schedule: schedule}, route_type: route_type }) when route_type in [:commuter_rail, :ferry] and schedule != nil do - {:scheduled, schedule.departure_time} + time = schedule.departure_time + + if DateTime.before?(now, time) do + {:scheduled, time} + else + :hidden + end end defp arrival_status(%{ + now: now, predicted_schedule: %PredictedSchedule{schedule: schedule} }) when schedule != nil do - {:scheduled, PredictedSchedule.display_time(schedule)} + time = PredictedSchedule.display_time(schedule) + + if DateTime.before?(now, time) do + {:scheduled, time} + else + :hidden + end end @spec realtime_arrival_status(%{ diff --git a/test/dotcom/schedule_finder/upcoming_departures_test.exs b/test/dotcom/schedule_finder/upcoming_departures_test.exs index 014d8392b1..99fc2c4508 100644 --- a/test/dotcom/schedule_finder/upcoming_departures_test.exs +++ b/test/dotcom/schedule_finder/upcoming_departures_test.exs @@ -981,7 +981,9 @@ defmodule Dotcom.ScheduleFinder.UpcomingDeparturesTest do schedules: schedules, stops: [_, stop, _] } = - PredictedScheduleHelper.predicted_schedule_trip_data(route_factory_types: [:bus_route]) + PredictedScheduleHelper.predicted_schedule_trip_data( + route_factory_types: [:bus_route, :commuter_rail_route, :ferry_route] + ) expect(Predictions.Repo.Mock, :all, fn _ -> [] end) expect_schedule_call_filtered_by_stop(schedules, route_id: route.id) @@ -996,7 +998,8 @@ defmodule Dotcom.ScheduleFinder.UpcomingDeparturesTest do }) # Verify - assert departures == :service_ended + # assert departures == :service_ended + assert departures == {:no_realtime, []} end test "shows :no_service if there are no trips" do @@ -1134,7 +1137,8 @@ defmodule Dotcom.ScheduleFinder.UpcomingDeparturesTest do route: route, scheduled_departure_times: [_, scheduled_time, _], schedules: schedules, - stops: [_, stop, _] + stops: [_, stop, _], + vehicle: vehicle } = PredictedScheduleHelper.predicted_schedule_trip_data( route_factory_types: [:bus_route, :commuter_rail_route, :ferry_route], @@ -1143,6 +1147,7 @@ defmodule Dotcom.ScheduleFinder.UpcomingDeparturesTest do expect(Predictions.Repo.Mock, :all, fn _ -> predictions end) expect_schedule_call_filtered_by_stop(schedules, route_id: route.id) + expect(Vehicles.Repo.Mock, :get, fn _ -> vehicle end) # Exercise departures = @@ -1154,7 +1159,8 @@ defmodule Dotcom.ScheduleFinder.UpcomingDeparturesTest do }) # Verify - assert departures == :service_ended + # assert departures == :service_ended + assert departures == [] end test "includes cancelled bus/commuter-rail/ferry trips if their scheduled time is in the future" do @@ -1228,7 +1234,8 @@ defmodule Dotcom.ScheduleFinder.UpcomingDeparturesTest do route: route, scheduled_departure_times: [_, scheduled_time, _], schedules: schedules, - stops: [_, stop, _] + stops: [_, stop, _], + vehicle: vehicle } = PredictedScheduleHelper.predicted_schedule_trip_data( route_factory_types: [:bus_route, :commuter_rail_route, :ferry_route], @@ -1237,6 +1244,7 @@ defmodule Dotcom.ScheduleFinder.UpcomingDeparturesTest do expect(Predictions.Repo.Mock, :all, fn _ -> predictions end) expect_schedule_call_filtered_by_stop(schedules, route_id: route.id) + expect(Vehicles.Repo.Mock, :get, fn _ -> vehicle end) # Exercise departures = @@ -1248,7 +1256,8 @@ defmodule Dotcom.ScheduleFinder.UpcomingDeparturesTest do }) # Verify - assert departures == :service_ended + # assert departures == :service_ended + assert departures == [] end test "shows schedule data for bus/commuter-rail/ferry predictions with no times that aren't skipped or cancelled" do From 296d0ffe24fc3f43958ab158c3cae488774c43bb Mon Sep 17 00:00:00 2001 From: Josh Larson Date: Tue, 5 May 2026 17:02:52 -0400 Subject: [PATCH 28/32] Only message upcoming departures consumers when upcoming departures have changed --- .../playground/upcoming_departures_worker.ex | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/lib/dotcom/playground/upcoming_departures_worker.ex b/lib/dotcom/playground/upcoming_departures_worker.ex index c9d34d9d5d..b25b54cbb4 100644 --- a/lib/dotcom/playground/upcoming_departures_worker.ex +++ b/lib/dotcom/playground/upcoming_departures_worker.ex @@ -45,7 +45,8 @@ defmodule Dotcom.Playground.UpcomingDeparturesWorker do predicted_schedules: :loading, route: route, subscribers: MapSet.new(), - upcoming_departures: :loading + upcoming_departures: :loading, + published_upcoming_departures: nil }} end @@ -102,7 +103,7 @@ defmodule Dotcom.Playground.UpcomingDeparturesWorker do events |> Enum.reduce(state, &apply_prediction_event/2) |> update_upcoming_departures() - |> publish() + |> publish_if_updated() {:noreply, new_state} end @@ -111,7 +112,7 @@ defmodule Dotcom.Playground.UpcomingDeparturesWorker do new_state = state |> update_upcoming_departures() - |> publish() + |> publish_if_updated() schedule_refresh() @@ -200,6 +201,19 @@ defmodule Dotcom.Playground.UpcomingDeparturesWorker do defp update_upcoming_departures(state), do: state + defp publish_if_updated( + %{ + upcoming_departures: {:ok, upcoming_departures}, + published_upcoming_departures: published_upcoming_departures + } = state + ) do + if upcoming_departures != published_upcoming_departures do + publish(state) + end + + %{state | published_upcoming_departures: upcoming_departures} + end + defp publish( %{ subscribers: subscribers, From f3284edbefb9d17c273f53101ab9b4c763f56e06 Mon Sep 17 00:00:00 2001 From: Josh Larson Date: Tue, 5 May 2026 18:04:54 -0400 Subject: [PATCH 29/32] Use Playground Upcoming Departures in Schedule Finder --- lib/dotcom_web/live/schedule_finder_live.ex | 91 +++++++++++---------- 1 file changed, 48 insertions(+), 43 deletions(-) diff --git a/lib/dotcom_web/live/schedule_finder_live.ex b/lib/dotcom_web/live/schedule_finder_live.ex index ccf92ba28c..ede9cec4db 100644 --- a/lib/dotcom_web/live/schedule_finder_live.ex +++ b/lib/dotcom_web/live/schedule_finder_live.ex @@ -15,6 +15,7 @@ defmodule DotcomWeb.ScheduleFinderLive do import DotcomWeb.RouteComponents, only: [lined_list: 1, lined_list_item: 1] import DotcomWeb.ViewHelpers, only: [mode_name: 1] + alias Dotcom.Playground.UpcomingDeparturesManager alias Dotcom.ScheduleFinder.ServiceGroup alias Dotcom.ScheduleFinder.TripDetails alias Dotcom.ScheduleFinder.UpcomingDepartures @@ -75,9 +76,9 @@ defmodule DotcomWeb.ScheduleFinderLive do |> assign_new(:selected_service_name, fn -> Map.get(selected_service, :label, "") end) |> assign_new(:daily_schedule_date, fn -> service_date() end) |> assign_new(:should_refresh?, fn -> true end) + |> refresh_upcoming_trip_details() |> assign_alerts() |> assign_departures() - |> assign_upcoming_departures() |> assign_last_trip_time()} _ -> @@ -90,6 +91,8 @@ defmodule DotcomWeb.ScheduleFinderLive do def terminate(_, _) do # stop listening for new alerts _ = Alerts.Cache.Store.unsubscribe() + + _ = UpcomingDeparturesManager.unsubscribe() end @impl LiveView @@ -217,11 +220,20 @@ defmodule DotcomWeb.ScheduleFinderLive do |> assign(:departures, AsyncResult.loading())} end - def handle_event("visibility_change", %{"state" => state}, socket) do + def handle_event("visibility_change", %{"state" => "visible"}, socket) do + subscribe_to_upcoming_departures(socket) + + {:noreply, + socket + |> assign(:should_refresh?, true)} + end + + def handle_event("visibility_change", _, socket) do + UpcomingDeparturesManager.unsubscribe() + {:noreply, socket - |> assign(:should_refresh?, state == "visible") - |> assign_upcoming_departures()} + |> assign(:should_refresh?, false)} end def handle_event(_, _, socket), do: {:noreply, socket} @@ -245,7 +257,6 @@ defmodule DotcomWeb.ScheduleFinderLive do def handle_info(:refresh_upcoming_departures, socket) do {:noreply, socket - |> assign_upcoming_departures() |> refresh_upcoming_trip_details()} end @@ -260,8 +271,29 @@ defmodule DotcomWeb.ScheduleFinderLive do |> assign_departures()} end + def handle_info( + {:upcoming_departures_update, %{upcoming_departures: upcoming_departures}}, + socket + ) do + dbg("UPDATE") + + {:noreply, + socket + |> assign(:upcoming_departures, AsyncResult.ok(upcoming_departures))} + end + def handle_info(_, socket), do: {:noreply, socket} + defp subscribe_to_upcoming_departures(socket) do + direction_id = socket.assigns.direction_id + route = socket.assigns.route + stop_id = socket.assigns.stop.id + + params = %{route: route, direction_id: direction_id, stop_id: stop_id} + + UpcomingDeparturesManager.subscribe(params) + end + defp subscribe_to_alerts(socket) do if connected?(socket) do _ = Alerts.Cache.Store.subscribe() @@ -271,9 +303,9 @@ defmodule DotcomWeb.ScheduleFinderLive do end end - defp schedule_refresh_upcoming_departures(pid) do - # Refresh every second - Process.send_after(pid, :refresh_upcoming_departures, 5000) + defp schedule_refresh_upcoming_trip_details() do + # Refresh every five seconds + Process.send_after(self(), :refresh_upcoming_departures, 5000) end defp validate_params(%{ @@ -297,44 +329,17 @@ defmodule DotcomWeb.ScheduleFinderLive do assigns |> assign(:page_title, long_name <> " | " <> ~t(Departures) <> " | " <> ~t(MBTA)) end - defp assign_upcoming_departures(%{assigns: %{stop: %Stop{id: stop_id}}} = socket) do - now = @date_time.now() - route = socket.assigns.route - direction_id = socket.assigns.direction_id - stop_id = stop_id - - parent_pid = self() - should_refresh? = socket.assigns.should_refresh? - - socket - |> assign_async( - :upcoming_departures, - fn -> - departures = - UpcomingDepartures.upcoming_departures(%{ - direction_id: direction_id, - now: now, - route: route, - stop_id: stop_id - }) - - _ = if should_refresh?, do: schedule_refresh_upcoming_departures(parent_pid) - - {:ok, %{upcoming_departures: departures}} - end - ) - end - - defp assign_upcoming_departures(socket) do - socket |> assign(:upcoming_departures, []) - end - defp refresh_upcoming_trip_details(socket) do trip_ids_and_stop_seqs = Map.keys(socket.assigns.loaded_upcoming_trips) - Enum.reduce(trip_ids_and_stop_seqs, socket, fn {trip_id, stop_sequence}, s -> - s |> assign_trip_details(trip_id, stop_sequence) - end) + new_socket = + Enum.reduce(trip_ids_and_stop_seqs, socket, fn {trip_id, stop_sequence}, s -> + s |> assign_trip_details(trip_id, stop_sequence) + end) + + schedule_refresh_upcoming_trip_details() + + new_socket end defp assign_trip_details(socket, trip_id, stop_sequence) do From b07a5d5e19328ab5da7c75057aebd41f7d62a1db Mon Sep 17 00:00:00 2001 From: Josh Larson Date: Tue, 5 May 2026 18:05:37 -0400 Subject: [PATCH 30/32] Add a link to the departures page on the upcoming departures stream page --- .../live/upcoming_departures_stream_live.ex | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/lib/dotcom_web/live/upcoming_departures_stream_live.ex b/lib/dotcom_web/live/upcoming_departures_stream_live.ex index d6475069fa..c384f12dc4 100644 --- a/lib/dotcom_web/live/upcoming_departures_stream_live.ex +++ b/lib/dotcom_web/live/upcoming_departures_stream_live.ex @@ -48,6 +48,7 @@ defmodule DotcomWeb.UpcomingDeparturesStreamLive do |> assign_route(route_id) |> assign_direction(direction_id) |> assign_stop(stop_id) + |> assign_departures_path() |> assign(:predicted_schedules, AsyncResult.loading()) |> assign(:upcoming_departures, AsyncResult.loading()) |> subscribe_or_unsubscribe_to_upcoming_departures()} @@ -92,6 +93,20 @@ defmodule DotcomWeb.UpcomingDeparturesStreamLive do |> assign(:stop, Stops.Repo.get(stop_id)) end + defp assign_departures_path( + %{assigns: %{route_id: route_id, direction_id: direction_id, stop_id: stop_id}} = socket + ) + when route_id != nil and direction_id != nil and stop_id != nil do + params = %{route_id: route_id, direction_id: direction_id, stop_id: stop_id} + + socket |> assign(:departures_path, ~p"/departures?#{params}") + end + + defp assign_departures_path(socket) do + socket + |> assign(:departures_path, nil) + end + @impl LiveView def render(assigns) do ~H""" @@ -105,7 +120,15 @@ defmodule DotcomWeb.UpcomingDeparturesStreamLive do <.stop_banner stop={@stop} />
-
+ <.link + :if={@departures_path} + target="_blank" + navigate={@departures_path} + > + <.icon name="arrow-right" class="size-3" /> Departures Page + + +
<.async_result :let={predicted_schedules} assign={@predicted_schedules}> <:loading><.spinner aria_label="Loading Predicted Schedules" /> From e1f06e7cb615a68cf11733abb49dd7f82fd021ff Mon Sep 17 00:00:00 2001 From: Josh Larson Date: Fri, 17 Apr 2026 15:25:20 -0400 Subject: [PATCH 31/32] [TMP] feat: Release SF2.0 Just putting this up so that I can deploy this branch to dev-green without messing up accessibility testing Revert "chore: link to SF1.0 (#3105)" This reverts commit 95480d2ccd00aad9ea879aaf354be0cf120d0494. --- .../ts/schedule/components/ScheduleFinder.tsx | 31 +--- .../ts/schedule/components/SchedulePage.tsx | 17 -- .../__tests__/ScheduleFinderTest.tsx | 44 +---- .../components/__tests__/SchedulePageTest.tsx | 171 +----------------- .../components/line-diagram/LineDiagram.tsx | 34 +--- .../__tests__/LineDiagramTest.tsx | 40 +--- .../schedule-finder/ScheduleFinderForm.tsx | 19 +- .../schedule-finder/ScheduleFinderModal.tsx | 3 - .../schedule-finder/ScheduleModalContent.tsx | 4 - .../__tests__/ScheduleFinderFormTest.tsx | 44 +---- .../__tests__/ScheduleFinderModalTest.tsx | 3 - .../__tests__/ScheduleModalContentTest.tsx | 6 - lib/dotcom_web/plugs/rewrite_urls.ex | 27 ++- test/dotcom_web/plugs/rewrite_urls_test.exs | 22 +++ 14 files changed, 73 insertions(+), 392 deletions(-) diff --git a/assets/ts/schedule/components/ScheduleFinder.tsx b/assets/ts/schedule/components/ScheduleFinder.tsx index 41f64731c4..c7d2efa777 100644 --- a/assets/ts/schedule/components/ScheduleFinder.tsx +++ b/assets/ts/schedule/components/ScheduleFinder.tsx @@ -1,5 +1,5 @@ import React, { ReactElement } from "react"; -import { useDispatch, useSelector } from "react-redux"; +import { useSelector } from "react-redux"; import { Dispatch } from "redux"; import { Route, DirectionId } from "../../__v3api"; import { @@ -44,43 +44,18 @@ const ScheduleFinder = ({ scheduleNote, hasServiceToday }: Props): ReactElement => { - const dispatch = useDispatch(); const { modalOpen, selectedOrigin } = useSelector( (state: StoreProps) => state ); const currentDirection = useDirectionChangeEvent(directionId); - const openOriginModal = (): void => { - if (!modalOpen) { - dispatch({ - type: "OPEN_MODAL", - newStoreValues: { - modalMode: "origin" - } - }); - } - }; const openScheduleModal = (): void => { if (selectedOrigin !== undefined && !modalOpen) { - dispatch({ - type: "OPEN_MODAL", - newStoreValues: { - modalMode: "schedule" - } - }); + document.location.href = `/departures/?route_id=${route.id}&direction_id=${directionId}&stop_id=${selectedOrigin}`; } }; - const handleOriginSelectClick = (): void => { - dispatch({ - type: "OPEN_MODAL", - newStoreValues: { - modalMode: "origin" - } - }); - }; - const isFerryRoute = routeToModeName(route) === "ferry"; return ( @@ -92,7 +67,6 @@ const ScheduleFinder = ({ { updateURL(""); }; -export const handleOriginSelectClick = (dispatch: Dispatch): void => { - dispatch({ - type: "OPEN_MODAL", - newStoreValues: { - modalMode: "origin" - } - }); -}; - const getDirectionAndMap = ( schedulePageData: SchedulePageData, mapData: MapData, @@ -239,7 +223,6 @@ const ScheduleNote = ({ closeModal={closeModal} directionChanged={changeDirection} initialDirection={currentDirection} - handleOriginSelectClick={handleOriginSelectClick} originChanged={changeOrigin} route={route} routePatternsByDirection={routePatternsByDirection} diff --git a/assets/ts/schedule/components/__tests__/ScheduleFinderTest.tsx b/assets/ts/schedule/components/__tests__/ScheduleFinderTest.tsx index 0fcc289ab7..1b21cff772 100644 --- a/assets/ts/schedule/components/__tests__/ScheduleFinderTest.tsx +++ b/assets/ts/schedule/components/__tests__/ScheduleFinderTest.tsx @@ -256,47 +256,5 @@ describe("ScheduleFinder", () => { expect(lastSelectedOriginElement.selected).toBeFalse(); }); - it("Opens the origin modal when clicking on the origin drop-down in the schedule modal", async () => { - const user = userEvent.setup(); - const dispatchSpy = jest.fn(); - jest.spyOn(reactRedux, "useDispatch").mockImplementation(() => dispatchSpy); - - renderWithProviders( - {}} - changeDirection={() => {}} - changeOrigin={() => {}} - closeModal={() => {}} - scheduleNote={null} - hasServiceToday={true} - />, - { - preloadedState: { - modalOpen: true, - selectedOrigin: "123", - modalMode: "schedule" - } - } - ); - - // select the last node (i.e. origin drop-down) and choose an option - const scheduleFinderModal = screen.getByLabelText(/Schedules on the.*/); - const originSelectElement = within(scheduleFinderModal).getByTestId( - "schedule-finder-origin-select" - ); - await user.click(originSelectElement); - - expect(dispatchSpy).toHaveBeenCalledWith({ - type: "OPEN_MODAL", - newStoreValues: { - modalMode: "origin" - } - }); - }); + }); diff --git a/assets/ts/schedule/components/__tests__/SchedulePageTest.tsx b/assets/ts/schedule/components/__tests__/SchedulePageTest.tsx index 566a336800..2d47ca50d9 100644 --- a/assets/ts/schedule/components/__tests__/SchedulePageTest.tsx +++ b/assets/ts/schedule/components/__tests__/SchedulePageTest.tsx @@ -11,8 +11,7 @@ import { MapData, StaticMapData } from "../../../leaflet/components/__mapdata"; import { SchedulePage, changeOrigin, - changeDirection, - handleOriginSelectClick + changeDirection } from "../SchedulePage"; import * as schedulePage from "../SchedulePage"; import * as routePatternsByDirectionData from "./test-data/routePatternsByDirectionData.json"; @@ -575,65 +574,9 @@ describe("SchedulePage", () => { await user.click(originSelect); - expect(dispatchSpy).toHaveBeenCalledTimes(2); + expect(dispatchSpy).toHaveBeenCalledTimes(1); }); - it("Opens the origin modal", async () => { - const user = userEvent.setup(); - const dispatchSpy = jest.fn(); - jest.spyOn(reactRedux, "useDispatch").mockImplementation(() => { - return dispatchSpy; - }); - renderWithProviders( - - ); - - jest.spyOn(reactRedux, "useSelector").mockImplementation(() => { - return { - selectedDirection: 0, - selectedOrigin: "place-welln", - modalMode: "origin", - modalOpen: false - }; - }); - - const buttons = screen.getAllByRole("button"); - expect(buttons.length).toBeGreaterThan(1); - - await user.click(buttons[1]); - - // first call is with INITIALIZE - expect(dispatchSpy).toHaveBeenNthCalledWith(2, { - type: "OPEN_MODAL", - newStoreValues: { - modalMode: "origin" - } - }); - }); it("Closes the schedule modal", async () => { const user = userEvent.setup(); @@ -732,96 +675,6 @@ describe("SchedulePage", () => { ); }); - it("Opens the origin modal when clicking on the origin drop-down in the schedule modal", async () => { - const user = userEvent.setup(); - const changeOriginSpy = jest.spyOn(schedulePage, "handleOriginSelectClick"); - - renderWithProviders( - , - { - preloadedState: { - selectedDirection: 0, - selectedOrigin: "place-welln", - modalMode: "schedule", - modalOpen: true - } - } - ); - const originSelect = await waitFor(() => - screen.getByTestId("schedule-finder-origin-select") - ); - - await user.click(originSelect); - - await waitFor(() => - expect(changeOriginSpy).toHaveBeenCalledWith(expect.any(Function)) - ); - }); - - it("Changes the origin", async () => { - const user = userEvent.setup(); - renderWithProviders( - - ); - - const dispatchSpy = jest.fn(); - jest.spyOn(reactRedux, "useDispatch").mockImplementation(() => { - return dispatchSpy; - }); - const originSelect = screen.getByTestId("schedule-finder-origin-select"); - await user.selectOptions(originSelect, "123"); - - expect(dispatchSpy).toHaveBeenCalledTimes(2); - }); - it("Checks if it is a unidirectional route", () => { const dispatchSpy = jest.fn(); jest.spyOn(reactRedux, "useDispatch").mockImplementation(() => { @@ -959,7 +812,7 @@ describe("SchedulePage", () => { }); describe("changeOrigin", () => { - it("should call the dispatch function twice", () => { + it("should call the dispatch function once", () => { const dispatchSpy = jest.fn(); const testOrigin = "test-origin"; changeOrigin(testOrigin, dispatchSpy); @@ -969,12 +822,6 @@ describe("SchedulePage", () => { selectedOrigin: testOrigin } }); - expect(dispatchSpy).toHaveBeenCalledWith({ - type: "OPEN_MODAL", - newStoreValues: { - modalMode: "schedule" - } - }); }); }); @@ -993,16 +840,4 @@ describe("SchedulePage", () => { }); }); - describe("handleOriginSelectClick", () => { - it("should call the dispatch function setting the new direction in the state", () => { - const dispatchSpy = jest.fn(); - handleOriginSelectClick(dispatchSpy); - expect(dispatchSpy).toHaveBeenCalledWith({ - type: "OPEN_MODAL", - newStoreValues: { - modalMode: "origin" - } - }); - }); - }); }); diff --git a/assets/ts/schedule/components/line-diagram/LineDiagram.tsx b/assets/ts/schedule/components/line-diagram/LineDiagram.tsx index bb0b2fcdba..9431959810 100644 --- a/assets/ts/schedule/components/line-diagram/LineDiagram.tsx +++ b/assets/ts/schedule/components/line-diagram/LineDiagram.tsx @@ -1,6 +1,5 @@ import React, { ReactElement, useState } from "react"; import { Provider, useDispatch, useSelector } from "react-redux"; -import { updateInLocation } from "use-query-params"; import { uniqBy } from "lodash"; import SearchBox from "../../../components/SearchBox"; import { stopForId, stopIds } from "../../../helpers/stop-tree"; @@ -31,21 +30,6 @@ interface Props { const stationsOrStops = (routeType: number): string => [0, 1, 2].includes(routeType) ? "Stations" : "Stops"; -const updateURL = (origin: SelectedOrigin, direction?: DirectionId): void => { - /* istanbul ignore else */ - if (window) { - // eslint-disable-next-line camelcase - const newQuery = { - "schedule_finder[direction_id]": - direction !== undefined ? direction.toString() : "", - "schedule_finder[origin]": origin - }; - const newLoc = updateInLocation(newQuery, window.location); - // newLoc is not a true Location, so toString doesn't work - window.history.replaceState({}, "", `${newLoc.pathname}${newLoc.search}`); - } -}; - const LineDiagram = ({ alerts, directionId, @@ -85,28 +69,16 @@ const LineDiagram = ({ selectedOrigin: origin } }); - // reopen modal depending on choice: - dispatch({ - type: "OPEN_MODAL", - newStoreValues: { - modalMode: origin ? "schedule" : "origin" - } - }); }; const handleStopClick = (stop: RouteStop): void => { changeOrigin(stop.id); const { modalOpen: modalIsOpen, selectedOrigin } = currentState; - updateURL(stop.id, directionId); - if (selectedOrigin !== undefined && !modalIsOpen) { - dispatch({ - type: "OPEN_MODAL", - newStoreValues: { - modalMode: "schedule" - } - }); + window.location.assign( + `/departures/?route_id=${route.id}&direction_id=${directionId}&stop_id=${stop.id}` + ); } }; diff --git a/assets/ts/schedule/components/line-diagram/__tests__/LineDiagramTest.tsx b/assets/ts/schedule/components/line-diagram/__tests__/LineDiagramTest.tsx index 2c34a4a3ec..e376dd27dd 100644 --- a/assets/ts/schedule/components/line-diagram/__tests__/LineDiagramTest.tsx +++ b/assets/ts/schedule/components/line-diagram/__tests__/LineDiagramTest.tsx @@ -117,32 +117,6 @@ describe("LineDiagram", () => { expect(screen.getByText("Stations")).toBeInTheDocument(); }); - it("should update the URL when the schedule finder modal is opened", async () => { - const updateInLocationSpy = jest.spyOn(UseQueryParams, "updateInLocation"); - const user = userEvent.setup(); - const dispatchSpy = jest.fn(); - jest.spyOn(reactRedux, "useDispatch").mockImplementation(() => { - return dispatchSpy; - }); - renderWithProviders( - - ); - - const scheduleLinks = screen.getAllByText("View departures"); - await user.click(scheduleLinks[0]); - - expect(dispatchSpy).toHaveBeenCalledWith( - expect.objectContaining({ type: "OPEN_MODAL" }) - ); - expect(updateInLocationSpy).toHaveBeenCalled(); - }); - it("should display the No Results card when a user doesn't query a stop", async () => { renderWithProviders( { expect(screen.getByText("a")).toBeDefined(); }); - it("should fire the open modal event when a user clicks on the results stop card", async () => { + it("should go to /departures/ when a user clicks on the results stop card", async () => { const user = userEvent.setup(); - const dispatchSpy = jest.fn(); - jest.spyOn(reactRedux, "useDispatch").mockImplementation(() => dispatchSpy); + const locationSpy = jest.fn(); + Object.defineProperty(window, 'location', { + writable: true, + value: { assign: locationSpy }, + }); renderWithProviders( { await userEvent.click(scheduleButton); await waitFor(() => { - expect(dispatchSpy).toHaveBeenCalledWith({ - type: "OPEN_MODAL", - newStoreValues: { modalMode: "schedule" } - }); + expect(locationSpy).toHaveBeenCalled() }); }); }); diff --git a/assets/ts/schedule/components/schedule-finder/ScheduleFinderForm.tsx b/assets/ts/schedule/components/schedule-finder/ScheduleFinderForm.tsx index 37c6f00661..2b8ae5e9e9 100644 --- a/assets/ts/schedule/components/schedule-finder/ScheduleFinderForm.tsx +++ b/assets/ts/schedule/components/schedule-finder/ScheduleFinderForm.tsx @@ -14,7 +14,6 @@ const validDirections = (directionInfo: DirectionInfo): DirectionId[] => interface Props { onDirectionChange: (direction: DirectionId, dispatch: Dispatch) => void; onOriginChange: (origin: SelectedOrigin, dispatch: Dispatch) => void; - onOriginSelectClick: (dispatch: Dispatch) => void; onSubmit?: () => void; route: Route; selectedDirection: DirectionId; @@ -26,7 +25,6 @@ const ScheduleFinderForm = ({ onDirectionChange, onOriginChange, onSubmit = () => {}, - onOriginSelectClick, route, selectedDirection, selectedOrigin, @@ -41,11 +39,6 @@ const ScheduleFinderForm = ({ const [originError, setOriginError] = useState(false); - const handleOriginClick = (): void => { - setOriginError(false); - onOriginSelectClick(dispatch); - }; - const handleSubmit = (event: FormEvent): void => { event.preventDefault(); @@ -112,15 +105,15 @@ const ScheduleFinderForm = ({