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

+
+
+

Rover sites

+ + {length(@rover_sites)} site(s) — paths recompute on add/remove + +
+ + + +

+ 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()