Skip to content
Permalink

Comparing changes

This is a direct comparison between two commits made in this repository or its related repositories. View the default comparison for this range or learn more about diff comparisons.

Open a pull request

Create a new pull request by comparing changes across two branches. If you need to, you can also . Learn more about diff comparisons here.
base repository: mbta/dotcom
Failed to load repositories. Confirm that selected base ref is valid, then try again.
Loading
base: 0a31d302d568e5044b7b922c168355d005f84fd5
Choose a base ref
..
head repository: mbta/dotcom
Failed to load repositories. Confirm that selected head ref is valid, then try again.
Loading
compare: 58b5500adf2cd2ecf414682758430ca1a2a4e089
Choose a head ref
2 changes: 2 additions & 0 deletions assets/js/app.js
Original file line number Diff line number Diff line change
@@ -17,6 +17,7 @@ import geoLocation from "./geolocation";
import addressSearch from "./address-search";
import googleTranslate from "./google-translate";
import translateAnalytics from "./translate-analytics.js";
import scrollTo from "./scroll-to";
import stickyTooltip from "./sticky-tooltip";
import timetableScroll from "./timetable-scroll";
import timetableStyle from "./timetable-style";
@@ -131,6 +132,7 @@ geoLocation();
addressSearch();
googleTranslate();
translateAnalytics();
scrollTo();
tabbedNav();
timetableScroll();
timetableStyle();
38 changes: 38 additions & 0 deletions assets/js/scroll-to.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
export default () => {
window.addEventListener("load", scrollTo, { passive: true });
};

const scrollTo = () => {
window.requestAnimationFrame(() => {
const initialElToScrollTo = document.querySelector("[data-scroll-to]");
if (initialElToScrollTo) {
doScrollTo(initialElToScrollTo);
}
});
};

const doScrollTo = el => {
const childLeft = el.offsetLeft;
const parentLeft = el.parentNode.offsetLeft;
const firstSiblingWidth = firstSibling(el).clientWidth;

// childLeft - parentLeft scrolls the first row to the start of the
// visible area.
const scrollLeft = childLeft - parentLeft - firstSiblingWidth;
let table = el.parentNode;
while (table.nodeName !== "TABLE") {
table = table.parentNode;
}
table.parentNode.scrollLeft = scrollLeft;
};

const firstSibling = element => {
const sibling = element.parentNode.firstChild;
if (sibling.nodeType === 1) {
return sibling;
} else if (sibling) {
return sibling.nextElementSibling;
} else {
return null;
}
};
6 changes: 3 additions & 3 deletions lib/dotcom/system_status/alerts.ex
Original file line number Diff line number Diff line change
@@ -60,7 +60,7 @@ defmodule Dotcom.SystemStatus.Alerts do
"""
def active_on_day?(alert, datetime) do
Enum.any?(alert.active_period, fn {active_period_start, active_period_end} ->
starts_before_end_of_day?(active_period_start, datetime) &&
starts_before_end_of_service?(active_period_start, datetime) &&
has_not_ended?(active_period_end, datetime)
end)
end
@@ -92,8 +92,8 @@ defmodule Dotcom.SystemStatus.Alerts do

# Returns true if the alert (as signified by the active_period_start provided)
# starts before the end of datetime's day.
defp starts_before_end_of_day?(active_period_start, datetime) do
datetime |> Timex.end_of_day() |> Timex.after?(active_period_start)
defp starts_before_end_of_service?(active_period_start, datetime) do
datetime |> Util.end_of_service() |> Timex.after?(active_period_start)
end

# Returns true if the alert (as signified by the active_period_end provided)
198 changes: 151 additions & 47 deletions lib/dotcom/system_status/groups.ex
Original file line number Diff line number Diff line change
@@ -5,19 +5,21 @@ defmodule Dotcom.SystemStatus.Groups do

alias Alerts.Alert

# &groups/2 returns an ordered data structure, sorted in the order
# given by `@lines`.
@lines ["Blue", "Orange", "Red", "Green"]
@green_line_branches ["Green-B", "Green-C", "Green-D", "Green-E"]
@routes ["Blue", "Orange", "Red", "Mattapan"] ++ @green_line_branches
@routes ["Blue", "Mattapan", "Orange", "Red"] ++ @green_line_branches

def groups(alerts, now) do
def groups(alerts, time) do
grouped_alerts = Map.new(@routes, &{&1, alerts_for_line(alerts, &1)})

@routes
|> Enum.map(fn route_id ->
statuses =
grouped_alerts
|> Map.get(route_id)
|> alerts_to_statuses(now)
|> alerts_to_statuses(time)
|> consolidate_duplicate_descriptions()
|> sort_statuses()
|> stringify_times()
@@ -30,6 +32,9 @@ defmodule Dotcom.SystemStatus.Groups do
|> sort_routes_and_sub_routes()
end

# Given `alerts` and `line_id`, filters out only the alerts
# applicable to the given line, using the alert's "informed
# entities".
defp alerts_for_line(alerts, line_id) do
alerts
|> Enum.filter(fn %Alert{informed_entity: informed_entity} ->
@@ -41,18 +46,32 @@ defmodule Dotcom.SystemStatus.Groups do
end)
end

defp alerts_to_statuses([], _now) do
# Maps a list of alerts to a list of statuses, where a status is a
# simple structure with a route, a description, and a few additional
# fields that determine how it will render in the frontend.
defp alerts_to_statuses(alerts, time)

# If there are no alerts, then we want a single status indicating
# "Normal Service".
defp alerts_to_statuses([], _time) do
[%{description: "Normal Service", time: nil}]
end

defp alerts_to_statuses(alerts, now) do
# If there are alerts, then create a starting list of statuses that
# maps one-to-one with the alerts provided.
defp alerts_to_statuses(alerts, time) do
alerts
|> Enum.map(fn alert ->
alert_to_status(alert, now)
alert_to_status(alert, time)
end)
end

defp alert_to_status(alert, now) do
# Translates an alert to a status:
# - The effect is humanized into a description for the status.
# - If the alert's already active, `time` is set to `nil`.
# - If the alert is in the future, `time` is set to the alert's
# start time
defp alert_to_status(alert, time) do
description =
case alert.effect do
:delay -> "Delays"
@@ -61,40 +80,94 @@ defmodule Dotcom.SystemStatus.Groups do
:suspension -> "Suspension"
end

time = future_start_time(alert.active_period, now)
time = future_start_time(alert.active_period, time)

%{description: description, time: time}
end

defp stringify_times(statuses) do
statuses
|> Enum.map(fn status ->
case status do
%{time: nil} ->
status

%{time: time} ->
%{status | time: Timex.format!(time, "%-I:%M%p", :strftime) |> String.downcase()}
end
end)
end

# - If the active period is in the future, returns its start_time.
# - If the active period indicates that the alert is currently active, returns nil.
# - Raises an error if the alert is completely in the past.
defp future_start_time([{start_time, end_time} | more_active_periods], now) do
defp future_start_time([{start_time, end_time} | more_active_periods], time) do
cond do
Timex.before?(end_time, now) -> future_start_time(more_active_periods, now)
Timex.before?(start_time, now) -> nil
Timex.before?(end_time, time) -> future_start_time(more_active_periods, time)
Timex.before?(start_time, time) -> nil
true -> start_time
end
end

# Combines statuses that have the same active time and description
# into a single pluralized status (e.g. "Station Closures" instead
# of "Station Closure").
defp consolidate_duplicate_descriptions(statuses) do
statuses
|> Enum.group_by(fn %{time: time, description: description} -> {time, description} end)
|> Enum.map(fn
{_, [status]} -> status
{_, [status | _]} -> pluralize_description(status)
end)
end

# Replaces the description of a status with its plural form, for use
# in &consolidate_duplicate_descriptions/2, when multiple statuses
# have the same time and effect.
defp pluralize_description(%{description: description} = status) do
new_description =
case description do
"Suspension" -> "Suspensions"
"Station Closure" -> "Station Closures"
_ -> description
end

%{status | description: new_description}
end

# Sorts the given list of statuses first by time, then by
# description, so that earlier statuses show up before later ones,
# and then to keep statuses in a stable order.
#
# This takes advantage of the fact that `nil` is sorted before
# anything else, which allows it to automatically sort active
# statuses before future ones.
#
# This should be called before &stringify_times/1, otherwise times
# will get sorted lexically instead of temporally (e.g. 10:00pm will
# get sorted ahead of 9:00pm).
defp sort_statuses(statuses) do
statuses
|> Enum.sort_by(fn %{time: time, description: description} -> {time, description} end)
end

# Transforms the time in each status given into a human-readable
# string or nil.
defp stringify_times(statuses) do
statuses
|> Enum.map(fn status ->
%{
status
| time: stringify_time(status.time)
}
end)
end

# Returns a human-readable version of the time given, formatted like
# "8:30pm", for example. Leaves nil unchanged.
defp stringify_time(nil), do: nil
defp stringify_time(time), do: Timex.format!(time, "%-I:%M%p", :strftime) |> String.downcase()

# The `time` attribute of a status governs how a status should be
# rendered. An alert that's currently active should typically only
# show its description, while an alert in the future should show the
# stringified time as well. If there is both an active status and a
# future status, then active statuses should show "Now" instead of
# not showing a time.
#
# We accomplish this by setting the `time` attribute of a status to
# `nil` if we don't want it to be rendered, and to some string if we
# want that string rendered.
#
# This function checks whether there are any future statuses, and if
# so, replaces any `nil` times with "Now".
defp maybe_add_now_text(statuses) do
if any_future_statuses?(statuses) do
statuses |> Enum.map(&add_now_text/1)
@@ -103,6 +176,9 @@ defmodule Dotcom.SystemStatus.Groups do
end
end

# Checks the list of statuses to see if any of them have a non-nil
# `time` field, which would indicate that at least one status is for
# the future, rather than currently active.
defp any_future_statuses?(statuses) do
statuses
|> Enum.any?(fn
@@ -111,29 +187,20 @@ defmodule Dotcom.SystemStatus.Groups do
end)
end

# If the time for a status is `nil`, replaces it with the string "Now".
# Leaves the status unchanged if it has a non-nil `time` attribute.
defp add_now_text(%{time: nil} = status), do: %{status | time: "Now"}
defp add_now_text(status), do: status

defp consolidate_duplicate_descriptions(statuses) do
statuses
|> Enum.group_by(fn %{time: time, description: description} -> {time, description} end)
|> Enum.map(fn
{_, [status]} -> status
{_, [status | _]} -> pluralize_description(status)
end)
end

defp pluralize_description(%{description: description} = status) do
new_description =
case description do
"Suspension" -> "Suspensions"
"Station Closure" -> "Station Closures"
_ -> description
end

%{status | description: new_description}
end

# This is a special-purpose function to accommodate the fact that
# the Green line is actually composed of four different routes under
# the hood, but we want to display Green line entries grouped
# together when that makes sense.
#
# This function takes the entries corresponding to the four
# different Green line branches, combines ones that are the same,
# and uses the specific branch name (e.g. "Green-B") as a
# `sub_route` instead of a `route_id`.
defp combine_green_line_branches(statuses_by_route) do
{green_line_entries, other_entries} =
statuses_by_route
@@ -143,12 +210,22 @@ defmodule Dotcom.SystemStatus.Groups do
green_line_entries
|> Enum.group_by(& &1.statuses)
|> Enum.to_list()
|> convert_gl_branches_to_sub_routes()
|> convert_branches_to_sub_routes()

other_entries ++ consolidated_green_line_entries
end

defp convert_gl_branches_to_sub_routes([{statuses, _}]) do
# Used by &combine_green_line_branches/1, takes the result of
# grouping Green line entries and returns one or more entries with
# the `route_id` set to "Green", and the `sub_routes` set to
# the specific branch route ID's (e.g. "Green-B").
defp convert_branches_to_sub_routes(entries)

# If there's only one entry, that means that all of the Green line
# branches have the same entries. Since we only use `sub_routes`
# when some entries are different from others, we don't need
# `sub_routes` in this case, so we leave it empty.
defp convert_branches_to_sub_routes([{statuses, _}]) do
[
%{
route_id: "Green",
@@ -158,7 +235,12 @@ defmodule Dotcom.SystemStatus.Groups do
]
end

defp convert_gl_branches_to_sub_routes(entries) do
# If there are multiple entries, that means that not all Green line
# branches have the same entries, which means that we do need to
# distinguish them using `sub_routes`. This function returns a list
# of the entries given, with the `route_id` set to "Green", and the
# `sub_routes` set to all of the applicable Green line branch ID's.
defp convert_branches_to_sub_routes(entries) do
entries
|> Enum.map(fn {statuses, routes} ->
%{
@@ -169,6 +251,18 @@ defmodule Dotcom.SystemStatus.Groups do
end)
end

# This is a special-purpose function to accomodate the fact that
# most riders consider the Mattapan trolley an extension of the Red
# line, so we group Mattapan entries under the Red line if there
# are any.
#
# If there are no Mattapan entries, then we drop the whole Mattapan
# entry, leaving riders to infer that "Normal Service" on the Red
# line includes the Mattapan trolley.
#
# If there are Mattapan entries, then this transforms them into
# entries under the "Red" `route_id`, and uses "Mattapan" as the
# `sub_route`.
defp combine_mattapan_with_red_line(statuses_by_route) do
{mattapan_entries, other_entries} =
statuses_by_route
@@ -189,6 +283,16 @@ defmodule Dotcom.SystemStatus.Groups do
other_entries ++ new_mattapan_entries
end

# Sorts entries by the following criteria:
# - The subway lines should be sorted in the order given by @lines.
# - Entries with no sub-routes should come before entries with
# sub-routes (this mainly serves to sort Red line entries before
# Mattapan ones).
# - "Normal Service" should come before other descriptions (this
# applies mostly to Green line entries where some branches might
# be normal, and others not).
# - Sub-routes should be sorted lexically, so all else equal, Green-B
# should be sorted before Green-C, for instance.
defp sort_routes_and_sub_routes(entries) do
line_indexes = @lines |> Enum.with_index() |> Map.new()

Loading