feat(rover-planning): clickable rows + callsign·grid labels + 10-char heading grid

- Rows in each rover-location group are now phx-click handlers that
  navigate to /path with source (rover-loc as 10-char Maidenhead grid),
  destination (callsign > grid > coords), band, and rover/station
  heights pre-filled — one click takes you to a ready-to-compute Path
  Calculator screen.
- Station label now shows "AA5C · EM12kp" when both callsign and grid
  resolved, instead of falling back to lat/lon. Bare-grid and
  bare-coord stations keep their existing labels.
- Group heading uses Maidenhead.from_latlon(lat, lon, 10) so the
  10-char grid is shown in full instead of being truncated to 6
  characters.
This commit is contained in:
Graham McIntire 2026-05-03 11:49:18 -05:00
parent 5ac625915d
commit dbd4a78502
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
2 changed files with 96 additions and 2 deletions

View file

@ -10,6 +10,7 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Show do
alias Microwaveprop.Radio.Maidenhead alias Microwaveprop.Radio.Maidenhead
alias Microwaveprop.RoverPlanning alias Microwaveprop.RoverPlanning
alias Microwaveprop.RoverPlanning.Mission alias Microwaveprop.RoverPlanning.Mission
alias Phoenix.LiveView.JS
@impl true @impl true
def mount(%{"id" => id}, _session, socket) do def mount(%{"id" => id}, _session, socket) do
@ -152,6 +153,9 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Show do
defp format_meters(nil), do: "" defp format_meters(nil), do: ""
defp format_meters(value) when is_number(value), do: "#{Float.round(value * 1.0, 1)} m" defp format_meters(value) when is_number(value), do: "#{Float.round(value * 1.0, 1)} m"
defp station_label(%{station: %{callsign: c, grid: g}}) when is_binary(c) and c != "" and is_binary(g) and g != "",
do: "#{c} · #{g}"
defp station_label(%{station: %{callsign: c}}) when is_binary(c) and c != "", do: c 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 defp station_label(%{station: %{grid: g}}) when is_binary(g) and g != "", do: g
@ -160,6 +164,41 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Show do
defp station_label(_), do: "" defp station_label(_), do: ""
# Endpoint string used for /path?destination=…. Prefers callsign, then
# grid (so PathLive's LocationResolver re-resolves to the same point),
# falling back to bare coordinates.
defp station_endpoint(%{callsign: c}) when is_binary(c) and c != "", do: c
defp station_endpoint(%{grid: g}) when is_binary(g) and g != "", do: g
defp station_endpoint(%{lat: lat, lon: lon}) when is_number(lat) and is_number(lon), do: "#{lat},#{lon}"
defp station_endpoint(_), do: ""
# Builds the /path URL pre-filling source (rover-location, max-precision
# grid), destination (station endpoint), band, and rover/station heights
# so the user lands on a ready-to-compute Path Calculator screen.
defp path_url(_mission, %{rover_location: nil}), do: nil
defp path_url(_mission, %{station: nil}), do: nil
defp path_url(%Mission{} = mission, %{rover_location: %{lat: lat, lon: lon}, station: station})
when is_number(lat) and is_number(lon) do
src = Maidenhead.from_latlon(lat, lon, 10)
dst = station_endpoint(station)
query =
URI.encode_query(%{
"source" => src,
"destination" => dst,
"band" => Integer.to_string(mission.band_mhz),
"src_height_ft" => Float.to_string(mission.rover_height_ft * 1.0),
"dst_height_ft" => Float.to_string(mission.station_height_ft * 1.0)
})
"/path?" <> query
end
defp path_url(_, _), do: nil
@impl true @impl true
def render(assigns) do def render(assigns) do
summary = progress_summary(assigns.paths) summary = progress_summary(assigns.paths)
@ -270,7 +309,14 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Show do
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr :for={path <- group_paths}> <tr
:for={path <- group_paths}
phx-click={path_url(@mission, path) && JS.navigate(path_url(@mission, path))}
class={[
path_url(@mission, path) && "cursor-pointer hover:bg-base-200"
]}
title={path_url(@mission, path) && "Open in Path Calculator"}
>
<td class="font-mono text-xs">{station_label(path)}</td> <td class="font-mono text-xs">{station_label(path)}</td>
<td>{status_badge(path.status)}</td> <td>{status_badge(path.status)}</td>
<td class="text-xs"> <td class="text-xs">
@ -297,7 +343,10 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Show do
end end
defp rover_location_heading(%{lat: lat, lon: lon}) when is_number(lat) and is_number(lon) do defp rover_location_heading(%{lat: lat, lon: lon}) when is_number(lat) and is_number(lon) do
grid = Maidenhead.from_latlon(lat, lon, 6) # 10-char grid is the maximum useful Maidenhead precision; anything
# finer is below the float lat/lon's resolution. The user wants the
# full precision, not a 6-char truncation.
grid = Maidenhead.from_latlon(lat, lon, 10)
"#{grid} (#{Float.round(lat, 4)}, #{Float.round(lon, 4)})" "#{grid} (#{Float.round(lat, 4)}, #{Float.round(lon, 4)})"
end end

View file

@ -4,6 +4,7 @@ defmodule MicrowavepropWeb.RoverPlanningLiveTest do
import Phoenix.LiveViewTest import Phoenix.LiveViewTest
alias Microwaveprop.AccountsFixtures alias Microwaveprop.AccountsFixtures
alias Microwaveprop.Radio.Maidenhead
alias Microwaveprop.Repo alias Microwaveprop.Repo
alias Microwaveprop.Rover alias Microwaveprop.Rover
alias Microwaveprop.RoverPlanning alias Microwaveprop.RoverPlanning
@ -74,6 +75,50 @@ defmodule MicrowavepropWeb.RoverPlanningLiveTest do
assert html =~ "Path profiles" assert html =~ "Path profiles"
end end
test "group heading uses max-precision Maidenhead grid (10 chars)", %{conn: conn} do
user = AccountsFixtures.user_fixture()
mission = create_mission(user, "Precision mission")
{:ok, _lv, html} = live(conn, ~p"/rover-planning/#{mission.id}")
# Maidenhead.from_latlon(32.0, -97.0, 10) → "EM12VX00AA" (or similar
# 10-char grid). The group heading must NOT truncate at 6 chars.
grid_10 = Maidenhead.from_latlon(32.0, -97.0, 10)
assert String.length(grid_10) == 10
assert html =~ grid_10
end
test "station label shows callsign + grid together when both are known", %{conn: conn} do
user = AccountsFixtures.user_fixture()
mission = create_mission(user, "Callsign-grid mission")
[path | _] = Path |> Repo.all() |> Repo.preload(:station)
{:ok, _} = path.station |> Ecto.Changeset.change(%{callsign: "AA5C", grid: "EM13se"}) |> Repo.update()
{:ok, _lv, html} = live(conn, ~p"/rover-planning/#{mission.id}")
# The path table should display "AA5C · EM13se" so the operator can
# see both at a glance — not just one or the other and not lat/lon.
assert html =~ "AA5C · EM13se"
end
test "path rows link to /path with mission params prefilled", %{conn: conn} do
user = AccountsFixtures.user_fixture()
mission = create_mission(user, "Click-through mission")
{:ok, lv, _html} = live(conn, ~p"/rover-planning/#{mission.id}")
# Each path row carries a phx-click that navigates to /path with
# the mission's band + heights and the rover/station endpoints
# filled in. Assert the click handler is present on the <tr>.
rendered = render(lv)
assert rendered =~ "phx-click"
assert rendered =~ "/path?"
assert rendered =~ "band=10000"
assert rendered =~ "src_height_ft=8.0"
assert rendered =~ "dst_height_ft=30.0"
end
test "groups paths by rover location", %{conn: conn} do test "groups paths by rover location", %{conn: conn} do
user = AccountsFixtures.user_fixture() user = AccountsFixtures.user_fixture()