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.
This commit is contained in:
Graham McIntire 2026-05-03 14:00:47 -05:00
parent 23d002e8e0
commit b247e8a719
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
2 changed files with 96 additions and 3 deletions

View file

@ -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
</button>
</form>
<div :if={@rover_site_input_matches != []} class="mt-2 text-xs text-warning">
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>
</div>
<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.

View file

@ -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")