diff --git a/lib/dotcom/timetables.ex b/lib/dotcom/timetables.ex new file mode 100644 index 0000000000..82da3ac045 --- /dev/null +++ b/lib/dotcom/timetables.ex @@ -0,0 +1,426 @@ +defmodule Dotcom.Timetables do + @moduledoc """ + A module to construct a data structure that represents a timetable out of a list of schedules. + See `from_schedules/1` for more details. + """ + + use Memoize + + @doc """ + Given a list of structs of type `Schedules.Schedule`, returns a `Dotcom.Timetables.Timetable` + that can be nicely slotted into a table. The `rows` attribute is a list of lists; the top-level + lists are rows in the timetable, which means they correspond to visits to a particular stop. + The entries in each list correspond to the trips that visit that stop, so, for instance, the + second item in each list will all be visits from the same trip. + + iex> Dotcom.Timetables.from_schedules( + ...> [ + ...> %Schedules.Schedule{ + ...> departure_time: ~N[2026-05-27T12:05:00] |> Timex.Timezone.convert("America/New_York"), + ...> stop: %Stops.Stop{id: "first_stop"}, + ...> trip: %Schedules.Trip{id: "first_trip"} + ...> }, + ...> %Schedules.Schedule{ + ...> departure_time: ~N[2026-05-27T12:25:00] |> Timex.Timezone.convert("America/New_York"), + ...> stop: %Stops.Stop{id: "second_stop"}, + ...> trip: %Schedules.Trip{id: "first_trip"} + ...> }, + ...> %Schedules.Schedule{ + ...> departure_time: ~N[2026-05-27T13:05:00] |> Timex.Timezone.convert("America/New_York"), + ...> stop: %Stops.Stop{id: "first_stop"}, + ...> trip: %Schedules.Trip{id: "second_trip"} + ...> }, + ...> %Schedules.Schedule{ + ...> departure_time: ~N[2026-05-27T13:25:00] |> Timex.Timezone.convert("America/New_York"), + ...> stop: %Stops.Stop{id: "second_stop"}, + ...> trip: %Schedules.Trip{id: "second_trip"} + ...> } + ...> ] + ...> ) + %Dotcom.Timetables.Timetable{ + rows: [ + # First row is the visits to `first_stop`. It has two cells, one for each trip. + [ + %Dotcom.Timetables.Timetable.Cell{ + stop_id: "first_stop", + time: "12:05 PM", + trip: %Schedules.Trip{id: "first_trip"} + }, + %Dotcom.Timetables.Timetable.Cell{ + stop_id: "first_stop", + time: "1:05 PM", + trip: %Schedules.Trip{id: "second_trip"} + } + ], + # Second row is the visits to `second_stop`. It has cells for all the same trips + # as the first row. + [ + %Dotcom.Timetables.Timetable.Cell{ + stop_id: "second_stop", + time: "12:25 PM", + trip: %Schedules.Trip{id: "first_trip"} + }, + %Dotcom.Timetables.Timetable.Cell{ + stop_id: "second_stop", + time: "1:25 PM", + trip: %Schedules.Trip{id: "second_trip"} + } + ] + ] + } + + For trips that don't visit all of the stops, `from_schedules/1` inserts empty cells (with + `time = ""`) in order to make the rows and columns line up: + + iex> Dotcom.Timetables.from_schedules( + ...> [ + ...> # First trip visits all of the stops + ...> %Schedules.Schedule{ + ...> departure_time: ~N[2026-05-27T12:05:00] |> Timex.Timezone.convert("America/New_York"), + ...> stop: %Stops.Stop{id: "first_stop"}, + ...> trip: %Schedules.Trip{id: "first_trip"} + ...> }, + ...> %Schedules.Schedule{ + ...> departure_time: ~N[2026-05-27T12:25:00] |> Timex.Timezone.convert("America/New_York"), + ...> stop: %Stops.Stop{id: "second_stop"}, + ...> trip: %Schedules.Trip{id: "first_trip"} + ...> }, + ...> %Schedules.Schedule{ + ...> departure_time: ~N[2026-05-27T12:45:00] |> Timex.Timezone.convert("America/New_York"), + ...> stop: %Stops.Stop{id: "third_stop"}, + ...> trip: %Schedules.Trip{id: "first_trip"} + ...> }, + ...> # Second trip doesn't visit `second_stop`. + ...> %Schedules.Schedule{ + ...> departure_time: ~N[2026-05-27T13:05:00] |> Timex.Timezone.convert("America/New_York"), + ...> stop: %Stops.Stop{id: "first_stop"}, + ...> trip: %Schedules.Trip{id: "second_trip"} + ...> }, + ...> %Schedules.Schedule{ + ...> departure_time: ~N[2026-05-27T13:35:00] |> Timex.Timezone.convert("America/New_York"), + ...> stop: %Stops.Stop{id: "third_stop"}, + ...> trip: %Schedules.Trip{id: "second_trip"} + ...> } + ...> ] + ...> ) + %Dotcom.Timetables.Timetable{ + rows: [ + [ + %Dotcom.Timetables.Timetable.Cell{ + stop_id: "first_stop", + time: "12:05 PM", + trip: %Schedules.Trip{id: "first_trip"} + }, + %Dotcom.Timetables.Timetable.Cell{ + stop_id: "first_stop", + time: "1:05 PM", + trip: %Schedules.Trip{id: "second_trip"} + }, + ], + # Second cell in this row, where the missing `second_trip`/`second_stop` + # would be, is blank + [ + %Dotcom.Timetables.Timetable.Cell{ + stop_id: "second_stop", + time: "12:25 PM", + trip: %Schedules.Trip{id: "first_trip"} + }, + %Dotcom.Timetables.Timetable.Cell{ + stop_id: "second_stop", + time: "", + trip: %{id: "second_trip", name: nil} + }, + ], + [ + %Dotcom.Timetables.Timetable.Cell{ + stop_id: "third_stop", + time: "12:45 PM", + trip: %Schedules.Trip{id: "first_trip"} + }, + %Dotcom.Timetables.Timetable.Cell{ + stop_id: "third_stop", + time: "1:35 PM", + trip: %Schedules.Trip{id: "second_trip"} + }, + ] + ] + } + + When different trips visit the same stops in a different order, or when a single trip visits the same stop + multiple times, `from_schedules/1` add multiple rows for the same stop. + + iex> Dotcom.Timetables.from_schedules( + ...> [ + ...> %Schedules.Schedule{ + ...> departure_time: ~N[2026-05-27T12:05:00] |> Timex.Timezone.convert("America/New_York"), + ...> stop: %Stops.Stop{id: "first_and_last_stop"}, + ...> trip: %Schedules.Trip{id: "loop_trip"} + ...> }, + ...> %Schedules.Schedule{ + ...> departure_time: ~N[2026-05-27T12:25:00] |> Timex.Timezone.convert("America/New_York"), + ...> stop: %Stops.Stop{id: "second_stop"}, + ...> trip: %Schedules.Trip{id: "loop_trip"} + ...> }, + ...> %Schedules.Schedule{ + ...> departure_time: ~N[2026-05-27T12:45:00] |> Timex.Timezone.convert("America/New_York"), + ...> stop: %Stops.Stop{id: "first_and_last_stop"}, + ...> trip: %Schedules.Trip{id: "loop_trip"} + ...> } + ...> ] + ...> ) + %Dotcom.Timetables.Timetable{ + rows: [ + [ + %Dotcom.Timetables.Timetable.Cell{ + stop_id: "first_and_last_stop", + time: "12:05 PM", + trip: %Schedules.Trip{id: "loop_trip"} + } + ], + [ + %Dotcom.Timetables.Timetable.Cell{ + stop_id: "second_stop", + time: "12:25 PM", + trip: %Schedules.Trip{id: "loop_trip"} + } + ], + [ + %Dotcom.Timetables.Timetable.Cell{ + stop_id: "first_and_last_stop", + time: "12:45 PM", + trip: %Schedules.Trip{id: "loop_trip"} + } + ] + ] + } + + iex> Dotcom.Timetables.from_schedules( + ...> [ + ...> # First trip visits `first_or_last_stop` last. + ...> %Schedules.Schedule{ + ...> departure_time: ~N[2026-05-27T12:05:00] |> Timex.Timezone.convert("America/New_York"), + ...> stop: %Stops.Stop{id: "second_stop"}, + ...> trip: %Schedules.Trip{id: "first_trip"} + ...> }, + ...> %Schedules.Schedule{ + ...> departure_time: ~N[2026-05-27T12:25:00] |> Timex.Timezone.convert("America/New_York"), + ...> stop: %Stops.Stop{id: "third_stop"}, + ...> trip: %Schedules.Trip{id: "first_trip"} + ...> }, + ...> %Schedules.Schedule{ + ...> departure_time: ~N[2026-05-27T12:45:00] |> Timex.Timezone.convert("America/New_York"), + ...> stop: %Stops.Stop{id: "first_or_last_stop"}, + ...> trip: %Schedules.Trip{id: "first_trip"} + ...> }, + ...> %Schedules.Schedule{ + ...> departure_time: ~N[2026-05-27T13:05:00] |> Timex.Timezone.convert("America/New_York"), + ...> stop: %Stops.Stop{id: "first_or_last_stop"}, + ...> trip: %Schedules.Trip{id: "second_trip"} + ...> }, + ...> %Schedules.Schedule{ + ...> departure_time: ~N[2026-05-27T13:25:00] |> Timex.Timezone.convert("America/New_York"), + ...> stop: %Stops.Stop{id: "second_stop"}, + ...> trip: %Schedules.Trip{id: "second_trip"} + ...> }, + ...> %Schedules.Schedule{ + ...> departure_time: ~N[2026-05-27T13:45:00] |> Timex.Timezone.convert("America/New_York"), + ...> stop: %Stops.Stop{id: "third_stop"}, + ...> trip: %Schedules.Trip{id: "second_trip"} + ...> }, + ...> ] + ...> ) + %Dotcom.Timetables.Timetable{ + rows: [ + # First row has a blank cell because `first_trip` doesn't visit + # `first_or_last_stop` before `second_stop`. + [ + %Dotcom.Timetables.Timetable.Cell{ + stop_id: "first_or_last_stop", + time: "", + trip: %{id: "first_trip", name: nil} + }, + %Dotcom.Timetables.Timetable.Cell{ + stop_id: "first_or_last_stop", + time: "1:05 PM", + trip: %Schedules.Trip{id: "second_trip"} + } + ], + [ + %Dotcom.Timetables.Timetable.Cell{ + stop_id: "second_stop", + time: "12:05 PM", + trip: %Schedules.Trip{id: "first_trip"} + }, + %Dotcom.Timetables.Timetable.Cell{ + stop_id: "second_stop", + time: "1:25 PM", + trip: %Schedules.Trip{id: "second_trip"} + } + ], + [ + %Dotcom.Timetables.Timetable.Cell{ + stop_id: "third_stop", + time: "12:25 PM", + trip: %Schedules.Trip{id: "first_trip"} + }, + %Dotcom.Timetables.Timetable.Cell{ + stop_id: "third_stop", + time: "1:45 PM", + trip: %Schedules.Trip{id: "second_trip"} + } + ], + # Last row has a blank cell because `second_trip` doesn't visit + # `first_or_last_stop` after `third_stop`. + [ + %Dotcom.Timetables.Timetable.Cell{ + stop_id: "first_or_last_stop", + time: "12:45 PM", + trip: %Schedules.Trip{id: "first_trip"} + }, + %Dotcom.Timetables.Timetable.Cell{ + stop_id: "first_or_last_stop", + time: "", + trip: %{id: "second_trip", name: nil} + } + ] + ] + } + """ + + # Implementation notes (not necessary for the doc): + # + # The algorithm works in two steps. First, it generates a list of + # stops that go on the left of the timetable. It does this by taking + # a list of the visited stop ID's from each trip, and then using + # `combine_stop_lists/2` to combine them into a single list + # containing all of the stop ID's. + # + # Second, it uses `build_timetable_rows/2` to map each trip onto the + # combined stop list, inserting gaps where necessary. + @spec from_schedules([Schedules.Schedule.t()]) :: __MODULE__.Timetable.t() + def from_schedules(schedules) do + trips = + schedules + |> Enum.group_by(&%{id: &1.trip.id, name: &1.trip.name}) + |> Enum.map(fn {trip, schedules} -> + {trip, schedules |> Enum.sort_by(&time/1, DateTime)} + end) + |> Enum.sort_by( + fn {_trip, [first_schedule | _]} -> + time(first_schedule) + end, + DateTime + ) + + %__MODULE__.Timetable{ + rows: + trips + |> Enum.map(fn {_trip, schedules} -> schedules |> Enum.map(& &1.stop.id) end) + |> Enum.reduce([], &combine_stop_lists/2) + |> build_timetable_rows(trips) + } + end + + # Given a list of stops (the list that goes on the left on the + # timetable, constructed from `combine_stop_lists/2, and a list of + # trips (where a trip is a list of `Schedule`'s), this function + # builds the actual timetable rows, such that: + # + # - Each row corresponds to a particular stop, and is a list of + # visits to that stop, one per trip. + # + # - Each trip lines up with a column, so the n'th entry in each row + # comes from the same trip. + # + # - Empty cells are inserted for trips that don't visit a given stop. + # + # It works recursively - for each trip, we take the first stop if it + # matches the first stop of the stop list, or insert a blank cell if + # it doesn't. + defp build_timetable_rows([first_stop_id | stop_ids], trips) do + cells_at_stop = + trips + |> Enum.map(fn + {_trip, [%{stop: %{id: ^first_stop_id}} = first | _]} -> + first + + {trip, _} -> + %{ + departure_time: nil, + arrival_time: nil, + trip: trip + } + end) + + trips_after_stop = + trips + |> Enum.map(fn + {trip, [%{stop: %{id: ^first_stop_id}} | rest]} -> {trip, rest} + all -> all + end) + + first_row = + cells_at_stop + |> Enum.map( + &%__MODULE__.Timetable.Cell{ + time: &1 |> time() |> format!(), + trip: &1.trip, + stop_id: first_stop_id + } + ) + + [first_row | build_timetable_rows(stop_ids, trips_after_stop)] + end + + defp build_timetable_rows([], _), do: [] + + defp time(schedule) do + schedule.departure_time || schedule.arrival_time + end + + defp format!(nil), do: "" + defp format!(time), do: Dotcom.Utils.Time.format!(time, :hour_12_minutes) + + # This function combines two lists of stops into a single list that + # has all of the stops for both lists in the right order, possibly + # with additional entries in between. For instance, if the two input + # lists are ["foo", "bar", "baz"], and ["foo", "quux", "bar"], then + # the end result will be ["foo", "quux", "bar", "baz"]. + # + # It works using recursion - if the first stops in each list are the + # same, then that's the first stop of the resulting list - + # otherwise, try each of: a) take the first item off of the first + # list and keep going; b) take the first item off of the second list + # and keep going; and then choose whichever result yields a shorter + # list. + # + # It uses defmemop (def memo p[rivate]) to memoize intermediate + # results because otherwise this re-runs intermediate calculations + # for smaller lists over and over; the number of times it does this + # scales exponentially with the number of stops. + defmemop combine_stop_lists([], stop_list_2) do + stop_list_2 + end + + defmemop combine_stop_lists(stop_list_1, []) do + stop_list_1 + end + + defmemop combine_stop_lists( + [stop_id_1 | remaining_1], + [stop_id_2 | remaining_2] + ) + when stop_id_1 == stop_id_2 do + [stop_id_1 | combine_stop_lists(remaining_1, remaining_2)] + end + + defmemop combine_stop_lists( + [stop_id_1 | remaining_1] = stop_list_1, + [stop_id_2 | remaining_2] = stop_list_2 + ) do + result_1 = [stop_id_1 | combine_stop_lists(remaining_1, stop_list_2)] + result_2 = [stop_id_2 | combine_stop_lists(stop_list_1, remaining_2)] + + [result_1, result_2] |> Enum.min_by(&Enum.count/1) + end +end diff --git a/lib/dotcom/timetables/timetable.ex b/lib/dotcom/timetables/timetable.ex new file mode 100644 index 0000000000..9ddc869a13 --- /dev/null +++ b/lib/dotcom/timetables/timetable.ex @@ -0,0 +1,23 @@ +defmodule Dotcom.Timetables.Timetable do + @moduledoc """ + A struct representing timetables. See `Dotcom.Timetables` for more information. + """ + defstruct [:rows] + + @type t() :: %__MODULE__{ + rows: [[__MODULE__.Cell.t()]] + } + + defmodule Cell do + @moduledoc """ + A struct representing timetable cells. See `Dotcom.Timetables` for more information. + """ + defstruct [:time, :trip, :stop_id] + + @type t() :: %__MODULE__{ + time: String.t(), + trip: %{id: Schedules.Trip.id_t(), name: String.t()} | Schedules.Trip.t(), + stop_id: Stops.Stop.id_t() + } + end +end diff --git a/lib/dotcom/timetables/timetable.ex~ b/lib/dotcom/timetables/timetable.ex~ new file mode 100644 index 0000000000..f58c373bce --- /dev/null +++ b/lib/dotcom/timetables/timetable.ex~ @@ -0,0 +1,3 @@ +defmodule Dotcom.Timetables.Timetable do + defstruct :rows +end diff --git a/lib/dotcom_web/controllers/schedule/timetable_controller.ex b/lib/dotcom_web/controllers/schedule/timetable_controller.ex index 6bb50f0aaa..8549f74c2f 100644 --- a/lib/dotcom_web/controllers/schedule/timetable_controller.ex +++ b/lib/dotcom_web/controllers/schedule/timetable_controller.ex @@ -8,7 +8,7 @@ defmodule DotcomWeb.ScheduleController.TimetableController do import Dotcom.SystemStatus.CommuterRail, only: [commuter_rail_route_status: 1] - alias Dotcom.TimetableLoader + alias Dotcom.Timetables alias DotcomWeb.ScheduleView alias Plug.Conn alias RoutePatterns.RoutePattern @@ -17,7 +17,7 @@ defmodule DotcomWeb.ScheduleController.TimetableController do @route_patterns_repo Application.compile_env!(:dotcom, :repo_modules)[:route_patterns] @stops_repo Application.compile_env!(:dotcom, :repo_modules)[:stops] - @loop_ferries ["Boat-F6", "Boat-F7"] + @routes_repo Application.compile_env!(:dotcom, :repo_modules)[:routes] plug(DotcomWeb.Plugs.Route) @@ -231,32 +231,35 @@ defmodule DotcomWeb.ScheduleController.TimetableController do end def assign_trip_schedules( - %{assigns: %{route: route, blocking_alert: nil, date_in_rating?: true}} = conn + %{ + assigns: %{ + route: route, + blocking_alert: nil, + date_in_rating?: true + } + } = conn ) - when route.id in @loop_ferries do - case TimetableLoader.from_csv(route.id, conn.assigns.direction_id, conn.assigns.date) do - {:ok, timetable_schedules} -> - header_schedules = List.first(timetable_schedules, []) - - header_stops = - timetable_schedules - |> Enum.map(&List.first/1) - |> Enum.with_index(fn trip, index -> - {@stops_repo.get(trip.stop_id), index} - end) - - conn - |> assign(:use_pdf_schedules?, true) - |> assign(:timetable_schedules, timetable_schedules) - |> assign(:header_schedules, header_schedules) - |> assign(:header_stops, header_stops) + when route.type == 4 do + timetable_schedules = + conn + |> timetable_schedules() + |> Timetables.from_schedules() + |> then(& &1.rows) - {:error, _} -> - conn - |> assign(:suppress_timetable?, true) - |> assign(:timetable_schedules, []) - |> assign(:header_schedules, []) - end + header_schedules = List.first(timetable_schedules, []) + + header_stops = + timetable_schedules + |> Enum.map(&List.first/1) + |> Enum.with_index(fn trip, index -> + {@stops_repo.get(trip.stop_id), index} + end) + + conn + |> assign(:use_pdf_schedules?, true) + |> assign(:timetable_schedules, timetable_schedules) + |> assign(:header_schedules, header_schedules) + |> assign(:header_stops, header_stops) end def assign_trip_schedules( diff --git a/mix.exs b/mix.exs index 05c75fd52e..2e3dd97919 100644 --- a/mix.exs +++ b/mix.exs @@ -128,6 +128,7 @@ defmodule DotCom.Mixfile do # reverted from 0.4 {:mail, "0.3.1"}, {:mbta_metro, "1.1.2", runtime: false}, + {:memoize, "1.4.5"}, {:mock, "0.3.9", [only: :test]}, {:mox, "1.2.0", [only: [:dev, :test]]}, {:msgpack, "0.8.1"}, diff --git a/mix.lock b/mix.lock index 3bd22b2cd2..1517572bba 100644 --- a/mix.lock +++ b/mix.lock @@ -81,6 +81,7 @@ "makeup_html": {:hex, :makeup_html, "0.2.0", "9f810da8d43d625ccd3f7ea25997e588fa541d80e0a8c6b895157ad5c7e9ca13", [:mix], [{:makeup, "~> 1.2", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "0856f7beb9a6a642ab1307e06d990fe39f0ba58690d0b8e662aa2e027ba331b2"}, "mbta_metro": {:hex, :mbta_metro, "1.1.2", "c1388da300ca39ab01a147e6a634eaed4ad8180ea0d1c88687cb9b2df16622d4", [:mix], [{:cva, "~> 0.2", [hex: :cva, repo: "hexpm", optional: false]}, {:esbuild, "~> 0.10", [hex: :esbuild, repo: "hexpm", optional: false]}, {:floki, "~> 0.38", [hex: :floki, repo: "hexpm", optional: false]}, {:gettext, "~> 1.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:heroicons, "~> 0.5", [hex: :heroicons, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.8", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_storybook, "~> 0.9", [hex: :phoenix_storybook, repo: "hexpm", optional: false]}, {:tailwind, "~> 0.3", [hex: :tailwind, repo: "hexpm", optional: false]}], "hexpm", "a3885e8bcb6f8aefa85691536e13ce852376af39756d6e49cc823f3cc78daf29"}, "meck": {:hex, :meck, "0.9.2", "85ccbab053f1db86c7ca240e9fc718170ee5bda03810a6292b5306bf31bae5f5", [:rebar3], [], "hexpm", "81344f561357dc40a8344afa53767c32669153355b626ea9fcbc8da6b3045826"}, + "memoize": {:hex, :memoize, "1.4.5", "cd939ae2cc21772361a0944c7e2aa6eec6396820c1b8d22cc2725bcc7f4f978c", [:mix], [], "hexpm", "72cd8cb9871e83a8c7ac316ea88813a31f8e918ffb4880892e361c9e4f696b9a"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mimerl": {:hex, :mimerl, "1.4.0", "3882a5ca67fbbe7117ba8947f27643557adec38fa2307490c4c4207624cb213b", [:rebar3], [], "hexpm", "13af15f9f68c65884ecca3a3891d50a7b57d82152792f3e19d88650aa126b144"}, diff --git a/test/dotcom/timetables_test.exs b/test/dotcom/timetables_test.exs new file mode 100644 index 0000000000..c43b4d1696 --- /dev/null +++ b/test/dotcom/timetables_test.exs @@ -0,0 +1,446 @@ +defmodule Dotcom.TimetablesTest do + use ExUnit.Case, async: true + doctest Dotcom.Timetables + + import Mox + + alias Test.Support.FactoryHelpers + alias Dotcom.Utils.ServiceDateTime + alias Dotcom.Timetables + alias Test.Support.{Factories, Generators} + + # [ + # [ + # %{time: "6:15 AM", trip: %{id: "0615", name: ""}, stop_id: "Boat-Quincy"}, + # %{time: "7:30 AM", trip: %{id: "0730", name: ""}, stop_id: "Boat-Quincy"}, + # %{time: "8:45 AM", trip: %{id: "0845", name: ""}, stop_id: "Boat-Quincy"}, + # %{time: "10:00 AM", trip: %{id: "1000", name: ""}, stop_id: "Boat-Quincy"}, + # %{time: "11:15 AM", trip: %{id: "1115", name: ""}, stop_id: "Boat-Quincy"}, + # %{time: "", trip: %{id: "1350", name: ""}, stop_id: "Boat-Quincy"}, + # %{time: "2:40 PM", trip: %{id: "1440", name: ""}, stop_id: "Boat-Quincy"}, + # %{time: "3:55 PM", trip: %{id: "1555", name: ""}, stop_id: "Boat-Quincy"}, + # %{time: "5:10 PM", trip: %{id: "1710", name: ""}, stop_id: "Boat-Quincy"}, + # %{time: "6:25 PM", trip: %{id: "1825", name: ""}, stop_id: "Boat-Quincy"}, + # %{time: "7:40 PM", trip: %{id: "1940", name: ""}, stop_id: "Boat-Quincy"} + # ], + # [ + # %{time: "", trip: %{id: "0615", name: ""}, stop_id: "Boat-Logan"}, + # %{time: "", trip: %{id: "0730", name: ""}, stop_id: "Boat-Logan"}, + # %{time: "", trip: %{id: "0845", name: ""}, stop_id: "Boat-Logan"}, + # %{time: "", trip: %{id: "1000", name: ""}, stop_id: "Boat-Logan"}, + # %{time: "", trip: %{id: "1115", name: ""}, stop_id: "Boat-Logan"}, + # %{time: "", trip: %{id: "1350", name: ""}, stop_id: "Boat-Logan"}, + # %{time: "3:05 PM", trip: %{id: "1440", name: ""}, stop_id: "Boat-Logan"}, + # %{time: "4:20 PM", trip: %{id: "1555", name: ""}, stop_id: "Boat-Logan"}, + # %{time: "5:35 PM", trip: %{id: "1710", name: ""}, stop_id: "Boat-Logan"}, + # %{time: "6:50 PM", trip: %{id: "1825", name: ""}, stop_id: "Boat-Logan"}, + # %{time: "8:05 PM", trip: %{id: "1940", name: ""}, stop_id: "Boat-Logan"} + # ], + # [ + # %{time: "6:40 AM", trip: %{id: "0615", name: ""}, stop_id: "Boat-Fan"}, + # %{time: "7:55 AM", trip: %{id: "0730", name: ""}, stop_id: "Boat-Fan"}, + # %{time: "9:10 AM", trip: %{id: "0845", name: ""}, stop_id: "Boat-Fan"}, + # %{time: "10:25 AM", trip: %{id: "1000", name: ""}, stop_id: "Boat-Fan"}, + # %{time: "11:40 AM", trip: %{id: "1115", name: ""}, stop_id: "Boat-Fan"}, + # %{time: "1:50 PM", trip: %{id: "1350", name: ""}, stop_id: "Boat-Fan"}, + # %{time: "3:15 PM", trip: %{id: "1440", name: ""}, stop_id: "Boat-Fan"}, + # %{time: "4:30 PM", trip: %{id: "1555", name: ""}, stop_id: "Boat-Fan"}, + # %{time: "5:45 PM", trip: %{id: "1710", name: ""}, stop_id: "Boat-Fan"}, + # %{time: "7:00 PM", trip: %{id: "1825", name: ""}, stop_id: "Boat-Fan"}, + # %{time: "8:20 PM", trip: %{id: "1940", name: ""}, stop_id: "Boat-Fan"} + # ], + # [ + # %{time: "6:50 AM", trip: %{id: "0615", name: ""}, stop_id: "Boat-Aquarium"}, + # %{time: "8:05 AM", trip: %{id: "0730", name: ""}, stop_id: "Boat-Aquarium"}, + # %{time: "9:20 AM", trip: %{id: "0845", name: ""}, stop_id: "Boat-Aquarium"}, + # %{time: "10:35 AM", trip: %{id: "1000", name: ""}, stop_id: "Boat-Aquarium"}, + # %{time: "11:50 AM", trip: %{id: "1115", name: ""}, stop_id: "Boat-Aquarium"}, + # %{time: "2:00 PM", trip: %{id: "1350", name: ""}, stop_id: "Boat-Aquarium"}, + # %{time: "3:25 PM", trip: %{id: "1440", name: ""}, stop_id: "Boat-Aquarium"}, + # %{time: "4:40 PM", trip: %{id: "1555", name: ""}, stop_id: "Boat-Aquarium"}, + # %{time: "5:55 PM", trip: %{id: "1710", name: ""}, stop_id: "Boat-Aquarium"}, + # %{time: "7:10 PM", trip: %{id: "1825", name: ""}, stop_id: "Boat-Aquarium"}, + # %{time: "8:30 PM", trip: %{id: "1940", name: ""}, stop_id: "Boat-Aquarium"} + # ], + # [ + # %{time: "7:00 AM", trip: %{id: "0615", name: ""}, stop_id: "Boat-Logan"}, + # %{time: "8:15 AM", trip: %{id: "0730", name: ""}, stop_id: "Boat-Logan"}, + # %{time: "9:30 AM", trip: %{id: "0845", name: ""}, stop_id: "Boat-Logan"}, + # %{time: "10:45 AM", trip: %{id: "1000", name: ""}, stop_id: "Boat-Logan"}, + # %{time: "", trip: %{id: "1115", name: ""}, stop_id: "Boat-Logan"}, + # %{time: "2:10 PM", trip: %{id: "1350", name: ""}, stop_id: "Boat-Logan"}, + # %{time: "", trip: %{id: "1440", name: ""}, stop_id: "Boat-Logan"}, + # %{time: "", trip: %{id: "1555", name: ""}, stop_id: "Boat-Logan"}, + # %{time: "", trip: %{id: "1710", name: ""}, stop_id: "Boat-Logan"}, + # %{time: "", trip: %{id: "1825", name: ""}, stop_id: "Boat-Logan"}, + # %{time: "", trip: %{id: "1940", name: ""}, stop_id: "Boat-Logan"} + # ], + # [ + # %{time: "7:25 AM", trip: %{id: "0615", name: ""}, stop_id: "Boat-Quincy"}, + # %{time: "8:40 AM", trip: %{id: "0730", name: ""}, stop_id: "Boat-Quincy"}, + # %{time: "9:55 AM", trip: %{id: "0845", name: ""}, stop_id: "Boat-Quincy"}, + # %{time: "11:10 AM", trip: %{id: "1000", name: ""}, stop_id: "Boat-Quincy"}, + # %{time: "", trip: %{id: "1115", name: ""}, stop_id: "Boat-Quincy"}, + # %{time: "2:35 PM", trip: %{id: "1350", name: ""}, stop_id: "Boat-Quincy"}, + # %{time: "3:50 PM", trip: %{id: "1440", name: ""}, stop_id: "Boat-Quincy"}, + # %{time: "5:05 PM", trip: %{id: "1555", name: ""}, stop_id: "Boat-Quincy"}, + # %{time: "6:20 PM", trip: %{id: "1710", name: ""}, stop_id: "Boat-Quincy"}, + # %{time: "7:35 PM", trip: %{id: "1825", name: ""}, stop_id: "Boat-Quincy"}, + # %{time: "8:55 PM", trip: %{id: "1940", name: ""}, stop_id: "Boat-Quincy"} + # ] + # ] + + setup do + stub_with(Dotcom.Utils.DateTime.Mock, Dotcom.Utils.DateTime) + + :ok + end + + describe "from_schedules/1" do + test "returns an empty list of rows if there are no schedules" do + assert %Timetables.Timetable{rows: []} = Timetables.from_schedules([]) + end + + test "serializes a single schedule into a single-cell timetable" do + stop_1 = Factories.Stops.Stop.build(:stop) + + [time_1] = generate_times(1) + trip = Factories.Schedules.Trip.build(:trip) + + schedules = [ + Factories.Schedules.Schedule.build(:schedule, + trip: trip, + departure_time: time_1, + stop: stop_1 + ) + ] + + assert %Timetables.Timetable{ + rows: [ + [entry_1] + ] + } = Timetables.from_schedules(schedules) + + assert entry_1.time == format!(time_1) + assert entry_1.trip.id == trip.id + assert entry_1.stop_id == stop_1.id + end + + test "serializes a single trip into a single-column timetable" do + stop_1 = Factories.Stops.Stop.build(:stop) + stop_2 = Factories.Stops.Stop.build(:stop) + + [time_1, time_2] = generate_times(2) + trip = Factories.Schedules.Trip.build(:trip) + + schedules = + [ + Factories.Schedules.Schedule.build(:schedule, + trip: trip, + departure_time: time_1, + stop: stop_1 + ), + Factories.Schedules.Schedule.build(:schedule, + trip: trip, + departure_time: time_2, + stop: stop_2 + ) + ] + + assert %Timetables.Timetable{ + rows: [ + [entry_1], + [entry_2] + ] + } = Timetables.from_schedules(schedules) + + assert entry_1.time == format!(time_1) + assert entry_1.trip.id == trip.id + assert entry_1.stop_id == stop_1.id + + assert entry_2.time == format!(time_2) + assert entry_2.trip.id == trip.id + assert entry_2.stop_id == stop_2.id + end + + test "sorts visits within a trip by time" do + stop_1 = Factories.Stops.Stop.build(:stop) + stop_2 = Factories.Stops.Stop.build(:stop) + + [time_1, time_2] = generate_times(2) + trip = Factories.Schedules.Trip.build(:trip) + + schedules = + [ + Factories.Schedules.Schedule.build(:schedule, + trip: trip, + departure_time: time_2, + stop: stop_2 + ), + Factories.Schedules.Schedule.build(:schedule, + trip: trip, + departure_time: time_1, + stop: stop_1 + ) + ] + + assert %Timetables.Timetable{ + rows: [ + [entry_1], + [entry_2] + ] + } = Timetables.from_schedules(schedules) + + assert entry_1.time == format!(time_1) + assert entry_1.trip.id == trip.id + assert entry_1.stop_id == stop_1.id + + assert entry_2.time == format!(time_2) + assert entry_2.trip.id == trip.id + assert entry_2.stop_id == stop_2.id + end + + test "serializes visits to a single stop into a single-row timetable" do + stop = Factories.Stops.Stop.build(:stop) + + [time_1, time_2] = generate_times(2) + [trip_1, trip_2] = generate_trips(2) + + schedules = [ + Factories.Schedules.Schedule.build(:schedule, + trip: trip_1, + departure_time: time_1, + stop: stop + ), + Factories.Schedules.Schedule.build(:schedule, + trip: trip_2, + departure_time: time_2, + stop: stop + ) + ] + + assert %Timetables.Timetable{ + rows: [ + [entry_1, entry_2] + ] + } = Timetables.from_schedules(schedules) + + assert entry_1.time == format!(time_1) + assert entry_1.trip.id == trip_1.id + assert entry_1.stop_id == stop.id + + assert entry_2.time == format!(time_2) + assert entry_2.trip.id == trip_2.id + assert entry_2.stop_id == stop.id + end + + test "sorts trips by first-stop time" do + stop = Factories.Stops.Stop.build(:stop) + + [time_1, time_2] = generate_times(2) + + # For actual test coverage purposes, it doesn't matter what + # order `trip_1` and `trip_2` are defined in, but empirically, + # this test mostly passed even without the sorting logic when + # these were assigned as `[trip_1, trip2]`. + [trip_2, trip_1] = generate_trips(2) + + schedules = [ + Factories.Schedules.Schedule.build(:schedule, + trip: trip_2, + departure_time: time_2, + stop: stop + ), + Factories.Schedules.Schedule.build(:schedule, + trip: trip_1, + departure_time: time_1, + stop: stop + ) + ] + + assert %Timetables.Timetable{ + rows: [ + [entry_1, entry_2] + ] + } = Timetables.from_schedules(schedules) + + assert entry_1.time == format!(time_1) + assert entry_1.trip.id == trip_1.id + assert entry_1.stop_id == stop.id + + assert entry_2.time == format!(time_2) + assert entry_2.trip.id == trip_2.id + assert entry_2.stop_id == stop.id + end + + test "inserts a blank cell when a trip does not visit the second stop" do + stop_1 = Factories.Stops.Stop.build(:stop) + stop_2 = Factories.Stops.Stop.build(:stop) + + [time_1_1, time_1_2, time_2_1] = generate_times(3) + [trip_1, trip_2] = generate_trips(2) + + schedules = [ + Factories.Schedules.Schedule.build(:schedule, + trip: trip_1, + departure_time: time_1_1, + stop: stop_1 + ), + Factories.Schedules.Schedule.build(:schedule, + trip: trip_1, + departure_time: time_1_2, + stop: stop_2 + ), + Factories.Schedules.Schedule.build(:schedule, + trip: trip_2, + departure_time: time_2_1, + stop: stop_1 + ) + ] + + assert %Timetables.Timetable{ + rows: [ + [entry_1_1, entry_2_1], + [entry_1_2, entry_2_2] + ] + } = Timetables.from_schedules(schedules) + + assert entry_1_1.time == format!(time_1_1) + assert entry_1_1.trip.id == trip_1.id + assert entry_1_1.stop_id == stop_1.id + + assert entry_1_2.time == format!(time_1_2) + assert entry_1_2.trip.id == trip_1.id + assert entry_1_2.stop_id == stop_2.id + + assert entry_2_1.time == format!(time_2_1) + assert entry_2_1.trip.id == trip_2.id + assert entry_2_1.stop_id == stop_1.id + + assert entry_2_2.time == "" + assert entry_2_2.trip.id == trip_2.id + assert entry_2_2.stop_id == stop_2.id + end + + test "inserts a blank cell when a trip does not visit the first stop" do + stop_1 = Factories.Stops.Stop.build(:stop) + stop_2 = Factories.Stops.Stop.build(:stop) + + [time_1_1, time_1_2, time_2_2] = generate_times(3) + [trip_1, trip_2] = generate_trips(2) + + schedules = [ + Factories.Schedules.Schedule.build(:schedule, + trip: trip_1, + departure_time: time_1_1, + stop: stop_1 + ), + Factories.Schedules.Schedule.build(:schedule, + trip: trip_1, + departure_time: time_1_2, + stop: stop_2 + ), + Factories.Schedules.Schedule.build(:schedule, + trip: trip_2, + departure_time: time_2_2, + stop: stop_2 + ) + ] + + assert %Timetables.Timetable{ + rows: [ + [entry_1_1, entry_2_1], + [entry_1_2, entry_2_2] + ] + } = Timetables.from_schedules(schedules) + + assert entry_1_1.time == format!(time_1_1) + assert entry_1_1.trip.id == trip_1.id + assert entry_1_1.stop_id == stop_1.id + + assert entry_1_2.time == format!(time_1_2) + assert entry_1_2.trip.id == trip_1.id + assert entry_1_2.stop_id == stop_2.id + + assert entry_2_1.time == "" + assert entry_2_1.trip.id == trip_2.id + assert entry_2_1.stop_id == stop_1.id + + assert entry_2_2.time == format!(time_2_2) + assert entry_2_2.trip.id == trip_2.id + assert entry_2_2.stop_id == stop_2.id + end + + test "inserts blank cells for the first trip" do + stop_1 = Factories.Stops.Stop.build(:stop) + stop_2 = Factories.Stops.Stop.build(:stop) + + [time_1_2, time_2_1, time_2_2] = generate_times(3) + [trip_1, trip_2] = generate_trips(2) + + schedules = [ + Factories.Schedules.Schedule.build(:schedule, + trip: trip_1, + departure_time: time_1_2, + stop: stop_2 + ), + Factories.Schedules.Schedule.build(:schedule, + trip: trip_2, + departure_time: time_2_1, + stop: stop_1 + ), + Factories.Schedules.Schedule.build(:schedule, + trip: trip_2, + departure_time: time_2_2, + stop: stop_2 + ) + ] + + assert %Timetables.Timetable{ + rows: [ + [entry_1_1, entry_2_1], + [entry_1_2, entry_2_2] + ] + } = Timetables.from_schedules(schedules) + + assert entry_1_1.time == "" + assert entry_1_1.trip.id == trip_1.id + assert entry_1_1.stop_id == stop_1.id + + assert entry_1_2.time == format!(time_1_2) + assert entry_1_2.trip.id == trip_1.id + assert entry_1_2.stop_id == stop_2.id + + assert entry_2_1.time == format!(time_2_1) + assert entry_2_1.trip.id == trip_2.id + assert entry_2_1.stop_id == stop_1.id + + assert entry_2_2.time == format!(time_2_2) + assert entry_2_2.trip.id == trip_2.id + assert entry_2_2.stop_id == stop_2.id + end + end + + defp generate_trips(count) do + count + |> Faker.Util.sample_uniq(fn -> FactoryHelpers.build(:id) end) + |> Enum.map(&Factories.Schedules.Trip.build(:trip, id: &1)) + end + + defp generate_times(count) do + today = Generators.Date.random_date() + + count + |> Faker.Util.sample_uniq(fn -> + Generators.DateTime.random_time_range_date_time({ + ServiceDateTime.beginning_of_service_day(today), + ServiceDateTime.end_of_service_day(today) + }) + end) + |> Enum.sort(DateTime) + end + + defp format!(time) do + Dotcom.Utils.Time.format!(time, :hour_12_minutes) + end +end