Break a Plani out by stop, and ring its radius

11bc288 · Gabriel Morell · 2026-09-03 18:02

43 files +202 -21
Message
{commit_body(@commit)}

Files changed

modified ?
+0 −0

Binary file — not rendered.

modified ?
+0 −0

Binary file — not rendered.

modified apps/room_sanctum/lib/room_sanctum/configuration/plani.ex
+8 −1
@@ -36,6 +36,12 @@ defmodule RoomSanctum.Configuration.Plani do
36 36 field :radius, :integer, default: 800
37 37 field :limit, :integer, default: 5
38 38
39 + # One entry per stop rather than one per source. A vision publishes an
40 + # entry per query and a query is one stop, so a client that draws a card
41 + # per entry needs to know nothing about grouping. Blended is fewer entries
42 + # and asks the client to render a list of its own.
43 + field :break_out, :boolean, default: false
44 +
39 45 timestamps()
40 46 end
41 47
@@ -51,7 +57,8 @@ defmodule RoomSanctum.Configuration.Plani do
51 57 :sources,
52 58 :follow_tint,
53 59 :radius,
54 − :limit
60 + :limit,
61 + :break_out
55 62 ])
56 63 |> validate_required([:name, :home_foci_id])
57 64 # 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
+72 −16
@@ -133,9 +133,19 @@ defmodule RoomSanctum.Worker.Plani do
133 133 Logger.warning("plani #{state.id} source #{source_id}: #{message}")
134 134 {data, queries, places, Map.put(notes, source_id, {:error, message})}
135 135
136 − {key, results, described, found} ->
137 − {Map.put(data, key, results), [described | queries], found ++ places,
138 − Map.put(notes, source_id, {:ok, length(results)})}
136 + {entries, found} ->
137 + # One source can answer with several entries now: broken out, a
138 + # stop is an entry of its own.
139 + data =
140 + Enum.reduce(entries, data, fn {key, results, _q}, acc ->
141 + Map.put(acc, key, results)
142 + end)
143 +
144 + described = Enum.map(entries, fn {_key, _results, q} -> q end)
145 + counted = entries |> Enum.map(fn {_k, results, _q} -> length(results) end) |> Enum.sum()
146 +
147 + {data, Enum.reverse(described) ++ queries, found ++ places,
148 + Map.put(notes, source_id, {:ok, counted})}
139 149 end
140 150 end)
141 151
@@ -195,14 +205,37 @@ defmodule RoomSanctum.Worker.Plani do
195 205 :gtfs ->
196 206 stops = Storage.nearby_stops(source_id, anchor, plani.limit, plani.radius)
197 207
198 − arrivals =
199 − stops
200 − |> Enum.flat_map(&Storage.get_upcoming_arrivals_for_stop(source_id, &1.stop_id, 8, :now, source.config.tz))
201 − |> Storage.fix_arrival_times()
202 − |> Enum.sort_by(& &1.arrival_time)
203 − |> Enum.take(16)
204 −
205 − {{source_id, :gtfs}, arrivals, described(source), places(stops, source, :stop)}
208 + by_stop =
209 + Enum.map(stops, fn stop ->
210 + arrivals =
211 + source_id
212 + |> Storage.get_upcoming_arrivals_for_stop(stop.stop_id, 8, :now, source.config.tz)
213 + |> Storage.fix_arrival_times()
214 + |> Enum.sort_by(& &1.arrival_time)
215 +
216 + {stop, arrivals}
217 + end)
218 +
219 + entries =
220 + if plani.break_out do
221 + # One entry per stop, named after it, the way a vision's queries
222 + # are. A stop with nothing due is left out rather than published
223 + # empty -- the same call the clients already make.
224 + for {stop, arrivals} <- by_stop, arrivals != [] do
225 + key = "#{source_id}:#{stop.stop_id}"
226 + {{key, :gtfs}, arrivals, described(source, key, stop.stop_name)}
227 + end
228 + else
229 + blended =
230 + by_stop
231 + |> Enum.flat_map(fn {_stop, arrivals} -> arrivals end)
232 + |> Enum.sort_by(& &1.arrival_time)
233 + |> Enum.take(16)
234 +
235 + [{{source_id, :gtfs}, blended, described(source)}]
236 + end
237 +
238 + {entries, places(stops, source, :stop)}
206 239
207 240 :gbfs ->
208 241 # Both, because a source does not say which kind it is. A docked
@@ -211,12 +244,29 @@ defmodule RoomSanctum.Worker.Plani do
211 244 bikes = Storage.free_bikes_near(source_id, anchor, plani.radius)
212 245 docks = Storage.stations_near(source_id, anchor, plani.radius)
213 246
214 − {{source_id, :gbfs}, bikes ++ docks, described(source),
215 − places(bikes, source, :bike) ++ places(docks, source, :dock)}
247 + entries =
248 + if plani.break_out do
249 + # A dock is a place, so it gets its own entry. Loose bikes are not
250 + # -- they are a count of what is lying about nearby -- so they stay
251 + # together under the source.
252 + per_dock =
253 + for dock <- docks do
254 + key = "#{source_id}:#{dock.station_id}"
255 + {{key, :gbfs}, [dock], described(source, key, dock.name)}
256 + end
257 +
258 + if bikes == [],
259 + do: per_dock,
260 + else: [{{source_id, :gbfs}, bikes, described(source)} | per_dock]
261 + else
262 + [{{source_id, :gbfs}, bikes ++ docks, described(source)}]
263 + end
264 +
265 + {entries, places(bikes, source, :bike) ++ places(docks, source, :dock)}
216 266
217 267 :aqi ->
218 268 readings = Storage.nearby_aqi_stations(source_id, anchor, plani.limit)
219 − {{source_id, :aqi}, readings, described(source), places(readings, source, :monitor)}
269 + {[{{source_id, :aqi}, readings, described(source)}], places(readings, source, :monitor)}
220 270
221 271 other ->
222 272 # Sources with nothing located in them, or nothing near-able yet. Left
@@ -242,8 +292,14 @@ defmodule RoomSanctum.Worker.Plani do
242 292 # so a Plani hands them something query-shaped. The empty `query` matters --
243 293 # the GTFS condenser reads a stop out of it to look up stop-specific alerts,
244 294 # and a Plani has no one stop any more than an area query does.
245 − defp described(source) do
246 − %{id: source.id, name: source.name, meta: %{}, query: %{}}
295 + # What the condensers expect alongside the data: a vision hands them a query,
296 + # so a Plani hands them something query-shaped. The id has to match the key
297 + # the data went under -- that is how the two are paired again -- so a broken
298 + # out entry carries its own, or its name is dropped on the way out.
299 + defp described(source), do: described(source, source.id, source.name)
300 +
301 + defp described(source, id, name) do
302 + %{id: id, name: name, meta: %{}, query: %{}}
247 303 end
248 304
249 305 # Where the things it found actually are, for anything drawing a map.
modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_plani_live/form_component.ex
+12 −0
@@ -123,6 +123,18 @@ defmodule RoomSanctumWeb.PlaniLive.FormComponent do
123 123 <.input field={@form[:radius]} type="number" label="Radius (metres, 50-3000)" />
124 124 <.input field={@form[:limit]} type="number" label="How many of each (1-20)" />
125 125
126 + <.input
127 + field={@form[:break_out]}
128 + type="checkbox"
129 + label="One card per stop"
130 + />
131 + <p class="text-xs text-base-content/60">
132 + Publishes an entry per stop rather than one per source, which is how a
133 + vision looks on the wire — so a client draws a card each and needs to
134 + know nothing about grouping. Off, the stops near you arrive blended
135 + into one entry per source.
136 + </p>
137 +
126 138 <:actions>
127 139 <.button phx-disable-with="Saving...">Save Plani</.button>
128 140 </:actions>
modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_plani_live/show.ex
+27 −0

Click to load diff…

modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_plani_live/show.html.heex
+2 −0

Click to load diff…

added apps/room_sanctum/priv/repo/migrations/20260903160000_add_break_out_to_plani.exs
+19 −0

Click to load diff…

modified apps/room_sanctum/test/room_sanctum_web/live/plani_live_test.exs
+19 −0

Click to load diff…

modified clients/android/app/src/main/java/io/neiam/apolloscrib/types/Wire.kt
+7 −4

Click to load diff…

modified clients/android/app/src/test/java/io/neiam/apolloscrib/types/WireTest.kt
+36 −0

Click to load diff…

Parents: fa3ea0b