diff --git a/assets/src/components/departures/departure_time.tsx b/assets/src/components/departures/departure_time.tsx index e018b38bc..e11494aa7 100644 --- a/assets/src/components/departures/departure_time.tsx +++ b/assets/src/components/departures/departure_time.tsx @@ -47,10 +47,16 @@ const DepartureTimePart: ComponentType = ({ ); } - case "status": - return ( -
{time.pages[currentPage]}
- ); + case "status": { + const pages = time.pages; + if (pages.length === 1) { + return
{pages[0]}
; + } else { + return ( +
{pages[currentPage]}
+ ); + } + } case "overnight": return ; diff --git a/lib/screens/headways.ex b/lib/screens/headways.ex index 098abd20f..351364ba2 100644 --- a/lib/screens/headways.ex +++ b/lib/screens/headways.ex @@ -28,7 +28,7 @@ defmodule Screens.Headways do green_c: [70211..70220, 70223..70238], green_d: [70160..70183, 70186..70187], green_e: [70239..70258, 70260..70260], - green_trunk: [70151..70159, 70196..70208, 70501..70502, 71150..71151], + green_trunk: [70150..70159, 70196..70208, 70501..70502, 71150..71151], mattapan_trunk: [70261..70261, 70263..70276], orange_trunk: [70001..70036, 70278..70279], red_ashmont: [70085..70094], diff --git a/lib/screens/v2/candidate_generator/dup_new/departures.ex b/lib/screens/v2/candidate_generator/dup_new/departures.ex index 6914bfebb..1335c8bd2 100644 --- a/lib/screens/v2/candidate_generator/dup_new/departures.ex +++ b/lib/screens/v2/candidate_generator/dup_new/departures.ex @@ -7,11 +7,12 @@ defmodule Screens.V2.CandidateGenerator.DupNew.Departures do alias Screens.V2.Departure alias Screens.V2.RDS - alias Screens.V2.RDS.{Countdowns, FirstTrip, NoService, ServiceEnded} + alias Screens.V2.RDS.{Countdowns, FirstTrip, Headways, NoService, ServiceEnded} alias Screens.V2.WidgetInstance.Departures, as: DeparturesWidget alias Screens.V2.WidgetInstance.Departures.{ + HeadwaySection, NoDataSection, NormalSection, NoServiceSection, @@ -117,7 +118,9 @@ defmodule Screens.V2.CandidateGenerator.DupNew.Departures do num_departures_per_section = div(@max_departures_per_rotation, section_count) cond do - # all headways -> HeadwaySection() + headways?(rds_list) -> + create_headway_section(rds_list) + no_service?(rds_list) -> %NoServiceSection{ routes: @@ -160,6 +163,67 @@ defmodule Screens.V2.CandidateGenerator.DupNew.Departures do Enum.all?(rds_list, &is_struct(&1.state, ServiceEnded)) end + defp headways?(rds_list) do + Enum.all?(rds_list, &is_struct(&1.state, Headways)) + end + + @spec create_headway_section([RDS.t()]) :: HeadwaySection.t() + + # Bidirectional -> Use no headsign for the trains message + defp create_headway_section([ + %RDS{state: %{route_id: route_id, direction_name: direction_name_one, range: range}} + | [%RDS{state: %{route_id: route_id, direction_name: direction_name_two, range: range}}] + ]) + when direction_name_one != direction_name_two do + %HeadwaySection{ + route: route_id, + time_range: range, + headsign: nil + } + end + + # Use the headsign if the destinations have the same headsign, + # use the direction name if they have the same direction name, + # otherwise default to no headsign + defp create_headway_section( + [ + %RDS{ + headsign: first_headsign, + state: %Headways{ + route_id: first_route_id, + direction_name: first_direction_name, + range: first_range + } + } + | _ + ] = destinations + ) do + %HeadwaySection{ + route: first_route_id, + time_range: first_range, + headsign: + cond do + Enum.all?(destinations, fn %RDS{headsign: headsign} -> + headsign == first_headsign + end) -> + first_headsign + + Enum.all?( + destinations, + fn %RDS{ + state: %Headways{route_id: other_route_id, direction_name: other_direction_name} + } -> + other_direction_name == first_direction_name and other_route_id == first_route_id + end + ) -> + first_direction_name + + true -> + nil + end + } + end + defp build_instances( slot_names, _departure_sections, @@ -238,10 +302,19 @@ defmodule Screens.V2.CandidateGenerator.DupNew.Departures do @spec create_and_sort_rows([RDS.t()]) :: [NormalSection.row()] defp create_and_sort_rows(rds_list) do - {service_ended_rds, rds} = - Enum.split_with(rds_list, &match?(%RDS{state: %ServiceEnded{}}, &1)) + grouped_rds = + Enum.group_by(rds_list, fn + %RDS{state: %ServiceEnded{}} -> :service_ended + %RDS{state: %Headways{}} -> :headways + _ -> :other + end) + + service_ended_rds = Map.get(grouped_rds, :service_ended, []) + headway_rds = Map.get(grouped_rds, :headways, []) + rds = Map.get(grouped_rds, :other, []) sorted_departures_from_rds(rds) ++ + headways_from_rds(headway_rds) ++ sorted_departures_from_rds(service_ended_rds, true) end @@ -268,7 +341,7 @@ defmodule Screens.V2.CandidateGenerator.DupNew.Departures do end rds - |> Enum.flat_map(&departures_from_state(&1)) + |> Enum.flat_map(&departure_rows_from_state(&1)) |> Enum.sort_by( &departure_time(&1), { @@ -278,22 +351,48 @@ defmodule Screens.V2.CandidateGenerator.DupNew.Departures do ) end - @spec departures_from_state(RDS.t()) :: [Departure.t()] - defp departures_from_state(%RDS{state: %Countdowns{departures: departures}}), do: departures + @spec headways_from_rds([RDS.t()]) :: [NormalSection.headway_row()] + defp headways_from_rds(headway_rds) do + headway_rds + |> Enum.group_by(fn %RDS{line: line, state: %Headways{departure: departure}} -> + direction_id = Departure.direction_id(departure) + route = Departure.route(departure) + + direction_name = + route + |> Route.normalized_direction_names() + |> Enum.at(direction_id, nil) - defp departures_from_state(%RDS{ + {line, direction_name} + end) + |> Enum.flat_map(fn + {{_line, _direction_name}, [%RDS{state: %Headways{departure: departure, range: range}}]} -> + [{departure, range, nil, :headways}] + + # If there are multiple headways with the same line but different headsigns, + # combine them and use the direction name + {{_line, direction_name}, [%RDS{state: %Headways{departure: departure, range: range}} | _]} -> + [{departure, range, direction_name, :headways}] + end) + end + + @spec departure_rows_from_state(RDS.t()) :: + [Departure.t()] | [{Departure.t(), NormalSection.special_trip_type()}] + defp departure_rows_from_state(%RDS{state: %Countdowns{departures: departures}}), do: departures + + defp departure_rows_from_state(%RDS{ state: %FirstTrip{first_scheduled_departure: first_scheduled_departure} }) do [{first_scheduled_departure, :first_trip}] end - defp departures_from_state(%RDS{ + defp departure_rows_from_state(%RDS{ state: %ServiceEnded{last_scheduled_departure: last_scheduled_departure} }) do [{last_scheduled_departure, :last_trip}] end - defp departures_from_state(%RDS{state: %NoService{}}), do: [] + defp departure_rows_from_state(%RDS{state: %NoService{}}), do: [] @spec departure_time(Departure.t()) :: DateTime.t() defp departure_time(%Departure{} = departure), do: Departure.time(departure) @@ -311,4 +410,7 @@ defmodule Screens.V2.CandidateGenerator.DupNew.Departures do defp departure_direction_id({last_scheduled_departure, :last_trip}), do: Departure.direction_id(last_scheduled_departure) + + defp departure_direction_id({departure, _range, _headsign, :headways}), + do: Departure.direction_id(departure) end diff --git a/lib/screens/v2/rds.ex b/lib/screens/v2/rds.ex index 34f26dd45..f25d07cb8 100644 --- a/lib/screens/v2/rds.ex +++ b/lib/screens/v2/rds.ex @@ -14,7 +14,7 @@ defmodule Screens.V2.RDS do alias Screens.Alerts.Alert alias Screens.Alerts.InformedEntity alias Screens.Config.Cache - alias Screens.Headways + alias Screens.Headways, as: Headway alias Screens.Lines.Line alias Screens.RoutePatterns.RoutePattern alias Screens.Routes.Route @@ -105,9 +105,25 @@ defmodule Screens.V2.RDS do defstruct ~w[last_scheduled_departure]a end + defmodule Headways do + @moduledoc """ + State for if we're in an active period, but we have no predictions + and there are no alerts associated with the destination. + + Shows an every “X-Y” minutes message. + """ + @type t :: %__MODULE__{ + departure: Departure.t(), + route_id: Route.id(), + direction_name: String.t(), + range: Headway.range() + } + defstruct ~w[departure route_id direction_name range]a + end + @alert injected(Alert) @departure injected(Departure) - @headways injected(Headways) + @headways injected(Headway) @route_pattern injected(RoutePattern) @schedule injected(Schedule) @stop injected(Stop) @@ -212,7 +228,7 @@ defmodule Screens.V2.RDS do destinations = (tuples_from_departures(departures, now) ++ tuples_from_patterns(typical_patterns, child_stops)) - |> Enum.uniq() + |> Enum.uniq_by(fn {stop, line, headsign} -> {stop.id, line.id, headsign} end) # Destinations that are affected by current alerts at the present stop ID impacted_destinations = informed_destinations(destinations, alerts, typical_patterns) @@ -299,7 +315,7 @@ defmodule Screens.V2.RDS do [Departure.t()], Stop.id(), [Route.t()], - Headways.range() | nil, + Headway.range() | nil, boolean(), DateTime.t() ) :: rds_state() @@ -342,14 +358,25 @@ defmodule Screens.V2.RDS do :after_scheduled_end -> %ServiceEnded{last_scheduled_departure: last_scheduled_departure} - :active_period -> - %Countdowns{departures: departures_for_headsign} - :service_impacted -> %Countdowns{departures: departures_for_headsign} :no_service -> %NoService{routes: routes_for_section} + + :active_period -> + route = Departure.route(first_scheduled_departure) + direction_id = Departure.direction_id(first_scheduled_departure) + + %Headways{ + departure: first_scheduled_departure, + route_id: route.id, + direction_name: + route + |> Route.normalized_direction_names() + |> Enum.at(direction_id, nil), + range: headway_for_stop + } end end @@ -424,7 +451,7 @@ defmodule Screens.V2.RDS do @spec classify_service_state( Departure.t(), Departure.t(), - Headways.range() | nil, + Headway.range() | nil, boolean(), DateTime.t() ) :: service_state() diff --git a/lib/screens/v2/widget_instance/departures.ex b/lib/screens/v2/widget_instance/departures.ex index 4434647b4..891d7351c 100644 --- a/lib/screens/v2/widget_instance/departures.ex +++ b/lib/screens/v2/widget_instance/departures.ex @@ -3,6 +3,7 @@ defmodule Screens.V2.WidgetInstance.Departures do Provides real-time departure information, consisting of an ordered list of "sections". """ + alias Screens.Headways alias Screens.Predictions.Prediction alias Screens.Routes.Route alias Screens.Schedules.Schedule @@ -22,7 +23,13 @@ defmodule Screens.V2.WidgetInstance.Departures do @type special_trip_type :: :first_trip | :last_trip - @type row :: Departure.t() | {Departure.t(), special_trip_type()} | FreeTextLine.t() + @type headway_row :: {Departure.t(), Headways.range(), String.t() | nil, :headways} + + @type row :: + Departure.t() + | {Departure.t(), special_trip_type()} + | headway_row() + | FreeTextLine.t() @type t :: %__MODULE__{ header: Header.t(), @@ -477,6 +484,38 @@ defmodule Screens.V2.WidgetInstance.Departures do } end + defp serialize_departure_group( + [ + {departure, {lo, hi}, headsign, :headways} + | _ + ], + screen, + _now, + route_pill_serializer + ) do + departure_id = Departure.id(departure) + departures = [departure] + + %{ + id: hash_and_encode(departure_id), + type: :departure_row, + route: serialize_route(departures, route_pill_serializer), + headsign: + if headsign do + %{headsign: headsign} + else + serialize_headsign(departures, screen) + end, + times_with_crowding: [ + %{ + id: departure_id, + time: %{type: :status, pages: ["every #{lo}-#{hi}m"]} + } + ], + direction_id: serialize_direction_id(departures) + } + end + defp serialize_departure_group([%FreeTextLine{} = text], _screen, _now, _pill_serializer) do %{ type: :notice_row, diff --git a/test/screens/v2/candidate_generator/dup_new/departures_test.exs b/test/screens/v2/candidate_generator/dup_new/departures_test.exs index 181fb6670..64482c641 100644 --- a/test/screens/v2/candidate_generator/dup_new/departures_test.exs +++ b/test/screens/v2/candidate_generator/dup_new/departures_test.exs @@ -91,6 +91,23 @@ defmodule Screens.V2.CandidateGenerator.DupNew.DeparturesTest do } end + defp headways(stop_id, line_id, headsign, first_scheduled, route_id, direction_name, range) do + %RDS{ + stop: %Stop{id: stop_id}, + line: %Line{id: line_id}, + headsign: headsign, + state: %RDS.Headways{ + departure: %Departure{ + prediction: nil, + schedule: first_scheduled + }, + route_id: route_id, + direction_name: direction_name, + range: range + } + } + end + defp expected_departures_widget( config, expected_primary_sections, @@ -954,5 +971,223 @@ defmodule Screens.V2.CandidateGenerator.DupNew.DeparturesTest do assert actual_instances == expected_instances end + + test "creates HeadwaySections for destinations with headways" do + primary_departures = [ + %Section{query: %Query{params: %Query.Params{stop_ids: ["s1"]}}}, + %Section{query: %Query{params: %Query.Params{stop_ids: ["s2"]}}} + ] + + expected_route_id = "r1" + expected_time_range = {6, 10} + expected_headsign = "headsign" + + schedule = %Schedule{ + departure_time: ~U[2024-10-11 13:15:00Z], + route: %Route{id: "r3", line: %Line{id: "l3"}, type: :ferry}, + stop: %Stop{id: "s3"}, + trip: %Trip{headsign: "other3", pattern_headsign: "h3"} + } + + expected_primary_sections = [ + %Screens.V2.WidgetInstance.Departures.HeadwaySection{ + headsign: expected_headsign, + route: expected_route_id, + time_range: expected_time_range + } + ] + + config = + @config + |> put_primary_departures(primary_departures) + + expect(@rds, :get, fn _primary_departures, @now -> + [ + {:ok, + [ + headways( + "s1", + "l1", + "headsign", + schedule, + expected_route_id, + "Northbound", + expected_time_range + ), + headways( + "s1", + "l1", + "headsign", + schedule, + expected_route_id, + "Northbound", + expected_time_range + ) + ]} + ] + end) + + expect(@rds, :get, fn _primary_departures, @now -> [{:ok, []}] end) + + expected_instances = + expected_departures_widget(config, expected_primary_sections, expected_primary_sections) + + actual_instances = DupNew.Departures.instances(config, @now) + + assert actual_instances == expected_instances + end + + test "creates HeadwaySections for destinations with headways with different headsigns" do + primary_departures = [ + %Section{query: %Query{params: %Query.Params{stop_ids: ["s1"]}}}, + %Section{query: %Query{params: %Query.Params{stop_ids: ["s2"]}}} + ] + + expected_route_id = "r1" + expected_time_range = {6, 10} + expected_direction_name = "Northbound" + + schedule = %Schedule{ + departure_time: ~U[2024-10-11 13:15:00Z], + route: %Route{id: "r3", line: %Line{id: "l3"}, type: :ferry}, + stop: %Stop{id: "s3"}, + trip: %Trip{headsign: "other3", pattern_headsign: "h3"} + } + + expected_primary_sections = [ + %Screens.V2.WidgetInstance.Departures.HeadwaySection{ + headsign: expected_direction_name, + route: expected_route_id, + time_range: expected_time_range + } + ] + + config = + @config + |> put_primary_departures(primary_departures) + + expect(@rds, :get, fn _primary_departures, @now -> + [ + {:ok, + [ + headways( + "s1", + "l1", + "headsign", + schedule, + expected_route_id, + expected_direction_name, + expected_time_range + ), + headways( + "s1", + "l1", + "other_headsign", + schedule, + expected_route_id, + expected_direction_name, + expected_time_range + ) + ]} + ] + end) + + expect(@rds, :get, fn _primary_departures, @now -> [{:ok, []}] end) + + expected_instances = + expected_departures_widget(config, expected_primary_sections, expected_primary_sections) + + actual_instances = DupNew.Departures.instances(config, @now) + + assert actual_instances == expected_instances + end + + test "creates NormalSections for departures and headways" do + expected_route_id = "r1" + expected_time_range = {6, 10} + expected_direction_name = "Northbound" + + schedule = %Schedule{ + departure_time: ~U[2024-10-11 13:15:00Z], + route: %Route{ + id: "r3", + line: %Line{id: "l3"}, + type: :ferry, + direction_names: ["Northbound", "Southbound"] + }, + stop: %Stop{id: "s3"}, + trip: %Trip{headsign: "other3", pattern_headsign: "h3", direction_id: 0} + } + + primary_departures = [ + %Section{query: %Query{params: %Query.Params{stop_ids: ["s1"]}}}, + %Section{query: %Query{params: %Query.Params{stop_ids: ["s2"]}}} + ] + + expected_primary_departure = + %Departure{ + prediction: %Prediction{ + arrival_time: ~U[2024-10-11 12:27:00Z], + departure_time: ~U[2024-10-11 12:30:00Z], + route: %Route{id: "r1", line: %Line{id: "l1"}, type: :bus}, + stop: %Stop{id: "s1"}, + trip: %Trip{headsign: "other1", pattern_headsign: "h1"} + }, + schedule: nil + } + + expected_primary_sections = [ + %Screens.V2.WidgetInstance.Departures.NormalSection{ + header: %ScreensConfig.Departures.Header{ + arrow: nil, + read_as: nil, + subtitle: nil, + title: nil + }, + layout: %ScreensConfig.Departures.Layout{ + base: nil, + include_later: false, + max: nil, + min: 1 + }, + grouping_type: :time, + rows: [ + expected_primary_departure, + {%Departure{prediction: nil, schedule: schedule}, {6, 10}, nil, :headways} + ] + } + ] + + config = + @config + |> put_primary_departures(primary_departures) + + expect(@rds, :get, fn _primary_departures, @now -> + [ + {:ok, + [ + rds_countdown("s1", "l1", "other1", [expected_primary_departure]), + headways( + "s1", + "l1", + "other_headsign", + schedule, + expected_route_id, + expected_direction_name, + expected_time_range + ) + ]} + ] + end) + + expect(@rds, :get, fn _secondary_departures, @now -> [{:ok, []}] end) + + expected_instances = + expected_departures_widget(config, expected_primary_sections, expected_primary_sections) + + actual_instances = DupNew.Departures.instances(config, @now) + + assert actual_instances == expected_instances + end end end diff --git a/test/screens/v2/rds_test.exs b/test/screens/v2/rds_test.exs index 303a70288..d3a2cfc77 100644 --- a/test/screens/v2/rds_test.exs +++ b/test/screens/v2/rds_test.exs @@ -112,6 +112,20 @@ defmodule Screens.V2.RDSTest do } end + defp headways(stop_id, line_id, headsign, first_scheduled_departure, route_id, direction_name) do + %RDS{ + stop: %Stop{id: stop_id}, + line: %Line{id: line_id}, + headsign: headsign, + state: %RDS.Headways{ + departure: %Departure{prediction: nil, schedule: first_scheduled_departure}, + route_id: route_id, + direction_name: direction_name, + range: {5, 10} + } + } + end + defp station(id, child_stop_ids) do %Stop{id: id, child_stops: Enum.map(child_stop_ids, &stop/1)} end @@ -670,6 +684,117 @@ defmodule Screens.V2.RDSTest do ]} ] end + + test "creates headways for destinations" do + now = ~U[2024-10-11 11:44:00Z] + stop_ids = ~w[s0 s1] + + first_schedule_one = + %Schedule{ + departure_time: ~U[2024-10-11 10:45:00Z], + route: %Route{ + id: "r1", + line: %Line{id: "l1"}, + type: :bus, + direction_names: ["Northbound", "Southbound"] + }, + stop: %Stop{id: "sA"}, + trip: %Trip{headsign: "h1", pattern_headsign: "hA", direction_id: 0} + } + + last_schedule_one = + %Schedule{ + departure_time: ~U[2024-10-12 01:45:00Z], + route: %Route{ + id: "r1", + line: %Line{id: "l1"}, + type: :bus, + direction_names: ["Northbound", "Southbound"] + }, + stop: %Stop{id: "sA"}, + trip: %Trip{headsign: "h1", pattern_headsign: "hA", direction_id: 0} + } + + first_schedule_two = %Schedule{ + departure_time: ~U[2024-10-11 10:45:00Z], + route: %Route{ + id: "r2", + line: %Line{id: "l2"}, + type: :bus, + direction_names: ["Eastbound", "Westbound"] + }, + stop: %Stop{id: "sB"}, + trip: %Trip{headsign: "h2", pattern_headsign: "hB", direction_id: 1} + } + + last_schedule_two = + %Schedule{ + departure_time: ~U[2024-10-12 01:45:00Z], + route: %Route{ + id: "r2", + line: %Line{id: "l2"}, + type: :bus, + direction_names: ["Eastbound", "Westbound"] + }, + stop: %Stop{id: "sB"}, + trip: %Trip{headsign: "h2", pattern_headsign: "hB", direction_id: 1} + } + + first_schedule_three = + %Schedule{ + departure_time: ~U[2024-10-11 10:45:00Z], + route: %Route{ + id: "r2", + line: %Line{id: "l2"}, + type: :bus, + direction_names: ["Eastbound", "Westbound"] + }, + stop: %Stop{id: "sC"}, + trip: %Trip{headsign: "h3", pattern_headsign: "hC", direction_id: 0} + } + + last_schedule_three = + %Schedule{ + departure_time: ~U[2024-10-12 01:45:00Z], + route: %Route{ + id: "r2", + line: %Line{id: "l2"}, + type: :bus, + direction_names: ["Eastbound", "Westbound"] + }, + stop: %Stop{id: "sC"}, + trip: %Trip{headsign: "h3", pattern_headsign: "hC", direction_id: 0} + } + + all_schedules = [ + first_schedule_one, + last_schedule_one, + first_schedule_two, + last_schedule_two, + first_schedule_three, + last_schedule_three + ] + + departures = %Departures{ + sections: [ + %Section{query: %Query{params: %Query.Params{route_type: :bus, stop_ids: stop_ids}}} + ] + } + + stub(@headways, :get, fn _, _ -> {5, 10} end) + expect(@schedule, :fetch, fn %{stop_ids: ^stop_ids}, _now -> {:ok, all_schedules} end) + expect_standard_stations(stop_ids) + expect_standard_route_patterns(stop_ids) + + assert RDS.get(departures, now) == [ + {:ok, + [ + headways("sA", "l1", "hA", first_schedule_one, "r1", "Northbound"), + headways("sB", "l2", "hB", first_schedule_two, "r2", "Westbound"), + headways("sC", "l2", "hC", first_schedule_three, "r2", "Eastbound") + ]} + ] + end end describe "get/1 API failure" do