Show a line only at the nearest stop it calls at

3402380 · Gabriel Morell · 2026-09-03 18:52

20 files +157 -1
Message
{commit_body(@commit)}

Files changed

modified PLANI.md
+18 −0
@@ -83,6 +83,24 @@ and a foci id otherwise. `weather`, `ephem`, `pollen` and `icarus` relocate;
83 83 `cronos` uses its foci for a timezone rather than a position, and `calendar`
84 84 for filtering, so neither moves.
85 85
86 +### What "nearest" over-answers
87 +
88 +An areal query asks *every* stop inside the radius, and a route calls at
89 +several of them — so the 87 toward Arlington arrives once per stop, all of them
90 +the same bus. **One stop per line** (`nearest_per_route`) keeps a route only at
91 +the nearest stop that has it.
92 +
93 +Two things it deliberately does not do. It claims per *stop*, not per arrival,
94 +so the winning stop keeps all of that line's departures — showing one time for
95 +the nearest stop would be the wrong half of the point. And direction is part of
96 +the key, so the two sides of a street stay separate: those are genuinely
97 +different departures, and merging them would send somebody the wrong way. The
98 +key is route-and-direction rather than headsign, since a short turn gets a sign
99 +of its own and is still the same line.
100 +
101 +This applies to both blended and broken-out modes, since it happens before the
102 +split.
103 +
86 104 **Sources with no location.** `github`, `gitlab`, `packages`, `mailbox`,
87 105 `treasury`, `bourse`. A Plani should either refuse these or pass them through
88 106 untouched; "the nearest CI job" is not a thing. Worth deciding explicitly
modified apps/room_sanctum/lib/room_sanctum/configuration/plani.ex
+7 −1
@@ -42,6 +42,11 @@ defmodule RoomSanctum.Configuration.Plani do
42 42 # and asks the client to render a list of its own.
43 43 field :break_out, :boolean, default: false
44 44
45 + # Show a line only at the nearest stop it calls at. An areal query asks
46 + # every stop in the radius and a route calls at several, so without this
47 + # the same bus is listed once per stop.
48 + field :nearest_per_route, :boolean, default: false
49 +
45 50 timestamps()
46 51 end
47 52
@@ -58,7 +63,8 @@ defmodule RoomSanctum.Configuration.Plani do
58 63 :follow_tint,
59 64 :radius,
60 65 :limit,
61 − :break_out
66 + :break_out,
67 + :nearest_per_route
62 68 ])
63 69 |> validate_required([:name, :home_foci_id])
64 70 # The same bounds an area query keeps, and for the same reason: past a
modified apps/room_sanctum/lib/room_sanctum/workers/plani_worker.ex
+47 −0
@@ -220,6 +220,7 @@ defmodule RoomSanctum.Worker.Plani do
220 220
221 221 {stop, arrivals}
222 222 end)
223 + |> nearest_per_route(plani)
223 224
224 225 entries =
225 226 if plani.break_out do
@@ -309,6 +310,52 @@ defmodule RoomSanctum.Worker.Plani do
309 310 # so a Plani hands them something query-shaped. The id has to match the key
310 311 # the data went under -- that is how the two are paired again -- so a broken
311 312 # out entry carries its own, or its name is dropped on the way out.
313 + # Keep a line only at the nearest stop it calls at.
314 + #
315 + # An areal query asks every stop inside the radius, and a bus route calls at
316 + # several of them -- so the 87 toward Arlington turns up three times, once
317 + # per stop, all of them the same bus. Nobody walks to the second-nearest stop
318 + # to catch it.
319 + #
320 + # Claimed per stop rather than per arrival: the nearest stop keeps *all* of
321 + # that line's departures, which is the point -- it is the next two or three
322 + # times that make a departure board worth reading. Claiming as each arrival
323 + # went past would have left the winning stop showing one time.
324 + #
325 + # Direction is part of the key, so the two sides of a street stay separate.
326 + # Those are genuinely different departures, and collapsing them would send
327 + # somebody the wrong way.
328 + @doc false
329 + # Public only so a test can reach it; nothing outside calls this.
330 + def nearest_per_route(by_stop, %{nearest_per_route: true}) do
331 + {kept, _claimed} =
332 + Enum.map_reduce(by_stop, MapSet.new(), fn {stop, arrivals}, claimed ->
333 + # Stops arrive nearest-first, so a first claim is the closest one.
334 + mine = arrivals |> Enum.map(&line_of/1) |> Enum.uniq() |> Enum.reject(&(&1 in claimed))
335 +
336 + {{stop, Enum.filter(arrivals, &(line_of(&1) in mine))},
337 + MapSet.union(claimed, MapSet.new(mine))}
338 + end)
339 +
340 + kept
341 + end
342 +
343 + def nearest_per_route(by_stop, _plani), do: by_stop
344 +
345 + # A route in a direction. Not the headsign: a short turn gets a sign of its
346 + # own and is still the same line calling at the same stop. A feed that does
347 + # not say which direction leaves it nil, which groups them -- the honest
348 + # answer when there is nothing to tell them apart by.
349 + defp line_of(arrival) do
350 + direction =
351 + case arrival.trip.direction do
352 + %{direction: direction} -> direction
353 + _ -> nil
354 + end
355 +
356 + {arrival.trip.route_id, direction}
357 + end
358 +
312 359 defp described(source), do: described(source, source.id, source.name)
313 360
314 361 defp described(source, id, name) do
modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_plani_live/form_component.ex
+12 −0
@@ -146,6 +146,18 @@ defmodule RoomSanctumWeb.PlaniLive.FormComponent do
146 146 into one entry per source.
147 147 </p>
148 148
149 + <.input
150 + field={@form[:nearest_per_route]}
151 + type="checkbox"
152 + label="One stop per line"
153 + />
154 + <p class="text-xs text-base-content/60">
155 + A route that calls at several stops inside the radius is shown only at
156 + the nearest one — otherwise the same bus is listed once per stop. The
157 + two sides of a street stay separate, since those are different
158 + departures.
159 + </p>
160 +
149 161 <:actions>
150 162 <.button phx-disable-with="Saving...">Save Plani</.button>
151 163 </:actions>
added apps/room_sanctum/priv/repo/migrations/20260903170000_add_nearest_per_route_to_plani.exs
+17 −0
@@ -0,0 +1,17 @@
1 +defmodule RoomSanctum.Repo.Migrations.AddNearestPerRouteToPlani do
2 + use Ecto.Migration
3 +
4 + @moduledoc """
5 + Whether a line is shown only at the nearest stop it calls at.
6 +
7 + An areal query asks every stop inside the radius, and a bus route calls at
8 + several of them -- so the same departure appears once per stop. Nobody walks
9 + to the second-nearest stop to catch a bus that is also stopping at the first.
10 + """
11 +
12 + def change do
13 + alter table(:cfg_plani) do
14 + add :nearest_per_route, :boolean, default: false, null: false
15 + end
16 + end
17 +end
added apps/room_sanctum/test/room_sanctum/plani_nearest_per_route_test.exs
+56 −0
@@ -0,0 +1,56 @@
1 +defmodule RoomSanctum.PlaniNearestPerRouteTest do
2 + use ExUnit.Case, async: true
3 +
4 + alias RoomSanctum.Worker.Plani
5 +
6 + defp arrival(route, direction, time) do
7 + %{arrival_time: time, trip: %{route_id: route, direction: %{direction: direction}}}
8 + end
9 +
10 + # Nearest first, the order `nearby_stops/4` returns.
11 + defp by_stop do
12 + [
13 + {%{stop_id: "a", stop_name: "Nearest"},
14 + [arrival("87", "In", "08:00"), arrival("87", "In", "08:12")]},
15 + {%{stop_id: "b", stop_name: "Across the street"},
16 + [arrival("87", "Out", "08:03"), arrival("87", "In", "08:01")]},
17 + {%{stop_id: "c", stop_name: "Furthest"},
18 + [arrival("87", "In", "08:02"), arrival("96", "In", "08:30")]}
19 + ]
20 + end
21 +
22 + test "a line is kept only at the nearest stop that has it" do
23 + [_, _, {_, furthest}] = Plani.nearest_per_route(by_stop(), %{nearest_per_route: true})
24 +
25 + # The 87 belongs to the nearest stop; the 96 stops nowhere nearer.
26 + assert Enum.map(furthest, & &1.trip.route_id) == ["96"]
27 + end
28 +
29 + test "the winning stop keeps every departure of that line, not just the first" do
30 + # Claiming per arrival rather than per stop would leave one time here,
31 + # which is the wrong half of the point -- a board is worth reading because
32 + # it shows the next two.
33 + [{_, nearest} | _] = Plani.nearest_per_route(by_stop(), %{nearest_per_route: true})
34 +
35 + assert Enum.map(nearest, & &1.arrival_time) == ["08:00", "08:12"]
36 + end
37 +
38 + test "the other side of the street is a different departure and survives" do
39 + [_, {_, across}, _] = Plani.nearest_per_route(by_stop(), %{nearest_per_route: true})
40 +
41 + assert Enum.map(across, & &1.trip.direction.direction) == ["Out"]
42 + end
43 +
44 + test "a feed that names no direction groups rather than raising" do
45 + stops = [
46 + {%{stop_id: "a"}, [%{arrival_time: "08:00", trip: %{route_id: "87", direction: nil}}]},
47 + {%{stop_id: "b"}, [%{arrival_time: "08:05", trip: %{route_id: "87", direction: nil}}]}
48 + ]
49 +
50 + assert [{_, [_]}, {_, []}] = Plani.nearest_per_route(stops, %{nearest_per_route: true})
51 + end
52 +
53 + test "off, it changes nothing" do
54 + assert Plani.nearest_per_route(by_stop(), %{nearest_per_route: false}) == by_stop()
55 + end
56 +end

Parents: b14afb0