diff --git a/assets/css/colors.scss b/assets/css/colors.scss
index 71753a1d3..b4b0fb251 100644
--- a/assets/css/colors.scss
+++ b/assets/css/colors.scss
@@ -9,6 +9,7 @@ $line-color-ferry: #008eaa;
$alert-yellow: #fd0;
+$true-grey-45: #737373;
$true-grey-70: #b2b1af;
$warm-neutral-80: #cccbc8;
diff --git a/assets/css/elevator_v2.scss b/assets/css/elevator_v2.scss
index c874acaab..cdc11dd92 100644
--- a/assets/css/elevator_v2.scss
+++ b/assets/css/elevator_v2.scss
@@ -6,6 +6,7 @@
@import "v2/lcd_common/simulation";
@import "v2/elevator/header";
@import "v2/elevator/footer";
+@import "v2/lcd_common/route_pill";
body {
margin: 0;
diff --git a/assets/css/v2/elevator/elevator_closures.scss b/assets/css/v2/elevator/elevator_closures.scss
index b8f33e3b7..d81e94555 100644
--- a/assets/css/v2/elevator/elevator_closures.scss
+++ b/assets/css/v2/elevator/elevator_closures.scss
@@ -8,32 +8,126 @@
.in-station-summary {
display: flex;
+ gap: 82px;
justify-content: space-between;
- padding: 24px 58px;
+ padding: 24px 48px;
font-size: 48px;
font-weight: 400;
line-height: 64px;
color: $cool-black-30;
}
- hr {
- width: 100%;
- height: 24px;
- margin-top: 0;
- margin-bottom: 0;
+ hr.thick {
+ min-height: 24px;
+ margin: 0;
background-color: $cool-black-15;
+ border: none;
+ }
+
+ hr.thin {
+ min-height: 2px;
+ margin: 24px 0 0;
+ background-color: $true-grey-45;
+ border: none;
+ opacity: 0.5;
}
.outside-alert-list {
+ position: relative;
height: 100%;
+ font-family: Inter;
background-color: $warm-neutral-90;
- .header {
+ .header-container {
+ margin: 48px;
+ margin-bottom: 0;
+
+ .header {
+ display: flex;
+ max-height: 432px;
+ font-size: 112px;
+ font-weight: 700;
+ line-height: 112px;
+
+ &__title {
+ word-spacing: 9999px;
+ }
+ }
+ }
+
+ .alert-list-container {
+ overflow: hidden;
+
+ .alert-list {
+ display: flex;
+ flex-flow: column wrap;
+ height: 904px;
+ transform: translateX(calc(-100% * var(--alert-list-offset)));
+
+ .alert-row {
+ margin: 24px 48px 0;
+
+ &__station-name {
+ font-size: 62px;
+ font-weight: 600;
+ line-height: 80px;
+ color: $cool-black-15;
+ }
+
+ &__name-and-pills {
+ display: flex;
+ gap: 24px;
+ align-items: center;
+ margin-bottom: 14px;
+
+ .route-pill {
+ width: 132px;
+ height: 68.13px;
+
+ & > * {
+ height: 100%;
+ }
+ }
+ }
+
+ &__elevator-name {
+ line-height: 64px;
+ }
+
+ &__elevator-name.list-item {
+ display: list-item;
+ margin-bottom: 8px;
+ margin-left: 48px;
+ }
+ }
+ }
+ }
+
+ .paging-info-container {
+ position: absolute;
+ bottom: 0;
display: flex;
- padding: 48px;
- font-size: 150px;
- font-weight: 700;
- line-height: 150px;
+ justify-content: space-between;
+ width: 100%;
+ height: 72px;
+
+ & > * {
+ margin: 0 48px 12px;
+ }
+
+ .paging-indicators {
+ display: flex;
+ gap: 27px;
+ align-items: center;
+ margin-right: 66px;
+ }
+ }
+
+ .paging-info-container,
+ .alert-row__elevator-name {
+ font-size: 48px;
+ font-weight: 400;
+ color: $cool-black-30;
}
}
}
diff --git a/assets/src/components/v2/elevator/elevator_closures.tsx b/assets/src/components/v2/elevator/elevator_closures.tsx
index 0e7b668c6..9374e29ec 100644
--- a/assets/src/components/v2/elevator/elevator_closures.tsx
+++ b/assets/src/components/v2/elevator/elevator_closures.tsx
@@ -1,9 +1,65 @@
-import React from "react";
+import React, {
+ ComponentType,
+ useEffect,
+ useLayoutEffect,
+ useMemo,
+ useState,
+} from "react";
+import cx from "classnames";
import NormalService from "Images/svgr_bundled/normal-service.svg";
import AccessibilityAlert from "Images/svgr_bundled/accessibility-alert.svg";
+import PagingDotUnselected from "Images/svgr_bundled/paging_dot_unselected.svg";
+import PagingDotSelected from "Images/svgr_bundled/paging_dot_selected.svg";
+import makePersistent, { WrappedComponentProps } from "../persistent_wrapper";
+import RoutePill, { routePillKey, type Pill } from "../departures/route_pill";
+
+type StationWithAlert = {
+ id: string;
+ name: string;
+ routes: Pill[];
+ alerts: ElevatorClosure[];
+};
+
+type ElevatorClosure = {
+ id: string;
+ elevator_name: string;
+ elevator_id: string;
+ description: string;
+ header_text: string;
+};
+
+interface AlertRowProps {
+ station: StationWithAlert;
+}
+
+const AlertRow = ({ station }: AlertRowProps) => {
+ const { name, alerts, routes, id } = station;
+
+ return (
+
+
+ {routes.map((route) => (
+
+ ))}
+
{name}
+
+ {alerts.map((alert) => (
+
1,
+ })}
+ >
+ {alert.elevator_name} ({alert.elevator_id})
+
+ ))}
+
+
+ );
+};
interface InStationSummaryProps {
- alerts: string[];
+ alerts: ElevatorClosure[];
}
const InStationSummary = ({ alerts }: InStationSummaryProps) => {
@@ -19,44 +75,138 @@ const InStationSummary = ({ alerts }: InStationSummaryProps) => {
-
+
>
);
};
-interface OutsideAlertListProps {
- alerts: string[];
+interface OutsideAlertListProps extends WrappedComponentProps {
+ stations: StationWithAlert[];
+ lastUpdate: number | null;
}
-const OutsideAlertList = (_props: OutsideAlertListProps) => {
+const OutsideAlertList = ({
+ stations,
+ lastUpdate,
+ onFinish,
+}: OutsideAlertListProps) => {
+ const [isFirstRender, setIsFirstRender] = useState(true);
+ const [pageIndex, setPageIndex] = useState(0);
+
+ // Each value represents the pageIndex the row is visible on
+ const [rowPageIndexes, setRowPageIndexes] = useState([]);
+
+ const [numPages, numOffsetRows] = useMemo(
+ () => [
+ rowPageIndexes.filter((val, i, self) => self.indexOf(val) === i).length,
+ rowPageIndexes.filter((offset) => offset !== pageIndex).length,
+ ],
+ [rowPageIndexes],
+ );
+
+ useEffect(() => {
+ if (lastUpdate != null) {
+ if (isFirstRender) {
+ setIsFirstRender(false);
+ } else {
+ setPageIndex((i) => i + 1);
+ }
+ }
+ }, [lastUpdate]);
+
+ useEffect(() => {
+ if (pageIndex === numPages - 1) {
+ onFinish();
+ }
+ }, [pageIndex]);
+
+ useLayoutEffect(() => {
+ const alertRows = Array.from(document.getElementsByClassName("alert-row"));
+ const screenWidth = 1080;
+ const totalXMargins = 48;
+
+ const rowPageIndexes = alertRows.map((alert) => {
+ const val = (alert as HTMLDivElement).offsetLeft - totalXMargins;
+ return val / screenWidth;
+ });
+
+ setRowPageIndexes(rowPageIndexes);
+ }, [stations]);
+
+ const getPagingIndicators = (num: number) => {
+ const indicators: JSX.Element[] = [];
+ for (let i = 0; i < num; i++) {
+ const indicator =
+ pageIndex === i ? (
+
+ ) : (
+
+ );
+ indicators.push(indicator);
+ }
+
+ return indicators;
+ };
+
return (
-
-
MBTA Elevator Closures
-
-
-
+
+
+
MBTA Elevator Closures
+
+
+
+
+
+ {
+
+ {stations.map((station) => (
+
+ ))}
+
+ }
+
+
+
+{numOffsetRows} more elevators
+
{getPagingIndicators(numPages)}
);
};
-interface Props {
+interface Props extends WrappedComponentProps {
id: string;
- in_station_alerts: string[];
- outside_alerts: string[];
+ in_station_alerts: ElevatorClosure[];
+ other_stations_with_alerts: StationWithAlert[];
}
const ElevatorClosures: React.ComponentType
= ({
+ other_stations_with_alerts: otherStationsWithAlerts,
in_station_alerts: inStationAlerts,
- outside_alerts: outsideAlerts,
+ lastUpdate,
+ onFinish,
}: Props) => {
return (
-
+
);
};
-export default ElevatorClosures;
+export default makePersistent(
+ ElevatorClosures as ComponentType,
+);
diff --git a/assets/static/images/svgr_bundled/paging_dot_selected.svg b/assets/static/images/svgr_bundled/paging_dot_selected.svg
new file mode 100644
index 000000000..44cdf0c59
--- /dev/null
+++ b/assets/static/images/svgr_bundled/paging_dot_selected.svg
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/assets/static/images/svgr_bundled/paging_dot_unselected.svg b/assets/static/images/svgr_bundled/paging_dot_unselected.svg
new file mode 100644
index 000000000..9ce2cd659
--- /dev/null
+++ b/assets/static/images/svgr_bundled/paging_dot_unselected.svg
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/config/test.exs b/config/test.exs
index 21faa85b7..d47c5d39e 100644
--- a/config/test.exs
+++ b/config/test.exs
@@ -170,6 +170,12 @@ config :screens, Screens.V2.RDS,
route_pattern_module: Screens.RoutePatterns.MockRoutePattern,
stop_module: Screens.Stops.MockStop
+config :screens, Screens.V2.CandidateGenerator.Elevator.Closures,
+ stop_module: Screens.Stops.MockStop,
+ facility_module: Screens.Facilities.MockFacility,
+ alert_module: Screens.Alerts.MockAlert,
+ route_module: Screens.Routes.MockRoute
+
config :screens, Screens.LastTrip,
trip_updates_adapter: Screens.LastTrip.TripUpdates.Noop,
vehicle_positions_adapter: Screens.LastTrip.VehiclePositions.Noop
diff --git a/lib/screens/alerts/alert.ex b/lib/screens/alerts/alert.ex
index ae342482c..19dc4ac31 100644
--- a/lib/screens/alerts/alert.ex
+++ b/lib/screens/alerts/alert.ex
@@ -1,7 +1,7 @@
defmodule Screens.Alerts.Alert do
@moduledoc false
- alias Screens.Alerts.InformedEntity
+ alias Screens.Alerts.{Alert, InformedEntity}
alias Screens.Routes.Route
alias Screens.RouteType
alias Screens.Stops.Stop
@@ -204,6 +204,7 @@ defmodule Screens.Alerts.Alert do
end
end
+ @callback fetch_elevator_alerts_with_facilities() :: {:ok, list(Alert.t())} | :error
def fetch_elevator_alerts_with_facilities(get_json_fn \\ &V3Api.get_json/2) do
query_opts = [activity: "USING_WHEELCHAIR", include: ~w[facilities]]
diff --git a/lib/screens/facilities/facility.ex b/lib/screens/facilities/facility.ex
new file mode 100644
index 000000000..3bf1e8ffb
--- /dev/null
+++ b/lib/screens/facilities/facility.ex
@@ -0,0 +1,22 @@
+defmodule Screens.Facilities.Facility do
+ @moduledoc """
+ Functions for fetching facility data from the V3 API.
+ """
+
+ alias Screens.Stops
+
+ @type id :: String.t()
+
+ @callback fetch_stop_for_facility(id()) :: {:ok, Stops.Stop.t()} | {:error, term()}
+ def fetch_stop_for_facility(facility_id) do
+ case Screens.V3Api.get_json("facilities/#{facility_id}", %{
+ "include" => "stop"
+ }) do
+ {:ok, %{"data" => _data, "included" => [stop_map]}} ->
+ {:ok, Stops.Parser.parse_stop(stop_map)}
+
+ error ->
+ {:error, error}
+ end
+ end
+end
diff --git a/lib/screens/routes/route.ex b/lib/screens/routes/route.ex
index 78e2825c6..fc4386f34 100644
--- a/lib/screens/routes/route.ex
+++ b/lib/screens/routes/route.ex
@@ -48,8 +48,8 @@ defmodule Screens.Routes.Route do
end
end
- @spec fetch() :: {:ok, [t()]} | :error
- @spec fetch(params()) :: {:ok, [t()]} | :error
+ @callback fetch() :: {:ok, [t()]} | :error
+ @callback fetch(params()) :: {:ok, [t()]} | :error
def fetch(opts \\ %{}, get_json_fn \\ &V3Api.get_json/2) do
params =
opts
diff --git a/lib/screens/stops/stop.ex b/lib/screens/stops/stop.ex
index c935a130a..82abadb0d 100644
--- a/lib/screens/stops/stop.ex
+++ b/lib/screens/stops/stop.ex
@@ -17,7 +17,7 @@ defmodule Screens.Stops.Stop do
alias Screens.Stops
alias Screens.Util
alias Screens.V3Api
- alias ScreensConfig.V2.{BusEink, BusShelter, Dup, GlEink, PreFare}
+ alias ScreensConfig.V2.{BusEink, BusShelter, Dup, Elevator, GlEink, PreFare}
defstruct ~w[id name location_type platform_code platform_name]a
@@ -251,6 +251,7 @@ defmodule Screens.Stops.Stop do
# --- These functions involve the API ---
+ @callback fetch_parent_station_name_map() :: {:ok, list(%{String.t() => String.t()})} | :error
def fetch_parent_station_name_map(get_json_fn \\ &V3Api.get_json/2) do
case get_json_fn.("stops", %{
"filter[location_type]" => 1
@@ -442,11 +443,11 @@ defmodule Screens.Stops.Stop do
@doc """
Fetches all the location context for a screen given its app type, stop id, and time
"""
- @spec fetch_location_context(
- screen_type(),
- id(),
- DateTime.t()
- ) :: {:ok, LocationContext.t()} | :error
+ @callback fetch_location_context(
+ screen_type(),
+ id(),
+ DateTime.t()
+ ) :: {:ok, LocationContext.t()} | :error
def fetch_location_context(app, stop_id, now) do
Screens.Telemetry.span(
~w[screens stops stop fetch_location_context]a,
@@ -493,6 +494,7 @@ defmodule Screens.Stops.Stop do
# WTC is a special bus-only case
def get_route_type_filter(Dup, "place-wtcst"), do: [:bus]
def get_route_type_filter(Dup, _), do: [:light_rail, :subway]
+ def get_route_type_filter(Elevator, _), do: [:subway]
@spec upstream_stop_id_set(String.t(), list(list(id()))) :: MapSet.t(id())
def upstream_stop_id_set(stop_id, stop_sequences) do
@@ -530,7 +532,8 @@ defmodule Screens.Stops.Stop do
RoutePattern.fetch_tagged_parent_station_sequences_through_stop(stop_id, route_ids)
end
- defp fetch_tagged_stop_sequences_by_app(PreFare, stop_id, routes_at_stop) do
+ defp fetch_tagged_stop_sequences_by_app(app, stop_id, routes_at_stop)
+ when app in [Elevator, PreFare] do
route_ids = Route.route_ids(routes_at_stop)
# We limit results to canonical route patterns only--no stop sequences for nonstandard patterns.
diff --git a/lib/screens/v2/candidate_generator/elevator.ex b/lib/screens/v2/candidate_generator/elevator.ex
index eeefbdb50..75f47458e 100644
--- a/lib/screens/v2/candidate_generator/elevator.ex
+++ b/lib/screens/v2/candidate_generator/elevator.ex
@@ -2,8 +2,9 @@ defmodule Screens.V2.CandidateGenerator.Elevator do
@moduledoc false
alias Screens.V2.CandidateGenerator
+ alias Screens.V2.CandidateGenerator.Elevator.Closures, as: ElevatorClosures
alias Screens.V2.Template.Builder
- alias Screens.V2.WidgetInstance.{ElevatorClosures, Footer, NormalHeader}
+ alias Screens.V2.WidgetInstance.{Footer, NormalHeader}
alias ScreensConfig.Screen
alias ScreensConfig.V2.Elevator
@@ -23,16 +24,17 @@ defmodule Screens.V2.CandidateGenerator.Elevator do
|> Builder.build_template()
end
- def candidate_instances(config, now \\ DateTime.utc_now()) do
- [header_instance(config, now), elevator_closures_instance(config), footer_instance(config)]
+ def candidate_instances(
+ config,
+ now \\ DateTime.utc_now(),
+ elevator_closure_instances_fn \\ &ElevatorClosures.elevator_status_instances/2
+ ) do
+ [header_instance(config, now), footer_instance(config)] ++
+ elevator_closure_instances_fn.(config, now)
end
def audio_only_instances(_widgets, _config), do: []
- defp elevator_closures_instance(config) do
- %ElevatorClosures{screen: config, alerts: []}
- end
-
defp header_instance(%Screen{app_params: %Elevator{elevator_id: elevator_id}} = config, now) do
%NormalHeader{text: "Elevator #{elevator_id}", screen: config, time: now}
end
diff --git a/lib/screens/v2/candidate_generator/elevator/closures.ex b/lib/screens/v2/candidate_generator/elevator/closures.ex
new file mode 100644
index 000000000..f83165e72
--- /dev/null
+++ b/lib/screens/v2/candidate_generator/elevator/closures.ex
@@ -0,0 +1,160 @@
+defmodule Screens.V2.CandidateGenerator.Elevator.Closures do
+ @moduledoc false
+
+ require Logger
+
+ alias Screens.Alerts.{Alert, InformedEntity}
+ alias Screens.Facilities.Facility
+ alias Screens.Routes.Route
+ alias Screens.Stops.Stop
+ alias Screens.V2.WidgetInstance.ElevatorClosures
+ alias Screens.V2.WidgetInstance.Serializer.RoutePill
+ alias ScreensConfig.Screen
+ alias ScreensConfig.V2.Elevator
+
+ @stop Application.compile_env(:screens, [__MODULE__, :stop_module], Stop)
+ @facility Application.compile_env(:screens, [__MODULE__, :facility_module], Facility)
+ @alert Application.compile_env(:screens, [__MODULE__, :alert_module], Alert)
+ @route Application.compile_env(:screens, [__MODULE__, :route_module], Route)
+
+ @spec elevator_status_instances(Screen.t()) :: list(ElevatorClosures.t())
+ @spec elevator_status_instances(Screen.t(), DateTime.t()) :: list(ElevatorClosures.t())
+ def elevator_status_instances(
+ %Screen{
+ app_params: %Elevator{
+ elevator_id: elevator_id
+ }
+ },
+ now \\ DateTime.utc_now()
+ ) do
+ with {:ok, %Stop{id: stop_id}} <- @facility.fetch_stop_for_facility(elevator_id),
+ {:ok, location_context} <- @stop.fetch_location_context(Elevator, stop_id, now),
+ {:ok, parent_station_map} <- @stop.fetch_parent_station_name_map(),
+ {:ok, alerts} <- @alert.fetch_elevator_alerts_with_facilities() do
+ elevator_closures = relevant_alerts(alerts)
+ routes_map = get_routes_map(elevator_closures, stop_id)
+
+ {in_station_alerts, outside_alerts} =
+ split_alerts_by_location(elevator_closures, location_context)
+
+ [
+ %ElevatorClosures{
+ id: elevator_id,
+ in_station_alerts: Enum.map(in_station_alerts, &alert_to_elevator_closure/1),
+ other_stations_with_alerts:
+ format_outside_alerts(outside_alerts, parent_station_map, routes_map)
+ }
+ ]
+ else
+ :error ->
+ []
+
+ {:error, error} ->
+ Logger.error("[elevator_status_instances] #{inspect(error)}")
+ []
+ end
+ end
+
+ defp relevant_alerts(alerts) do
+ Enum.filter(alerts, &(&1.effect == :elevator_closure))
+ end
+
+ defp get_routes_map(elevator_closures, home_parent_station_id) do
+ elevator_closures
+ |> get_parent_station_ids_from_entities()
+ |> MapSet.new()
+ |> MapSet.put(home_parent_station_id)
+ |> Enum.map(fn station_id ->
+ {station_id, station_id |> route_ids_serving_stop() |> routes_to_labels()}
+ end)
+ |> Enum.into(%{})
+ end
+
+ defp get_parent_station_ids_from_entities(alerts) do
+ alerts
+ |> Enum.flat_map(fn %Alert{informed_entities: informed_entities} ->
+ informed_entities
+ |> Enum.map(fn %{stop: stop_id} -> stop_id end)
+ |> Enum.filter(&String.starts_with?(&1, "place-"))
+ end)
+ end
+
+ defp route_ids_serving_stop(stop_id) do
+ case @route.fetch(%{stop_id: stop_id}) do
+ {:ok, routes} -> routes
+ # Show no route pills instead of crashing the screen
+ :error -> []
+ end
+ end
+
+ defp routes_to_labels(routes) do
+ routes
+ |> Enum.map(fn
+ %Route{type: :subway, id: id} -> id |> String.downcase() |> String.to_atom()
+ %Route{type: :light_rail, id: "Green-" <> _} -> :green
+ %Route{type: :light_rail, id: "Mattapan" <> _} -> :mattapan
+ %Route{type: :bus, short_name: "SL" <> _} -> :silver
+ %Route{type: :rail} -> :cr
+ %Route{type: type} -> type
+ end)
+ |> Enum.uniq()
+ end
+
+ defp split_alerts_by_location(alerts, location_context) do
+ Enum.split_with(alerts, fn %Alert{informed_entities: informed_entities} ->
+ location_context.home_stop in Enum.map(informed_entities, & &1.stop)
+ end)
+ end
+
+ defp get_informed_facility(entities) do
+ entities
+ |> Enum.find_value(fn
+ %{facility: facility} -> facility
+ _ -> false
+ end)
+ end
+
+ defp alert_to_elevator_closure(%Alert{
+ id: id,
+ informed_entities: entities,
+ description: description,
+ header: header
+ }) do
+ facility = get_informed_facility(entities)
+
+ %{
+ id: id,
+ elevator_name: facility.name,
+ elevator_id: facility.id,
+ description: description,
+ header_text: header
+ }
+ end
+
+ defp format_outside_alerts(alerts, station_id_to_name, station_id_to_routes) do
+ alerts
+ |> Enum.group_by(&get_parent_station_id_from_informed_entities(&1.informed_entities))
+ |> Enum.map(fn {parent_station_id, alerts} ->
+ alerts_at_station = Enum.map(alerts, &alert_to_elevator_closure/1)
+
+ route_pills =
+ station_id_to_routes
+ |> Map.fetch!(parent_station_id)
+ |> Enum.map(&RoutePill.serialize_icon/1)
+
+ %{
+ id: parent_station_id,
+ name: Map.fetch!(station_id_to_name, parent_station_id),
+ routes: route_pills,
+ alerts: alerts_at_station
+ }
+ end)
+ end
+
+ defp get_parent_station_id_from_informed_entities(entities) do
+ entities
+ |> Enum.find_value(fn
+ ie -> if InformedEntity.parent_station?(ie), do: ie.stop
+ end)
+ end
+end
diff --git a/lib/screens/v2/screen_data/parameters.ex b/lib/screens/v2/screen_data/parameters.ex
index 2169f757a..bdea93454 100644
--- a/lib/screens/v2/screen_data/parameters.ex
+++ b/lib/screens/v2/screen_data/parameters.ex
@@ -38,7 +38,7 @@ defmodule Screens.V2.ScreenData.Parameters do
},
elevator_v2: %Static{
candidate_generator: CandidateGenerator.Elevator,
- refresh_rate: 30
+ refresh_rate: 8
},
gl_eink_v2: %Static{
audio_active_time: @all_times,
diff --git a/lib/screens/v2/widget_instance/elevator_closures.ex b/lib/screens/v2/widget_instance/elevator_closures.ex
index 59f612653..3197bbf3d 100644
--- a/lib/screens/v2/widget_instance/elevator_closures.ex
+++ b/lib/screens/v2/widget_instance/elevator_closures.ex
@@ -1,22 +1,60 @@
defmodule Screens.V2.WidgetInstance.ElevatorClosures do
@moduledoc false
- alias Screens.Alerts.Alert
- alias ScreensConfig.Screen
- alias ScreensConfig.V2.Elevator
+ alias Screens.Stops.Stop
- defstruct screen: nil,
- alerts: nil
+ defstruct ~w[id in_station_alerts other_stations_with_alerts]a
@type t :: %__MODULE__{
- screen: Screen.t(),
- alerts: list(Alert.t())
+ id: String.t(),
+ in_station_alerts: list(__MODULE__.Alert.t()),
+ other_stations_with_alerts: list(__MODULE__.Station.t())
}
- def serialize(%__MODULE__{screen: %Screen{app_params: %Elevator{elevator_id: id}}}) do
- %{id: id, in_station_alerts: [], outside_alerts: []}
+ defmodule Station do
+ @moduledoc false
+
+ alias Screens.V2.WidgetInstance.ElevatorClosures.Alert
+
+ @derive Jason.Encoder
+
+ defstruct ~w[id name routes alerts]a
+
+ @type t :: %__MODULE__{
+ id: Stop.id(),
+ name: String.t(),
+ routes: list(String.t()),
+ alerts: list(Alert.t())
+ }
+ end
+
+ defmodule Alert do
+ @moduledoc false
+
+ @derive Jason.Encoder
+
+ defstruct ~w[id elevator_name elevator_id description header_text]a
+
+ @type t :: %__MODULE__{
+ id: String.t(),
+ elevator_name: String.t(),
+ elevator_id: String.t(),
+ description: String.t(),
+ header_text: String.t()
+ }
end
+ def serialize(%__MODULE__{
+ id: id,
+ in_station_alerts: in_station_alerts,
+ other_stations_with_alerts: other_stations_with_alerts
+ }),
+ do: %{
+ id: id,
+ in_station_alerts: in_station_alerts,
+ other_stations_with_alerts: other_stations_with_alerts
+ }
+
defimpl Screens.V2.WidgetInstance do
alias Screens.V2.WidgetInstance.ElevatorClosures
diff --git a/test/screens/v2/candidate_generator/elevator/closures_test.exs b/test/screens/v2/candidate_generator/elevator/closures_test.exs
new file mode 100644
index 000000000..4ea01b3c5
--- /dev/null
+++ b/test/screens/v2/candidate_generator/elevator/closures_test.exs
@@ -0,0 +1,210 @@
+defmodule Screens.V2.CandidateGenerator.Elevator.ClosuresTest do
+ use ExUnit.Case, async: true
+
+ import Mox
+ setup :verify_on_exit!
+
+ alias Screens.Alerts.{Alert, MockAlert}
+ alias Screens.Facilities.MockFacility
+ alias Screens.LocationContext
+ alias Screens.Routes.{MockRoute, Route}
+ alias Screens.Stops.{MockStop, Stop}
+ alias Screens.V2.CandidateGenerator.Elevator.Closures, as: ElevatorClosures
+ alias ScreensConfig.Screen
+ alias ScreensConfig.V2.Elevator
+
+ describe "elevator_status_instances/5" do
+ test "Only returns alerts with effect of :elevator_closure" do
+ now = ~U[2024-10-01T05:00:00Z]
+
+ expect(MockFacility, :fetch_stop_for_facility, fn "111" ->
+ {:ok, %Stop{id: "place-test"}}
+ end)
+
+ expect(MockStop, :fetch_location_context, fn Elevator, "place-test", ^now ->
+ {:ok, %LocationContext{home_stop: "place-test"}}
+ end)
+
+ expect(MockStop, :fetch_parent_station_name_map, fn ->
+ {:ok, %{"place-test" => "Place Test"}}
+ end)
+
+ expect(MockRoute, :fetch, fn %{stop_id: "place-test"} ->
+ {:ok, [%Route{id: "Red", type: :subway}]}
+ end)
+
+ expect(MockAlert, :fetch_elevator_alerts_with_facilities, fn ->
+ alerts = [
+ struct(Alert,
+ id: "1",
+ effect: :elevator_closure,
+ informed_entities: [
+ %{stop: "place-test", facility: %{name: "Test", id: "facility-test"}}
+ ]
+ ),
+ struct(Alert,
+ effect: :detour,
+ informed_entities: [
+ %{stop: "place-test", facility: %{name: "Test 2", id: "facility-test2"}}
+ ]
+ )
+ ]
+
+ {:ok, alerts}
+ end)
+
+ [
+ %Screens.V2.WidgetInstance.ElevatorClosures{
+ id: "111",
+ in_station_alerts: [
+ %{
+ id: "1",
+ description: nil,
+ elevator_name: "Test",
+ elevator_id: "facility-test",
+ header_text: nil
+ }
+ ],
+ other_stations_with_alerts: []
+ }
+ ] =
+ ElevatorClosures.elevator_status_instances(
+ struct(Screen, app_id: :elevator_v2, app_params: %Elevator{elevator_id: "111"}),
+ now
+ )
+ end
+
+ test "Groups outside alerts by station" do
+ now = ~U[2024-10-01T05:00:00Z]
+
+ expect(MockFacility, :fetch_stop_for_facility, fn "111" ->
+ {:ok, %Stop{id: "place-test"}}
+ end)
+
+ expect(MockStop, :fetch_location_context, fn Elevator, "place-test", ^now ->
+ {:ok, %LocationContext{home_stop: "place-test"}}
+ end)
+
+ expect(MockStop, :fetch_parent_station_name_map, fn ->
+ {:ok, %{"place-haecl" => "Haymarket"}}
+ end)
+
+ expect(MockRoute, :fetch, 2, fn
+ %{stop_id: "place-haecl"} ->
+ {:ok, [%Route{id: "Orange", type: :subway}]}
+
+ %{stop_id: "place-test"} ->
+ {:ok, [%Route{id: "Red", type: :subway}]}
+ end)
+
+ expect(MockAlert, :fetch_elevator_alerts_with_facilities, fn ->
+ alerts = [
+ struct(Alert,
+ id: "1",
+ effect: :elevator_closure,
+ informed_entities: [
+ %{stop: "place-haecl", facility: %{name: "Test 1", id: "facility-test-1"}}
+ ]
+ ),
+ struct(Alert,
+ id: "2",
+ effect: :elevator_closure,
+ informed_entities: [
+ %{stop: "place-haecl", facility: %{name: "Test 2", id: "facility-test-2"}}
+ ]
+ )
+ ]
+
+ {:ok, alerts}
+ end)
+
+ [
+ %Screens.V2.WidgetInstance.ElevatorClosures{
+ id: "111",
+ in_station_alerts: [],
+ other_stations_with_alerts: [
+ %{
+ id: "place-haecl",
+ name: "Haymarket",
+ routes: [%{type: :text, text: "OL", color: :orange}],
+ alerts: [
+ %{
+ id: "1",
+ description: nil,
+ elevator_name: "Test 1",
+ elevator_id: "facility-test-1",
+ header_text: nil
+ },
+ %{
+ id: "2",
+ description: nil,
+ elevator_name: "Test 2",
+ elevator_id: "facility-test-2",
+ header_text: nil
+ }
+ ]
+ }
+ ]
+ }
+ ] =
+ ElevatorClosures.elevator_status_instances(
+ struct(Screen, app_id: :elevator_v2, app_params: %Elevator{elevator_id: "111"}),
+ now
+ )
+ end
+
+ test "Return empty routes on API error" do
+ now = ~U[2024-10-01T05:00:00Z]
+
+ expect(MockFacility, :fetch_stop_for_facility, fn "111" ->
+ {:ok, %Stop{id: "place-test"}}
+ end)
+
+ expect(MockStop, :fetch_location_context, fn Elevator, "place-test", ^now ->
+ {:ok, %LocationContext{home_stop: "place-test"}}
+ end)
+
+ expect(MockStop, :fetch_parent_station_name_map, fn ->
+ {:ok, %{"place-test" => "Place Test"}}
+ end)
+
+ expect(MockRoute, :fetch, fn %{stop_id: "place-test"} ->
+ :error
+ end)
+
+ expect(MockAlert, :fetch_elevator_alerts_with_facilities, fn ->
+ alerts = [
+ struct(Alert,
+ id: "1",
+ effect: :elevator_closure,
+ informed_entities: [
+ %{stop: "place-test", facility: %{name: "Test", id: "facility-test"}}
+ ]
+ )
+ ]
+
+ {:ok, alerts}
+ end)
+
+ [
+ %Screens.V2.WidgetInstance.ElevatorClosures{
+ id: "111",
+ in_station_alerts: [
+ %{
+ id: "1",
+ description: nil,
+ elevator_name: "Test",
+ elevator_id: "facility-test",
+ header_text: nil
+ }
+ ],
+ other_stations_with_alerts: []
+ }
+ ] =
+ ElevatorClosures.elevator_status_instances(
+ struct(Screen, app_id: :elevator_v2, app_params: %Elevator{elevator_id: "111"}),
+ now
+ )
+ end
+ end
+end
diff --git a/test/screens/v2/widget_instance/elevator_closures_test.exs b/test/screens/v2/widget_instance/elevator_closures_test.exs
index 96ef7e858..71750b1c6 100644
--- a/test/screens/v2/widget_instance/elevator_closures_test.exs
+++ b/test/screens/v2/widget_instance/elevator_closures_test.exs
@@ -3,14 +3,35 @@ defmodule Screens.V2.WidgetInstance.ElevatorClosuresTest do
alias Screens.V2.WidgetInstance
alias Screens.V2.WidgetInstance.ElevatorClosures
- alias ScreensConfig.Screen
- alias ScreensConfig.V2.Elevator
setup do
%{
instance: %ElevatorClosures{
- screen: struct(Screen, %{app_params: %Elevator{elevator_id: "111"}}),
- alerts: []
+ id: "111",
+ in_station_alerts: [
+ %ElevatorClosures.Alert{
+ description: "Test Alert Description",
+ elevator_name: "Test Elevator",
+ elevator_id: "111",
+ id: "1",
+ header_text: "Test Alert Header"
+ }
+ ],
+ other_stations_with_alerts: [
+ %ElevatorClosures.Station{
+ name: "Forest Hills",
+ routes: ["Orange"],
+ alerts: [
+ %ElevatorClosures.Alert{
+ description: "FH Alert Description",
+ elevator_name: "FH Elevator",
+ elevator_id: "222",
+ id: "2",
+ header_text: "FH Alert Header"
+ }
+ ]
+ }
+ ]
}
}
end
@@ -23,11 +44,7 @@ defmodule Screens.V2.WidgetInstance.ElevatorClosuresTest do
describe "serialize/1" do
test "returns map with id and alerts", %{instance: instance} do
- assert %{
- id: "111",
- in_station_alerts: [],
- outside_alerts: []
- } == WidgetInstance.serialize(instance)
+ assert Map.from_struct(instance) == WidgetInstance.serialize(instance)
end
end
diff --git a/test/support/mocks.ex b/test/support/mocks.ex
index d035ff3b4..4cad77793 100644
--- a/test/support/mocks.ex
+++ b/test/support/mocks.ex
@@ -3,3 +3,6 @@ Mox.defmock(Screens.RoutePatterns.MockRoutePattern, for: Screens.RoutePatterns.R
Mox.defmock(Screens.Stops.MockStop, for: Screens.Stops.Stop)
Mox.defmock(Screens.V2.MockDeparture, for: Screens.V2.Departure)
Mox.defmock(Screens.V2.ScreenData.MockParameters, for: Screens.V2.ScreenData.Parameters)
+Mox.defmock(Screens.Facilities.MockFacility, for: Screens.Facilities.Facility)
+Mox.defmock(Screens.Alerts.MockAlert, for: Screens.Alerts.Alert)
+Mox.defmock(Screens.Routes.MockRoute, for: Screens.Routes.Route)