neiam
/apollos_crib
It's Apollo's Crib
public · Issues · Pulls 1 · Labels · Forks · Compare · Actions failure · Badges · Packages
⭐
Log in to mark this repository.
Show a line only at the nearest stop it calls at
3402380 · Gabriel Morell · 2026-09-03 18:52
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