From b247e8a71999fbf5cdb62ed70a0756e518d902b2 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sun, 3 May 2026 14:00:47 -0500 Subject: [PATCH] feat(rover-planning): warn when new site overlaps an existing 6-char grid The add-rover-site form now resolves the input on every change and, if any existing rover-location shares the same 6-char Maidenhead cell (~5 km), surfaces a 'Already saved as' link to that location's detail page. Avoids users accidentally duplicating a site they (or another operator) already added. --- .../live/rover_planning_live/show.ex | 49 ++++++++++++++++-- .../live/rover_planning_live_test.exs | 50 +++++++++++++++++++ 2 files changed, 96 insertions(+), 3 deletions(-) diff --git a/lib/microwaveprop_web/live/rover_planning_live/show.ex b/lib/microwaveprop_web/live/rover_planning_live/show.ex index 223f0073..72a6d2d0 100644 --- a/lib/microwaveprop_web/live/rover_planning_live/show.ex +++ b/lib/microwaveprop_web/live/rover_planning_live/show.ex @@ -8,6 +8,7 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Show do alias Microwaveprop.Accounts.User alias Microwaveprop.Radio.Maidenhead + alias Microwaveprop.Repo alias Microwaveprop.Rover alias Microwaveprop.Rover.Location alias Microwaveprop.RoverPlanning @@ -35,7 +36,8 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Show do mission: mission, paths: RoverPlanning.list_paths(mission), rover_sites: RoverPlanning.candidate_rover_locations(mission), - rover_site_input: "" + rover_site_input: "", + rover_site_input_matches: [] )} end end @@ -47,7 +49,11 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Show do @impl true def handle_event("rover_site_input_change", %{"input" => input}, socket) do - {:noreply, assign(socket, rover_site_input: input)} + {:noreply, + assign(socket, + rover_site_input: input, + rover_site_input_matches: find_grid_matches(input) + )} end def handle_event("add_rover_site", %{"input" => input}, socket) do @@ -97,7 +103,8 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Show do |> assign( rover_sites: RoverPlanning.candidate_rover_locations(mission), paths: RoverPlanning.list_paths(mission), - rover_site_input: "" + rover_site_input: "", + rover_site_input_matches: [] ) |> put_flash(:info, flash_message) end @@ -106,6 +113,30 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Show do defp authenticated(_), do: {:error, :unauthenticated} + # Resolve the user-typed input and surface any existing rover-locations + # that share its 6-char Maidenhead grid (sub-grid resolution ≈ 5 km). + # Cheap enough for ~hundreds of locations: we map them in memory rather + # than maintaining a stored grid column. + defp find_grid_matches(input) 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) + + Location + |> Repo.all() + |> Enum.filter(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 @@ -407,6 +438,18 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Show do +
+ 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. diff --git a/test/microwaveprop_web/live/rover_planning_live_test.exs b/test/microwaveprop_web/live/rover_planning_live_test.exs index e0ad5c0f..432622ab 100644 --- a/test/microwaveprop_web/live/rover_planning_live_test.exs +++ b/test/microwaveprop_web/live/rover_planning_live_test.exs @@ -409,6 +409,56 @@ defmodule MicrowavepropWeb.RoverPlanningLiveTest do assert Repo.aggregate(Location, :count) == 2 end + test "typing a new rover site surfaces an existing match by 6-char grid", + %{conn: conn} do + user = AccountsFixtures.user_fixture() + mission = create_mission(user, "Match-existing mission") + conn = log_in_user(conn, user) + + # Existing site at a known grid; the user-typed coords below + # land in the same 6-char Maidenhead cell, so the form should + # surface a link to the existing site rather than letting the + # user duplicate it. + {:ok, existing} = + Rover.create_location(user, %{ + lat: 32.5, + lon: -97.5, + status: :good, + notes: "Existing parking spot" + }) + + {:ok, lv, _html} = live(conn, ~p"/rover-planning/#{mission.id}") + + html = + lv + |> form("form[phx-submit='add_rover_site']", %{"input" => "32.5004, -97.4998"}) + |> render_change() + + # The 6-char grid for both points is identical, so a match link + # to the existing site appears with its grid label. + assert html =~ ~s(href="/rover-locations/#{existing.id}) + grid6 = Maidenhead.from_latlon(32.5, -97.5, 6) + assert html =~ grid6 + end + + test "non-matching input shows no existing-site link", %{conn: conn} do + user = AccountsFixtures.user_fixture() + mission = create_mission(user, "No-match mission") + conn = log_in_user(conn, user) + + {:ok, _other} = + Rover.create_location(user, %{lat: 32.5, lon: -97.5, status: :good}) + + {:ok, lv, _html} = live(conn, ~p"/rover-planning/#{mission.id}") + + html = + lv + |> form("form[phx-submit='add_rover_site']", %{"input" => "45.0, -120.0"}) + |> render_change() + + refute html =~ "Already saved as" + 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")