show.ex (rover-planning) and show.ex (rover-locations) auth + delete
flows go from nested case/case/case to a `with` ladder fed by a single
`authenticated/1` clause that returns `{:ok, user}` or
`{:error, :unauthenticated}`. The else block enumerates the small set
of failure tuples instead of rebuilding nested error returns.
Other idiomatic-Elixir tightening:
- `progress_summary/1` reduces with a `tally_path/2` multi-clause
helper (status as a head pattern, not three Enum.count passes).
- `paths_by_rover_location/1` extracts `group_to_pair/1`,
`station_position/1`, and `group_lat/1` so each transformation is a
pattern match instead of an anonymous fn with `&& fallbacks`.
- `error_summary/1` is two clauses (empty vs populated errors)
instead of a pipe-into-case.
- Drag-to-edit show.ex consolidates the working-coords assigns into
`assign_working_coords/3`.
Bug fix uncovered while writing tests: `add_station` on a fresh
/rover-planning/new form was a visual no-op on the first click —
cast_assoc replaced the unseeded default Station struct with the new
params row instead of appending. Now the initial changeset is built
from `%{"stations" => %{"0" => %{"position" => "0"}}}` so add_station
appends from the get-go. Regression test added at
test/microwaveprop_web/live/rover_planning_live_test.exs.
New test branches:
- form: add_station appends on first click + remove_station drops
the targeted row.
- rover-planning show: add_rover_site whitespace-input rejection,
delete_rover_site permission denial.
- rover-locations show: save_edit rejects unauthenticated drivers.
Suite: 3228 tests, 0 failures. Credo strict: 0 issues.
497 lines
19 KiB
Elixir
497 lines
19 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.Rover
|
|
alias Microwaveprop.Rover.Location
|
|
alias Microwaveprop.RoverPlanning
|
|
alias Microwaveprop.RoverPlanning.Mission
|
|
alias MicrowavepropWeb.LocationResolver
|
|
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),
|
|
rover_sites: RoverPlanning.candidate_rover_locations(mission),
|
|
rover_site_input: ""
|
|
)}
|
|
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)}
|
|
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.replace_mission_paths(mission)
|
|
|
|
socket
|
|
|> assign(
|
|
rover_sites: RoverPlanning.candidate_rover_locations(mission),
|
|
paths: RoverPlanning.list_paths(mission),
|
|
rover_site_input: ""
|
|
)
|
|
|> 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 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 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
|
|
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, &station_position/1)}
|
|
end
|
|
|
|
defp group_to_pair({_id, []}), do: {nil, []}
|
|
|
|
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_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"
|
|
|
|
# Grid+callsign-first station label. Stations entered as raw lat/lon
|
|
# have no callsign or grid stored, so derive a 6-char grid from the
|
|
# coords — that's what the user wants to see, not bare decimals.
|
|
defp station_label(%{station: %{} = station}), do: station_label_for(station)
|
|
defp station_label(_), do: "—"
|
|
|
|
defp station_label_for(%{callsign: c, grid: g}) when is_binary(c) and c != "" and is_binary(g) and g != "",
|
|
do: "#{c} · #{g}"
|
|
|
|
defp station_label_for(%{callsign: c, lat: lat, lon: lon})
|
|
when is_binary(c) and c != "" and is_number(lat) and is_number(lon),
|
|
do: "#{c} · #{Maidenhead.from_latlon(lat, lon, 6)}"
|
|
|
|
defp station_label_for(%{callsign: c}) when is_binary(c) and c != "", do: c
|
|
defp station_label_for(%{grid: g}) when is_binary(g) and g != "", do: g
|
|
|
|
defp station_label_for(%{lat: lat, lon: lon}) when is_number(lat) and is_number(lon),
|
|
do: Maidenhead.from_latlon(lat, lon, 6)
|
|
|
|
defp station_label_for(_), do: "—"
|
|
|
|
# Endpoint string used for /path?destination=…. Prefers callsign, then
|
|
# any stored or derived grid (PathLive's LocationResolver re-resolves
|
|
# both back to the same point), falling back to bare coordinates only
|
|
# when nothing else is available.
|
|
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: Maidenhead.from_latlon(lat, lon, 8)
|
|
|
|
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 mb-4">
|
|
<div class="flex items-center justify-between mb-2 gap-2 flex-wrap">
|
|
<h3 class="font-semibold">Rover sites</h3>
|
|
<span class="text-xs text-base-content/60">
|
|
{length(@rover_sites)} site(s) — paths recompute on add/remove
|
|
</span>
|
|
</div>
|
|
|
|
<ul :if={@rover_sites != []} class="text-sm space-y-1 mb-3">
|
|
<li :for={site <- @rover_sites} class="flex flex-wrap items-center gap-2">
|
|
<span class="font-mono">{Maidenhead.from_latlon(site.lat, site.lon, 10)}</span>
|
|
<span class="text-base-content/60 font-mono text-xs">
|
|
({Float.round(site.lat, 4)}, {Float.round(site.lon, 4)})
|
|
</span>
|
|
<span :if={site.notes && site.notes != ""} class="text-base-content/60 text-xs">
|
|
· {site.notes}
|
|
</span>
|
|
<button
|
|
:if={can_delete_site?(assigns, site)}
|
|
type="button"
|
|
phx-click="delete_rover_site"
|
|
phx-value-id={site.id}
|
|
data-confirm="Remove this rover site? This affects every mission that uses it."
|
|
class="btn btn-ghost btn-xs text-error ml-auto"
|
|
title="Remove rover site"
|
|
>
|
|
<.icon name="hero-trash" class="w-4 h-4" />
|
|
</button>
|
|
</li>
|
|
</ul>
|
|
|
|
<p :if={@rover_sites == []} class="text-sm text-base-content/60 mb-3">
|
|
No rover sites match this mission's scope yet — add one below.
|
|
</p>
|
|
|
|
<%!--
|
|
The add form takes the same flexible input the station inputs use:
|
|
callsign, Maidenhead grid, or `lat, lon`. Sign-in required.
|
|
--%>
|
|
<form
|
|
:if={@current_scope && @current_scope.user}
|
|
phx-submit="add_rover_site"
|
|
phx-change="rover_site_input_change"
|
|
class="flex flex-wrap gap-2 items-stretch"
|
|
>
|
|
<input
|
|
type="text"
|
|
name="input"
|
|
value={@rover_site_input}
|
|
placeholder="Callsign, EM12kp, or 32.91, -97.06"
|
|
class="input input-sm flex-1 min-w-[14rem]"
|
|
/>
|
|
<button type="submit" class="btn btn-primary btn-sm" disabled={@rover_site_input == ""}>
|
|
<.icon name="hero-plus" class="w-4 h-4" /> Add rover site
|
|
</button>
|
|
</form>
|
|
|
|
<p :if={!(@current_scope && @current_scope.user)} class="text-xs text-base-content/60">
|
|
<.link navigate={~p"/users/log-in"} class="link">Sign in</.link>
|
|
to add or remove rover sites.
|
|
</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
|