Plus views: show the trains fleet marks call out

07c2f71 · Gabriel Morell · 2026-10-15 01:33

37 files +117 -11
Message
{commit_body(@commit)}

Files changed

modified apps/room_sanctum/lib/room_sanctum_web/live/live_preview.ex
+22 −1
@@ -229,7 +229,7 @@ use PhoenixHTMLHelpers
229 229 <%= if @carriages != [] do %>
230 230 <span class="inline-flex items-center gap-px align-middle" title={carriages_label(@carriages)}>
231 231 <%= for c <- @carriages do %>
232 − <span class={"inline-block w-2.5 h-3.5 rounded-sm #{carriage_class(c.occupancy)}"}></span>
232 + <span class={"inline-block w-2.5 h-3.5 rounded-sm #{carriage_class(c.occupancy)} #{if Map.get(c, :marked), do: "ring-2 ring-accent"}"}></span>
233 233 <% end %>
234 234 </span>
235 235 <% end %>
@@ -250,10 +250,24 @@ use PhoenixHTMLHelpers
250 250 defp carriage_class(:NOT_BOARDABLE), do: "bg-gray-400"
251 251 defp carriage_class(_), do: "border border-gray-400"
252 252
253 + # Which cars, for a mixed consist: on a platform, that is which end to
254 + # stand at.
255 + defp marked_label(arrival) do
256 + cars = Map.get(arrival, :carriages) || []
257 + marked = Enum.filter(cars, &Map.get(&1, :marked))
258 +
259 + if marked != [] and length(marked) < length(cars) do
260 + "#{length(marked)} of #{length(cars)} cars new: " <> Enum.map_join(marked, ", ", &(&1.label || "?"))
261 + else
262 + "New train"
263 + end
264 + end
265 +
253 266 defp carriages_label(carriages) do
254 267 carriages
255 268 |> Enum.map(fn c ->
256 269 name = c.label || (c.sequence && "car #{c.sequence}") || "car"
270 + name = if Map.get(c, :marked), do: "#{name} (new)", else: name
257 271
258 272 case c.occupancy do
259 273 nil -> "#{name}: no data"
@@ -400,6 +414,13 @@ use PhoenixHTMLHelpers
400 414 <%= if arrival_flag(a) do %>
401 415 <span class={"badge badge-sm #{flag_class(a)}"}><%= arrival_flag(a) %></span>
402 416 <% end %>
417 + <%!-- One of the trains the source's fleet marks call out. Only
418 + true is drawn: nil is "cannot tell yet", not "old". --%>
419 + <%= if Map.get(a, :marked) == true do %>
420 + <span class="badge badge-sm badge-accent gap-1" title={marked_label(a)}>
421 + <i class="fa-solid fa-fw fa-star fa-2xs"></i> New
422 + </span>
423 + <% end %>
403 424 <%= if a.delay && a.delay != 0 && !cancelled?(a) do %>
404 425 <span class="badge badge-sm"><%= delay_label(a.delay) %></span>
405 426 <% end %>
modified apps/room_sanctum/test/room_sanctum_web/components/occupancy_test.exs
+8 −0
@@ -78,6 +78,14 @@ defmodule RoomSanctumWeb.OccupancyTest do
78 78 assert html =~ "car 1: empty"
79 79 assert html =~ "car 2: no data"
80 80 end
81 +
82 + test "a marked carriage is ringed and named as new" do
83 + html = strip([Map.put(car(1, :EMPTY), :marked, true), Map.put(car(2, :EMPTY), :marked, false)])
84 +
85 + assert length(String.split(html, "ring-accent")) - 1 == 1
86 + assert html =~ "car 1 (new): empty"
87 + refute html =~ "car 2 (new)"
88 + end
81 89 end
82 90
83 91 describe "arrival flags" do
modified clients/android/app/src/main/java/io/neiam/apolloscrib/targets/GtfsPlusRenderer.kt
+10 −3
@@ -67,7 +67,13 @@ object GtfsPlusRenderer : SourceRenderer {
67 67 val note = noteOf(arrival)
68 68
69 69 Stamp(
70 − iconRes = if (arrival.live()) R.drawable.fa_tower_broadcast else R.drawable.fa_clock,
70 + // The star stands in for the broadcast tower: a marked train
71 + // is only known to be one because a vehicle is live on it.
72 + iconRes = when {
73 + arrival.marked == true -> R.drawable.fa_star
74 + arrival.live() -> R.drawable.fa_tower_broadcast
75 + else -> R.drawable.fa_clock
76 + },
71 77 text = if (note == null) time else "$time · $note",
72 78 // Smartspace list rows are plain text, so the glyph cannot
73 79 // carry the meaning and the word has to.
@@ -81,7 +87,8 @@ object GtfsPlusRenderer : SourceRenderer {
81 87 *
82 88 * Cancellation first: a rider who reads only the time and walks to the
83 89 * stop has been failed by the card. Then lateness, then how full — in the
84 − * order that changes what somebody does.
90 + * order that changes what somebody does. A marked train comes last: it is
91 + * worth knowing, but not over the train being late or full.
85 92 */
86 93 private fun noteOf(arrival: PlusArrival): String? = when {
87 94 arrival.trip_status == "CANCELED" -> "cancelled"
@@ -92,7 +99,7 @@ object GtfsPlusRenderer : SourceRenderer {
92 99 minutes <= -1 -> "${-minutes} early"
93 100 else -> null
94 101 }
95 − } ?: crowding(arrival)
102 + } ?: crowding(arrival) ?: if (arrival.marked == true) "new" else null
96 103 }
97 104
98 105 private fun crowding(arrival: PlusArrival): String? = when (arrival.occupancy) {
modified clients/android/app/src/main/java/io/neiam/apolloscrib/types/GtfsPlus.kt
+9 −1
@@ -55,6 +55,12 @@ data class PlusArrival(
55 55 val name: String? = null,
56 56 val bikes: String? = null,
57 57 val carriages: List<PlusCarriage>? = null,
58 + /**
59 + * One of the vehicles the source's fleet marks call out -- the MBTA's new
60 + * trains, say. Null is not "no": it is a source with no rules for this
61 + * route, or a trip no vehicle has been seen on yet.
62 + */
63 + val marked: Boolean? = null,
58 64 /** Not running at all: `CANCELED`, `ADDED`, `DUPLICATED`. */
59 65 val trip_status: String? = null,
60 66 /** Running, but not calling here: `SKIPPED`, `NO_DATA`. */
@@ -88,5 +94,7 @@ data class PlusCarriage(
88 94 val id: String? = null,
89 95 val label: String? = null,
90 96 val occupancy: String? = null,
91 − val occupancy_pct: Int? = null
97 + val occupancy_pct: Int? = null,
98 + /** This car is one the fleet marks call out. */
99 + val marked: Boolean? = null
92 100 )
modified clients/android/app/src/main/java/io/neiam/apolloscrib/ui/RichCard.kt
+35 −6
@@ -185,12 +185,30 @@ private fun Arrival(arrival: PlusArrival, dim: Color) {
185 185
186 186 Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) {
187 187 val status = statusOf(arrival)
188 − if (status != null) {
189 − Text(
190 − status,
191 − style = MaterialTheme.typography.labelMedium,
192 − color = if (cancelled) MaterialTheme.colorScheme.error else dim
193 − )
188 + if (status != null || arrival.marked == true) {
189 + Row(verticalAlignment = Alignment.CenterVertically) {
190 + // In the accent, ahead of the status: it is the thing
191 + // somebody who set fleet marks is waiting to see.
192 + if (arrival.marked == true) {
193 + val accent = LocalAppTheme.current.accent
194 + Glyph(R.drawable.fa_star, 12.dp, accent)
195 + Spacer(Modifier.width(4.dp))
196 + Text(
197 + markedOf(arrival),
198 + style = MaterialTheme.typography.labelMedium,
199 + color = accent
200 + )
201 + if (status != null) Spacer(Modifier.width(8.dp))
202 + }
203 +
204 + if (status != null) {
205 + Text(
206 + status,
207 + style = MaterialTheme.typography.labelMedium,
208 + color = if (cancelled) MaterialTheme.colorScheme.error else dim
209 + )
210 + }
211 + }
194 212 }
195 213
196 214 val detail = listOfNotNull(
@@ -236,6 +254,17 @@ private fun statusOf(arrival: PlusArrival): String? = when {
236 254 }
237 255 }
238 256
257 +/**
258 + * "New train", or "2 of 6 cars new" for a mixed consist -- which end of the
259 + * platform to stand at, if it is the new cars you are after.
260 + */
261 +private fun markedOf(arrival: PlusArrival): String {
262 + val cars = arrival.carriages.orEmpty()
263 + val marked = cars.count { it.marked == true }
264 +
265 + return if (marked in 1 until cars.size) "$marked of ${cars.size} cars new" else "New train"
266 +}
267 +
239 268 // GTFS-RT spells these `FEW_SEATS_AVAILABLE`; nobody says that out loud.
240 269 private fun occupancyOf(arrival: PlusArrival): String? {
241 270 arrival.occupancy_pct?.let { return "$it% full" }
added clients/android/app/src/main/res/drawable/fa_star.xml
+12 −0
@@ -0,0 +1,12 @@
1 +<!-- Generated by tools/fa_to_vector.py from Font Awesome Free 6.2.0 (star).
2 + Font Awesome Free icons are CC BY 4.0. Do not edit by hand. -->
3 +<vector xmlns:android="http://schemas.android.com/apk/res/android"
4 + android:width="24dp"
5 + android:height="24dp"
6 + android:viewportWidth="576"
7 + android:viewportHeight="512"
8 + android:tint="?android:attr/colorControlNormal">
9 + <path
10 + android:fillColor="@android:color/white"
11 + android:pathData="M316.9 18C311.6 7 300.4 0 288.1 0s-23.4 7-28.8 18L195 150.3 51.4 171.5c-12 1.8-22 10.2-25.7 21.7s-.7 24.2 7.9 32.7L137.8 329 113.2 474.7c-2 12 3 24.2 12.9 31.3s23 8 33.8 2.3l128.3-68.5 128.3 68.5c10.8 5.7 23.9 4.9 33.8-2.3s14.9-19.3 12.9-31.3L438.5 329 542.7 225.9c8.6-8.5 11.7-21.2 7.9-32.7s-13.7-19.9-25.7-21.7L381.2 150.3 316.9 18z" />
12 +</vector>
modified clients/android/app/src/test/java/io/neiam/apolloscrib/types/WireTest.kt
+19 −0
@@ -183,6 +183,25 @@ class WireTest {
183 183 assertTrue(second.cancelled())
184 184 }
185 185
186 + @Test
187 + fun `a marked train and its marked cars come through`() {
188 + val payload = """
189 + {"gtfs_plus-40": [{"route":"Red","arrivals":[
190 + {"time":"09:00:00","marked":true,
191 + "carriages":[{"label":"1916","marked":true},{"label":"1752","marked":false}]},
192 + {"time":"09:10:00"}
193 + ]}]}
194 + """.trimIndent()
195 +
196 + val (marked, unsaid) = Wire.parse(payload).single()
197 + .decode<List<GtfsPlusCondensed>>()!!.single().arrivals
198 +
199 + assertEquals(true, marked.marked)
200 + assertEquals(listOf(true, false), marked.carriages!!.map { it.marked })
201 + // Absent is not false: no rules, or no vehicle seen on the trip yet.
202 + assertEquals(null, unsaid.marked)
203 + }
204 +
186 205 @Test
187 206 fun `a plus arrival with no realtime claims nothing`() {
188 207 val payload = """
modified clients/android/tools/fa_to_vector.py
+2 −0
@@ -36,6 +36,8 @@ ICONS = [
36 36 "bicycle", "bolt-lightning", "square-parking", "battery-half",
37 37 # Which way to walk to it, on a Plani's cards.
38 38 "location-arrow",
39 + # A train the source's fleet marks call out -- the MBTA's new ones.
40 + "star",
39 41 # What the sky is doing. A detailed weather card says "Clouds" in words
40 42 # and the word is the smallest part of the answer; these are the picture.
41 43 "cloud", "cloud-sun", "cloud-rain", "cloud-showers-heavy", "cloud-bolt",

Parents: 5ac303a