From 4a82571cda9f4b35174b9f36f42c1e5bae6d438f Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sat, 2 May 2026 16:50:18 -0500 Subject: [PATCH] feat(rover): two-way grid <-> lat/lon sync on location form Adds a Maidenhead grid input to the rover-location form. Typing a grid fills in lat/lon (using square center); editing lat/lon recomputes the grid at 6-char precision. The phx-change `_target` decides direction so edits never clobber what the user just typed. --- .../live/rover_locations_live.ex | 76 ++++++++++++++++--- .../live/rover_locations_live_test.exs | 42 ++++++++++ 2 files changed, 106 insertions(+), 12 deletions(-) diff --git a/lib/microwaveprop_web/live/rover_locations_live.ex b/lib/microwaveprop_web/live/rover_locations_live.ex index 45c6f139..828d4fb2 100644 --- a/lib/microwaveprop_web/live/rover_locations_live.ex +++ b/lib/microwaveprop_web/live/rover_locations_live.ex @@ -56,6 +56,7 @@ defmodule MicrowavepropWeb.RoverLocationsLive do page_title: "Rover Locations", form: nil, editing_id: nil, + grid_input: "", status_filter: nil ) |> assign(:data_provider, {__MODULE__, :visible_query_provider, [nil]})} @@ -75,7 +76,7 @@ defmodule MicrowavepropWeb.RoverLocationsLive do def handle_event("new", _params, socket) do if user = current_user(socket) do - {:noreply, assign(socket, form: blank_form(user), editing_id: nil)} + {:noreply, assign(socket, form: blank_form(user), editing_id: nil, grid_input: "")} else {:noreply, put_flash(socket, :error, "Sign in to add a location.")} end @@ -87,7 +88,8 @@ defmodule MicrowavepropWeb.RoverLocationsLive do case Repo.get(Location, id) do %Location{user_id: ^user_id} = loc -> cs = Location.changeset(loc, %{}) - {:noreply, assign(socket, form: to_form(cs), editing_id: id)} + grid = Maidenhead.from_latlon(loc.lat, loc.lon, 6) + {:noreply, assign(socket, form: to_form(cs), editing_id: id, grid_input: grid)} _ -> {:noreply, put_flash(socket, :error, "You can only edit your own locations.")} @@ -99,13 +101,16 @@ defmodule MicrowavepropWeb.RoverLocationsLive do end def handle_event("cancel", _, socket) do - {:noreply, assign(socket, form: nil, editing_id: nil)} + {:noreply, assign(socket, form: nil, editing_id: nil, grid_input: "")} end - def handle_event("validate", %{"location" => params}, socket) do + def handle_event("validate", %{"location" => params} = event_params, socket) do + target = event_params["_target"] + {params, grid_input} = sync_grid_and_latlon(params, target, socket.assigns.grid_input) + base = if socket.assigns.editing_id, do: location_for_edit(socket), else: %Location{} cs = base |> Location.changeset(params) |> Map.put(:action, :validate) - {:noreply, assign(socket, form: to_form(cs))} + {:noreply, assign(socket, form: to_form(cs), grid_input: grid_input)} end def handle_event("save", %{"location" => params}, socket) do @@ -201,6 +206,44 @@ defmodule MicrowavepropWeb.RoverLocationsLive do defp parse_status(value) when value in @valid_status_filters, do: value defp parse_status(_), do: nil + # Two-way grid <-> lat/lon sync. `_target` from the phx-change event + # tells us which field the user actually edited; we mirror the change + # into the other side. Unparseable input leaves the other side alone. + defp sync_grid_and_latlon(params, ["location", "grid"], _old_grid) do + raw = params |> Map.get("grid", "") |> to_string() |> String.trim() + + case Maidenhead.to_latlon(raw) do + {:ok, {lat, lon}} -> + params = + params + |> Map.put("lat", format_coord(lat)) + |> Map.put("lon", format_coord(lon)) + + {params, raw} + + :error -> + {params, raw} + end + end + + defp sync_grid_and_latlon(params, ["location", field], old_grid) when field in ["lat", "lon"] do + with {lat, ""} <- params |> Map.get("lat", "") |> to_string() |> String.trim() |> Float.parse(), + {lon, ""} <- params |> Map.get("lon", "") |> to_string() |> String.trim() |> Float.parse(), + lat when lat >= -90.0 and lat <= 90.0 <- lat, + lon when lon >= -180.0 and lon <= 180.0 <- lon do + grid = Maidenhead.from_latlon(lat, lon, 6) + {Map.put(params, "grid", grid), grid} + else + _ -> {params, old_grid} + end + end + + defp sync_grid_and_latlon(params, _target, old_grid), do: {params, old_grid} + + defp format_coord(value) do + value |> Float.round(6) |> Float.to_string() + end + defp current_user(%Phoenix.LiveView.Socket{assigns: assigns}), do: scope_user(assigns) defp current_user(assigns) when is_map(assigns), do: scope_user(assigns) @@ -325,7 +368,22 @@ defmodule MicrowavepropWeb.RoverLocationsLive do {if @editing_id, do: "Edit location", else: "New location"} <.form for={@form} phx-change="validate" phx-submit="save" id="location-form"> -
+
+ <.input + name="location[grid]" + value={@grid_input} + type="text" + label="Grid (Maidenhead)" + placeholder="EM12kp" + /> + <.input + field={@form[:status]} + type="select" + label="Status" + options={[{"Ideal", :ideal}, {"Off Limits", :off_limits}]} + /> +
+
<.input field={@form[:lat]} type="number" @@ -340,12 +398,6 @@ defmodule MicrowavepropWeb.RoverLocationsLive do label="Longitude" placeholder="-97.5" /> - <.input - field={@form[:status]} - type="select" - label="Status" - options={[{"Ideal", :ideal}, {"Off Limits", :off_limits}]} - />
<.input field={@form[:notes]} diff --git a/test/microwaveprop_web/live/rover_locations_live_test.exs b/test/microwaveprop_web/live/rover_locations_live_test.exs index 91d5d153..b2ed7f68 100644 --- a/test/microwaveprop_web/live/rover_locations_live_test.exs +++ b/test/microwaveprop_web/live/rover_locations_live_test.exs @@ -79,6 +79,48 @@ defmodule MicrowavepropWeb.RoverLocationsLiveTest do assert html =~ "test note" end + test "typing a grid populates lat/lon", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/rover-locations") + lv |> element("button", "Add location") |> render_click() + + html = + lv + |> form("#location-form", + location: %{ + grid: "EM12kp", + lat: "", + lon: "", + status: "ideal", + notes: "" + } + ) + |> render_change(%{"_target" => ["location", "grid"]}) + + # EM12kp center: lat 32.645833, lon -97.125 + assert html =~ ~s(value="32.645833") + assert html =~ ~s(value="-97.125") + end + + test "typing lat/lon populates the grid field", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/rover-locations") + lv |> element("button", "Add location") |> render_click() + + html = + lv + |> form("#location-form", + location: %{ + grid: "", + lat: "32.5", + lon: "-97.5", + status: "ideal", + notes: "" + } + ) + |> render_change(%{"_target" => ["location", "lat"]}) + + assert html =~ ~s(value="EM12gm") + end + test "edit/delete buttons appear only on the owner's row", %{conn: conn, user: user} do other = Microwaveprop.AccountsFixtures.user_fixture() {:ok, mine} = Rover.create_location(user, %{lat: 32.0, lon: -97.0, status: :ideal})