feat(rover): auto-resolve grid from callsign on add; inline-edit grid per row; mi/ft units

This commit is contained in:
Graham McIntire 2026-04-25 17:14:24 -05:00
parent ea95ae3596
commit 5631444abd
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59

View file

@ -199,20 +199,51 @@ defmodule MicrowavepropWeb.RoverLive do
def handle_event("add_station", params, socket) do
callsign = params |> Map.get("callsign", "") |> String.trim()
grid = params |> Map.get("grid", "") |> String.trim()
attrs = build_station_attrs(callsign, grid)
case create_station(socket, attrs) do
{:ok, stations} ->
{:noreply,
socket
|> assign(fixed_stations: stations, station_form_error: nil)
|> sync_url()}
case resolve_station_grid(callsign, grid) do
{:ok, resolved_grid} ->
attrs = build_station_attrs(callsign, resolved_grid)
case create_station(socket, attrs) do
{:ok, stations} ->
{:noreply,
socket
|> assign(fixed_stations: stations, station_form_error: nil)
|> sync_url()}
{:error, msg} ->
{:noreply, assign(socket, station_form_error: msg)}
end
{:error, msg} ->
{:noreply, assign(socket, station_form_error: msg)}
end
end
def handle_event("update_station_grid", %{"station_id" => id, "grid" => new_grid}, socket) do
new_grid = String.trim(new_grid)
cond do
new_grid == "" ->
{:noreply, socket}
not Maidenhead.valid?(new_grid) ->
{:noreply, assign(socket, station_form_error: "invalid grid: #{new_grid}")}
true ->
case mutate_station_grid(socket, id, new_grid) do
{:ok, stations} ->
{:noreply,
socket
|> assign(fixed_stations: stations, station_form_error: nil)
|> sync_url()}
{:error, msg} ->
{:noreply, assign(socket, station_form_error: msg)}
end
end
end
def handle_event("set_home_qth", params, socket) do
input = params |> Map.get("home_grid", "") |> String.trim()
@ -283,6 +314,30 @@ defmodule MicrowavepropWeb.RoverLive do
put_flash(socket, :info, Enum.join(warnings, " "))
end
# When the user supplies just a callsign, look it up via QRZ (with the
# Google Maps geocoder fallback already wired into CallsignClient) and
# use the returned 10-char grid. Explicit user-entered grids win over
# the lookup so an operator can override an old QRZ entry.
defp resolve_station_grid(_call, grid) when is_binary(grid) and grid != "", do: {:ok, grid}
defp resolve_station_grid("", _grid), do: {:error, "callsign required"}
defp resolve_station_grid(call, _grid) do
case CallsignClient.locate(call) do
{:ok, %{gridsquare: g}} when is_binary(g) and byte_size(g) >= 4 ->
{:ok, g}
{:ok, %{lat: lat, lon: lon}} when is_number(lat) and is_number(lon) ->
{:ok, Maidenhead.from_latlon(lat, lon, 10)}
{:ok, _} ->
{:error, "QRZ lookup for #{call} returned no grid or coords"}
{:error, reason} ->
{:error, "couldn't locate #{call}: #{inspect(reason)}"}
end
end
# ── Mutation helpers ────────────────────────────────────────────────
defp handle_station_mutation(socket, id, fun) do
@ -350,6 +405,32 @@ defmodule MicrowavepropWeb.RoverLive do
{:ok, stations}
end
defp mutate_station_grid(%{assigns: %{persisted?: true}} = socket, id, new_grid) do
user = current_user(socket)
case Rover.update_station(user, id, %{"grid" => new_grid, "lat" => nil, "lon" => nil}) do
{:ok, _} -> {:ok, Rover.list_stations(user)}
{:error, :not_found} -> {:error, "station not found"}
{:error, %Ecto.Changeset{} = cs} -> {:error, changeset_first_error(cs)}
end
end
defp mutate_station_grid(socket, id, new_grid) do
stations =
Enum.map(socket.assigns.fixed_stations, fn s ->
if station_id(s) == id, do: replace_anonymous_station_grid(s, new_grid), else: s
end)
{:ok, stations}
end
defp replace_anonymous_station_grid(station, new_grid) do
case Maidenhead.to_latlon(new_grid) do
{:ok, {lat, lon}} -> %{station | grid: new_grid, lat: lat, lon: lon}
:error -> station
end
end
defp create_station(%{assigns: %{persisted?: true}} = socket, attrs) do
user = current_user(socket)
@ -620,6 +701,12 @@ defmodule MicrowavepropWeb.RoverLive do
defp station_id(%{callsign: call}) when is_binary(call), do: "call:" <> call
defp station_id(_), do: ""
defp km_to_mi(km) when is_number(km), do: round(km * 0.621371)
defp km_to_mi(_), do: 0
defp elev_ft(nil), do: ""
defp elev_ft(m) when is_number(m), do: "#{round(m * 3.28084)} ft"
# ── Render ───────────────────────────────────────────────────────────
@impl true
@ -683,10 +770,10 @@ defmodule MicrowavepropWeb.RoverLive do
</span>
</div>
<div class="text-xs opacity-70">
{round(c.distance_km)} km {c.bearing_compass} of home
{km_to_mi(c.distance_km)} mi {c.bearing_compass} of home
</div>
<div class="text-xs opacity-70">
{c.elev_m} m · {round(c.drive_min)} min drive
{elev_ft(c.elev_m)} · {round(c.drive_min)} min drive
</div>
</div>
</div>
@ -725,7 +812,7 @@ defmodule MicrowavepropWeb.RoverLive do
<.sidebar_section title="FIXED STATIONS">
<div class="flex flex-col gap-0.5">
<div :for={s <- @fixed_stations} class="flex items-center gap-2 py-1">
<label class="cursor-pointer flex items-center gap-2 flex-1 min-w-0">
<label class="cursor-pointer flex items-center gap-2 min-w-0 shrink-0">
<input
type="checkbox"
class="checkbox checkbox-xs"
@ -733,9 +820,26 @@ defmodule MicrowavepropWeb.RoverLive do
phx-click="toggle_station"
phx-value-id={station_id(s)}
/>
<span class="font-mono text-xs flex-1 truncate">{s.callsign}</span>
<span :if={s.grid} class="opacity-60 text-[11px]">{s.grid}</span>
<span class="font-mono text-xs truncate">{s.callsign}</span>
</label>
<form
phx-submit="update_station_grid"
class="flex-1 min-w-0"
id={"station-grid-form-" <> station_id(s)}
>
<input type="hidden" name="station_id" value={station_id(s)} />
<input
type="text"
name="grid"
value={s.grid || ""}
placeholder="grid"
autocomplete="off"
spellcheck="false"
phx-debounce="blur"
title="Click to edit; press Enter or click away to save"
class="font-mono text-[11px] w-full bg-transparent border-0 border-b border-transparent focus:border-base-content/30 focus:bg-base-100/30 focus:outline-none px-1 py-0 opacity-60 focus:opacity-100"
/>
</form>
<button
type="button"
class="btn btn-ghost btn-xs btn-square opacity-50 hover:opacity-100 shrink-0"
@ -751,7 +855,7 @@ defmodule MicrowavepropWeb.RoverLive do
<input
type="text"
name="callsign"
placeholder="Call"
placeholder="Call (grid auto-looked-up)"
class="input input-bordered input-xs flex-1"
autocomplete="off"
/>