diff --git a/lib/dotcom_web/components/components.ex b/lib/dotcom_web/components/components.ex index f582b5066d..c061dde9c8 100644 --- a/lib/dotcom_web/components/components.ex +++ b/lib/dotcom_web/components/components.ex @@ -11,6 +11,7 @@ defmodule DotcomWeb.Components do endpoint: DotcomWeb.Endpoint, router: DotcomWeb.Router + import DotcomWeb.ViewHelpers, only: [mode_name: 1] import MbtaMetro.Components.Badge, only: [badge: 1] import MbtaMetro.Components.Button, only: [button: 1] import MbtaMetro.Components.Icon, only: [icon: 1] @@ -402,4 +403,35 @@ defmodule DotcomWeb.Components do """ end + + attr :route_type_atom, :atom, required: true + @doc "Renders a banner with a call-to-action to download the MBTA Go app" + def mbta_go_cta(%{route_type_atom: route_type_atom} = assigns) do + assigns = + assigns + |> assign( + :route_type_text, + route_type_atom + |> mode_name() + |> String.downcase() + ) + + ~H""" + + """ + end end diff --git a/lib/dotcom_web/components/departures.ex b/lib/dotcom_web/components/departures.ex new file mode 100644 index 0000000000..533c019f19 --- /dev/null +++ b/lib/dotcom_web/components/departures.ex @@ -0,0 +1,79 @@ +defmodule DotcomWeb.Components.Departures do + @moduledoc """ + Components for showing information about departures in various contexts + """ + use DotcomWeb, :component + + alias DotcomWeb.RouteComponents + + attr :route, Routes.Route, + required: true, + doc: "The route for the departure trip, used to render an icon" + + slot :headsign, + required: true, + doc: "The headsign of the departure, describing the trip destination" + + slot :additional_info, + doc: + "Additional information to display below the headsign, such as a platform name, vehicle name, or train number" + + slot :time, required: true, doc: "The formatted time of the departure." + + @doc """ + Wrapper for a single row of departure information + """ + def departure_heading(assigns) do + ~H""" +
+
+
+ + + {render_slot(@headsign)} +
+
0} + class="flex items-center gap-2" + > + + +
+ {render_slot(@additional_info)} +
+
+
+ +
+ {render_slot(@time)} +
+
+ """ + end + + attr :time, DateTime, required: true + + def formatted_time(assigns) do + ~H""" + + """ + end + + attr :stop_name, :string, required: true + attr :platform_name, :string, default: nil + + def stop_label(assigns) do + ~H""" +
+
{@stop_name}
+
+ {@platform_name} +
+
+ """ + end +end diff --git a/lib/dotcom_web/live/schedule_finder_live.ex b/lib/dotcom_web/live/schedule_finder_live.ex index 71e1a31f0e..b48acb4837 100644 --- a/lib/dotcom_web/live/schedule_finder_live.ex +++ b/lib/dotcom_web/live/schedule_finder_live.ex @@ -8,22 +8,18 @@ defmodule DotcomWeb.ScheduleFinderLive do import CSSHelpers import DotcomWeb.Components.Alerts - import Dotcom.Utils.Diff, only: [minutes_to_localized_minutes: 1] import Dotcom.Utils.ServiceDateTime, only: [service_date: 0] - import Dotcom.Utils.Time, only: [format!: 2] import DotcomWeb.RouteComponents, only: [lined_list: 1, lined_list_item: 1] - import DotcomWeb.ViewHelpers, only: [mode_name: 1] alias Dotcom.ScheduleFinder.ServiceGroup - alias Dotcom.ScheduleFinder.TripDetails - alias Dotcom.UpcomingDepartures + alias DotcomWeb.Components.Departures + alias DotcomWeb.Live.UpcomingDeparturesLive alias DotcomWeb.RouteComponents alias MbtaMetro.Components.SystemIcons alias Phoenix.{LiveView, LiveView.AsyncResult} alias Routes.Route alias Stops.Stop - @date_time Application.compile_env!(:dotcom, :date_time_module) @routes_repo Application.compile_env!(:dotcom, :repo_modules)[:routes] @schedule_finder Application.compile_env!(:dotcom, :schedule_finder_module) @stops_repo Application.compile_env!(:dotcom, :repo_modules)[:stops] @@ -47,35 +43,33 @@ defmodule DotcomWeb.ScheduleFinderLive do all_services = Enum.flat_map(service_groups, & &1.services) selected_service = Enum.find(all_services, %{}, &(&1.now_date || &1.next_date)) - {:ok, - socket - |> subscribe_to_alerts() - |> assign_new(:route, fn -> route end) - |> assign_page_title(route) - |> assign_new(:vehicle_name, fn -> Route.vehicle_name(route) end) - |> assign_new(:direction_id, fn -> direction_id end) - |> assign_new(:stop, fn -> stop end) - |> assign_new(:upcoming_departures, fn -> AsyncResult.loading([]) end) - |> assign_new(:loaded_upcoming_trips, fn -> %{} end) - |> assign_new(:last_trip_time, fn -> AsyncResult.loading() end) - |> assign_new(:alerts, fn -> [] end) - |> assign_new(:service_groups, fn -> service_groups end) - |> assign_new(:loaded_trips, fn -> %{} end) - |> assign_new(:selected_service_name, fn -> Map.get(selected_service, :label, "") end) - |> assign_new(:service_today?, fn -> Enum.any?(all_services, &(!is_nil(&1.now_date))) end) - |> assign_new(:daily_schedule_date, fn assigns -> - # Current date if there's service today, next available service date otherwise... or current date if there's no service at all! - if assigns.service_today? do - service_date() - else - Map.get(selected_service, :next_date, service_date()) - end - end) - |> assign_new(:should_refresh?, fn -> true end) - |> assign_alerts() - |> assign_departures() - |> assign_upcoming_departures() - |> assign_last_trip_time()} + { + :ok, + socket + |> subscribe_to_alerts() + |> assign_new(:route, fn -> route end) + |> assign_page_title(route) + |> assign_new(:vehicle_name, fn -> Route.vehicle_name(route) end) + |> assign_new(:direction_id, fn -> direction_id end) + |> assign_new(:stop, fn -> stop end) + |> assign_new(:alerts, fn -> [] end) + |> assign_new(:service_groups, fn -> service_groups end) + |> assign_new(:loaded_trips, fn -> %{} end) + |> assign_new(:selected_service_name, fn -> Map.get(selected_service, :label, "") end) + |> assign_new(:service_today?, fn -> + Enum.any?(all_services, &(!is_nil(&1.now_date))) + end) + |> assign_new(:daily_schedule_date, fn assigns -> + # Current date if there's service today, next available service date otherwise... or current date if there's no service at all! + if assigns.service_today? do + service_date() + else + Map.get(selected_service, :next_date, service_date()) + end + end) + |> assign_alerts() + |> assign_departures() + } _ -> # Raising this error will render the 404 page @@ -97,37 +91,20 @@ defmodule DotcomWeb.ScheduleFinderLive do
<.alert_banner alerts={@alerts} />

{~t"Upcoming Departures"}

<%= if @service_today? do %> - -
- <.async_result :let={upcoming_departures} assign={@upcoming_departures}> - <:loading> -
- <.spinner aria_label={~t"Loading upcoming departures"} /> -
- - <:failed :let={_fail}> - <.callout>{~t(There was a problem loading upcoming departures)} - - <%= if upcoming_departures do %> - <.upcoming_departures_section - stop={@stop} - loaded_upcoming_trips={@loaded_upcoming_trips} - upcoming_departures={upcoming_departures} - route={@route} - last_trip_time={@last_trip_time} - /> - <% end %> - -
+ {live_render(@socket, UpcomingDeparturesLive, + id: "upcoming-#{@route.id}-#{@direction_id}-#{@stop.id}", + session: %{ + "route_id" => @route.id, + "direction_id" => @direction_id, + "stop_id" => @stop.id + } + )} <% else %> <.callout>{~t(No service today)} <% end %> @@ -203,16 +180,6 @@ defmodule DotcomWeb.ScheduleFinderLive do end end - def handle_event( - "open_upcoming_trip", - %{"stop-sequence" => stop_sequence, "trip-id" => trip_id}, - socket - ) do - {:noreply, - socket - |> assign_trip_details(trip_id, String.to_integer(stop_sequence))} - end - def handle_event("select_service", %{"selected_service" => selected_service_label}, socket) do send(self(), %{selected_service: selected_service_label}) @@ -221,13 +188,6 @@ defmodule DotcomWeb.ScheduleFinderLive do |> assign(:departures, AsyncResult.loading())} end - def handle_event("visibility_change", %{"state" => state}, socket) do - {:noreply, - socket - |> assign(:should_refresh?, state == "visible") - |> assign_upcoming_departures()} - end - def handle_event(_, _, socket), do: {:noreply, socket} @impl Phoenix.LiveView @@ -246,13 +206,6 @@ defmodule DotcomWeb.ScheduleFinderLive do end @impl LiveView - def handle_info(:refresh_upcoming_departures, socket) do - {:noreply, - socket - |> assign_upcoming_departures() - |> refresh_upcoming_trip_details()} - end - def handle_info(%{event: "alerts_updated"}, socket) do {:noreply, assign_alerts(socket)} end @@ -275,11 +228,6 @@ defmodule DotcomWeb.ScheduleFinderLive do end end - defp schedule_refresh_upcoming_departures(pid) do - # Refresh every second - Process.send_after(pid, :refresh_upcoming_departures, 5000) - end - defp validate_params(%{ "direction_id" => direction, "route_id" => route_id, @@ -301,64 +249,6 @@ 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) - end - - defp assign_trip_details(socket, trip_id, stop_sequence) do - now = @date_time.now() - stop_id = socket.assigns.stop.id - - trip_details = - UpcomingDepartures.trip_details(%{ - now: now, - stop_id: stop_id, - stop_sequence: stop_sequence, - trip_id: trip_id - }) - - socket - |> update(:loaded_upcoming_trips, fn loaded_upcoming_trips -> - Map.put(loaded_upcoming_trips, {trip_id, stop_sequence}, AsyncResult.ok(trip_details)) - end) - end - defp assign_alerts(%{assigns: %{stop: stop}} = socket) when not is_nil(stop) do route = socket.assigns.route @@ -375,34 +265,6 @@ defmodule DotcomWeb.ScheduleFinderLive do defp assign_alerts(socket), do: assign(socket, :alerts, []) - defp assign_last_trip_time(socket) do - route_id = socket.assigns.route.id - direction_id = socket.assigns.direction_id - date = DateTime.to_date(@date_time.now()) |> Date.to_string() - stop = socket.assigns.stop - - assign_async( - socket, - :last_trip_time, - fn -> - case get_departures(route_id, direction_id, stop.id, date) do - {_, %{departures: departures}} -> - last_trip_time = - departures - |> Enum.sort_by(fn departure -> DateTime.to_unix(departure.time) end) - |> Enum.at(-1, %{}) - |> Map.get(:time) - - {:ok, %{last_trip_time: last_trip_time}} - - _ -> - {:ok, %{last_trip_time: nil}} - end - end, - reset: true - ) - end - defp assign_departures(socket) do route_id = socket.assigns.route.id direction_id = socket.assigns.direction_id @@ -595,13 +457,13 @@ defmodule DotcomWeb.ScheduleFinderLive do
{gettext("First %{vehicle}", vehicle: String.downcase(@vehicle_name))}: - <.formatted_time time={@first} /> +
{gettext("Last %{vehicle}", vehicle: String.downcase(@vehicle_name))}: - <.formatted_time time={@last} /> + {~t(the next morning)} @@ -618,50 +480,6 @@ defmodule DotcomWeb.ScheduleFinderLive do defp next_day?(_, _), do: false - defp formatted_time(assigns) do - ~H""" - - """ - end - - attr :route, Route, required: true - - slot :headsign, required: true - slot :additional_info - slot :time, required: true - - defp departure_heading(assigns) do - ~H""" -
-
-
- - - {render_slot(@headsign)} -
-
0} - class="flex items-center gap-2" - > - - -
- {render_slot(@additional_info)} -
-
-
- -
- {render_slot(@time)} -
-
- """ - end - attr :departures, :list, required: true attr :loaded_trips, :map, required: true @@ -680,7 +498,7 @@ defmodule DotcomWeb.ScheduleFinderLive do phx-value-trip={departure.trip_id} > <:heading> - <.departure_heading route={departure.route}> + <:headsign>
{departure.headsign} @@ -697,8 +515,8 @@ defmodule DotcomWeb.ScheduleFinderLive do {~t(Train)} {departure.trip_name} - <:time><.formatted_time time={departure.time} /> - + <:time> + <:content> <.async_result @@ -720,9 +538,12 @@ defmodule DotcomWeb.ScheduleFinderLive do class={if(index == 0, do: "font-bold")} stop_pin?={index == 0} > - <.stop_label stop_name={arrival.stop_name} platform_name={arrival.platform_name} /> + - <.formatted_time time={arrival.time} /> + @@ -744,639 +565,6 @@ defmodule DotcomWeb.ScheduleFinderLive do """ end - defp mbta_go_cta(%{route_type_atom: route_type_atom} = assigns) do - assigns = - assigns - |> assign( - :route_type_text, - route_type_atom - |> mode_name() - |> String.downcase() - ) - - ~H""" - - """ - end - - defp upcoming_departures_section( - %{upcoming_departures: {:before_service, upcoming_departure}} = - assigns - ) do - assigns = assigns |> assign(:upcoming_departure, upcoming_departure) - - ~H""" -
- <.upcoming_departure_heading upcoming_departure={@upcoming_departure} /> -
- <.attached_callout> - {~t"Predicted departure times aren’t available yet, but they’ll appear here before the scheduled first trip."} - - """ - end - - defp upcoming_departures_section(%{upcoming_departures: :service_ended} = assigns) do - ~H""" - <.callout>{~t"Service ended"} - """ - end - - defp upcoming_departures_section(%{upcoming_departures: :no_service} = assigns) do - ~H""" - <.callout>{~t"No service today"} - """ - end - - defp upcoming_departures_section(%{upcoming_departures: :no_realtime} = assigns) do - ~H""" - <.callout>{~t"There are currently no realtime departures available."} - """ - end - - defp upcoming_departures_section( - %{upcoming_departures: {:no_realtime, upcoming_departures}} = assigns - ) do - assigns = assign(assigns, :upcoming_departures, upcoming_departures) - - ~H""" - <.attached_callout> - {~t"There are currently no realtime departures available. Scheduled departures are shown below."} - - <.upcoming_departures_section - stop={@stop} - loaded_upcoming_trips={@loaded_upcoming_trips} - upcoming_departures={@upcoming_departures} - route={@route} - last_trip_time={@last_trip_time} - no_realtime - /> - """ - end - - defp upcoming_departures_section(assigns) do - ~H""" - <.mbta_go_cta - :if={!Map.has_key?(assigns, :no_realtime)} - route_type_atom={Route.type_atom(@route)} - /> - <.upcoming_departures_table - stop_id={@stop.id} - upcoming_departures={@upcoming_departures |> Enum.take(5)} - loaded_upcoming_trips={@loaded_upcoming_trips} - /> - <.remaining_service - loaded_upcoming_trips={@loaded_upcoming_trips} - remaining_departures={@upcoming_departures |> Enum.drop(5)} - route={@route} - route_type={@route.type} - stop_id={@stop.id} - last_trip_time={@last_trip_time} - /> - """ - end - - attr :loaded_upcoming_trips, AsyncResult - attr :stop_id, :string - attr :upcoming_departures, :list - - defp upcoming_departures_table(assigns) do - ~H""" -
- <.unstyled_accordion - :for={upcoming_departure <- @upcoming_departures} - phx-click="open_upcoming_trip" - phx-value-trip-id={upcoming_departure.trip_id} - phx-value-stop-sequence={upcoming_departure.stop_sequence} - id={"upcoming-departure-#{upcoming_departure.trip_id}-#{upcoming_departure.stop_sequence}"} - summary_class="flex items-center border-gray-lightest py-3 px-2 gap-2 group-open:bg-gray-lightest hover:bg-brand-primary-lightest group-open:hover:bg-brand-primary-lightest" - > - <:heading> - <.upcoming_departure_heading upcoming_departure={upcoming_departure} /> - - <:content> - <.trip_details_wrapper - route={upcoming_departure.route} - trip_details={ - Map.get( - @loaded_upcoming_trips, - {upcoming_departure.trip_id, upcoming_departure.stop_sequence}, - AsyncResult.loading() - ) - } - /> - - -
- """ - end - - defp upcoming_departure_heading(assigns) do - ~H""" - <.departure_heading route={@upcoming_departure.route}> - <:headsign> -
- {@upcoming_departure.headsign} - <.badge :if={@upcoming_departure.last_trip?} class="bg-charcoal-80 text-nowrap text-sm"> - {~t"Last"} - -
- - - <:additional_info :if={@upcoming_departure.trip_name}> - {@upcoming_departure.trip_name} - - {@upcoming_departure.platform_name} - - - <:additional_info :if={ - @upcoming_departure.vehicle_name && - @upcoming_departure.route.type == 4 - }> - {@upcoming_departure.vehicle_name} - - - <:time> -
-
- <.prediction_time_display arrival_status={@upcoming_departure.arrival_status} /> - <.vehicle_crowding crowding={@upcoming_departure.crowding} /> -
- <.prediction_substatus_display arrival_substatus={@upcoming_departure.arrival_substatus} /> -
- - - """ - end - - defp trip_details_wrapper(assigns) do - ~H""" - <.async_result :let={trip_details} assign={@trip_details}> - <:loading> -
- <.spinner aria_label={~t"Loading trip details"} /> -
- - <:failed> - <.callout>{~t(There was a problem loading trip details)} - - - <.trip_details trip_details={trip_details} route={@route} /> - - """ - end - - defp trip_details(assigns) do - ~H""" - <.lined_list> - <.lined_list_item - route={@route} - variant="mode" - stop_pin?={@trip_details.stop == nil} - > -
- <.vehicle_label - vehicle_info={@trip_details.vehicle_info} - route={@route} - /> -
-
- <.formatted_time time={@trip_details.vehicle_info.departure_time} /> -
- -
0} - class="group/details" - > - - <.lined_list_item - background="charcoal-90" - class="group-open/details:hidden" - route={@route} - variant="squiggle" - > -
- - {~t"Show more stops"} - -
-
- <.icon name="chevron-down" class="h-3 w-3" /> -
- - <.lined_list_item - background="charcoal-90" - class="hidden group-open/details:flex" - route={@route} - variant="none" - > -
- - {~t"Show fewer stops"} - -
-
- <.icon name="chevron-down" class="h-3 w-3 rotate-180" /> -
- -
- <.other_stop - :for={other_stop <- @trip_details.stops_before} - background="charcoal-90" - class="border-t-xs border-gray-lightest bg-charcoal-90" - other_stop={other_stop} - route={@route} - /> -
- - <.other_stop - :if={@trip_details.stop} - highlight - other_stop={@trip_details.stop} - route={@route} - /> - <.other_stop - :for={other_stop <- @trip_details.stops_after} - other_stop={other_stop} - route={@route} - /> - - """ - end - - defp vehicle_label(assigns) do - ~H""" -
- - {Route.vehicle_name(@route)} - - {vehicle_status_message(@vehicle_info.status)} -
- <.stop_label stop_name={@vehicle_info.stop_name} platform_name={@vehicle_info.platform_name} /> - <.vehicle_crowding - crowding={crowding(@vehicle_info)} - show_label? - /> - """ - end - - defp vehicle_status_message(:scheduled_to_depart), do: ~t"Scheduled to depart" - defp vehicle_status_message(:waiting_to_depart), do: ~t"Waiting to depart" - defp vehicle_status_message(:in_transit), do: ~t"Next stop" - defp vehicle_status_message(:incoming), do: ~t"Approaching" - defp vehicle_status_message(:stopped), do: ~t"Now at" - defp vehicle_status_message(:location_unavailable), do: ~t"Location unavailable" - defp vehicle_status_message(:finishing_another_trip), do: ~t"Finishing another trip" - - defp crowding(%TripDetails.VehicleInfo{crowding: crowding}), do: crowding - defp crowding(_), do: nil - - attr :crowding, :atom - attr :show_label?, :boolean, default: false - - defp vehicle_crowding(%{show_label?: true} = assigns) do - ~H""" -
- <.crowding_icon class="size-4" crowding={@crowding} aria-hidden /> -
{crowding_message(@crowding)}
-
- """ - end - - defp vehicle_crowding(assigns) do - ~H""" - <.crowding_icon :if={@crowding} crowding={@crowding} aria-label={crowding_message(@crowding)} /> - """ - end - - attr :class, :string, default: "" - attr :crowding, :atom - attr :rest, :global - - defp crowding_icon(assigns) do - ~H""" - <.icon - type="icon-svg" - name="icon-crowding" - class={"c-icon__crowding c-icon__crowding--#{@crowding} #{@class}"} - {@rest} - /> - """ - end - - defp crowding_message(:not_crowded), do: ~t"Not crowded" - defp crowding_message(:some_crowding), do: ~t"Some crowding" - defp crowding_message(:crowded), do: ~t"Crowded" - defp crowding_message(_), do: "" - - attr :background, :string, default: "white", values: ["white", "charcoal-90"] - attr :class, :string, default: "" - attr :route, Route, required: true - attr :other_stop, :any, required: true - attr :highlight, :boolean, default: false - - defp other_stop(assigns) do - ~H""" - <.lined_list_item - background={@background} - route={@route} - class={@class} - stop_pin?={@highlight} - variant={if @other_stop.cancelled?, do: "cancelled", else: "default"} - > -
- <.stop_label stop_name={@other_stop.stop_name} platform_name={@other_stop.platform_name} /> -
-
-
- <.trip_stop_time cancelled?={@other_stop.cancelled?} time={@other_stop.time} /> -
-
- - """ - end - - attr :stop_name, :string, required: true - attr :platform_name, :string, default: nil - - defp stop_label(assigns) do - ~H""" -
-
{@stop_name}
-
- {@platform_name} -
-
- """ - end - - defp trip_stop_time(%{cancelled?: true} = assigns) do - ~H""" -
- <.icon aria-hidden type="icon-svg" name="icon-cancelled-default" class="size-3" /> {~t(Skipped)} -
- """ - end - - defp trip_stop_time(%{time: {:time, time}} = assigns) do - assigns = assigns |> assign(:time, time) - - ~H""" - <.formatted_time time={@time} /> - """ - end - - defp trip_stop_time(%{time: {:status, status}} = assigns) do - assigns = assigns |> assign(:status, status) - - ~H""" - {@status} - """ - end - - defp prediction_time_display(%{arrival_status: {:scheduled, time}} = assigns) do - assigns = assigns |> assign(:time, time) - - ~H""" - <.formatted_time time={@time} /> - """ - end - - defp prediction_time_display(%{arrival_status: {:first_scheduled, time}} = assigns) do - assigns = assigns |> assign(:time, time) - - ~H""" - - <.formatted_time time={@time} /> - - """ - end - - defp prediction_time_display(%{arrival_status: {status, time}} = assigns) - when status in [:cancelled, :skipped] do - assigns = assigns |> assign(:time, time) - - ~H""" - - <.formatted_time time={@time} /> - - """ - end - - defp prediction_time_display(%{arrival_status: {:status, status}} = assigns) do - assigns = assigns |> assign(:status, status) - - ~H""" - <.realtime_display> - {@status} - - """ - end - - defp prediction_time_display(%{arrival_status: {:time, time}} = assigns) do - assigns = assigns |> assign(:time, time) - - ~H""" - <.realtime_display> - <.formatted_time time={@time} /> - - """ - end - - defp prediction_time_display(assigns), - do: ~H""" - <.realtime_display> - {realtime_text(@arrival_status)} - - """ - - slot :inner_block - - defp realtime_display(assigns) do - ~H""" - - <.icon - type="icon-svg" - name="icon-realtime-tracking" - class="size-3" - /> - {render_slot(@inner_block)} - - """ - end - - defp realtime_text({:arrival_minutes, minutes}), - do: minutes_to_localized_minutes(minutes) - - defp realtime_text({:departure_minutes, minutes}), - do: minutes_to_localized_minutes(minutes) - - defp realtime_text(:arriving), do: ~t"Arriving" - defp realtime_text(:boarding), do: ~t"Boarding" - defp realtime_text(:now), do: ~t"Now" - - defp prediction_substatus_display(%{arrival_substatus: nil} = assigns), do: ~H"" - - defp prediction_substatus_display(%{arrival_substatus: {:delayed_from, time}} = assigns) do - assigns = - assigns - |> assign(:time, time) - |> assign(:readout_time, time |> format!(:hour_12_minutes)) - - ~H""" - - <.formatted_time time={@time} /> - - """ - end - - defp prediction_substatus_display(%{arrival_substatus: {:early_from, time}} = assigns) do - assigns = - assigns - |> assign(:time, time) - |> assign(:readout_time, time |> format!(:hour_12_minutes)) - - ~H""" - - <.formatted_time time={@time} /> - - """ - end - - defp prediction_substatus_display(%{arrival_substatus: {:status, status}} = assigns) do - assigns = assigns |> assign(:status, status) - - ~H""" - {@status} - """ - end - - defp prediction_substatus_display(%{arrival_substatus: :scheduled_sr_only} = assigns) do - ~H""" - {~t"Scheduled"} - """ - end - - defp prediction_substatus_display(assigns) do - ~H""" -
- <.substatus_icon arrival_substatus={@arrival_substatus} /> - {substatus_text(@arrival_substatus)} -
- """ - end - - defp substatus_text(:on_time), do: ~t"On Time" - defp substatus_text(:scheduled), do: ~t"Scheduled" - defp substatus_text(:cancelled), do: ~t"Cancelled" - defp substatus_text(:skipped), do: ~t"Stop Skipped" - defp substatus_text(text), do: text - - defp substatus_icon(%{arrival_substatus: substatus} = assigns) - when substatus in [:cancelled, :skipped], - do: ~H""" - <.icon aria-hidden type="icon-svg" name="icon-cancelled-default" class="size-3" /> - """ - - defp substatus_icon(assigns), do: ~H"" - - defp show_last_service?(%{ - remaining_departures: remaining_departures, - last_trip_time: %{result: last_trip_time} - }) - when remaining_departures != [] do - last_departure = remaining_departures |> Enum.at(-1) - - has_last_trip? = - !is_nil( - remaining_departures - |> Enum.find(nil, fn departure -> departure |> Map.get(:last_trip?, nil) end) - ) - - last_departure_time = last_departure.time - - if(is_nil(last_departure_time)) do - true - else - if (not is_nil(last_trip_time) and DateTime.after?(last_departure_time, last_trip_time)) or - DateTime.before?(last_trip_time, @date_time.now()) or - has_last_trip? do - false - else - true - end - end - end - - defp show_last_service?(_) do - true - end - - defp remaining_service(%{route_type: route_type} = assigns) when route_type in [0, 1] do - if show_last_service?(assigns) do - ~H""" - <.attached_callout :if={@last_trip_time.result}> - {gettext("Scheduled service continues until %{end_of_service}", - end_of_service: format!(@last_trip_time.result, :hour_12_minutes) - )} - - """ - else - ~H"" - end - end - - defp remaining_service(%{remaining_departures: []} = assigns), do: ~H"" - - defp remaining_service(assigns) do - assigns = - assigns - |> assign(:remaining_departures_count, Enum.count(assigns.remaining_departures)) - - ~H""" -
- - <.attached_callout> - - {ngettext( - "1 trip later today", - "%{count} trips later today", - @remaining_departures_count, - count: @remaining_departures_count - )} - - - {~t"Show"} - - - - - <.upcoming_departures_table - loaded_upcoming_trips={@loaded_upcoming_trips} - stop_id={@stop_id} - upcoming_departures={@remaining_departures} - /> -
- """ - end - defp no_service_message(service_groups, route, stop) do route_name = if(route.type == 3 && not Route.silver_line?(route), @@ -1393,14 +581,4 @@ defmodule DotcomWeb.ScheduleFinderLive do ) end end - - slot :inner_block - - defp attached_callout(assigns) do - ~H""" -
- {render_slot(@inner_block)} -
- """ - end end diff --git a/lib/dotcom_web/live/upcoming_departures_live.ex b/lib/dotcom_web/live/upcoming_departures_live.ex new file mode 100644 index 0000000000..58c1f67de7 --- /dev/null +++ b/lib/dotcom_web/live/upcoming_departures_live.ex @@ -0,0 +1,810 @@ +defmodule DotcomWeb.Live.UpcomingDeparturesLive do + @moduledoc """ + Displays information about upcoming departures for a given route, direction, and stop, updating in real-time. + """ + + use DotcomWeb, :live_view + + require Logger + + import Dotcom.Utils.Diff, only: [minutes_to_localized_minutes: 1] + import Dotcom.Utils.ServiceDateTime, only: [service_date: 0] + import Dotcom.Utils.Time, only: [format!: 2] + import DotcomWeb.RouteComponents, only: [lined_list: 1, lined_list_item: 1] + + alias DotcomWeb.Components.Departures + alias Phoenix.{LiveView, LiveView.AsyncResult} + + @date_time Application.compile_env!(:dotcom, :date_time_module) + @routes_repo Application.compile_env!(:dotcom, :repo_modules)[:routes] + @schedules_repo Application.compile_env!(:dotcom, :repo_modules)[:schedules] + @stops_repo Application.compile_env!(:dotcom, :repo_modules)[:stops] + + @impl LiveView + def mount(_not_mounted_at_router, session, socket) do + %{ + "route_id" => route_id, + "direction_id" => direction_id, + "stop_id" => stop_id + } = session + + {:ok, + socket + |> assign(:direction_id, direction_id) + |> assign_new(:route, fn -> @routes_repo.get(route_id) end) + |> assign_new(:stop, fn -> @stops_repo.get(stop_id) end) + |> assign_new(:should_refresh?, fn -> true end) + |> assign_new(:loaded_upcoming_trips, fn -> %{} end) + |> assign_new(:last_trip_time, fn assigns -> + if assigns.route.type in [0, 1] do + @schedules_repo.by_route_ids([route_id], + direction_id: direction_id, + stop_ids: [assigns.stop.id], + date: service_date() + ) + |> List.last(%{}) + |> Map.get(:time) + end + end) + |> assign(:departures, AsyncResult.loading()) + |> assign_upcoming_departures()} + end + + @impl LiveView + def render(assigns) do + ~H""" +
+ +
+ <.async_result :let={departures} assign={@departures}> + <:loading> +
+ <.spinner aria_label={~t"Loading upcoming departures"} /> +
+ + <:failed :let={_fail}> + <.callout>{~t(There was a problem loading upcoming departures)} + + <%= if departures do %> + <.upcoming_departures_section + stop={@stop} + loaded_upcoming_trips={@loaded_upcoming_trips} + upcoming_departures={departures} + route={@route} + last_trip_time={@last_trip_time} + /> + <% end %> + +
+
+ """ + end + + @impl LiveView + def handle_async(:get_upcoming_departures, {:ok, {:ok, departures}}, socket) do + {:noreply, assign(socket, :departures, AsyncResult.ok(departures))} + end + + def handle_async(:get_upcoming_departures, other, socket) do + _ = Logger.error("module=#{__MODULE__} error=#{inspect(other)}") + departures = socket.assigns.departures + {:noreply, assign(socket, :departures, AsyncResult.failed(departures, :other))} + end + + @impl LiveView + def handle_event( + "open_upcoming_trip", + %{"stop-sequence" => stop_sequence, "trip-id" => trip_id}, + socket + ) do + {:noreply, + socket + |> assign_trip_details(trip_id, String.to_integer(stop_sequence))} + end + + def handle_event("visibility_change", %{"state" => state}, socket) do + { + :noreply, + socket + |> assign(:should_refresh?, state == "visible") + |> assign_upcoming_departures() + } + end + + def handle_event(_, _, socket), do: {:noreply, socket} + + @impl LiveView + def handle_info(:refresh_upcoming_departures, socket) do + {:noreply, + socket + |> assign_upcoming_departures() + |> refresh_upcoming_trip_details()} + end + + defp assign_trip_details(socket, trip_id, stop_sequence) do + now = @date_time.now() + stop_id = socket.assigns.stop.id + + trip_details = + Dotcom.UpcomingDepartures.trip_details(%{ + now: now, + stop_id: stop_id, + stop_sequence: stop_sequence, + trip_id: trip_id + }) + + socket + |> update(:loaded_upcoming_trips, fn loaded_upcoming_trips -> + Map.put(loaded_upcoming_trips, {trip_id, stop_sequence}, AsyncResult.ok(trip_details)) + end) + end + + defp assign_upcoming_departures(socket) do + direction_id = socket.assigns.direction_id + route = socket.assigns.route + stop_id = socket.assigns.stop.id + + parent_pid = self() + should_refresh? = socket.assigns.should_refresh? + + socket + |> start_async(:get_upcoming_departures, fn -> + now = @date_time.now() + + _ = if should_refresh?, do: schedule_refresh_upcoming_departures(parent_pid) + + {:ok, + Dotcom.UpcomingDepartures.upcoming_departures(%{ + direction_id: direction_id, + now: now, + route: route, + stop_id: stop_id + })} + end) + end + + defp schedule_refresh_upcoming_departures(pid) do + # Refresh every second + Process.send_after(pid, :refresh_upcoming_departures, 5000) + 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) + end + + attr :upcoming_departures, :any, + required: true, + doc: "Output of Dotcom.UpcomingDepartures.upcoming_departures/1" + + attr :last_trip_time, DateTime, required: false + attr :loaded_upcoming_trips, :map, required: true + attr :no_realtime, :boolean + attr :route, Routes.Route, required: true + attr :stop, Stops.Stop, required: true + + def upcoming_departures_section( + %{upcoming_departures: {:before_service, upcoming_departure}} = + assigns + ) do + assigns = assigns |> assign(:upcoming_departure, upcoming_departure) + + ~H""" +
+ <.upcoming_departure_heading upcoming_departure={@upcoming_departure} /> +
+ <.attached_callout> + {~t"Predicted departure times aren’t available yet, but they’ll appear here before the scheduled first trip."} + + """ + end + + def upcoming_departures_section(%{upcoming_departures: :service_ended} = assigns) do + ~H""" + <.callout>{~t"Service ended"} + """ + end + + def upcoming_departures_section(%{upcoming_departures: :no_service} = assigns) do + ~H""" + <.callout>{~t"No service today"} + """ + end + + def upcoming_departures_section(%{upcoming_departures: :no_realtime} = assigns) do + ~H""" + <.callout>{~t"There are currently no realtime departures available."} + """ + end + + def upcoming_departures_section( + %{upcoming_departures: {:no_realtime, upcoming_departures}} = assigns + ) do + assigns = assign(assigns, :upcoming_departures, upcoming_departures) + + ~H""" + <.attached_callout> + {~t"There are currently no realtime departures available. Scheduled departures are shown below."} + + <.upcoming_departures_section + stop={@stop} + loaded_upcoming_trips={@loaded_upcoming_trips} + upcoming_departures={@upcoming_departures} + route={@route} + last_trip_time={@last_trip_time} + no_realtime + /> + """ + end + + def upcoming_departures_section(assigns) do + ~H""" + <.mbta_go_cta + :if={!Map.has_key?(assigns, :no_realtime)} + route_type_atom={Routes.Route.type_atom(@route)} + /> + <.upcoming_departures_table + stop_id={@stop.id} + upcoming_departures={@upcoming_departures |> Enum.take(5)} + loaded_upcoming_trips={@loaded_upcoming_trips} + /> + <.remaining_service + loaded_upcoming_trips={@loaded_upcoming_trips} + remaining_departures={@upcoming_departures |> Enum.drop(5)} + route={@route} + route_type={@route.type} + stop_id={@stop.id} + last_trip_time={@last_trip_time} + /> + """ + end + + attr :loaded_upcoming_trips, Phoenix.LiveView.AsyncResult + attr :stop_id, :string + attr :upcoming_departures, :list + + defp upcoming_departures_table(assigns) do + ~H""" +
+ <.unstyled_accordion + :for={upcoming_departure <- @upcoming_departures} + phx-click="open_upcoming_trip" + phx-value-trip-id={upcoming_departure.trip_id} + phx-value-stop-sequence={upcoming_departure.stop_sequence} + id={"upcoming-departure-#{upcoming_departure.trip_id}-#{upcoming_departure.stop_sequence}"} + summary_class="flex items-center border-gray-lightest py-3 px-2 gap-2 group-open:bg-gray-lightest hover:bg-brand-primary-lightest group-open:hover:bg-brand-primary-lightest" + > + <:heading> + <.upcoming_departure_heading upcoming_departure={upcoming_departure} /> + + <:content> + <.trip_details_wrapper + route={upcoming_departure.route} + trip_details={ + Map.get( + @loaded_upcoming_trips, + {upcoming_departure.trip_id, upcoming_departure.stop_sequence}, + AsyncResult.loading() + ) + } + /> + + +
+ """ + end + + attr :upcoming_departure, Dotcom.UpcomingDepartures.UpcomingDeparture, required: true + + defp upcoming_departure_heading(assigns) do + ~H""" + + <:headsign> +
+ {@upcoming_departure.headsign} + <.badge :if={@upcoming_departure.last_trip?} class="bg-charcoal-80 text-nowrap text-sm"> + {~t"Last"} + +
+ + + <:additional_info :if={@upcoming_departure.trip_name}> + {@upcoming_departure.trip_name} + + {@upcoming_departure.platform_name} + + + <:additional_info :if={ + @upcoming_departure.vehicle_name && + @upcoming_departure.route.type == 4 + }> + {@upcoming_departure.vehicle_name} + + + <:time> +
+
+ <.prediction_time_display arrival_status={@upcoming_departure.arrival_status} /> + <.vehicle_crowding crowding={@upcoming_departure.crowding} /> +
+ <.prediction_substatus_display arrival_substatus={@upcoming_departure.arrival_substatus} /> +
+ +
+ """ + end + + attr :trip_details, AsyncResult, required: true + attr :route, Routes.Route, required: true + + defp trip_details_wrapper(assigns) do + ~H""" + <.async_result :let={trip_details} assign={@trip_details}> + <:loading> +
+ <.spinner aria_label={~t"Loading trip details"} /> +
+ + <:failed> + <.callout>{~t(There was a problem loading trip details)} + + + <.trip_details trip_details={trip_details} route={@route} /> + + """ + end + + attr :trip_details, Dotcom.UpcomingDepartures.UpcomingTripDetails, required: true + attr :route, Routes.Route, required: true + + defp trip_details(assigns) do + ~H""" + <.lined_list> + <.lined_list_item + route={@route} + variant="mode" + stop_pin?={@trip_details.stop == nil} + > +
+ <.vehicle_label + vehicle_info={@trip_details.vehicle_info} + route={@route} + /> +
+
+ +
+ +
0} + class="group/details" + > + + <.lined_list_item + background="charcoal-90" + class="group-open/details:hidden" + route={@route} + variant="squiggle" + > +
+ + {~t"Show more stops"} + +
+
+ <.icon name="chevron-down" class="h-3 w-3" /> +
+ + <.lined_list_item + background="charcoal-90" + class="hidden group-open/details:flex" + route={@route} + variant="none" + > +
+ + {~t"Show fewer stops"} + +
+
+ <.icon name="chevron-down" class="h-3 w-3 rotate-180" /> +
+ +
+ <.other_stop + :for={other_stop <- @trip_details.stops_before} + background="charcoal-90" + class="border-t-xs border-gray-lightest bg-charcoal-90" + other_stop={other_stop} + route={@route} + /> +
+ + <.other_stop + :if={@trip_details.stop} + highlight + other_stop={@trip_details.stop} + route={@route} + /> + <.other_stop + :for={other_stop <- @trip_details.stops_after} + other_stop={other_stop} + route={@route} + /> + + """ + end + + attr :vehicle_info, Dotcom.ScheduleFinder.TripDetails.VehicleInfo, required: true + attr :route, Routes.Route, required: true + + defp vehicle_label(assigns) do + ~H""" +
+ + {Routes.Route.vehicle_name(@route)} + + {vehicle_status_message(@vehicle_info.status)} +
+ + <.vehicle_crowding + crowding={crowding(@vehicle_info)} + show_label? + /> + """ + end + + defp vehicle_status_message(:scheduled_to_depart), do: ~t"Scheduled to depart" + defp vehicle_status_message(:waiting_to_depart), do: ~t"Waiting to depart" + defp vehicle_status_message(:in_transit), do: ~t"Next stop" + defp vehicle_status_message(:incoming), do: ~t"Approaching" + defp vehicle_status_message(:stopped), do: ~t"Now at" + defp vehicle_status_message(:location_unavailable), do: ~t"Location unavailable" + defp vehicle_status_message(:finishing_another_trip), do: ~t"Finishing another trip" + + defp crowding(%Dotcom.ScheduleFinder.TripDetails.VehicleInfo{crowding: crowding}), do: crowding + defp crowding(_), do: nil + + attr :crowding, :atom + attr :show_label?, :boolean, default: false + + defp vehicle_crowding(%{show_label?: true} = assigns) do + ~H""" +
+ <.crowding_icon class="size-4" crowding={@crowding} aria-hidden /> +
{crowding_message(@crowding)}
+
+ """ + end + + defp vehicle_crowding(assigns) do + ~H""" + <.crowding_icon :if={@crowding} crowding={@crowding} aria-label={crowding_message(@crowding)} /> + """ + end + + attr :class, :string, default: "" + attr :crowding, :atom + attr :rest, :global + + defp crowding_icon(assigns) do + ~H""" + <.icon + type="icon-svg" + name="icon-crowding" + class={"c-icon__crowding c-icon__crowding--#{@crowding} #{@class}"} + {@rest} + /> + """ + end + + defp crowding_message(:not_crowded), do: ~t"Not crowded" + defp crowding_message(:some_crowding), do: ~t"Some crowding" + defp crowding_message(:crowded), do: ~t"Crowded" + defp crowding_message(_), do: "" + + attr :background, :string, default: "white", values: ["white", "charcoal-90"] + attr :class, :string, default: "" + attr :route, Routes.Route, required: true + attr :other_stop, :any, required: true + attr :highlight, :boolean, default: false + + defp other_stop(assigns) do + ~H""" + <.lined_list_item + background={@background} + route={@route} + class={@class} + stop_pin?={@highlight} + variant={if @other_stop.cancelled?, do: "cancelled", else: "default"} + > +
+ +
+
+
+ <.trip_stop_time cancelled?={@other_stop.cancelled?} time={@other_stop.time} /> +
+
+ + """ + end + + defp trip_stop_time(%{cancelled?: true} = assigns) do + ~H""" +
+ <.icon aria-hidden type="icon-svg" name="icon-cancelled-default" class="size-3" /> {~t(Skipped)} +
+ """ + end + + defp trip_stop_time(%{time: {:time, time}} = assigns) do + assigns = assigns |> assign(:time, time) + + ~H""" + + """ + end + + defp trip_stop_time(%{time: {:status, status}} = assigns) do + assigns = assigns |> assign(:status, status) + + ~H""" + {@status} + """ + end + + defp prediction_time_display(%{arrival_status: {:scheduled, time}} = assigns) do + assigns = assigns |> assign(:time, time) + + ~H""" + + """ + end + + defp prediction_time_display(%{arrival_status: {:first_scheduled, time}} = assigns) do + assigns = assigns |> assign(:time, time) + + ~H""" + + + + """ + end + + defp prediction_time_display(%{arrival_status: {status, time}} = assigns) + when status in [:cancelled, :skipped] do + assigns = assigns |> assign(:time, time) + + ~H""" + + + + """ + end + + defp prediction_time_display(%{arrival_status: {:status, status}} = assigns) do + assigns = assigns |> assign(:status, status) + + ~H""" + <.realtime_display> + {@status} + + """ + end + + defp prediction_time_display(%{arrival_status: {:time, time}} = assigns) do + assigns = assigns |> assign(:time, time) + + ~H""" + <.realtime_display> + + + """ + end + + defp prediction_time_display(assigns), + do: ~H""" + <.realtime_display> + {realtime_text(@arrival_status)} + + """ + + slot :inner_block + + defp realtime_display(assigns) do + ~H""" + + <.icon + type="icon-svg" + name="icon-realtime-tracking" + class="size-3" + /> + {render_slot(@inner_block)} + + """ + end + + defp realtime_text({:arrival_minutes, minutes}), + do: minutes_to_localized_minutes(minutes) + + defp realtime_text({:departure_minutes, minutes}), + do: minutes_to_localized_minutes(minutes) + + defp realtime_text(:arriving), do: ~t"Arriving" + defp realtime_text(:boarding), do: ~t"Boarding" + defp realtime_text(:now), do: ~t"Now" + + defp prediction_substatus_display(%{arrival_substatus: nil} = assigns), do: ~H"" + + defp prediction_substatus_display(%{arrival_substatus: {:delayed_from, time}} = assigns) do + assigns = + assigns + |> assign(:time, time) + |> assign(:readout_time, time |> format!(:hour_12_minutes)) + + ~H""" + + + + """ + end + + defp prediction_substatus_display(%{arrival_substatus: {:early_from, time}} = assigns) do + assigns = + assigns + |> assign(:time, time) + |> assign(:readout_time, time |> format!(:hour_12_minutes)) + + ~H""" + + + + """ + end + + defp prediction_substatus_display(%{arrival_substatus: {:status, status}} = assigns) do + assigns = assigns |> assign(:status, status) + + ~H""" + {@status} + """ + end + + defp prediction_substatus_display(%{arrival_substatus: :scheduled_sr_only} = assigns) do + ~H""" + {~t"Scheduled"} + """ + end + + defp prediction_substatus_display(assigns) do + ~H""" +
+ <.substatus_icon arrival_substatus={@arrival_substatus} /> + {substatus_text(@arrival_substatus)} +
+ """ + end + + defp substatus_text(:on_time), do: ~t"On Time" + defp substatus_text(:scheduled), do: ~t"Scheduled" + defp substatus_text(:cancelled), do: ~t"Cancelled" + defp substatus_text(:skipped), do: ~t"Stop Skipped" + defp substatus_text(text), do: text + + defp substatus_icon(%{arrival_substatus: substatus} = assigns) + when substatus in [:cancelled, :skipped], + do: ~H""" + <.icon aria-hidden type="icon-svg" name="icon-cancelled-default" class="size-3" /> + """ + + defp substatus_icon(assigns), do: ~H"" + + defp show_last_service?(%{ + remaining_departures: remaining_departures, + last_trip_time: last_trip_time + }) + when remaining_departures != [] do + last_departure = remaining_departures |> Enum.at(-1) + + has_last_trip? = + !is_nil( + remaining_departures + |> Enum.find(nil, fn departure -> departure |> Map.get(:last_trip?, nil) end) + ) + + last_departure_time = last_departure.time + + if is_nil(last_departure_time) do + true + else + if (not is_nil(last_trip_time) and DateTime.after?(last_departure_time, last_trip_time)) or + DateTime.before?(last_trip_time, @date_time.now()) or + has_last_trip? do + false + else + true + end + end + end + + defp show_last_service?(_) do + true + end + + defp remaining_service(%{route_type: route_type} = assigns) when route_type in [0, 1] do + if show_last_service?(assigns) do + ~H""" + <.attached_callout :if={@last_trip_time}> + {gettext("Scheduled service continues until %{end_of_service}", + end_of_service: format!(@last_trip_time, :hour_12_minutes) + )} + + """ + else + ~H"" + end + end + + defp remaining_service(%{remaining_departures: []} = assigns), do: ~H"" + + defp remaining_service(assigns) do + assigns = + assigns + |> assign(:remaining_departures_count, Enum.count(assigns.remaining_departures)) + + ~H""" +
+ + <.attached_callout> + + {ngettext( + "1 trip later today", + "%{count} trips later today", + @remaining_departures_count, + count: @remaining_departures_count + )} + + + {~t"Show"} + + + + + <.upcoming_departures_table + loaded_upcoming_trips={@loaded_upcoming_trips} + stop_id={@stop_id} + upcoming_departures={@remaining_departures} + /> +
+ """ + end + + slot :inner_block + + defp attached_callout(assigns) do + ~H""" +
+ {render_slot(@inner_block)} +
+ """ + end +end diff --git a/test/dotcom_web/live/schedule_finder_live_test.exs b/test/dotcom_web/live/schedule_finder_live_test.exs index 2b50d126ac..31b0f7d9ce 100644 --- a/test/dotcom_web/live/schedule_finder_live_test.exs +++ b/test/dotcom_web/live/schedule_finder_live_test.exs @@ -210,7 +210,7 @@ defmodule DotcomWeb.ScheduleFinderLiveTest do describe "Daily Departures" do test "indicates no service", %{conn: conn} do expect(Services.Repo.Mock, :by_route_id, 2, fn _ -> [] end) - expect(Dotcom.ScheduleFinder.Mock, :daily_departures, 2, fn _, _, _, _ -> {:ok, []} end) + expect(Dotcom.ScheduleFinder.Mock, :daily_departures, fn _, _, _, _ -> {:ok, []} end) assert {:ok, view, _html} = visit_with_valid_params(conn) no_service = @@ -228,7 +228,7 @@ defmodule DotcomWeb.ScheduleFinderLiveTest do active_services = Factories.Services.Service.build_list(15, :service) expect(Services.Repo.Mock, :by_route_id, 2, fn _ -> active_services end) - expect(Dotcom.ScheduleFinder.Mock, :daily_departures, 2, fn _, _, _, _ -> {:ok, []} end) + expect(Dotcom.ScheduleFinder.Mock, :daily_departures, fn _, _, _, _ -> {:ok, []} end) assert {:ok, view, _html} = visit_with_valid_params(conn) @@ -246,7 +246,7 @@ defmodule DotcomWeb.ScheduleFinderLiveTest do test "handles loading errors & shows custom message", %{conn: conn} do actual_error = "nonsense only a computer will understand" - expect(Dotcom.ScheduleFinder.Mock, :daily_departures, 2, fn _, _, _, _ -> + expect(Dotcom.ScheduleFinder.Mock, :daily_departures, fn _, _, _, _ -> {:error, actual_error} end) @@ -271,7 +271,7 @@ defmodule DotcomWeb.ScheduleFinderLiveTest do |> Faker.random_between(20) |> Factories.ScheduleFinder.build_list(:daily_departure) - expect(Dotcom.ScheduleFinder.Mock, :daily_departures, 2, fn _, _, _, _ -> + expect(Dotcom.ScheduleFinder.Mock, :daily_departures, fn _, _, _, _ -> {:ok, departures} end) @@ -324,7 +324,7 @@ defmodule DotcomWeb.ScheduleFinderLiveTest do end) Dotcom.ScheduleFinder.Mock - |> expect(:daily_departures, 2, fn _, _, _, _ -> {:ok, departures} end) + |> expect(:daily_departures, fn _, _, _, _ -> {:ok, departures} end) |> expect(:subway_groups, 1, fn ^departures, _, _ -> subway_groups end) assert {:ok, view, html} = visit_with_valid_params(conn, [0, 1]) diff --git a/test/dotcom_web/live/upcoming_departures_live_test.exs b/test/dotcom_web/live/upcoming_departures_live_test.exs new file mode 100644 index 0000000000..6e811575ab --- /dev/null +++ b/test/dotcom_web/live/upcoming_departures_live_test.exs @@ -0,0 +1,244 @@ +defmodule DotcomWeb.Live.UpcomingDeparturesLiveTest do + use DotcomWeb.ConnCase, async: true + + import Mox + import Phoenix.LiveViewTest + + alias DotcomWeb.Live.UpcomingDeparturesLive + alias Test.Support.{Factories, FactoryHelpers, PredictedScheduleHelper} + + @date_time_module Application.compile_env!(:dotcom, :date_time_module) + @moduletag capture_log: false + + setup :verify_on_exit! + + setup _ do + stub(Routes.Repo.Mock, :get, fn id -> + Factories.Routes.Route.build(:route, %{id: id}) + end) + + stub(Stops.Repo.Mock, :get, fn id -> + Factories.Stops.Stop.build(:stop, %{id: id}) + end) + + stub(Vehicles.Repo.Mock, :get, fn _ -> nil end) + + :ok + end + + test "loads, fetching route, stop info", %{conn: conn} do + stub_with(Dotcom.Utils.DateTime.Mock, Dotcom.Utils.DateTime) + route_id = FactoryHelpers.build(:id) + + route = + Factories.Routes.Route.build(:route, %{id: route_id, type: Faker.Util.pick([2, 3, 4])}) + + stop_id = FactoryHelpers.build(:id) + direction_id = FactoryHelpers.build(:direction_id) + + expect(Routes.Repo.Mock, :get, 2, fn ^route_id -> route end) + + expect(Stops.Repo.Mock, :get, 2, fn ^stop_id -> + Factories.Stops.Stop.build(:stop, %{id: stop_id}) + end) + + {:ok, _, html} = + live_isolated(conn, UpcomingDeparturesLive, + session: %{ + "route_id" => route_id, + "direction_id" => direction_id, + "stop_id" => stop_id + } + ) + + assert html =~ "Loading upcoming departures" + end + + test "fetches schedules info on load for subway", %{conn: conn} do + stub_with(Dotcom.Utils.DateTime.Mock, Dotcom.Utils.DateTime) + route_id = FactoryHelpers.build(:id) + route = Factories.Routes.Route.build(:route, %{id: route_id, type: Faker.Util.pick([0, 1])}) + stop_id = FactoryHelpers.build(:id) + direction_id = FactoryHelpers.build(:direction_id) + + expect(Routes.Repo.Mock, :get, 2, fn ^route_id -> route end) + + expect(Schedules.Repo.Mock, :by_route_ids, 2, fn routes, opts -> + assert routes == [route_id] + assert Keyword.get(opts, :direction_id) == direction_id + assert Keyword.get(opts, :stop_ids) == [stop_id] + assert Keyword.get(opts, :date) == Dotcom.Utils.ServiceDateTime.service_date() + + [] + end) + + {:ok, _, html} = + live_isolated(conn, UpcomingDeparturesLive, + session: %{ + "route_id" => route_id, + "direction_id" => direction_id, + "stop_id" => stop_id + } + ) + + assert html =~ "Loading upcoming departures" + end + + test "requests predictions info", %{conn: conn} do + stub_with(Dotcom.Utils.DateTime.Mock, Dotcom.Utils.DateTime) + stub(Schedules.Repo.Mock, :by_route_ids, fn _, _ -> [] end) + + expect(Predictions.Repo.Mock, :all, fn _ -> [] end) + + {:ok, view, _} = + live_isolated(conn, UpcomingDeparturesLive, + session: %{ + "route_id" => FactoryHelpers.build(:id), + "direction_id" => FactoryHelpers.build(:direction_id), + "stop_id" => FactoryHelpers.build(:id) + } + ) + + assert render_async(view) + end + + test "shows last scheduled trip for subway", %{conn: conn} do + stub_with(Dotcom.Utils.DateTime.Mock, Dotcom.Utils.DateTime) + route = Factories.Routes.Route.build(:subway_route) + stop = Factories.Stops.Stop.build(:stop) + route_id = route.id + stop_id = stop.id + direction_id = FactoryHelpers.build(:direction_id) + + stub(Routes.Repo.Mock, :get, fn _ -> route end) + stub(Stops.Repo.Mock, :get, fn _ -> stop end) + + schedules = Factories.Schedules.Schedule.build_list(3, :schedule) + + last_schedule = + Factories.Schedules.Schedule.build(:schedule, + time: @date_time_module.now() |> DateTime.shift(minute: 40) + ) + + expect(Schedules.Repo.Mock, :by_route_ids, 3, fn _, _ -> + schedules ++ [last_schedule] + end) + + expect(Predictions.Repo.Mock, :all, fn _ -> + Factories.Predictions.Prediction.build_list(15, :prediction, + route: route, + stop: stop, + time: @date_time_module.now() |> DateTime.shift(minute: 20) + ) + end) + + {:ok, view, _} = + live_isolated(conn, UpcomingDeparturesLive, + session: %{ + "route_id" => route_id, + "direction_id" => direction_id, + "stop_id" => stop_id + }, + on_error: :warn + ) + + {:ok, rendered_time} = Dotcom.Utils.Time.format(last_schedule.time, :hour_12_minutes) + assert render_async(view) =~ "Scheduled service continues until #{rendered_time}" + end + + test "shows service ended message", %{conn: conn} do + # Setup + stub_with(Dotcom.Utils.DateTime.Mock, Dotcom.Utils.DateTime) + + %{ + route: route, + scheduled_departure_times: [_, scheduled_time, _], + schedules: schedules, + stops: [_, stop, _] + } = + PredictedScheduleHelper.predicted_schedule_trip_data(route_factory_types: [:bus_route]) + + route_id = route.id + stop_id = stop.id + expect(Predictions.Repo.Mock, :all, fn _ -> [] end) + + stub(Dotcom.Utils.DateTime.Mock, :now, fn -> + Dotcom.Utils.ServiceDateTime.end_of_service_day(scheduled_time) + end) + + stub(Schedules.Repo.Mock, :by_route_ids, fn _, _ -> + schedules |> Enum.filter(&(&1.stop.id == stop_id)) + end) + + {:ok, view, _} = + live_isolated(conn, UpcomingDeparturesLive, + session: %{ + "route_id" => route_id, + "direction_id" => FactoryHelpers.build(:direction_id), + "stop_id" => stop_id + } + ) + + assert render_async(view) =~ "Service ended" + end + + describe "for non-subway routes" do + test "shows no realtime message", %{conn: conn} do + stub_with(Dotcom.Utils.DateTime.Mock, Dotcom.Utils.DateTime) + route_id = FactoryHelpers.build(:id) + + route = + Factories.Routes.Route.build(:route, %{id: route_id, type: Faker.Util.pick([2, 3, 4])}) + + stub(Routes.Repo.Mock, :get, fn _ -> route end) + + stub(Schedules.Repo.Mock, :by_route_ids, fn _, _ -> + Factories.Schedules.Schedule.build_list(10, :schedule, + time: @date_time_module.now() |> DateTime.shift(minute: 40) + ) + end) + + stub(Predictions.Repo.Mock, :all, fn _ -> [] end) + + {:ok, view, _} = + live_isolated(conn, UpcomingDeparturesLive, + session: %{ + "route_id" => route_id, + "direction_id" => FactoryHelpers.build(:direction_id), + "stop_id" => FactoryHelpers.build(:id) + } + ) + + assert render_async(view) =~ + "There are currently no realtime departures available. Scheduled departures are shown below." + end + end + + describe "for subway routes" do + test "shows no realtime message", %{conn: conn} do + stub_with(Dotcom.Utils.DateTime.Mock, Dotcom.Utils.DateTime) + route_id = FactoryHelpers.build(:id) + route = Factories.Routes.Route.build(:subway_route, %{id: route_id}) + stub(Routes.Repo.Mock, :get, fn _ -> route end) + + stub(Schedules.Repo.Mock, :by_route_ids, fn _, _ -> + Factories.Schedules.Schedule.build_list(10, :schedule, + time: @date_time_module.now() |> DateTime.shift(minute: 40) + ) + end) + + stub(Predictions.Repo.Mock, :all, fn _ -> [] end) + + {:ok, view, _} = + live_isolated(conn, UpcomingDeparturesLive, + session: %{ + "route_id" => route_id, + "direction_id" => FactoryHelpers.build(:direction_id), + "stop_id" => FactoryHelpers.build(:id) + } + ) + + render_async(view) =~ "There are currently no realtime departures available." + end + end +end