diff --git a/lib/microwaveprop_web/live/rover_planning_live/show.ex b/lib/microwaveprop_web/live/rover_planning_live/show.ex index d3c5b38d..802cf90e 100644 --- a/lib/microwaveprop_web/live/rover_planning_live/show.ex +++ b/lib/microwaveprop_web/live/rover_planning_live/show.ex @@ -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""" @@ -227,42 +236,76 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Show do No paths yet — they'll appear here as the workers run. -
- - - - - - - - - - - - - - - - - - - - - - - -
Rover locationStationStatusDistanceMin clearanceDiffractionVerdict
{rover_label(path)}{station_label(path)}{status_badge(path.status)} - {if path.result, do: format_distance(path.result["distance_km"]), else: "—"} - - {if path.result, do: format_meters(path.result["min_clearance_m"]), else: "—"} - - {if path.result, do: format_db(path.result["diffraction_db"]), else: "—"} - - {if path.result, do: verdict_badge(path.result["verdict"]), else: "—"} -
+
+
+
+
+ {rover_location_heading(location)} + + · {location.notes} + +
+
+ {group_progress(group_paths)} +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + +
StationStatusDistanceMin clearanceDiffractionVerdict
{station_label(path)}{status_badge(path.status)} + {if path.result, do: format_distance(path.result["distance_km"]), else: "—"} + + {if path.result, do: format_meters(path.result["min_clearance_m"]), else: "—"} + + {if path.result, do: format_db(path.result["diffraction_db"]), else: "—"} + + {if path.result, do: verdict_badge(path.result["verdict"]), else: "—"} +
+
+
""" 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 diff --git a/test/microwaveprop_web/live/rover_planning_live_test.exs b/test/microwaveprop_web/live/rover_planning_live_test.exs index d775e91e..fca4121a 100644 --- a/test/microwaveprop_web/live/rover_planning_live_test.exs +++ b/test/microwaveprop_web/live/rover_planning_live_test.exs @@ -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