From 8fd9759e4ef7c3dc9ff28db2437e7f01501640a9 Mon Sep 17 00:00:00 2001
From: Graham McIntire
Date: Sun, 3 May 2026 12:47:07 -0500
Subject: [PATCH] feat(rover-planning): add/remove rover sites inline on the
show page
A new "Rover sites" section between Stationary stations and Path
profiles lists the candidate rover locations the mission scores
against, with:
- An add form that takes the same flexible input as station inputs
(callsign, Maidenhead grid, or `lat,lon`); on submit it creates a
global :good rover-location and re-runs the path matrix.
- A per-row trash button visible to the location's owner / admins
that deletes the location and re-runs the matrix.
`RoverPlanning.candidate_rover_locations/1` is now public so the show
page can list exactly what the worker enqueues against. Add/remove
both call `replace_mission_paths/1` so the matrix stays consistent
with the rover-site set.
Anonymous visitors see a sign-in prompt instead of the form.
---
lib/microwaveprop/rover_planning.ex | 14 +-
.../live/rover_planning_live/show.ex | 136 +++++++++++++++++-
.../live/rover_planning_live_test.exs | 47 ++++++
3 files changed, 192 insertions(+), 5 deletions(-)
diff --git a/lib/microwaveprop/rover_planning.ex b/lib/microwaveprop/rover_planning.ex
index 824ae7d0..187387f2 100644
--- a/lib/microwaveprop/rover_planning.ex
+++ b/lib/microwaveprop/rover_planning.ex
@@ -137,12 +137,18 @@ defmodule Microwaveprop.RoverPlanning do
end
end
- defp candidate_rover_locations(%Mission{only_known_good: true}) do
- Repo.all(from(l in Location, where: l.status == :good))
+ @doc """
+ Rover-locations the matrix actually scores against, given the
+ mission's `only_known_good` flag. Public so the show LiveView can
+ render the same list the worker enqueues against.
+ """
+ @spec candidate_rover_locations(Mission.t()) :: [Location.t()]
+ def candidate_rover_locations(%Mission{only_known_good: true}) do
+ Repo.all(from(l in Location, where: l.status == :good, order_by: [desc: l.inserted_at]))
end
- defp candidate_rover_locations(%Mission{only_known_good: false}) do
- Repo.all(Location)
+ def candidate_rover_locations(%Mission{only_known_good: false}) do
+ Repo.all(from(l in Location, order_by: [desc: l.inserted_at]))
end
defp insert_pending_paths_and_jobs(mission, pairs) do
diff --git a/lib/microwaveprop_web/live/rover_planning_live/show.ex b/lib/microwaveprop_web/live/rover_planning_live/show.ex
index f047073e..70f37808 100644
--- a/lib/microwaveprop_web/live/rover_planning_live/show.ex
+++ b/lib/microwaveprop_web/live/rover_planning_live/show.ex
@@ -8,8 +8,11 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Show do
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
@@ -30,7 +33,9 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Show do
assign(socket,
page_title: "Mission · #{mission.name}",
mission: mission,
- paths: RoverPlanning.list_paths(mission)
+ paths: RoverPlanning.list_paths(mission),
+ rover_sites: RoverPlanning.candidate_rover_locations(mission),
+ rover_site_input: ""
)}
end
end
@@ -41,6 +46,57 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Show do
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 %User{} = user <- current_user(socket) || :anonymous,
+ {: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
+ :ok = RoverPlanning.replace_mission_paths(socket.assigns.mission)
+
+ {:noreply,
+ socket
+ |> assign(
+ rover_sites: RoverPlanning.candidate_rover_locations(socket.assigns.mission),
+ paths: RoverPlanning.list_paths(socket.assigns.mission),
+ rover_site_input: ""
+ )
+ |> put_flash(:info, "Rover site added — recomputing paths.")}
+ else
+ :anonymous -> {: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
+ case current_user(socket) do
+ %User{} = user ->
+ case Rover.delete_location(user, id) do
+ {:ok, _} ->
+ :ok = RoverPlanning.replace_mission_paths(socket.assigns.mission)
+
+ {:noreply,
+ socket
+ |> assign(
+ rover_sites: RoverPlanning.candidate_rover_locations(socket.assigns.mission),
+ paths: RoverPlanning.list_paths(socket.assigns.mission)
+ )
+ |> put_flash(:info, "Rover site removed — recomputing paths.")}
+
+ {:error, :not_found} ->
+ {:noreply, put_flash(socket, :error, "You can only delete rover sites you created.")}
+ end
+
+ _ ->
+ {:noreply, put_flash(socket, :error, "Sign in to remove rover sites.")}
+ end
+ end
+
def handle_event("delete", _params, socket) do
case current_user(socket) do
%User{} = user ->
@@ -73,6 +129,21 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Show do
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{} = cs) do
+ cs.errors
+ |> Enum.map_join("; ", fn {field, {msg, _}} -> "#{field} #{msg}" end)
+ |> case do
+ "" -> "Could not save rover site."
+ summary -> summary
+ end
+ 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"
@@ -271,6 +342,69 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Show do
+ No rover sites match this mission's scope yet — add one below.
+
+
+ <%!--
+ The add form takes the same flexible input the station inputs use:
+ callsign, Maidenhead grid, or `lat, lon`. Sign-in required.
+ --%>
+
+
+
+ <.link navigate={~p"/users/log-in"} class="link">Sign in
+ to add or remove rover sites.
+
+
+
Path profiles
diff --git a/test/microwaveprop_web/live/rover_planning_live_test.exs b/test/microwaveprop_web/live/rover_planning_live_test.exs
index 6df715f4..db5ee8c5 100644
--- a/test/microwaveprop_web/live/rover_planning_live_test.exs
+++ b/test/microwaveprop_web/live/rover_planning_live_test.exs
@@ -7,6 +7,7 @@ defmodule MicrowavepropWeb.RoverPlanningLiveTest do
alias Microwaveprop.Radio.Maidenhead
alias Microwaveprop.Repo
alias Microwaveprop.Rover
+ alias Microwaveprop.Rover.Location
alias Microwaveprop.RoverPlanning
alias Microwaveprop.RoverPlanning.Path
alias Microwaveprop.Terrain.ElevationClient
@@ -207,6 +208,52 @@ defmodule MicrowavepropWeb.RoverPlanningLiveTest do
assert html =~ "Clear"
end
+ test "owner can add a rover site from the show page", %{conn: conn} do
+ user = AccountsFixtures.user_fixture()
+ mission = create_mission(user, "Add-site mission")
+ conn = log_in_user(conn, user)
+
+ {:ok, lv, _html} = live(conn, ~p"/rover-planning/#{mission.id}")
+
+ html =
+ lv
+ |> form("form[phx-submit='add_rover_site']", %{"input" => "33.5, -97.5"})
+ |> render_submit()
+
+ # New site appears in the rover-sites list, and a flash confirms.
+ assert html =~ "Rover site added"
+ assert html =~ Maidenhead.from_latlon(33.5, -97.5, 10)
+ assert Repo.aggregate(Location, :count) == 2
+ end
+
+ test "owner can remove a rover site from the show page", %{conn: conn} do
+ user = AccountsFixtures.user_fixture()
+ mission = create_mission(user, "Remove-site mission")
+ conn = log_in_user(conn, user)
+
+ [site] = Repo.all(Location)
+
+ {:ok, lv, _html} = live(conn, ~p"/rover-planning/#{mission.id}")
+
+ html =
+ lv
+ |> element("button[phx-click='delete_rover_site'][phx-value-id='#{site.id}']")
+ |> render_click()
+
+ assert html =~ "Rover site removed"
+ assert Repo.aggregate(Location, :count) == 0
+ end
+
+ test "anonymous visitor sees a sign-in prompt instead of the add form", %{conn: conn} do
+ user = AccountsFixtures.user_fixture()
+ mission = create_mission(user, "Anon-view mission")
+
+ {:ok, lv, _html} = live(conn, ~p"/rover-planning/#{mission.id}")
+
+ refute has_element?(lv, "form[phx-submit='add_rover_site']")
+ assert has_element?(lv, "a", "Sign in")
+ end
+
test "redirects when mission missing", %{conn: conn} do
missing = Ecto.UUID.generate()