diff --git a/lib/dotcom/schedule_finder.ex b/lib/dotcom/schedule_finder.ex
index 8b838fdf6a..07b8b97b40 100644
--- a/lib/dotcom/schedule_finder.ex
+++ b/lib/dotcom/schedule_finder.ex
@@ -240,6 +240,9 @@ defmodule Dotcom.ScheduleFinder do
departures
|> Enum.group_by(&@routes_repo.get(&1.route_id))
|> Enum.map(&departures_with_destination(&1, direction_id, stop_id))
+ # If a group has fewer than 3 trips we can't calculate headways. It's also
+ # seldom enough that we'd rather just omit it.
+ |> Enum.reject(fn {_, _, times} -> length(times) <= 2 end)
end
@ashmont_branch_stops ~w(place-shmnl place-fldcr place-smmnl place-asmnl)
diff --git a/lib/dotcom/schedule_finder/service_groups.ex b/lib/dotcom/schedule_finder/service_groups.ex
new file mode 100644
index 0000000000..5e81a24251
--- /dev/null
+++ b/lib/dotcom/schedule_finder/service_groups.ex
@@ -0,0 +1,105 @@
+defmodule Dotcom.ScheduleFinder.ServiceGroup do
+ @moduledoc """
+ A group of services sharing a similar timeframe or trait, such as future,
+ current, holiday, planned work.
+ """
+
+ alias Dotcom.ServicePatterns
+
+ defstruct [:services, :group_label]
+
+ @type t :: %__MODULE__{
+ group_label: String.t(),
+ services: [
+ %{
+ label: String.t(),
+ next_date: Date.t() | nil,
+ now_date: Date.t() | nil,
+ last_service_date: Date.t()
+ }
+ ]
+ }
+
+ @spec for_route(Routes.Route.id_t(), Date.t()) :: [__MODULE__.t()]
+ def for_route(route_id, current_date) do
+ route_id
+ |> ServicePatterns.for_route()
+ |> Enum.map(fn sp ->
+ %{
+ now_date: if(current_date in sp.dates, do: current_date),
+ next_date: nil,
+ service_pattern: sp
+ }
+ end)
+ |> tag_next_available(current_date)
+ |> Enum.group_by(& &1.service_pattern.group_label)
+ |> Enum.sort_by(&group_sort_order/1)
+ |> Enum.map(&to_service_group/1)
+ end
+
+ defp tag_next_available([], _), do: []
+
+ defp tag_next_available(service_patterns, current_date) do
+ if Enum.any?(service_patterns, & &1.now_date) do
+ service_patterns
+ else
+ next_date =
+ service_patterns
+ |> Enum.flat_map(& &1.service_pattern.dates)
+ |> Enum.sort(Date)
+ |> Enum.find(&Date.after?(&1, current_date))
+
+ if next_date do
+ Enum.map(service_patterns, &maybe_add_next_date(&1, next_date))
+ else
+ service_patterns
+ end
+ end
+ end
+
+ defp maybe_add_next_date(%{service_pattern: %{dates: dates}} = service_pattern, next_date) do
+ if next_date in dates do
+ %{service_pattern | next_date: next_date}
+ else
+ service_pattern
+ end
+ end
+
+ defp group_sort_order({{group_key, _}, _}) do
+ [:current, :future, :extra, :holiday, :planned]
+ |> Enum.find_index(&(&1 == group_key))
+ end
+
+ defp to_service_group({{_, group_label}, patterns}) do
+ %__MODULE__{
+ group_label: group_label,
+ services:
+ patterns
+ |> Enum.sort_by(&pattern_mapper(&1.service_pattern.service_label))
+ |> Enum.map(fn pattern ->
+ Map.take(pattern, [:next_date, :now_date])
+ |> Map.put(:label, pattern_label(pattern))
+ |> Map.put(:last_service_date, pattern_date(pattern))
+ end)
+ }
+ end
+
+ # Sort such that typical patterns are first, in a specified ordering, followed
+ # by patterns with the other typicalities, ordered by date. Use label or
+ # description as the tie-breaker.
+ defp pattern_mapper({:typical, type_key, label}) do
+ index =
+ [:monday_thursday, :friday, :weekday, :saturday, :sunday, :weekend]
+ |> Enum.find_index(&(&1 == type_key))
+
+ {0, index, label}
+ end
+
+ defp pattern_mapper({typicality, first_date, description}) do
+ {1, typicality, to_string(first_date), description}
+ end
+
+ defp pattern_label(%{service_pattern: %{service_label: {_, _, label}}}), do: label
+
+ defp pattern_date(%{service_pattern: %{dates: dates}}), do: List.last(dates)
+end
diff --git a/lib/dotcom/service_patterns.ex b/lib/dotcom/service_patterns.ex
index 3a85dda6b8..069a2bdf9d 100644
--- a/lib/dotcom/service_patterns.ex
+++ b/lib/dotcom/service_patterns.ex
@@ -3,7 +3,10 @@ defmodule Dotcom.ServicePatterns do
Information about transit service!
"""
+ use Dotcom.Gettext.Sigils
+
alias Dotcom.Utils.ServiceDateTime
+ alias Services.Service
@services_repo Application.compile_env!(:dotcom, :repo_modules)[:services]
@@ -23,6 +26,260 @@ defmodule Dotcom.ServicePatterns do
opts
|> Keyword.fetch!(:route)
|> @services_repo.by_route_id()
- |> Enum.any?(&Services.Service.serves_date?(&1, date))
+ |> Enum.any?(&Service.serves_date?(&1, date))
+ end
+
+ @spec for_route(Routes.Route.id_t()) :: [Service.t()]
+ def for_route(route_id) do
+ route_id
+ |> @services_repo.by_route_id()
+ |> Stream.reject(&(&1.typicality == :canonical))
+ |> Stream.flat_map(&unwrap_multiple_holidays/1)
+ |> Stream.map(&add_single_date_description/1)
+ |> Stream.map(&adjust_planned_description/1)
+ |> Enum.reject(&Date.before?(&1.end_date, ServiceDateTime.service_date()))
+ |> dedup_identical_services()
+ |> dedup_similar_services()
+ |> to_service_pattern()
+ end
+
+ defp unwrap_multiple_holidays(
+ %{typicality: :holiday_service, added_dates: added_dates} = service
+ )
+ when length(added_dates) > 1 do
+ for added_date <- added_dates do
+ %{
+ service
+ | added_dates: [added_date],
+ added_dates_notes: Map.take(service.added_dates_notes, [added_date])
+ }
+ end
+ end
+
+ defp unwrap_multiple_holidays(service), do: [service]
+
+ defp add_single_date_description(
+ %{
+ added_dates: [single_date],
+ added_dates_notes: added_dates_notes,
+ typicality: typicality
+ } = service
+ )
+ when typicality in [:extra_service, :holiday_service] do
+ date_note = Map.get(added_dates_notes, single_date) || service.description
+
+ formatted_date =
+ single_date
+ |> Date.from_iso8601!()
+ |> format_tiny_date()
+
+ %{
+ service
+ | description: "#{date_note}, #{formatted_date}"
+ }
+ end
+
+ defp add_single_date_description(service), do: service
+
+ defp format_tiny_date(date), do: Dotcom.Utils.Time.format!(date, :month_day_short)
+
+ defp adjust_planned_description(%{typicality: :planned_disruption} = service) do
+ dates =
+ if service.start_date == service.end_date do
+ " (#{format_tiny_date(service.start_date)})"
+ else
+ " (#{format_tiny_date(service.start_date)} - #{format_tiny_date(service.end_date)})"
+ end
+
+ Map.update!(service, :description, &(&1 <> dates))
+ end
+
+ defp adjust_planned_description(service), do: service
+
+ defp dedup_identical_services(services) do
+ services
+ |> Enum.group_by(fn service ->
+ {service.start_date, service.end_date, service.valid_days, service.removed_dates,
+ service.added_dates}
+ end)
+ |> Enum.map(fn {_key, [service | _rest]} ->
+ service
+ end)
+ end
+
+ # If we have two services A and B with the same type and typicality,
+ # with the date range from A's start to A's end a subset of the date
+ # range from B's start to B's end, either A is in the list of services
+ # erroneously (for example, in the case of the 39 in the fall 2019
+ # rating), or A represents a special service that's not a holiday (for
+ # instance, the Thanksgiving-week extra service to Logan on the SL1 in
+ # the fall 2019 rating).
+ #
+ # However, in neither of these cases do we want to show service A. In the
+ # first case, we don't want to show A because it's erroneous, and in the
+ # second case, we don't want to show A for parity with the paper/PDF
+ # schedules, in which these special services are not generally called
+ # out.
+
+ @spec dedup_similar_services([Service.t()]) :: [Service.t()]
+ defp dedup_similar_services(services) do
+ services
+ |> Enum.group_by(&{&1.type, &1.typicality, &1.rating_description})
+ |> Enum.flat_map(fn {_, service_group} ->
+ service_group
+ |> drop_extra_weekday_schedule_if_friday_present()
+ |> then(fn services ->
+ Enum.reject(services, &service_completely_overlapped?(&1, services))
+ end)
+ end)
+ end
+
+ # If there's a Friday service and two overlapping weekday schedules, we want to show the Monday-Thursday one rather than the Monday-Friday one.
+ defp drop_extra_weekday_schedule_if_friday_present(services) do
+ if Enum.find(services, &Service.friday_typical_service?/1) &&
+ Enum.find(services, &Service.monday_to_thursday_typical_service?/1) do
+ Enum.reject(services, &(&1.valid_days == [1, 2, 3, 4, 5]))
+ else
+ services
+ end
+ end
+
+ defp service_completely_overlapped?(%{typicality: :holiday_service}, _), do: false
+
+ defp service_completely_overlapped?(service, services) do
+ Enum.any?(services, fn other_service ->
+ # There's an other service that
+ # - starts earlier/same time as this service
+ # - and ends later/same time as this service
+ # - and covers the same valid_days as this service
+ other_service != service && String.contains?(service.name, other_service.name) &&
+ Date.compare(other_service.start_date, service.start_date) != :gt &&
+ Date.compare(other_service.end_date, service.end_date) != :lt &&
+ Enum.all?(service.valid_days, &Enum.member?(other_service.valid_days, &1))
+ end)
+ end
+
+ defp to_service_pattern(services) do
+ services
+ |> Enum.map(fn service ->
+ %{
+ service: service,
+ dates: Service.all_valid_dates_for_service(service),
+ group_label: group_label(service)
+ }
+ end)
+ |> merge_similar_typical()
+ end
+
+ def group_label(service) do
+ case service.typicality do
+ :holiday_service ->
+ {:holiday, ~t"Holiday Schedules"}
+
+ :extra_service ->
+ {:extra, ~t"Extra Service"}
+
+ :planned_disruption ->
+ {:planned, gettext("%{rating} Planned Work", rating: service.rating_description)}
+
+ _ ->
+ typical_service_group(service)
+ end
+ end
+
+ defp typical_service_group(
+ %Service{
+ rating_description: rating_description,
+ rating_end_date: rating_end_date,
+ rating_start_date: rating_start_date
+ } = service
+ ) do
+ start_date = short_format(rating_start_date)
+
+ if Service.in_current_rating?(service) do
+ label =
+ if rating_end_date do
+ gettext("%{rating} Schedules, ends %{date}",
+ rating: rating_description,
+ date: short_format(rating_end_date)
+ )
+ else
+ gettext("%{rating} Schedules, starts %{date}",
+ rating: rating_description,
+ date: start_date
+ )
+ end
+
+ {:current, label}
+ else
+ if Service.in_future_rating?(service) do
+ label =
+ gettext("%{rating} Schedules, starts %{date}",
+ rating: rating_description,
+ date: start_date
+ )
+
+ {:future, label}
+ else
+ {:other, ~t"Other Schedules"}
+ end
+ end
+ end
+
+ defp short_format(date) do
+ case Dotcom.Utils.Time.format(date, :month_day_short) do
+ {:ok, value} -> value
+ _ -> nil
+ end
+ end
+
+ defp merge_similar_typical(all) do
+ all
+ |> Enum.group_by(&similar_typical_items/1)
+ |> Enum.map(&merge_items/1)
+ end
+
+ defp similar_typical_items(%{
+ dates: dates,
+ service: %Service{typicality: :typical_service} = service
+ }) do
+ typical_groups = [
+ {:monday_thursday, ~t"Monday - Thursday schedules",
+ fn s ->
+ s.type == :weekday &&
+ (s.valid_days == [1, 2, 3, 4] || s.description =~ "Monday - Thursday")
+ end},
+ {:friday, ~t"Friday schedules",
+ fn s ->
+ s.type == :weekday && (s.valid_days == [5] || s.description =~ "Friday")
+ end},
+ {:weekday, ~t"Weekday schedules", fn s -> s.type == :weekday end},
+ {:saturday, ~t"Saturday schedules", fn s -> s.type == :saturday end},
+ {:sunday, ~t"Sunday schedules", fn s -> s.type == :sunday end},
+ {:weekend, ~t"Weekend schedules", fn s -> s.type == :weekend end}
+ ]
+
+ case Enum.find(typical_groups, fn {_, _, func} -> func.(service) end) do
+ {key, label, _} -> {:typical, key, label}
+ _ -> {service.typicality, List.first(dates), service.description}
+ end
+ end
+
+ defp similar_typical_items(%{dates: dates, service: service}),
+ do: {service.typicality, List.first(dates), service.description}
+
+ defp merge_items({label, [%{service: _, dates: dates, group_label: group_label}]}) do
+ %{service_label: label, dates: dates, group_label: group_label}
+ end
+
+ defp merge_items({label, many_items}) do
+ merged_dates =
+ many_items
+ |> Enum.flat_map(& &1.dates)
+ |> Enum.uniq()
+ |> Enum.sort(Date)
+
+ merged_label = List.first(many_items) |> Map.get(:group_label)
+ %{service_label: label, dates: merged_dates, group_label: merged_label}
end
end
diff --git a/lib/dotcom_web/controllers/schedule/line_controller.ex b/lib/dotcom_web/controllers/schedule/line_controller.ex
index 90793b41fe..4f271c5842 100644
--- a/lib/dotcom_web/controllers/schedule/line_controller.ex
+++ b/lib/dotcom_web/controllers/schedule/line_controller.ex
@@ -8,7 +8,6 @@ defmodule DotcomWeb.ScheduleController.LineController do
alias DotcomWeb.{ScheduleView, ViewHelpers}
alias Plug.Conn
alias Routes.{Group, Route}
- alias Services.Repo, as: ServicesRepo
alias Services.Service
plug(DotcomWeb.Plugs.Route)
@@ -35,8 +34,6 @@ defmodule DotcomWeb.ScheduleController.LineController do
end
def assign_schedule_page_data(conn) do
- services_fn = Map.get(conn.assigns, :services_fn, &ServicesRepo.by_route_id/1)
-
service_date = Map.get(conn.assigns, :date_time, Util.now()) |> Util.service_date()
assign(
@@ -64,7 +61,7 @@ defmodule DotcomWeb.ScheduleController.LineController do
fare_link: ScheduleView.route_fare_link(conn.assigns.route),
holidays: conn.assigns.holidays,
route: Route.to_json_safe(conn.assigns.route),
- services: services(conn.assigns.route.id, service_date, services_fn),
+ services: services(conn.assigns.route.id, service_date),
schedule_note: ScheduleNote.new(conn.assigns.route),
stops: simple_stop_map(conn),
direction_id: conn.assigns.direction_id,
@@ -83,78 +80,11 @@ defmodule DotcomWeb.ScheduleController.LineController do
)
end
- # If we have two services A and B with the same type and typicality,
- # with the date range from A's start to A's end a subset of the date
- # range from B's start to B's end, either A is in the list of services
- # erroneously (for example, in the case of the 39 in the fall 2019
- # rating), or A represents a special service that's not a holiday (for
- # instance, the Thanksgiving-week extra service to Logan on the SL1 in
- # the fall 2019 rating).
- #
- # However, in neither of these cases do we want to show service A. In the
- # first case, we don't want to show A because it's erroneous, and in the
- # second case, we don't want to show A for parity with the paper/PDF
- # schedules, in which these special services are not generally called
- # out.
-
- @spec dedup_similar_services([Service.t()]) :: [Service.t()]
- def dedup_similar_services(services) do
- services
- |> Enum.group_by(&{&1.type, &1.typicality, &1.rating_description})
- |> Enum.flat_map(fn {_, service_group} ->
- service_group
- |> drop_extra_weekday_schedule_if_friday_present()
- |> then(fn services ->
- Enum.reject(services, &service_completely_overlapped?(&1, services))
- end)
- end)
- end
-
- # If there's a Friday service and two overlapping weekday schedules, we want to show the Monday-Thursday one rather than the Monday-Friday one.
- defp drop_extra_weekday_schedule_if_friday_present(services) do
- if Enum.find(services, &Service.friday_typical_service?/1) &&
- Enum.find(services, &Service.monday_to_thursday_typical_service?/1) do
- Enum.reject(services, &(&1.valid_days == [1, 2, 3, 4, 5]))
- else
- services
- end
- end
-
- defp service_completely_overlapped?(service, services) do
- Enum.any?(services, fn other_service ->
- # There's an other service that
- # - starts earlier/same time as this service
- # - and ends later/same time as this service
- # - and covers the same valid_days as this service
- other_service != service && String.contains?(service.name, other_service.name) &&
- Date.compare(other_service.start_date, service.start_date) != :gt &&
- Date.compare(other_service.end_date, service.end_date) != :lt &&
- Enum.all?(service.valid_days, &Enum.member?(other_service.valid_days, &1))
- end)
- end
-
- @spec dedup_identical_services([Service.t()]) :: [Service.t()]
- def dedup_identical_services(services) do
- services
- |> Enum.group_by(fn %{start_date: start_date, end_date: end_date, valid_days: valid_days} ->
- {start_date, end_date, valid_days}
- end)
- |> Enum.map(fn {_key, [service | _rest]} ->
- service
- end)
- end
-
@spec services(Routes.Route.id_t(), Date.t()) :: [Service.t()]
- def services(route_id, service_date, services_by_route_id_fn \\ &ServicesRepo.by_route_id/1) do
+ def services(route_id, service_date) do
route_id
- |> services_by_route_id_fn.()
- |> Enum.reject(fn service ->
- service.typicality == :canonical || service.description =~ "(no school)" ||
- Date.compare(service.end_date, service_date) == :lt
- end)
- |> dedup_identical_services()
- |> dedup_similar_services()
- |> Enum.sort_by(&sort_services_by_date/1)
+ |> Dotcom.ServicePatterns.for_route()
+ |> Enum.reject(&Date.before?(&1.end_date, service_date))
|> Enum.map(&Map.put(&1, :service_date, service_date))
|> tag_default_service()
end
diff --git a/lib/dotcom_web/live/schedule_finder_live.ex b/lib/dotcom_web/live/schedule_finder_live.ex
index 807751eaa6..63013a11bf 100644
--- a/lib/dotcom_web/live/schedule_finder_live.ex
+++ b/lib/dotcom_web/live/schedule_finder_live.ex
@@ -14,6 +14,7 @@ defmodule DotcomWeb.ScheduleFinderLive do
import Dotcom.Utils.Time, only: [format!: 2]
import DotcomWeb.RouteComponents, only: [lined_list: 1, lined_list_item: 1]
+ alias Dotcom.ScheduleFinder.ServiceGroup
alias Dotcom.ScheduleFinder.UpcomingDepartures
alias DotcomWeb.Components.Prototype
alias DotcomWeb.RouteComponents
@@ -38,8 +39,10 @@ defmodule DotcomWeb.ScheduleFinderLive do
|> assign_new(:upcoming_departures, fn -> [] end)
|> assign_new(:now, fn -> @date_time.now() end)
|> assign_new(:alerts, fn -> [] end)
+ |> assign_new(:service_groups, fn -> [] end)
|> assign_new(:loaded_trips, fn -> %{} end)
- |> assign_new(:date, fn -> nil end)}
+ |> assign_new(:selected_service_name, fn -> "" end)
+ |> assign_new(:daily_schedule_date, fn -> service_date() end)}
end
@impl LiveView
@@ -59,7 +62,7 @@ defmodule DotcomWeb.ScheduleFinderLive do
<.alert_banner alerts={@alerts} />
- {~t"Upcoming Departures"}
+ {~t"Upcoming Departures"}
<.upcoming_departures_table
:if={@stop}
now={@now}
@@ -80,14 +83,22 @@ defmodule DotcomWeb.ScheduleFinderLive do
{~t(Daily Schedules)}
+ <.service_picker
+ selected_service_name={@selected_service_name}
+ service_groups={@service_groups}
+ />
<.async_result :let={departures} :if={@stop} assign={@departures}>
- <:loading>Loading daily schedules...
+ <:loading>
+
+ <.spinner aria_label={~t"Loading schedules for selected service"} />
+
+
<:failed :let={fail}>
<.error_container title={inspect(fail)}>
{~t"There was a problem loading schedules"}
- <%= if departures do %>
+ <%= if length(departures) > 0 do %>
<%= if @route.type in [0, 1] do %>
<.departures_table departures={departures} route={@route} loaded_trips={@loaded_trips} />
<% end %>
+ <% else %>
+
+ {no_service_message(@service_groups, @route, @stop)}
+
<% end %>
@@ -114,14 +129,28 @@ defmodule DotcomWeb.ScheduleFinderLive do
end
@impl LiveView
- def handle_params(%{"direction_id" => direction, "route_id" => route} = params, _uri, socket) do
+ def handle_params(%{"direction_id" => direction, "route_id" => route_id} = params, _uri, socket) do
{direction_id, _} = Integer.parse(direction)
+ service_groups = ServiceGroup.for_route(route_id, service_date())
+
+ selected_service =
+ service_groups
+ |> Enum.flat_map(& &1.services)
+ |> Enum.find(%{}, &(&1.now_date || &1.next_date))
+
+ socket =
+ if Map.get(selected_service, :next_date) do
+ assign(socket, :daily_schedule_date, Date.to_iso8601(selected_service.next_date))
+ else
+ socket
+ end
{:noreply,
socket
- |> assign(:route, @routes_repo.get(route))
+ |> assign(:route, @routes_repo.get(route_id))
|> assign(:direction_id, direction_id)
- |> assign(:date, Map.get(params, "date", today()))
+ |> assign(:service_groups, service_groups)
+ |> assign(:selected_service_name, Map.get(selected_service, :label, ""))
|> assign_stop(params)
|> assign_alerts()
|> assign_departures()
@@ -134,7 +163,7 @@ defmodule DotcomWeb.ScheduleFinderLive do
%{"schedule_id" => schedule_id, "stop_sequence" => stop_sequence, "trip" => trip_id},
socket
) do
- date = socket.assigns.date
+ date = socket.assigns.daily_schedule_date
loaded_trips = socket.assigns.loaded_trips
if Map.get(loaded_trips, schedule_id) do
@@ -148,6 +177,22 @@ defmodule DotcomWeb.ScheduleFinderLive do
end
end
+ def handle_event("select_service", %{"selected_service" => selected_service_label}, socket) do
+ selected_dated_service =
+ socket.assigns.service_groups
+ |> Enum.flat_map(& &1.services)
+ |> Enum.find(&(&1.label == selected_service_label))
+
+ daily_schedule_date =
+ selected_dated_service.last_service_date
+
+ {:noreply,
+ socket
+ |> assign(:selected_service_name, selected_service_label)
+ |> assign(:daily_schedule_date, daily_schedule_date)
+ |> assign_departures()}
+ end
+
def handle_event(_, _, socket), do: {:noreply, socket}
@impl Phoenix.LiveView
@@ -206,8 +251,6 @@ defmodule DotcomWeb.ScheduleFinderLive do
socket |> assign(:upcoming_departures, [])
end
- defp today, do: service_date() |> format!(:iso_date)
-
defp assign_alerts(%{assigns: %{stop: stop}} = socket) when not is_nil(stop) do
route = socket.assigns.route
assign(socket, :alerts, current_alerts(stop, route))
@@ -218,7 +261,7 @@ defmodule DotcomWeb.ScheduleFinderLive do
defp assign_departures(socket) do
route_id = socket.assigns.route.id
direction_id = socket.assigns.direction_id
- date = socket.assigns.date
+ date = socket.assigns.daily_schedule_date
stop = socket.assigns.stop
if stop do
@@ -295,6 +338,45 @@ defmodule DotcomWeb.ScheduleFinderLive do
"""
end
+ attr :service_groups, :list, required: true
+ attr :selected_service_name, :string, default: ""
+
+ defp service_picker(assigns) do
+ ~H"""
+
+ """
+ end
+
attr :stop, Stop
defp stop_banner(assigns) do
@@ -319,7 +401,7 @@ defmodule DotcomWeb.ScheduleFinderLive do
attr :times, :list, required: true
attr :vehicle_name, :string, required: true
- defp first_last(%{times: [first | _] = times} = assigns) do
+ defp first_last(%{times: [first, _second | _] = times} = assigns) do
assigns =
assigns
|> assign(:first, first)
@@ -542,7 +624,7 @@ defmodule DotcomWeb.ScheduleFinderLive do
defp other_stop(assigns) do
~H"""
- <.lined_list_item route={@route} class={@class}>
+ <.lined_list_item :if={@other_stop} route={@route} class={@class}>
{@other_stop.stop_name}
@@ -661,4 +743,21 @@ defmodule DotcomWeb.ScheduleFinderLive do
|> Kernel.then(& &1.time)
|> format!(:hour_12_minutes)
end
+
+ defp no_service_message(service_groups, route, stop) do
+ route_name =
+ if(route.type == 3 && not Route.silver_line?(route),
+ do: gettext("Route %{route}", route: route.name),
+ else: route.name
+ )
+
+ if service_groups == [] do
+ gettext("There is currently no scheduled %{route_name}.", route_name: route_name)
+ else
+ gettext("There is no scheduled %{route} service at %{stop} for this time period.",
+ route: route_name,
+ stop: stop.name
+ )
+ end
+ end
end
diff --git a/test/dotcom/schedule_finder/service_groups_test.exs b/test/dotcom/schedule_finder/service_groups_test.exs
new file mode 100644
index 0000000000..51acee8de9
--- /dev/null
+++ b/test/dotcom/schedule_finder/service_groups_test.exs
@@ -0,0 +1,71 @@
+defmodule Dotcom.ScheduleFinder.ServiceGroupTest do
+ use ExUnit.Case
+
+ import Dotcom.ScheduleFinder.ServiceGroup
+ import Dotcom.Utils.ServiceDateTime, only: [service_date: 0]
+ import Mox
+ import Test.Support.Factories.Services.Service
+
+ alias Dotcom.ScheduleFinder.ServiceGroup
+ alias Test.Support.{FactoryHelpers, Generators}
+
+ setup :verify_on_exit!
+
+ setup do
+ stub_with(Dotcom.Utils.DateTime.Mock, Dotcom.Utils.DateTime)
+ :ok
+ end
+
+ describe "for_route/2" do
+ test "requests services for route" do
+ date = Generators.DateTime.random_date_time() |> DateTime.to_date()
+ route_id = FactoryHelpers.build(:id)
+
+ expect(Services.Repo.Mock, :by_route_id, fn ^route_id ->
+ []
+ end)
+
+ assert for_route(route_id, date)
+ end
+
+ test "marks active routes based on input date" do
+ services = build_list(1, :service, date: service_date())
+ date = List.first(services) |> Map.get(:start_date)
+ route_id = FactoryHelpers.build(:id)
+
+ expect(Services.Repo.Mock, :by_route_id, fn ^route_id ->
+ services
+ end)
+
+ assert [%ServiceGroup{services: [active_service]}] = for_route(route_id, date)
+ assert active_service.now_date
+ end
+
+ test "if no active routes, mark next active route" do
+ inactive_date = Faker.Date.backward(1) |> Date.shift(day: -120)
+ route_id = FactoryHelpers.build(:id)
+
+ expect(Services.Repo.Mock, :by_route_id, fn ^route_id ->
+ build_list(50, :service)
+ end)
+
+ # not every groups will necessarily have a service which is now/next since that's calculated across all groups. so check all of them
+ all_services = for_route(route_id, inactive_date) |> Enum.flat_map(& &1.services)
+ assert Enum.all?(all_services, &(&1.now_date == nil))
+ assert Enum.any?(all_services, &(&1.next_date != nil))
+ end
+
+ test "groups and labels every kind of service" do
+ date = Generators.DateTime.random_date_time() |> DateTime.to_date()
+ route_id = FactoryHelpers.build(:id)
+
+ expect(Services.Repo.Mock, :by_route_id, fn ^route_id ->
+ build_list(50, :service)
+ end)
+
+ for %ServiceGroup{group_label: label} <- for_route(route_id, date) do
+ assert is_binary(label)
+ end
+ end
+ end
+end
diff --git a/test/dotcom/schedule_finder_test.exs b/test/dotcom/schedule_finder_test.exs
index 4fbcd78a99..285b07fae9 100644
--- a/test/dotcom/schedule_finder_test.exs
+++ b/test/dotcom/schedule_finder_test.exs
@@ -134,10 +134,11 @@ defmodule Dotcom.ScheduleFinderTest do
test "returns route, destination, departure times" do
direction_id = Faker.Util.pick([0, 1])
stop_id = FactoryHelpers.build(:id)
+ route = Test.Support.Factories.Routes.Route.build(:route)
departures = departures()
- expect(Routes.Repo.Mock, :get, length(departures), fn id ->
- Test.Support.Factories.Routes.Route.build(:route, %{id: id})
+ expect(Routes.Repo.Mock, :get, length(departures), fn _ ->
+ route
end)
assert [{route, destination, times} | _] = subway_groups(departures, direction_id, stop_id)
@@ -152,9 +153,10 @@ defmodule Dotcom.ScheduleFinderTest do
~w(place-nqncy place-wlsta place-qnctr place-qamnl place-brntn) |> Faker.Util.pick()
departures = departures()
+ route = Test.Support.Factories.Routes.Route.build(:route, %{id: "Red"})
expect(Routes.Repo.Mock, :get, length(departures), fn _ ->
- Test.Support.Factories.Routes.Route.build(:route, %{id: "Red"})
+ route
end)
assert [{route, destination, _} | _] =
diff --git a/test/dotcom/service_patterns_test.exs b/test/dotcom/service_patterns_test.exs
index b56eb5d6c8..733086de92 100644
--- a/test/dotcom/service_patterns_test.exs
+++ b/test/dotcom/service_patterns_test.exs
@@ -2,6 +2,7 @@ defmodule Dotcom.ServicePatternsTest do
use ExUnit.Case, async: true
import Dotcom.ServicePatterns
+ import Dotcom.Utils.ServiceDateTime, only: [service_date: 0]
import Mox
import Test.Support.Factories.Services.Service
@@ -28,7 +29,7 @@ defmodule Dotcom.ServicePatternsTest do
test "returns true if there are services for that date" do
expect(Services.Repo.Mock, :by_route_id, fn _ ->
- build_list(5, :service, %{date: Dotcom.Utils.ServiceDateTime.service_date()})
+ build_list(5, :service, %{date: service_date()})
end)
assert has_service?(route: FactoryHelpers.build(:id))
@@ -44,15 +45,168 @@ defmodule Dotcom.ServicePatternsTest do
test "returns false if services only serve other dates" do
expect(Services.Repo.Mock, :by_route_id, fn _ ->
- build_list(5, :service, %{date: Dotcom.Utils.ServiceDateTime.service_date()})
+ build_list(5, :service, %{date: service_date()})
end)
other_date =
- Dotcom.Utils.DateTime.now()
- |> DateTime.to_date()
+ service_date()
|> Date.shift(year: 5)
refute has_service?(route: FactoryHelpers.build(:id), date: other_date)
end
end
+
+ describe "for_route/1" do
+ test "omits canonical typicality" do
+ route_id = FactoryHelpers.build(:id)
+
+ expect(Services.Repo.Mock, :by_route_id, fn ^route_id ->
+ [build(:service, date: service_date(), typicality: :canonical)]
+ end)
+
+ assert for_route(route_id) == []
+ end
+
+ test "renames (no school) typical services" do
+ route_id = FactoryHelpers.build(:id)
+
+ expect(Services.Repo.Mock, :by_route_id, fn _ ->
+ [
+ build(:service,
+ date: service_date(),
+ description: "Weekdays (no school)",
+ typicality: :typical_service,
+ type: :weekday
+ )
+ ]
+ end)
+
+ assert [%{service_label: {:typical, :weekday, "Weekday schedules"}}] = for_route(route_id)
+ end
+
+ test "omits services which ended" do
+ route_id = FactoryHelpers.build(:id)
+
+ expect(Services.Repo.Mock, :by_route_id, fn _ ->
+ [build(:service, end_date: Faker.Date.backward(1))]
+ end)
+
+ assert for_route(route_id) == []
+ end
+
+ test "splits multi-holiday services" do
+ route_id = FactoryHelpers.build(:id)
+ holiday_count = Faker.random_between(2, 4)
+ service = build(:service, date: Faker.Date.forward(1), typicality: :holiday_service)
+
+ added_dates =
+ Faker.Util.sample_uniq(holiday_count, fn ->
+ shift = Faker.random_between(2, 6)
+
+ Date.shift(service.end_date, day: shift)
+ |> Date.to_string()
+ end)
+
+ added_dates_notes =
+ Map.new(added_dates, fn d ->
+ {d, Faker.Commerce.product_name()}
+ end)
+
+ service = %{service | added_dates: added_dates, added_dates_notes: added_dates_notes}
+
+ expect(Services.Repo.Mock, :by_route_id, fn _ ->
+ [service]
+ end)
+
+ patterns = for_route(route_id)
+ assert Enum.count(patterns) == holiday_count
+ assert Enum.all?(patterns, &(&1.group_label == {:holiday, "Holiday Schedules"}))
+ end
+
+ test "adjusts description to add formatted date, for single date services" do
+ route_id = FactoryHelpers.build(:id)
+ added_date = Faker.random_between(2, 10) |> Faker.Date.forward() |> Date.to_string()
+ holiday_description = Faker.Commerce.product_name()
+ added_date_notes = Map.new([{added_date, holiday_description}])
+
+ expect(Services.Repo.Mock, :by_route_id, fn _ ->
+ [
+ build(:service,
+ date: service_date(),
+ typicality: :holiday_service,
+ added_dates: [added_date],
+ added_dates_notes: added_date_notes
+ )
+ ]
+ end)
+
+ assert patterns = for_route(route_id)
+ %{service_label: {_, _, text}} = List.first(patterns)
+ assert text =~ holiday_description
+
+ assert text =~
+ added_date |> Date.from_iso8601!() |> Dotcom.Utils.Time.format!(:month_day_short)
+ end
+
+ test "adjusts description for planned work" do
+ route_id = FactoryHelpers.build(:id)
+ disruption_service = build(:service, date: service_date(), typicality: :planned_disruption)
+
+ expect(Services.Repo.Mock, :by_route_id, fn _ ->
+ [disruption_service]
+ end)
+
+ assert [%{service_label: {:planned_disruption, _, label}}] = for_route(route_id)
+ assert disruption_service.description != label
+ assert label =~ disruption_service.description
+ end
+
+ test "omits duplicates" do
+ route_id = FactoryHelpers.build(:id)
+ service = build(:service, date: service_date())
+
+ expect(Services.Repo.Mock, :by_route_id, fn _ ->
+ [service, service]
+ end)
+
+ assert for_route(route_id) |> Enum.count() == 1
+ end
+
+ test "omits weekday service if similar" do
+ route_id = FactoryHelpers.build(:id)
+
+ mon_thurs_service =
+ build(:service,
+ date: service_date(),
+ typicality: :typical_service,
+ type: :weekday,
+ valid_days: [1, 2, 3, 4]
+ )
+
+ friday_service = mon_thurs_service |> Map.put(:valid_days, [5])
+ spurious_weekday_service = mon_thurs_service |> Map.put(:valid_days, [1, 2, 3, 4, 5])
+
+ expect(Services.Repo.Mock, :by_route_id, fn _ ->
+ [friday_service, mon_thurs_service, spurious_weekday_service]
+ end)
+
+ assert spurious_weekday_service not in for_route(route_id)
+ end
+
+ test "omits overlapping service" do
+ route_id = FactoryHelpers.build(:id)
+ service = build(:service, date: service_date(), typicality: :typical_service)
+
+ overlapping_service =
+ service
+ |> Map.update!(:start_date, &Date.shift(&1, day: -1))
+ |> Map.update!(:end_date, &Date.shift(&1, day: 1))
+
+ expect(Services.Repo.Mock, :by_route_id, fn _ ->
+ [overlapping_service, service]
+ end)
+
+ assert for_route(route_id) |> Enum.count() == 1
+ end
+ end
end
diff --git a/test/dotcom_web/controllers/schedule/line_controller_test.exs b/test/dotcom_web/controllers/schedule/line_controller_test.exs
index 88eaf42a68..b3ecdadf52 100644
--- a/test/dotcom_web/controllers/schedule/line_controller_test.exs
+++ b/test/dotcom_web/controllers/schedule/line_controller_test.exs
@@ -1,13 +1,21 @@
defmodule DotcomWeb.Schedule.LineControllerTest do
use DotcomWeb.ConnCase, async: false
- import Mock
-
alias DotcomWeb.ScheduleController.LineController
- alias Services.Service
+
+ import Mox
+ import Test.Support.Factories.Services.Service
+
+ setup :verify_on_exit!
@moduletag :external
+ setup do
+ stub_with(Dotcom.Utils.DateTime.Mock, Dotcom.Utils.DateTime)
+
+ :ok
+ end
+
setup_all do
# needed by DotcomWeb.ScheduleController.VehicleLocations plug
_ = start_supervised({Phoenix.PubSub, name: Vehicles.PubSub})
@@ -31,140 +39,17 @@ defmodule DotcomWeb.Schedule.LineControllerTest do
end
end
- describe "services/3" do
+ describe "services/2" do
test "omits services in the past" do
- service_date = ~D[2019-05-01]
-
- past_service = %Service{
- start_date: ~D[2019-04-30],
- end_date: ~D[2019-04-30]
- }
-
- current_service = %Service{
- start_date: ~D[2019-05-01],
- end_date: ~D[2019-05-01]
- }
-
- future_service = %Service{
- start_date: ~D[2019-05-02],
- end_date: ~D[2019-05-02]
- }
-
- repo_fn = fn _ ->
- [
- past_service,
- current_service,
- future_service
- ]
- end
-
- services = LineController.services("1", service_date, repo_fn)
- assert length(services) == 2
- refute Enum.member?(services, past_service)
- end
-
- @tag skip: "Stopped omitting these, might restore one day"
- test "omits no-school weekday services" do
- service_date = ~D[2019-12-11]
-
- school_service = %Service{
- start_date: ~D[2019-12-11],
- end_date: ~D[2019-12-11],
- name: "Weekday"
- }
-
- no_school_service = %Service{
- start_date: ~D[2019-12-12],
- end_date: ~D[2019-12-12],
- name: "Weekday (no school)"
- }
-
- repo_fn = fn _ ->
- [
- school_service,
- no_school_service
- ]
- end
-
- services = LineController.services("1", service_date, repo_fn)
- assert length(services) == 1
- refute Enum.member?(services, no_school_service)
- end
-
- test "omits services that are subsets of another service" do
- service_date = ~D[2019-05-01]
-
- subset_service = %Service{
- start_date: ~D[2019-05-30],
- end_date: ~D[2019-06-30]
- }
-
- superset_service = %Service{
- start_date: ~D[2019-04-29],
- end_date: ~D[2019-07-01]
- }
-
- unrelated_service = %Service{
- start_date: ~D[2019-07-02],
- end_date: ~D[2019-09-02]
- }
-
- repo_fn = fn _ ->
- [
- subset_service,
- superset_service,
- unrelated_service
- ]
- end
-
- services = LineController.services("1", service_date, repo_fn)
- assert length(services) == 2
- refute Enum.member?(services, subset_service)
- end
-
- test "does not break even when there's an error getting the current rating" do
- with_mock(Schedules.Repo, [:passthrough], end_of_rating: fn -> nil end) do
- service_date = ~D[2021-05-01]
-
- repo_fn = fn _ ->
- [
- %Service{
- start_date: ~D[2021-05-01],
- end_date: ~D[2021-05-01]
- }
- ]
- end
-
- services = LineController.services("1", service_date, repo_fn)
-
- assert length(services) == 1
- end
- end
-
- test "does not include canonical services" do
- service_date = ~D[2019-05-01]
-
- current_service = %Service{
- start_date: ~D[2019-05-01],
- end_date: ~D[2019-05-01]
- }
-
- canonical_service = %Service{
- typicality: :canonical,
- start_date: ~D[2019-05-02],
- end_date: ~D[2019-05-02]
- }
+ past_service =
+ build(:service, end_date: Faker.Date.backward(3), typicality: :typical_service)
- repo_fn = fn _ ->
- [
- current_service,
- canonical_service
- ]
- end
+ expect(Services.Repo.Mock, :by_route_id, fn _ ->
+ [past_service]
+ end)
- services = LineController.services("1", service_date, repo_fn)
- assert length(services) == 1
- refute Enum.member?(services, canonical_service)
+ services = LineController.services("1", Faker.Date.forward(3))
+ assert Enum.empty?(services)
end
end
diff --git a/test/services/service_test.exs b/test/services/service_test.exs
index d9b6b8aa35..ca304f7527 100644
--- a/test/services/service_test.exs
+++ b/test/services/service_test.exs
@@ -197,13 +197,13 @@ defmodule Services.ServiceTest do
end
test "accounts for nil start date" do
- service = build(:service, start_date: nil)
+ service = build(:service, date: Faker.Date.forward(1), start_date: nil)
assert dates = Service.all_valid_dates_for_service(service)
refute Enum.empty?(dates)
end
test "accounts for nil end date" do
- service = build(:service, end_date: nil)
+ service = build(:service, date: Faker.Date.backward(1), end_date: nil)
assert dates = Service.all_valid_dates_for_service(service)
refute Enum.empty?(dates)
end