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.Rover alias Microwaveprop.Rover.Location alias Microwaveprop.RoverPlanning alias Microwaveprop.RoverPlanning.Mission alias MicrowavepropWeb.LocationResolver @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), rover_sites: RoverPlanning.candidate_rover_locations(mission), rover_site_input: "", rover_site_input_matches: [] )} 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("rover_site_input_change", %{"input" => input}, socket) do {:noreply, assign(socket, rover_site_input: input, rover_site_input_matches: find_grid_matches(input, socket.assigns.rover_sites) )} end def handle_event("add_rover_site", %{"input" => input}, socket) do with {:ok, user} <- authenticated(socket), {:ok, %{lat: lat, lon: lon}} <- LocationResolver.resolve(input), attrs = %{lat: lat, lon: lon, status: :good, notes: String.trim(input)}, {:ok, _loc} <- Rover.create_location(user, attrs) do {:noreply, refresh_after_site_change(socket, "Rover site added — recomputing paths.")} else {:error, :unauthenticated} -> {:noreply, put_flash(socket, :error, "Sign in to add rover sites.")} :empty -> {:noreply, put_flash(socket, :error, "Enter a callsign, grid, or lat,lon.")} {:error, msg} when is_binary(msg) -> {:noreply, put_flash(socket, :error, msg)} {:error, %Ecto.Changeset{} = cs} -> {:noreply, put_flash(socket, :error, error_summary(cs))} end end def handle_event("delete_rover_site", %{"id" => id}, socket) do with {:ok, user} <- authenticated(socket), {:ok, _} <- Rover.delete_location(user, id) do {:noreply, refresh_after_site_change(socket, "Rover site removed — recomputing paths.")} else {:error, :unauthenticated} -> {:noreply, put_flash(socket, :error, "Sign in to remove rover sites.")} {:error, :not_found} -> {:noreply, put_flash(socket, :error, "You can only delete rover sites you created.")} end end def handle_event("delete", _params, socket) do with {:ok, user} <- authenticated(socket), {:ok, _} <- RoverPlanning.delete_mission(user, socket.assigns.mission.id) do {:noreply, socket |> put_flash(:info, "Mission deleted.") |> push_navigate(to: ~p"/rover-planning")} else {:error, :unauthenticated} -> {:noreply, put_flash(socket, :error, "Sign in required.")} {:error, :not_found} -> {:noreply, put_flash(socket, :error, "You can only delete your own missions.")} end end # Re-enqueues the mission's path matrix and reloads the show-page assigns # that depend on the rover-site set. defp refresh_after_site_change(socket, flash_message) do mission = socket.assigns.mission :ok = RoverPlanning.enqueue_reconcile(mission) socket |> assign( rover_sites: RoverPlanning.candidate_rover_locations(mission), paths: RoverPlanning.list_paths(mission), rover_site_input: "", rover_site_input_matches: [] ) |> put_flash(:info, flash_message) end defp authenticated(%Phoenix.LiveView.Socket{assigns: %{current_scope: %{user: %User{} = user}}}), do: {:ok, user} defp authenticated(_), do: {:error, :unauthenticated} defp find_grid_matches(input, locations) when is_binary(input) do trimmed = String.trim(input) case trimmed != "" && LocationResolver.resolve(trimmed) do {:ok, %{lat: lat, lon: lon}} when is_number(lat) and is_number(lon) -> target = Maidenhead.from_latlon(lat, lon, 6) Enum.filter(locations, fn loc -> Maidenhead.from_latlon(loc.lat, loc.lon, 6) == target end) _ -> [] end end defp find_grid_matches(_, _), do: [] 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 can_delete_site?(%{current_scope: %{user: %User{is_admin: true}}}, _), do: true defp can_delete_site?(%{current_scope: %{user: %User{id: id}}}, %Location{user_id: id}) when not is_nil(id), do: true defp can_delete_site?(_, _), do: false defp error_summary(%Ecto.Changeset{errors: []}), do: "Could not save rover site." defp error_summary(%Ecto.Changeset{errors: errors}), do: Enum.map_join(errors, "; ", fn {field, {msg, _}} -> "#{field} #{msg}" end) 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 bands_label(%Mission{} = mission) do case Mission.bands(mission) do [] -> "—" [single] -> band_label(single) list -> Enum.map_join(list, ", ", &band_label/1) end end defp status_badge(:complete) do assigns = %{} ~H|Complete| end defp status_badge(:computing) do assigns = %{} ~H|Computing| end defp status_badge(:failed) do assigns = %{} ~H|Failed| end defp status_badge(_) do assigns = %{} ~H|Pending| end defp verdict_badge("CLEAR") do assigns = %{} ~H|Clear| end defp verdict_badge("BLOCKED") do assigns = %{} ~H|Blocked| end defp verdict_badge("FRESNEL_PARTIAL") do assigns = %{} ~H|Fresnel partial| end defp verdict_badge("FRESNEL_MINOR") do assigns = %{} ~H|Fresnel minor| end defp verdict_badge(_), do: "" defp propagation_badge(score) when is_integer(score) and score >= 70 do assigns = %{score: score} ~H|{@score}| end defp propagation_badge(score) when is_integer(score) and score >= 40 do assigns = %{score: score} ~H|{@score}| end defp propagation_badge(score) when is_integer(score) do assigns = %{score: score} ~H|{@score}| end defp propagation_badge(_), do: "—" defp progress_summary(paths) do Enum.reduce(paths, %{total: 0, complete: 0, pending: 0, failed: 0}, &tally_path/2) end defp tally_path(%{status: :complete}, acc), do: %{acc | total: acc.total + 1, complete: acc.complete + 1} defp tally_path(%{status: status}, acc) when status in [:pending, :computing], do: %{acc | total: acc.total + 1, pending: acc.pending + 1} defp tally_path(%{status: :failed}, acc), do: %{acc | total: acc.total + 1, failed: acc.failed + 1} defp tally_path(_, acc), do: %{acc | total: acc.total + 1} # 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(&group_to_pair/1) |> Enum.sort_by(&group_lat/1, :desc) end defp group_to_pair({_id, [%{rover_location: location} | _] = group}) do {location, Enum.sort_by(group, &sort_key/1)} end defp group_to_pair({_id, []}), do: {nil, []} # Sort within each rover-location group by station position (so all # bands for a station cluster together) then ascending band — gives # 902 → 1296 → 2304 → … in each station block. defp sort_key(path), do: {station_position(path), path.band_mhz || 0} defp station_position(%{station: %{position: pos}}) when is_integer(pos), do: pos defp station_position(_), do: 0 defp group_lat({%{lat: lat}, _}) when is_number(lat), do: lat defp group_lat(_), do: 0 # 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_loss(db) when is_number(db), do: "#{Float.round(db * 1.0, 1)} dB" defp format_loss(_), do: "—" # Two-line station identity for the path table. Returns # `{primary, secondary}` — primary is the callsign (or grid when no # callsign), secondary is a Maidenhead grid (stored or derived from # lat/lon) when it adds info beyond `primary`. defp station_lines(%{station: %{} = station}), do: station_lines_for(station) defp station_lines(_), do: {"—", nil} defp station_lines_for(%{callsign: c} = s) when is_binary(c) and c != "", do: {c, station_grid(s)} defp station_lines_for(%{grid: g}) when is_binary(g) and g != "", do: {g, nil} defp station_lines_for(%{lat: lat, lon: lon}) when is_number(lat) and is_number(lon), do: {Maidenhead.from_latlon(lat, lon, 6), nil} defp station_lines_for(_), do: {"—", nil} defp station_grid(%{grid: g}) when is_binary(g) and g != "", do: g defp station_grid(%{lat: lat, lon: lon}) when is_number(lat) and is_number(lon), do: Maidenhead.from_latlon(lat, lon, 6) defp station_grid(_), do: nil # Inline label for the "Stationary stations" summary list. Prefers the # callsign (uppercased — legacy data was stored mixed-case), then the # grid, then the input string when neither is set. Bare lat/lon # coordinates are intentionally NOT used as a label here — the grid # half (via `stationary_grid/1`) carries that location info. defp stationary_label(%{callsign: c}) when is_binary(c) and c != "", do: String.upcase(c) defp stationary_label(%{grid: g}) when is_binary(g) and g != "", do: g defp stationary_label(%{input: i}) when is_binary(i) and i != "" do if LocationResolver.coordinate_pair?(i), do: nil, else: String.upcase(i) end defp stationary_label(_), do: nil defp stationary_grid(%{grid: g}) when is_binary(g) and g != "", do: g defp stationary_grid(%{lat: lat, lon: lon}) when is_number(lat) and is_number(lon), do: Maidenhead.from_latlon(lat, lon, 6) defp stationary_grid(_), do: nil # Endpoint string used for /path?destination=…. Prefers callsign, then # Row click goes straight to /path with the cached rover-path ID. # PathLive detects the param, deserializes the worker-stored # PathCompute result, and renders without re-running the pipeline. defp path_url(_mission, %{id: path_id}) when not is_nil(path_id), do: "/path?rover_path_id=#{path_id}" defp path_url(_, _), do: nil # Wraps a single table cell's content in a real when an href is # available, so the browser shows the destination on hover and # right-click / middle-click work natively. Falls back to a plain #
when the row has no path (e.g. compute hasn't started). attr :href, :string, default: nil slot :inner_block, required: true defp path_cell(%{href: href} = assigns) when is_binary(href) do ~H""" <.link navigate={@href} class="block px-3 py-2 text-inherit no-underline"> {render_slot(@inner_block)} """ end defp path_cell(assigns) do ~H"""
{render_slot(@inner_block)}
""" end @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""" <.header> {@mission.name} <:subtitle> {bands_label(@mission)} · {length(@mission.stations)} station(s) · rover {@mission.rover_height_ft}ft / station {@mission.station_height_ft}ft <: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 :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

Stationary stations

  • {stationary_label(station)} {stationary_grid(station)}

{@mission.notes}

Scope: {if @mission.only_known_good, do: "Known good locations only (status = good)", else: "All rover locations"} · Owner: {(@mission.user && @mission.user.callsign) || "—"}

Add rover site

{length(@rover_sites)} site(s) — paths recompute on add/remove
<%!-- The add form takes the same flexible input the station inputs use: callsign, Maidenhead grid, or `lat, lon`. Sign-in required. The site list itself is rendered inline as path-profile group headings below — each heading carries its own delete button. --%>
Already saved as: <.link :for={match <- @rover_site_input_matches} navigate={~p"/rover-locations/#{match.id}"} class="link link-warning font-mono ml-1" > {Maidenhead.from_latlon(match.lat, match.lon, 6)}{if match.notes && match.notes != "", do: " (#{match.notes})"}

<.link navigate={~p"/users/log-in"} class="link">Sign in to add or remove rover sites.

Path profiles

{@summary.complete}/{@summary.total} complete 0}>· {@summary.pending} pending 0} class="text-error"> · {@summary.failed} failed
No paths yet — they'll appear here as the workers run.
<.link :if={location && location.id} navigate={~p"/rover-locations/#{location.id}"} class="link link-hover" > {rover_location_heading(location)} {rover_location_heading(location)} · {location.notes}
{group_progress(group_paths)}
<%!-- Each cell wraps its content in <.link>, so the row behaves as a real anchor: native href hover preview, right-click "open in new tab", middle-click, etc. The row-hover highlight is preserved via :has(a:hover) so hovering any cell still tints the whole row. --%>
Station Band Status Distance Loss Verdict Propagation
<% {primary, secondary} = station_lines(path) %> <.path_cell href={path_url(@mission, path)}>
{primary}
{secondary}
<.path_cell href={path_url(@mission, path)}> {band_label(path.band_mhz || @mission.band_mhz)} <.path_cell href={path_url(@mission, path)}> {status_badge(path.status)} <.path_cell href={path_url(@mission, path)}> {if path.result, do: format_distance(path.result["distance_km"]), else: "—"} <.path_cell href={path_url(@mission, path)}> {if path.result, do: format_loss(path.result["total_baseline_loss_db"]), else: "—"} <.path_cell href={path_url(@mission, path)}> {if path.result, do: verdict_badge(path.result["verdict"]), else: "—"} <.path_cell href={path_url(@mission, path)}> {if path.result, do: propagation_badge(path.result["propagation_score"]), else: "—"}
""" 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