prop/lib/microwaveprop_web/live/rover_planning_live/show.ex
Graham McIntire dbd4a78502
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.
2026-05-03 11:49:18 -05:00

360 lines
13 KiB
Elixir

defmodule MicrowavepropWeb.RoverPlanningLive.Show do
@moduledoc """
`/rover-planning/:id` mission detail. Renders the planned stations
and the matrix of computed (or pending) terrain path profiles from
every candidate rover-location.
"""
use MicrowavepropWeb, :live_view
alias Microwaveprop.Accounts.User
alias Microwaveprop.Radio.Maidenhead
alias Microwaveprop.RoverPlanning
alias Microwaveprop.RoverPlanning.Mission
alias Phoenix.LiveView.JS
@impl true
def mount(%{"id" => id}, _session, socket) do
case RoverPlanning.get_mission_with_paths(id) do
nil ->
{:ok,
socket
|> put_flash(:error, "Mission not found.")
|> push_navigate(to: ~p"/rover-planning")}
%Mission{} = mission ->
if connected?(socket) do
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "rover_planning:#{mission.id}")
end
{:ok,
assign(socket,
page_title: "Mission · #{mission.name}",
mission: mission,
paths: RoverPlanning.list_paths(mission)
)}
end
end
@impl true
def handle_info({:rover_path_updated, _path_id}, socket) do
{:noreply, assign(socket, paths: RoverPlanning.list_paths(socket.assigns.mission))}
end
@impl true
def handle_event("delete", _params, socket) do
case current_user(socket) do
%User{} = user ->
case RoverPlanning.delete_mission(user, socket.assigns.mission.id) do
{:ok, _} ->
{:noreply,
socket
|> put_flash(:info, "Mission deleted.")
|> push_navigate(to: ~p"/rover-planning")}
{:error, :not_found} ->
{:noreply, put_flash(socket, :error, "You can only delete your own missions.")}
end
_ ->
{:noreply, put_flash(socket, :error, "Sign in required.")}
end
end
defp current_user(%Phoenix.LiveView.Socket{assigns: assigns}) do
case assigns[:current_scope] do
%{user: %User{} = user} -> user
_ -> nil
end
end
defp can_modify?(%{current_scope: %{user: %User{is_admin: true}}}, _), do: true
defp can_modify?(%{current_scope: %{user: %User{id: id}}}, %Mission{user_id: id}) when not is_nil(id), do: true
defp can_modify?(_, _), do: false
defp band_label(mhz) when is_integer(mhz) and mhz >= 1000 do
ghz = mhz / 1000
if ghz == trunc(ghz), do: "#{trunc(ghz)} GHz", else: "#{Float.round(ghz, 1)} GHz"
end
defp band_label(mhz), do: "#{mhz} MHz"
defp status_badge(:complete) do
assigns = %{}
~H|<span class="badge badge-success badge-sm">Complete</span>|
end
defp status_badge(:computing) do
assigns = %{}
~H|<span class="badge badge-info badge-sm">Computing</span>|
end
defp status_badge(:failed) do
assigns = %{}
~H|<span class="badge badge-error badge-sm">Failed</span>|
end
defp status_badge(_) do
assigns = %{}
~H|<span class="badge badge-ghost badge-sm">Pending</span>|
end
defp verdict_badge("clear") do
assigns = %{}
~H|<span class="badge badge-success badge-xs">Clear</span>|
end
defp verdict_badge("blocked") do
assigns = %{}
~H|<span class="badge badge-error badge-xs">Blocked</span>|
end
defp verdict_badge("marginal") do
assigns = %{}
~H|<span class="badge badge-warning badge-xs">Marginal</span>|
end
defp verdict_badge(_), do: ""
defp progress_summary(paths) do
total = length(paths)
complete = Enum.count(paths, &(&1.status == :complete))
pending = Enum.count(paths, &(&1.status in [:pending, :computing]))
failed = Enum.count(paths, &(&1.status == :failed))
%{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.
defp format_distance(km) when is_number(km), do: "#{Float.round(km * 1.0, 1)} km / #{Float.round(km * 0.621371, 1)} mi"
defp format_distance(_), do: ""
defp format_db(nil), do: ""
defp format_db(value) when is_number(value), do: "#{Float.round(value * 1.0, 1)} dB"
defp format_meters(nil), do: ""
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: %{grid: g}}) when is_binary(g) and g != "", do: g
defp station_label(%{station: %{lat: lat, lon: lon}}) when is_number(lat) and is_number(lon),
do: "#{Float.round(lat, 3)}, #{Float.round(lon, 3)}"
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
def render(assigns) do
summary = progress_summary(assigns.paths)
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">
<.header>
{@mission.name}
<:subtitle>
{band_label(@mission.band_mhz)} · {length(@mission.stations)} station(s) ·
rover {@mission.rover_height_ft}ft / station {@mission.station_height_ft}ft
</:subtitle>
<:actions>
<.link navigate={~p"/rover-planning"} class="btn btn-ghost btn-sm">
<.icon name="hero-arrow-left" class="w-4 h-4" /> All missions
</.link>
<.link
:if={can_modify?(assigns, @mission)}
navigate={~p"/rover-planning/#{@mission.id}/edit"}
class="btn btn-ghost btn-sm"
>
<.icon name="hero-pencil-square" class="w-4 h-4" /> Edit
</.link>
<button
:if={can_modify?(assigns, @mission)}
type="button"
phx-click="delete"
data-confirm="Delete this mission?"
class="btn btn-ghost btn-sm text-error"
>
<.icon name="hero-trash" class="w-4 h-4" /> Delete
</button>
</:actions>
</.header>
<div class="card bg-base-100 border border-base-300 p-4 mb-4">
<h3 class="font-semibold mb-2">Stationary stations</h3>
<ul class="text-sm space-y-1">
<li :for={station <- @mission.stations} class="flex flex-wrap gap-2">
<span class="font-mono">{station.callsign || station.grid || station.input}</span>
<span :if={station.grid} class="text-base-content/60">grid {station.grid}</span>
<span class="text-base-content/60 font-mono">
{Float.round(station.lat, 4)}, {Float.round(station.lon, 4)}
</span>
</li>
</ul>
<p :if={@mission.notes && @mission.notes != ""} class="mt-3 text-sm whitespace-pre-line">
{@mission.notes}
</p>
<p class="text-xs text-base-content/60 mt-3">
Scope: {if @mission.only_known_good,
do: "Known good locations only (status = good)",
else: "All rover locations"} · Owner: {(@mission.user && @mission.user.callsign) || "—"}
</p>
</div>
<div class="card bg-base-100 border border-base-300 p-4">
<div class="flex items-center justify-between mb-3 gap-2 flex-wrap">
<h3 class="font-semibold">Path profiles</h3>
<div class="text-xs text-base-content/70">
{@summary.complete}/{@summary.total} complete
<span :if={@summary.pending > 0}>· {@summary.pending} pending</span>
<span :if={@summary.failed > 0} class="text-error">
· {@summary.failed} failed
</span>
</div>
</div>
<div :if={@paths == []} class="text-sm text-base-content/60 py-4 text-center">
No paths yet — they'll appear here as the workers run.
</div>
<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}
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>{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
# 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)})"
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