diff --git a/lib/dotcom/system_status.ex b/lib/dotcom/system_status.ex new file mode 100644 index 0000000000..492e0548d4 --- /dev/null +++ b/lib/dotcom/system_status.ex @@ -0,0 +1,32 @@ +defmodule Dotcom.SystemStatus do + @moduledoc """ + Parent module for the system status feature + """ + + alias Dotcom.SystemStatus + + @doc """ + Returns a list of alerts that satisfy the following criteria: + - They are for one of the subway or trolley lines (including Mattapan), and + - They are either currently active, or will be later today + """ + def subway_alerts_for_today() do + subway_alerts_for_day(Timex.now()) + end + + defp subway_alerts_for_day(datetime) do + [ + "Red", + "Orange", + "Blue", + "Green-B", + "Green-C", + "Green-D", + "Green-E", + "Mattapan" + ] + |> Alerts.Repo.by_route_ids(datetime) + |> SystemStatus.Alerts.for_day(datetime) + |> SystemStatus.Alerts.filter_relevant() + end +end diff --git a/lib/dotcom/system_status/alerts.ex b/lib/dotcom/system_status/alerts.ex new file mode 100644 index 0000000000..d92b87e483 --- /dev/null +++ b/lib/dotcom/system_status/alerts.ex @@ -0,0 +1,106 @@ +defmodule Dotcom.SystemStatus.Alerts do + @moduledoc """ + A utility module intended to filter alerts for the system status feature, + relying on some specific criteria that are specific enough that they don't + belong in the main `Alerts` module. + """ + @relevant_effects [:delay, :shuttle, :suspension, :station_closure] + + @doc """ + Checks to see whether an alert is active at some point later today, possibly including + `now`. + + Returns `true` if + - The alert is currently active + - The alert will become active later in the day + + ## Example (Currently Active) + iex> now = Timex.to_datetime(~N[2025-01-05 14:00:00], "America/New_York") + iex> one_hour_ago = Timex.to_datetime(~N[2025-01-05 13:00:00], "America/New_York") + iex> one_hour_from_now = Timex.to_datetime(~N[2025-01-05 15:00:00], "America/New_York") + iex> Dotcom.SystemStatus.Alerts.active_on_day?( + ...> %Alerts.Alert{active_period: [{one_hour_ago, one_hour_from_now}]}, + ...> now + ...> ) + true + + ## Example (Active Later Today) + iex> now = Timex.to_datetime(~N[2025-01-05 14:00:00], "America/New_York") + iex> one_hour_from_now = Timex.to_datetime(~N[2025-01-05 15:00:00], "America/New_York") + iex> one_day_from_now = Timex.to_datetime(~N[2025-01-06 14:00:00], "America/New_York") + iex> Dotcom.SystemStatus.Alerts.active_on_day?( + ...> %Alerts.Alert{active_period: [{one_hour_from_now, one_day_from_now}]}, + ...> now + ...> ) + true + + Returns `false` if the alert is not currently active, and either + - Was only active in the past (even if earlier today) + - Will next become active after the end of the day today + + ## Example (Expired) + iex> now = Timex.to_datetime(~N[2025-01-05 14:00:00], "America/New_York") + iex> two_hours_ago = Timex.to_datetime(~N[2025-01-05 12:00:00], "America/New_York") + iex> one_hour_ago = Timex.to_datetime(~N[2025-01-05 13:00:00], "America/New_York") + iex> Dotcom.SystemStatus.Alerts.active_on_day?( + ...> %Alerts.Alert{active_period: [{two_hours_ago, one_hour_ago}]}, + ...> now + ...> ) + false + + ## Example (Not Active Until Tomorrow) + iex> now = Timex.to_datetime(~N[2025-01-05 14:00:00], "America/New_York") + iex> one_day_from_now = Timex.to_datetime(~N[2025-01-06 14:00:00], "America/New_York") + iex> two_days_from_now = Timex.to_datetime(~N[2025-01-07 14:00:00], "America/New_York") + iex> Dotcom.SystemStatus.Alerts.active_on_day?( + ...> %Alerts.Alert{active_period: [{one_day_from_now, two_days_from_now}]}, + ...> now + ...> ) + false + """ + 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) && + has_not_ended?(active_period_end, datetime) + end) + end + + @doc """ + Given a list of alerts, filters only the ones that are active today, as defined in `&active_on_day?/2`. + See that function for details + """ + def for_day(alerts, datetime) do + Enum.filter(alerts, &active_on_day?(&1, datetime)) + end + + @doc """ + Given a list of alerts, returns only the alerts whose effects are one of + `[:delay, :shuttle, :suspension, :station_closure]`. + + ## Examples + iex> alerts = [ + ...> %Alerts.Alert{id: "include_this", effect: :delay}, + ...> %Alerts.Alert{id: "exclude_this", effect: :escalator_closure} + ...> ] + iex> Dotcom.SystemStatus.Alerts.filter_relevant(alerts) |> Enum.map(& &1.id) + ["include_this"] + } + """ + def filter_relevant(alerts) do + alerts |> Enum.filter(fn %{effect: effect} -> effect in @relevant_effects end) + end + + # 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) + end + + # Returns true if the alert (as signified by the active_period_end provided) + # ends before the given datetime. If active_period_end is nil, then the alert + # is indefinite, which means that it definitionally has not ended. + defp has_not_ended?(nil, _datetime), do: true + + defp has_not_ended?(active_period_end, datetime), + do: datetime |> Timex.before?(active_period_end) +end diff --git a/lib/dotcom_web/live/admin.ex b/lib/dotcom_web/live/admin.ex index 572b4a0c8a..b14b290b56 100644 --- a/lib/dotcom_web/live/admin.ex +++ b/lib/dotcom_web/live/admin.ex @@ -16,6 +16,11 @@ defmodule DotcomWeb.Live.Admin do url: Helpers.live_path(socket, DotcomWeb.Live.TripPlanner), title: "Trip Planner Preview", description: "WIP on the trip planner rewrite." + }, + %{ + url: Helpers.live_path(socket, DotcomWeb.Live.SystemStatus), + title: "System Status Widget Preview", + description: "WIP on the system status widget." } ] )} diff --git a/lib/dotcom_web/live/system_status.ex b/lib/dotcom_web/live/system_status.ex new file mode 100644 index 0000000000..8e7457ca9e --- /dev/null +++ b/lib/dotcom_web/live/system_status.ex @@ -0,0 +1,35 @@ +defmodule DotcomWeb.Live.SystemStatus do + @moduledoc """ + A temporary LiveView for showing off the system status widget until we + put it into the homepage (and elsewhere). + """ + + alias Dotcom.SystemStatus + use DotcomWeb, :live_view + + def render(assigns) do + assigns = + assigns + |> assign(:alerts, SystemStatus.subway_alerts_for_today()) + + ~H""" +
+ <.alert :for={alert <- @alerts} alert={alert} /> +
+ """ + end + + defp alert(assigns) do + ~H""" +
+ + {@alert.severity} {@alert.effect}: {@alert.header} + +
+ Raw alert +
{inspect(@alert, pretty: true)}
+
+
+ """ + end +end diff --git a/lib/dotcom_web/router.ex b/lib/dotcom_web/router.ex index f693dccf6e..1fadd9a87b 100644 --- a/lib/dotcom_web/router.ex +++ b/lib/dotcom_web/router.ex @@ -274,6 +274,15 @@ defmodule DotcomWeb.Router do end end + scope "/preview", DotcomWeb do + import Phoenix.LiveView.Router + pipe_through([:browser, :browser_live, :basic_auth_readonly]) + + live_session :system_status, layout: {DotcomWeb.LayoutView, :preview} do + live "/system-status", Live.SystemStatus + end + end + scope "/api", DotcomWeb do pipe_through([:secure, :browser]) diff --git a/test/dotcom/system_status/alerts_test.exs b/test/dotcom/system_status/alerts_test.exs new file mode 100644 index 0000000000..4b8570eae4 --- /dev/null +++ b/test/dotcom/system_status/alerts_test.exs @@ -0,0 +1,178 @@ +defmodule Dotcom.SystemStatus.AlertsTest do + use ExUnit.Case, async: true + doctest Dotcom.SystemStatus.Alerts + + import Test.Support.Factories.Alerts.Alert + + alias Dotcom.SystemStatus.Alerts + + defp local_datetime(naive) do + DateTime.from_naive!(naive, "America/New_York") + end + + describe "active_on_day?/2" do + test "returns true if the alert is currently active" do + assert Alerts.active_on_day?( + build(:alert, + active_period: [ + { + local_datetime(~N[2025-01-09 12:00:00]), + local_datetime(~N[2025-01-09 20:00:00]) + } + ] + ), + local_datetime(~N[2025-01-09 13:00:00]) + ) + end + + test "returns false if the alert starts after end-of-service" do + refute Alerts.active_on_day?( + build(:alert, + active_period: [ + { + local_datetime(~N[2025-01-10 12:00:00]), + local_datetime(~N[2025-01-10 20:00:00]) + } + ] + ), + local_datetime(~N[2025-01-09 13:00:00]) + ) + end + + test "returns true if the alert starts later, but before end-of-service" do + assert Alerts.active_on_day?( + build(:alert, + active_period: [ + { + local_datetime(~N[2025-01-09 20:00:00]), + local_datetime(~N[2025-01-10 20:00:00]) + } + ] + ), + local_datetime(~N[2025-01-09 13:00:00]) + ) + end + + test "returns false if the alert has already ended" do + refute Alerts.active_on_day?( + build(:alert, + active_period: [ + { + local_datetime(~N[2025-01-09 10:00:00]), + local_datetime(~N[2025-01-09 12:00:00]) + } + ] + ), + local_datetime(~N[2025-01-09 13:00:00]) + ) + end + + test "returns true if the alert has no end time" do + alert = + build(:alert, + active_period: [ + { + local_datetime(~N[2025-01-09 10:00:00]), + nil + } + ] + ) + + assert Alerts.active_on_day?( + alert, + local_datetime(~N[2025-01-09 13:00:00]) + ) + end + + test "returns false if the alert has no end time but hasn't started yet" do + refute Alerts.active_on_day?( + build(:alert, + active_period: [ + { + local_datetime(~N[2025-01-10 10:00:00]), + nil + } + ] + ), + local_datetime(~N[2025-01-09 13:00:00]) + ) + end + + test "returns true if a later part of the alert's active period is active" do + assert Alerts.active_on_day?( + build(:alert, + active_period: [ + { + local_datetime(~N[2025-01-08 10:00:00]), + local_datetime(~N[2025-01-08 12:00:00]) + }, + { + local_datetime(~N[2025-01-09 10:00:00]), + local_datetime(~N[2025-01-09 12:00:00]) + } + ] + ), + local_datetime(~N[2025-01-09 11:00:00]) + ) + end + end + + describe "for_day/2" do + test "includes alerts that are active today" do + alert1 = + build(:alert, + active_period: [ + { + local_datetime(~N[2025-01-09 12:00:00]), + local_datetime(~N[2025-01-09 20:00:00]) + } + ] + ) + + alert2 = + build(:alert, + active_period: [ + { + local_datetime(~N[2025-01-10 12:00:00]), + local_datetime(~N[2025-01-10 20:00:00]) + } + ] + ) + + assert Alerts.for_day( + [alert1, alert2], + local_datetime(~N[2025-01-09 13:00:00]) + ) == [alert1] + end + end + + describe "filter_relevant/1" do + test "includes an alert if its effect is :delay" do + alert = build(:alert, effect: :delay) + assert Alerts.filter_relevant([alert]) == [alert] + end + + test "includes an alert if its effect is :shuttle" do + alert = build(:alert, effect: :shuttle) + assert Alerts.filter_relevant([alert]) == [alert] + end + + test "includes an alert if its effect is :suspension" do + alert = build(:alert, effect: :suspension) + assert Alerts.filter_relevant([alert]) == [alert] + end + + test "includes an alert if its effect is :station_closure" do + alert = build(:alert, effect: :station_closure) + assert Alerts.filter_relevant([alert]) == [alert] + end + + test "does not include alerts with other effects" do + assert Alerts.filter_relevant([ + build(:alert, effect: :policy_change), + build(:alert, effect: :extra_service), + build(:alert, effect: :stop_closure) + ]) == [] + end + end +end