Let the source page say whether service-day filtering is working

8c2ccfc · Gabriel Morell · 2026-09-01 20:14

12 files +115 -0
Message
{commit_body(@commit)}

Files changed

modified apps/room_gtfs/lib/room_gtfs/gtfs_worker.ex
+30 −0
@@ -589,6 +589,17 @@ defmodule RoomGtfs.Worker do
589 589 def source_stats(id) do
590 590 %{
591 591 calendars: Storage.count_calendars(id),
592 + # The three numbers that say whether service-day filtering is working for
593 + # this source, which a count of rows on its own does not.
594 + #
595 + # calendar_dates -- did calendar_dates.txt import at all
596 + # services_today -- zero means the filter stands down entirely and
597 + # the board shows every pattern, running or not
598 + # trips_no_service -- trips kept because neither file mentions their
599 + # service; a large share means the same thing
600 + calendar_dates: Storage.count_calendar_dates(id),
601 + services_today: services_today(id),
602 + trips_no_service: Storage.count_trips_without_service(id),
592 603 directions: Storage.count_directions(id),
593 604 routes: Storage.count_routes(id),
594 605 shapes: Storage.count_shapes(id),
@@ -598,6 +609,25 @@ defmodule RoomGtfs.Worker do
598 609 rt: RoomGtfs.Worker.RT.stats(id),
599 610 }
600 611 end
612 +
613 + # In the source's own timezone: a board in Boston rolls over to the next
614 + # service day at midnight there, not at midnight UTC, and for part of every
615 + # day the two disagree about which services are running.
616 + defp services_today(id) do
617 + tz =
618 + case Configuration.get_source!(:bare, id) do
619 + %{config: %{tz: tz}} when is_binary(tz) -> tz
620 + _otherwise -> "Etc/UTC"
621 + end
622 +
623 + tz
624 + |> DateTime.now!()
625 + |> DateTime.to_date()
626 + |> then(&Storage.services_on(id, &1))
627 + |> MapSet.size()
628 + rescue
629 + _ -> 0
630 + end
601 631 end
602 632
603 633 defmodule RoomGtfs.Worker.RT do
modified apps/room_sanctum/lib/room_sanctum/storage.ex
+38 −0
@@ -273,6 +273,44 @@ defmodule RoomSanctum.Storage do
273 273 CalendarDate.changeset(calendar_date, attrs)
274 274 end
275 275
276 + @doc """
277 + How many of a source's trips run on a service neither calendar file mentions.
278 +
279 + These are the trips the arrival filter keeps rather than hides, on the
280 + grounds that not knowing when something runs is not the same as knowing it
281 + does not. A large number here means the filter is mostly not filtering, which
282 + is worth being able to see rather than infer from a board that looks wrong.
283 + """
284 + def count_trips_without_service(source_id) do
285 + # Fully qualified: the Trip alias is introduced further down the file, and
286 + # `from` does not resolve the module until this runs -- so the short name
287 + # compiles here and fails at the first call.
288 + from(t in RoomSanctum.Storage.GTFS.Trip,
289 + as: :trip,
290 + where: t.source_id == ^source_id,
291 + where:
292 + not exists(
293 + from(c in Calendar,
294 + where:
295 + c.source_id == parent_as(:trip).source_id and
296 + c.service_id == parent_as(:trip).service_id,
297 + select: 1
298 + )
299 + ),
300 + where:
301 + not exists(
302 + from(cd in CalendarDate,
303 + where:
304 + cd.source_id == parent_as(:trip).source_id and
305 + cd.service_id == parent_as(:trip).service_id,
306 + select: 1
307 + )
308 + ),
309 + select: count(t.id)
310 + )
311 + |> Repo.one()
312 + end
313 +
276 314 @doc """
277 315 The service ids running on a date, as a MapSet.
278 316
modified apps/room_sanctum/test/room_sanctum/calendar_dates_test.exs
+47 −0
@@ -74,6 +74,53 @@ defmodule RoomSanctum.CalendarDatesTest do
74 74 end
75 75 end
76 76
77 + describe "count_trips_without_service/1" do
78 + alias RoomSanctum.Storage.GTFS.Trip
79 +
80 + defp trip(source, service_id) do
81 + Repo.insert!(%Trip{
82 + source_id: source.id,
83 + trip_id: "t-#{service_id}-#{System.unique_integer([:positive])}",
84 + route_id: "r1",
85 + service_id: service_id
86 + })
87 + end
88 +
89 + test "counts the trips the arrival filter has to keep on faith", %{source: source} do
90 + calendar(source, "known", [:tuesday], ~D[2026-08-31], ~D[2026-09-05])
91 + trip(source, "known")
92 + trip(source, "orphan-a")
93 + trip(source, "orphan-b")
94 +
95 + assert Storage.count_trips_without_service(source.id) == 2
96 + end
97 +
98 + test "an exception counts as knowing, even with no calendar row", %{source: source} do
99 + exception(source, "by-exception", ~D[2026-09-01], 1)
100 + trip(source, "by-exception")
101 +
102 + assert Storage.count_trips_without_service(source.id) == 0
103 + end
104 +
105 + test "another source's trips are not counted", %{source: source} do
106 + {:ok, other_user} =
107 + Accounts.register_user(%{
108 + email: "cnt#{System.unique_integer([:positive])}@example.com",
109 + password: "hello world!hello world!"
110 + })
111 +
112 + {:ok, other} =
113 + Configuration.create_source(%{
114 + name: "U", notes: "", type: :gtfs, enabled: true, user_id: other_user.id,
115 + config: %{"__type__" => "gtfs", "url" => "https://e.test/u.zip", "tz" => "UTC"}
116 + })
117 +
118 + trip(other, "theirs")
119 +
120 + assert Storage.count_trips_without_service(source.id) == 0
121 + end
122 + end
123 +
77 124 describe "services_on/2" do
78 125 test "a service whose weekday flag is set, inside its date range", %{source: source} do
79 126 # 2026-09-01 is a Tuesday.

Parents: af6ed7e