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.
Plus views: show the trains fleet marks call out
07c2f71 · Gabriel Morell · 2026-10-15 01:33
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