-
Notifications
You must be signed in to change notification settings - Fork 16
feat(ScheduleFinderLive): services picker #2849
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
Conversation
joshlarson
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Two preliminary code-level comments, plus...
As long as we're filtering out No Schools services, should we add in a way for a trip in the daily schedule to be marked as a school trip? The current behavior shows those trips on the M-Th schedule, with no way to tell that they don't run every Monday thru Thursday.
I imagine that that's in follow-up ticket territory, but just wanted to flag it, in case!
# Conflicts: # lib/services/service.ex
b0c31d0 to
bb54acf
Compare
joshlarson
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks great!
I left a bunch of style comments. None are blocking.
The one that's bugging me the most is the intermittent warning one - if you want to merge before fixing that, that's fine, and then I'll probably want one of us to follow up sooner rather than later to make the warnings go away.
| end | ||
| end | ||
|
|
||
| defp sort_services({:typical, typical_key, label}) do |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: Similar to an earlier comment, this doesn't actually sort anything - this is a mapper 🙂
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
renamed!
| 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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Praise: This whole function reads quite nicely, and makes it super clear what it's doing! Each step is a bit confusing (just inherently, because the problem is confusing), but this top-level function-call chain ties it all together nicely and makes the confusion a lot easier to sift through.
| # 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). |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Was this comment copy-pasted from somewhere? The fall 2019 rating was a long time ago
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yup!!
| |> Enum.sort_by(fn {{group_key, _}, _} -> | ||
| [:current, :future, :extra, :holiday, :planned] | ||
| |> Enum.find_index(&(&1 == group_key)) | ||
| end) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Suggestion: There's an opportunity here to extract some of these nested anonymous functions into named functions.
e.g.
|> #...
|> Enum.sort_by(&group_sort_order/1)
|> #...
#...
defp group_sort_order({{group_ley, _}, _}) do
[:current, :future, :extra, :holiday, :planned]
|> Enum.find_index(&(&1 == group_key))
endThere's kind of a lot going on here, and I bet that extracting more named functions would make it a decent bit easier to follow.
Another thought is that having to pattern-match on {group_key, _} and later {_, group_label}, where those tuples are defined in a different module, couples this module pretty tightly to the ServicePatterns one in an implicit way, since tuples are usually things like {:ok, data} | :error or things where the shape depends on the "type", like {:trip, trip_data} | {:route, route_data}, etc.
In this case, some remedies that I can think of are:
- Change it to a map -
%{key: group_key, label: group_label} - Make
group_labelandgroup_keytop-level values in the map returned byServicePatterns.to_service_pattern/1. So instead of%{..., group_label: {key, label}, ...}, have%{..., group_key: key, group_label: label, ...}. - Drop
group_labelentirely at this level and compute it in the frontend. This feels the most Right™ in a sense, because the label is a presentational concern, but does mean that we'd have to return more detailed fields out ofServicePatterns/ServiceGroups.
| test "splits multi-holiday services" do | ||
| route_id = FactoryHelpers.build(:id) | ||
| holiday_count = Faker.random_between(2, 4) | ||
|
|
||
| added_dates = | ||
| Faker.Util.sample_uniq(holiday_count, fn -> | ||
| Faker.random_between(2, 6) |> Faker.Date.forward() |> Date.to_string() | ||
| end) | ||
|
|
||
| added_dates_notes = | ||
| Map.new(added_dates, fn d -> | ||
| {d, Faker.Commerce.product_name()} | ||
| end) | ||
|
|
||
| service = | ||
| build(:service, | ||
| typicality: :holiday_service, | ||
| added_dates: added_dates, | ||
| added_dates_notes: added_dates_notes, | ||
| end_date: Faker.Date.forward(11) | ||
| ) | ||
|
|
||
| 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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This test is intermittently generating the following warning:
warning: a negative range was inferred for Date.range/2, call Date.range/3 instead with -1 as third argument
(elixir 1.18.4) lib/calendar/date.ex:111: Date.range/2
(dotcom 0.0.1) lib/services/service.ex:176: Services.Service.all_valid_dates_for_service/1
(dotcom 0.0.1) lib/dotcom/service_patterns.ex:167: anonymous fn/1 in Dotcom.ServicePatterns.to_service_pattern/1
(elixir 1.18.4) lib/enum.ex:1714: Enum."-map/2-lists^map/1-1-"/2
(dotcom 0.0.1) lib/dotcom/service_patterns.ex:164: Dotcom.ServicePatterns.to_service_pattern/1
test/dotcom/service_patterns_test.exs:123: Dotcom.ServicePatternsTest."test for_route/1 splits multi-holiday services"/1
(ex_unit 1.18.4) lib/ex_unit/runner.ex:511: ExUnit.Runner.exec_test/2
(ex_unit 1.18.4) lib/ex_unit/capture_log.ex:113: ExUnit.CaptureLog.with_log/2
(ex_unit 1.18.4) lib/ex_unit/runner.ex:460: anonymous fn/3 in ExUnit.Runner.maybe_capture_log/3
(stdlib 7.0.2) timer.erl:599: :timer.tc/2
(ex_unit 1.18.4) lib/ex_unit/runner.ex:433: anonymous fn/6 in ExUnit.Runner.spawn_test_monitor/4Seed 574611 does the trick, for instance - mix test test/dotcom/service_patterns_test.exs --seed 574611.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Was able to fix in feedback: fix test warning
| {index, label} | ||
| end | ||
|
|
||
| defp sort_services({typicality, key, label}) do |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If I'm reading this correctly, this is actually two separate sort_services functions. One that provides a sort order for :typical services, and one that provides a sort order for services with any other typicality, with the assumption that by the time those lists get to the sort_services phase, there won't be any intermingling between the two types.
I have mixed feelings about this. On the one hand, it's pretty elegant, because it means we can just call Enum.sort_by(&sort_services/1) and let sort_services figure out whether it's mapping :typical or a-:typical services.
On the other hand, this feels like something that could come back to bite, if this is ever given a list of services that includes services that span typicality, since then we'll be comparing {index, label} to {typicality, to_string(key), label}, which could lead to surprising results (my guess is that :typical services will get interleaved with everything else, because index and typicality are both single-digit integers).
Three ideas for addressing this:
- Explicitly make them separate functions
- Make the return types match more closely, maybe by having the typical case return
{0, index, ...}and having the atypical case return{1, typicality, ...}, so that typical will guaranteed get sorted first. - Don't worry about it, because if this does ever bite, we'll notice.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I opted for #2 for clarity in feedback(ServiceGroups): clearer mapping/sorting
| # 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 | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Praise: Thanks for moving this to its own backend module! Having all of this meaningful service-logic hiding out in a controller was pretty confusing!
Co-authored-by: Josh Larson <jlarson@mbta.com>
Summary of changes
Asana Ticket: Service selector: show dropdown to toggle between types of schedules
Most of these features are in parity with our current schedule finder:
<optgroup>) are edited for clarity, including dates when helpfulI also attempted to add some quality of life improvements around clarifying things like planned work, or extra service. Look at a commuter rail or Green Line for more on that.
I ended up building off of
DotcomWeb.ScheduleController.LineController.services/2(which powers the current Schedule Finder), though I ended up discarding its calculation ofdefault_service?because the one I wrote (captured in:now_dateand:next_dateproperties) works better (especially for cases with modified service). Ha!Still to come