Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Planned disruptions logic #2373

Merged
merged 69 commits into from
Feb 11, 2025
Merged
Show file tree
Hide file tree
Changes from 68 commits
Commits
Show all changes
69 commits
Select commit Hold shift + click to select a range
946ba02
date_time module and tests
anthonyshull Feb 7, 2025
ce6f9c1
docs
anthonyshull Feb 7, 2025
59eeb0e
linting
anthonyshull Feb 7, 2025
8de7cfd
some cleanup
anthonyshull Feb 7, 2025
1fcecd4
some cleanup
anthonyshull Feb 7, 2025
9d0d291
remove broken tests and add new tests
anthonyshull Feb 8, 2025
5be4845
this week test
anthonyshull Feb 8, 2025
2b9de8d
make service rollover time configurable
anthonyshull Feb 8, 2025
19f1523
split into two modules
anthonyshull Feb 8, 2025
dc8ada9
more tests
anthonyshull Feb 8, 2025
5e707d7
100% coverage
anthonyshull Feb 8, 2025
b454b57
some docs
anthonyshull Feb 8, 2025
c17011b
some docs
anthonyshull Feb 8, 2025
b1ff2e8
format
anthonyshull Feb 10, 2025
c77b10e
helper funcs
anthonyshull Feb 10, 2025
697bd25
move test to helper func
anthonyshull Feb 10, 2025
4029270
docs
anthonyshull Feb 10, 2025
cf25e6b
format
anthonyshull Feb 10, 2025
f4235b2
move private funcs to factory
anthonyshull Feb 10, 2025
7e46668
format
anthonyshull Feb 10, 2025
9432320
alphabetize config
anthonyshull Feb 10, 2025
e4d0e8a
remove unused import
anthonyshull Feb 10, 2025
dfcd997
service range and tests
anthonyshull Feb 10, 2025
b1186ee
PR feedback
anthonyshull Feb 10, 2025
ce97242
microsecond fidelity
anthonyshull Feb 10, 2025
f890a33
microsecond fidelity
anthonyshull Feb 10, 2025
04b942a
nil in range and docs
anthonyshull Feb 10, 2025
f757d30
more tests
anthonyshull Feb 10, 2025
189331e
docs
anthonyshull Feb 10, 2025
ff57b26
move in_range?
anthonyshull Feb 10, 2025
5d7931d
call it date time range
anthonyshull Feb 10, 2025
26dd3f1
docs
anthonyshull Feb 10, 2025
1510905
docs
anthonyshull Feb 10, 2025
dfff32f
one hundred percent
anthonyshull Feb 10, 2025
912da3a
spec ref
anthonyshull Feb 10, 2025
bf8e064
format
anthonyshull Feb 10, 2025
cad2bf8
tests
anthonyshull Feb 10, 2025
f534005
type spec
anthonyshull Feb 10, 2025
d591ba0
more date time tests
anthonyshull Feb 10, 2025
d8fa158
typo
anthonyshull Feb 10, 2025
31d65f1
some renaming
anthonyshull Feb 11, 2025
1c66c3c
docs
anthonyshull Feb 11, 2025
d7e62b4
naming and tests
anthonyshull Feb 11, 2025
5b83bb2
remove log and allow error
anthonyshull Feb 11, 2025
f8dfc84
add a mock for date_time
anthonyshull Feb 11, 2025
5aa779a
mocks, tests, format
anthonyshull Feb 11, 2025
da82601
docs
anthonyshull Feb 11, 2025
4847945
one hundred percent coverage
anthonyshull Feb 11, 2025
c75c844
make test more clear
anthonyshull Feb 11, 2025
65f4d26
move test
anthonyshull Feb 11, 2025
d2f86fe
docs
anthonyshull Feb 11, 2025
9c42b43
start work
anthonyshull Feb 10, 2025
24ec694
logic looks solid
anthonyshull Feb 10, 2025
13e38c7
use helper func
anthonyshull Feb 10, 2025
f8afecb
tests
anthonyshull Feb 10, 2025
6490bac
tests
anthonyshull Feb 10, 2025
075ad59
tests
anthonyshull Feb 10, 2025
2a6d31b
one hundred
anthonyshull Feb 10, 2025
e35c0ad
dialyzer
anthonyshull Feb 10, 2025
b69d409
format
anthonyshull Feb 10, 2025
a4196ca
specs
anthonyshull Feb 10, 2025
837a383
tests
anthonyshull Feb 10, 2025
0626b87
spec error
anthonyshull Feb 10, 2025
e29f2f5
change tests
anthonyshull Feb 11, 2025
342e158
type check
anthonyshull Feb 11, 2025
946552f
type check
anthonyshull Feb 11, 2025
84b0098
pr feedback
anthonyshull Feb 11, 2025
e500528
make behaviour load consistent
anthonyshull Feb 11, 2025
69c0a03
remove livebook
anthonyshull Feb 11, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ config :dotcom, :redix, Redix
config :dotcom, :redix_pub_sub, Redix.PubSub

config :dotcom, :repo_modules,
alerts: Alerts.Repo,
predictions: Predictions.Repo,
route_patterns: RoutePatterns.Repo,
routes: Routes.Repo,
Expand Down
1 change: 1 addition & 0 deletions config/test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ config :dotcom, :mbta_api_module, MBTA.Api.Mock
config :dotcom, :location_service, LocationService.Mock

config :dotcom, :repo_modules,
alerts: Alerts.Repo.Mock,
predictions: Predictions.Repo.Mock,
route_patterns: RoutePatterns.Repo.Mock,
routes: Routes.Repo.Mock,
Expand Down
5 changes: 4 additions & 1 deletion lib/alerts/repo.ex
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
defmodule Alerts.Repo do
alias Alerts.{Alert, Banner, Priority}
alias Alerts.Cache.Store
alias Alerts.Repo.Behaviour

@behaviour Behaviour

@spec all(DateTime.t()) :: [Alert.t()]
def all(now) do
Expand All @@ -17,7 +20,7 @@ defmodule Alerts.Repo do
Store.alert(id)
end

@spec by_route_ids([String.t()], DateTime.t()) :: [Alert.t()]
@impl Behaviour
def by_route_ids([], _now) do
[]
end
Expand Down
12 changes: 12 additions & 0 deletions lib/alerts/repo/behaviour.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
defmodule Alerts.Repo.Behaviour do
@moduledoc """
Behaviour for the Alerts repo.
"""

alias Alerts.Alert

@doc """
Return all alerts for the given route ids.
"""
@callback by_route_ids([String.t()], DateTime.t()) :: [Alert.t()]
end
23 changes: 23 additions & 0 deletions lib/dotcom/alerts.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
defmodule Dotcom.Alerts do
@moduledoc """
A collection of functions that help to work with alerts in a unified way.
"""

alias Alerts.Alert

@service_impacting_effects [:delay, :shuttle, :suspension, :station_closure]

@doc """
Does the alert have an effect that is considered service-impacting?
"""
@spec service_impacting_alert?(Alert.t()) :: boolean()
def service_impacting_alert?(%Alert{effect: effect}) do
effect in @service_impacting_effects
end

@doc """
Returns a list of the alert effects that are considered service-impacting.
"""
@spec service_impacting_effects() :: [atom()]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: I think you could move the type from SystemStatus.Alerts here.

(When you started on this PR, I don't think the PR introducing that type had been merged yet)

def service_impacting_effects(), do: @service_impacting_effects
end
55 changes: 55 additions & 0 deletions lib/dotcom/alerts/disruptions/subway.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
defmodule Dotcom.Alerts.Disruptions.Subway do
@moduledoc """
Disruptions are alerts that have `service_impacting_effects` grouped by `service_range`.
"""

import Dotcom.Alerts, only: [service_impacting_alert?: 1]
import Dotcom.Routes, only: [subway_route_ids: 0]
import Dotcom.Utils.ServiceDateTime, only: [service_range: 1]

alias Alerts.Alert
alias Dotcom.Utils

@alerts_repo Application.compile_env!(:dotcom, :repo_modules)[:alerts]

@doc """
Disruptions that occur any time after today's service range.
"""
@spec future_disruptions() :: %{Utils.ServiceDateTime.named_service_range() => [Alert.t()]}
def future_disruptions() do
disruption_groups() |> Map.take([:later_this_week, :next_week, :after_next_week])
end

@doc """
Disruptions that occur during today's service range.
"""
@spec todays_disruptions() :: %{today: [Alert.t()]}
def todays_disruptions() do
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I may be reading this wrong, but it looks like this will include alerts from earlier today, even if their effect periods have ended. If so, we should double-check the requirements, because I had thought that we only want to include alerts if they're either currently active or will be active later in the day.

Either way, I think it would be good to have a test explicitly highlighting the "earlier-today" alerts behavior.

Copy link
Contributor Author

@anthonyshull anthonyshull Feb 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is grabbing today's disruptions. If the view doesn't want ones that are earlier than now they can remove them. The intention here isn't to do all of the work for the only current consumer. They will still have to do grouping and sorting by line if they want it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And, it will only get those alerts if the effect period ended after the start of the service day. So, the disruption effected 'today'.

disruption_groups() |> Map.take([:today])
end

# Groups all disruption alerts by service range.
#
# 1. Gets all alerts for subway routes.
# 2. Filters out non-service-impacting alerts
# 3. Groups them according to service range.
defp disruption_groups() do
subway_route_ids()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this will work correctly (unless I missed a change in by_route_ids/2. Green line alerts aren't associated with the route ID "Green" - they're associated with the branches - "Green-B"/etc. This will also miss Mattapan alerts, I think.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was pulling Green Line alerts with this code. I can check to see if we want Mattapan alerts to show up in this component. Right now it only shows the main lines. Easy to add if we need/want them.

|> @alerts_repo.by_route_ids(Utils.DateTime.now())
|> Enum.filter(&service_impacting_alert?/1)
|> Enum.reduce(%{}, &group_alerts/2)
end

# Looks at every active period for an alert and groups that alert by service range.
# Alerts can overlap service ranges, in which case we want them to appear in both.
defp group_alerts(alert, groups) do
alert
|> Map.get(:active_period)
|> Enum.map(fn {start, stop} -> [service_range(start), service_range(stop)] end)
|> List.flatten()
|> Enum.uniq()
|> Enum.reduce(groups, fn service_range, groups ->
Map.update(groups, service_range, [alert], &(&1 ++ [alert]))
end)
end
end
13 changes: 13 additions & 0 deletions lib/dotcom/routes.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
defmodule Dotcom.Routes do
@moduledoc """
A collection of functions that help to work with routes in a unified way.
"""

@subway_route_ids ["Blue", "Green", "Orange", "Red"]
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can see a future where we work branches/lines/routes and relationships into here in some way.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couple of thoughts on this line of code in particular:

Idea: Would it be worth having a type for line ID that only allows these four values? Then the type of subway_route_ids would be [Dotcom.Routes.line_id()].

Also, the name subway_route_ids() is misleading, because "Green" is not a Route ID - the subway Route ID's are "Red", "Mattapan", "Orange", "Green-B", "Green-C", "Green-D", "Green-E", and "Blue". I'd call this subway_line_ids or subway_lines.

And finally - love extracting this and putting it somewhere clean and universal!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Eventually, we won't need this because we'll just be able to query routes by type.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And then query alerts by whatever attributes.


@doc """
Returns a list of route ids for all subway routes.
"""
@spec subway_route_ids() :: [String.t()]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Praise This is a better place for this than where it was - nice to see this attr gradually migrating to its optimal home 🏠

def subway_route_ids(), do: @subway_route_ids
end
10 changes: 3 additions & 7 deletions lib/dotcom/system_status/alerts.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,9 @@ defmodule Dotcom.SystemStatus.Alerts do
belong in the main `Alerts` module.
"""

import Dotcom.Alerts, only: [service_impacting_alert?: 1]

@type service_effect_t :: :delay | :shuttle | :suspension | :station_closure
@service_effects [:delay, :shuttle, :suspension, :station_closure]
@doc """
Returns a list of the alert effects that are considered
service-impacting.
"""
def service_effects(), do: @service_effects

@doc """
Returns `true` if the alert is active at some point during the day
Expand Down Expand Up @@ -92,7 +88,7 @@ defmodule Dotcom.SystemStatus.Alerts do
}
"""
def filter_relevant(alerts) do
alerts |> Enum.filter(fn %{effect: effect} -> effect in @service_effects end)
alerts |> Enum.filter(&service_impacting_alert?/1)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice!

I could eventually see this whole module getting lumped into Dotcom.Alerts, but that's a project for a future day.

end

# Returns true if the alert (as signified by the active_period_start provided)
Expand Down
2 changes: 2 additions & 0 deletions lib/dotcom/utils/date_time.ex
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ defmodule Dotcom.Utils.DateTime do
Timex will return an error if the time occurs when we "spring-forward" in DST transitions.
That is because one hour does not occur--02:00:00am to 03:00:00am.
In that case, we set the time to 03:00:00am.

If we are given something tha tis not a DateTime, AmbiguousDateTime, or an error tuple, we log the input and return `now`.
Comment on lines +42 to +43
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
If we are given something tha tis not a DateTime, AmbiguousDateTime, or an error tuple, we log the input and return `now`.

Begone!

"""
@impl Behaviour
def coerce_ambiguous_date_time(%DateTime{} = date_time), do: date_time
Expand Down
124 changes: 124 additions & 0 deletions livebooks/planned-disruptions.livemd
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# Planned Disruptions

## Section

```elixir
defmodule Dotcom.PlannedDisruptions.Display do
@moduledoc false

def buckets_to_output(buckets) do
Enum.each(buckets, &bucket_to_output/1)
end

defp alert_to_lines(alert) do
Enum.map(alert.informed_entity.entities, & &1.route)
|> Enum.uniq()
|> Enum.sort()
|> Enum.join(" / ")
end

def alert_to_output(alert) do
{start, stop} = time_range(alert.active_period)
lines = alert_to_lines(alert)
effect = alert.effect |> Atom.to_string() |> String.upcase()

IO.puts("#{lines} [#{effect}] #{format_date(start)}-#{format_date(stop)}")
IO.puts("---------------")
IO.puts(alert.header)
IO.puts("\n")
end

defp bucket_to_output({key, alerts}) do
key |> Atom.to_string() |> Recase.to_title() |> IO.puts()

IO.puts("===============")

Enum.each(alerts, &alert_to_output/1)

IO.puts("\n")
end

defp format_date(datetime) do
datetime |> Util.service_date() |> Timex.format!("%a %b %d", :strftime)
end

defp time_range(active_periods) do
periods = Enum.sort_by(active_periods, fn {start, _} -> start end)

{start, _} = List.first(periods)
{_, stop} = List.last(periods)

{start, stop}
end
end
```

```elixir
defmodule DotcomWeb.Components.PlannedDisruptions do
use Phoenix.Component

import MbtaMetro.Components.Accordion, only: [accordion: 1]

def disruptions(assigns) do
~H"""
<div :for={{service_range, alerts} <- @disruptions}>
<h2><%= service_range_string(service_range) %></h2>
<.disruption :for={alert <- alerts} alert={alert} />
</div>
"""
end

defp disruption(assigns) do
~H"""
<.accordion >
<:heading>
<.heading alert={@alert} />
</:heading>
<:content>
<.content alert={@alert} />
</:content>
</.accordion>
"""
end

defp heading(assigns) do
~H"""
<% {start, stop} = alert_date_time_range(@alert) %>
<%= "[#{@alert.effect}] #{format_date(start)}-#{format_date(stop)}" %>
"""
end

defp content(assigns) do
~H"""

"""
end

defp alert_date_time_range(%Alerts.Alert{active_period: active_period}) do
periods = Enum.sort_by(active_period, fn {start, _} -> start end)

{start, _} = List.first(periods)
{_, stop} = List.last(periods)

{start, stop}
end

defp format_date(datetime) do
datetime |> Util.service_date() |> Timex.format!("%a %b %d", :strftime)
end

defp service_range_string(service_range) do
service_range
|> Atom.to_string()
|> Recase.to_title()
end
end

alias DotcomWeb.Components.PlannedDisruptions

assigns = %{
disruptions: Dotcom.Alerts.Disruptions.Subway.future_disruptions()
}

PlannedDisruptions.disruptions(assigns) |> KinoLiveComponent.component()
```
92 changes: 92 additions & 0 deletions test/dotcom/alerts/disruptions/subway_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
defmodule Dotcom.Alerts.Disruptions.SubwayTest do
use ExUnit.Case

import Dotcom.Alerts, only: [service_impacting_effects: 0]
import Dotcom.Alerts.Disruptions.Subway

import Dotcom.Utils.ServiceDateTime,
only: [
service_range_day: 0,
service_range_later_this_week: 0,
service_range_next_week: 0,
service_range_after_next_week: 0
]

import Mox
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't forget the setup :verify_on_exit! cleanup! ✨


alias Test.Support.Factories

setup :verify_on_exit!

setup _ do
stub_with(Dotcom.Utils.DateTime.Mock, Dotcom.Utils.DateTime)

:ok
end

describe "future_disruptions/0" do
test "returns an empty map when there are no alerts" do
expect(Alerts.Repo.Mock, :by_route_ids, fn _route_ids, _now ->
[]
end)

# Exercise/Verify
assert %{} = future_disruptions()
end

test "returns alerts for later this week, next week, and after next week" do
# Setup
alert_today = service_range_day() |> disruption_alert()
alert_later_this_week = service_range_later_this_week() |> disruption_alert()
alert_next_week = service_range_next_week() |> disruption_alert()

{alert_after_next_week_start, _} = service_range_after_next_week()

alert_after_next_week =
{alert_after_next_week_start, Timex.shift(alert_after_next_week_start, days: 1)}
|> disruption_alert()

expect(Alerts.Repo.Mock, :by_route_ids, fn _route_ids, _now ->
[alert_today, alert_later_this_week, alert_next_week, alert_after_next_week]
end)

# Exercise/Verify
assert %{
later_this_week: [^alert_later_this_week],
next_week: [^alert_next_week],
after_next_week: [^alert_after_next_week]
} = future_disruptions()
end
end

describe "todays_disruptions/0" do
test "returns an empty map when there are no alerts" do
expect(Alerts.Repo.Mock, :by_route_ids, fn _route_ids, _now ->
[]
end)

# Exercise/Verify
assert %{} = todays_disruptions()
end

test "returns alerts for today only" do
# Setup
alert_today = service_range_day() |> disruption_alert()
alert_next_week = service_range_next_week() |> disruption_alert()

expect(Alerts.Repo.Mock, :by_route_ids, fn _route_ids, _now ->
[alert_today, alert_next_week]
end)

# Exercise/Verify
assert %{today: [^alert_today]} = todays_disruptions()
end
end

defp disruption_alert(active_period) do
Factories.Alerts.Alert.build(:alert,
active_period: [active_period],
effect: service_impacting_effects() |> Faker.Util.pick()
)
end
end
Loading
Loading