Skip to content

Conversation

@thecristen
Copy link
Collaborator

@thecristen thecristen commented Dec 18, 2025

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:

  • Default selection should reflect the current service day
    • If there happens to be no service on the current day, the default selection should be whichever service is next chronologically. e.g. if I look at 351 over the weekend (it doesn't run on weekends), it should select the weekday one.
  • Service descriptions and group names (in <optgroup>) are edited for clarity, including dates when helpful
    • Holidays are split out so we can show them one at a time
  • Shows a no service message at times.

I 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 of default_service? because the one I wrote (captured in :now_date and :next_date properties) works better (especially for cases with modified service). Ha!

Still to come

  • All the testing
  • More complete translations, maybe some refactoring around that
  • Followup tickets for the following:
    • Verifying the accessibility of this interaction
    • Maybe showing a nicer loading state??

@thecristen thecristen requested a review from a team as a code owner December 18, 2025 18:25
@thecristen thecristen added the dev-green Deploy to dev-green label Dec 18, 2025
@joshlarson joshlarson added dev-blue Deploy to dev-blue and removed dev-green Deploy to dev-green labels Dec 23, 2025
Copy link
Contributor

@joshlarson joshlarson left a 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!

@thecristen thecristen removed the dev-blue Deploy to dev-blue label Jan 2, 2026
@thecristen thecristen force-pushed the cbj/sf2.0-service-pick branch from b0c31d0 to bb54acf Compare January 2, 2026 18:12
@thecristen thecristen changed the title wip(ScheduleFinderLive): a new services picker! feat(ScheduleFinderLive): services picker Jan 2, 2026
Copy link
Contributor

@joshlarson joshlarson left a 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
Copy link
Contributor

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 🙂

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

renamed!

Comment on lines +33 to +44
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
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 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).
Copy link
Contributor

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

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

yup!!

Comment on lines 36 to 39
|> Enum.sort_by(fn {{group_key, _}, _} ->
[:current, :future, :extra, :holiday, :planned]
|> Enum.find_index(&(&1 == group_key))
end)
Copy link
Contributor

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))
end

There'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_label and group_key top-level values in the map returned by ServicePatterns.to_service_pattern/1. So instead of %{..., group_label: {key, label}, ...}, have %{..., group_key: key, group_label: label, ...}.
  • Drop group_label entirely 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 of ServicePatterns/ServiceGroups.

Comment on lines 97 to 126
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
Copy link
Contributor

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/4

Seed 574611 does the trick, for instance - mix test test/dotcom/service_patterns_test.exs --seed 574611.

Copy link
Collaborator Author

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
Copy link
Contributor

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:

  1. Explicitly make them separate functions
  2. 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.
  3. Don't worry about it, because if this does ever bite, we'll notice.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Comment on lines -86 to -146
# 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

Copy link
Contributor

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!

@thecristen thecristen merged commit 56fda44 into main Jan 5, 2026
17 checks passed
@thecristen thecristen deleted the cbj/sf2.0-service-pick branch January 5, 2026 19:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants