feat(rover-planning): group path profiles by rover location

The flat path table forced you to scan column 1 to mentally group rows
when standing at a specific rover spot. Each rover location now gets
its own card with a heading (grid + lat/lon + notes) and an inner
station-by-station table, so the paths to the fixed stations from the
spot you're at are read in one place.

Sorted by latitude (north → south) so the same location keeps the same
slot across re-renders. `rover_label/1` is removed (its content now
lives in the group heading).
This commit is contained in:
Graham McIntire 2026-05-03 11:34:36 -05:00
parent cff0c6775d
commit a18586046d
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
2 changed files with 123 additions and 44 deletions

View file

@ -124,6 +124,21 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Show do
%{total: total, complete: complete, pending: pending, failed: failed}
end
# Bucket paths by their rover-location and sort each bucket so stations
# appear in the order the user defined on the mission. The outer order
# is by rover-location lat (north → south) so the same rover location
# keeps the same slot across re-renders.
defp paths_by_rover_location(paths) do
paths
|> Enum.group_by(& &1.rover_location_id)
|> Enum.map(fn {_id, group} ->
location = (List.first(group) || %{}).rover_location
sorted = Enum.sort_by(group, &(&1.station && &1.station.position))
{location, sorted}
end)
|> Enum.sort_by(fn {loc, _} -> -((loc && loc.lat) || 0) end)
end
# JSONB round-trips bare zeros as integers (e.g. `0` not `0.0`), and
# `Float.round/2` only accepts floats. Coerce with `* 1.0` everywhere
# we round a value that came out of a `:map` column.
@ -137,13 +152,6 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Show do
defp format_meters(nil), do: ""
defp format_meters(value) when is_number(value), do: "#{Float.round(value * 1.0, 1)} m"
defp rover_label(%{rover_location: %{lat: lat, lon: lon}}) when is_number(lat) and is_number(lon) do
grid = Maidenhead.from_latlon(lat, lon, 6)
"#{grid} (#{Float.round(lat, 4)}, #{Float.round(lon, 4)})"
end
defp rover_label(_), do: ""
defp station_label(%{station: %{callsign: c}}) when is_binary(c) and c != "", do: c
defp station_label(%{station: %{grid: g}}) when is_binary(g) and g != "", do: g
@ -155,7 +163,8 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Show do
@impl true
def render(assigns) do
summary = progress_summary(assigns.paths)
assigns = assign(assigns, :summary, summary)
grouped = paths_by_rover_location(assigns.paths)
assigns = assign(assigns, summary: summary, grouped_paths: grouped)
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope} max_width="max-w-6xl">
@ -227,42 +236,76 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Show do
No paths yet they'll appear here as the workers run.
</div>
<div :if={@paths != []} class="overflow-x-auto">
<table class="table table-sm">
<thead>
<tr>
<th>Rover location</th>
<th>Station</th>
<th>Status</th>
<th>Distance</th>
<th>Min clearance</th>
<th>Diffraction</th>
<th>Verdict</th>
</tr>
</thead>
<tbody>
<tr :for={path <- @paths}>
<td class="font-mono text-xs">{rover_label(path)}</td>
<td class="font-mono text-xs">{station_label(path)}</td>
<td>{status_badge(path.status)}</td>
<td class="text-xs">
{if path.result, do: format_distance(path.result["distance_km"]), else: ""}
</td>
<td class="text-xs">
{if path.result, do: format_meters(path.result["min_clearance_m"]), else: ""}
</td>
<td class="text-xs">
{if path.result, do: format_db(path.result["diffraction_db"]), else: ""}
</td>
<td>
{if path.result, do: verdict_badge(path.result["verdict"]), else: ""}
</td>
</tr>
</tbody>
</table>
<div :if={@paths != []} class="space-y-4">
<div
:for={{location, group_paths} <- @grouped_paths}
data-rover-group
class="border border-base-300 rounded-lg overflow-hidden"
>
<div class="bg-base-200 px-3 py-2 flex flex-wrap items-center justify-between gap-2">
<div class="font-mono text-sm">
{rover_location_heading(location)}
<span
:if={location && location.notes && location.notes != ""}
class="text-base-content/60 text-xs"
>
· {location.notes}
</span>
</div>
<div class="text-xs text-base-content/70">
{group_progress(group_paths)}
</div>
</div>
<div class="overflow-x-auto">
<table class="table table-sm">
<thead>
<tr>
<th>Station</th>
<th>Status</th>
<th>Distance</th>
<th>Min clearance</th>
<th>Diffraction</th>
<th>Verdict</th>
</tr>
</thead>
<tbody>
<tr :for={path <- group_paths}>
<td class="font-mono text-xs">{station_label(path)}</td>
<td>{status_badge(path.status)}</td>
<td class="text-xs">
{if path.result, do: format_distance(path.result["distance_km"]), else: ""}
</td>
<td class="text-xs">
{if path.result, do: format_meters(path.result["min_clearance_m"]), else: ""}
</td>
<td class="text-xs">
{if path.result, do: format_db(path.result["diffraction_db"]), else: ""}
</td>
<td>
{if path.result, do: verdict_badge(path.result["verdict"]), else: ""}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</Layouts.app>
"""
end
defp rover_location_heading(%{lat: lat, lon: lon}) when is_number(lat) and is_number(lon) do
grid = Maidenhead.from_latlon(lat, lon, 6)
"#{grid} (#{Float.round(lat, 4)}, #{Float.round(lon, 4)})"
end
defp rover_location_heading(_), do: ""
defp group_progress(paths) do
total = length(paths)
complete = Enum.count(paths, &(&1.status == :complete))
"#{complete}/#{total} paths"
end
end

View file

@ -20,8 +20,11 @@ defmodule MicrowavepropWeb.RoverPlanningLiveTest do
:ok
end
defp create_mission(user, name) do
{:ok, _} = Rover.create_location(user, %{lat: 32.0, lon: -97.0, status: :good})
defp create_mission(user, name, opts \\ []) do
locations = Keyword.get(opts, :locations, [%{lat: 32.0, lon: -97.0, status: :good}])
stations = Keyword.get(opts, :stations, %{"0" => %{"input" => "EM12kp", "position" => 0}})
Enum.each(locations, fn attrs -> {:ok, _} = Rover.create_location(user, attrs) end)
{:ok, mission} =
RoverPlanning.create_mission(user, %{
@ -30,7 +33,7 @@ defmodule MicrowavepropWeb.RoverPlanningLiveTest do
"only_known_good" => true,
"rover_height_ft" => 8.0,
"station_height_ft" => 30.0,
"stations" => %{"0" => %{"input" => "EM12kp", "position" => 0}}
"stations" => stations
})
mission
@ -71,6 +74,39 @@ defmodule MicrowavepropWeb.RoverPlanningLiveTest do
assert html =~ "Path profiles"
end
test "groups paths by rover location", %{conn: conn} do
user = AccountsFixtures.user_fixture()
mission =
create_mission(user, "Grouped mission",
locations: [
%{lat: 32.0, lon: -97.0, status: :good, notes: "Spot Alpha"},
%{lat: 33.0, lon: -98.0, status: :good, notes: "Spot Bravo"}
],
stations: %{
"0" => %{"input" => "EM12kp", "position" => 0},
"1" => %{"input" => "EM13qc", "position" => 1}
}
)
{:ok, lv, html} = live(conn, ~p"/rover-planning/#{mission.id}")
# One group container per rover location; both lat/lon labels appear
# as group headings (not inline in cells like the old flat table).
assert has_element?(lv, "[data-rover-group]")
assert html =~ "32.0"
assert html =~ "33.0"
# 2 locations × 2 stations = 4 path rows split into 2 groups.
group_count =
lv
|> render()
|> then(&Regex.scan(~r/data-rover-group/, &1))
|> length()
assert group_count == 2
end
test "renders integer path-result values without crashing", %{conn: conn} do
# JSONB round-trips bare zeros as integers (e.g. `0` not `0.0`), so the
# show page MUST tolerate non-float numerics in `result`. Previously