prop/lib/microwaveprop_web/live/rover_planning_live/form.ex
Graham McIntire a4f0e171e8
feat(rover-planning): worker pre-computes full /path output, PathLive renders cached
Three connected changes:

1) Extract PathLive.compute_path/4 + every helper it owned (resolve,
   profile grid lookup, sounding/ionosphere readouts, scoring, loss /
   power budgets) into Microwaveprop.Propagation.PathCompute. PathLive
   now delegates; ~410 lines of dead helpers deleted from PathLive.

2) RoverPathProfileWorker calls PathCompute.compute/4 with the
   mission's heights and PathLive's default station params (10 W TX,
   30 dBi gains). Stores the full atom-keyed compute output as a
   Base64-encoded :erlang.term_to_binary/1 blob alongside the flat
   summary fields the rover-planning show table reads. The blob
   roundtrips structs and DateTime exactly (Jason.encode would lose
   them).

3) PathLive accepts ?rover_path_id=UUID. When set, loads the cached
   Path, decodes the term (binary_to_term :safe), assigns it as
   @result, and renders normally — no compute_path call. The
   rover-planning show table now links rows directly to
   /path?rover_path_id=UUID, so a click opens the full Path
   Calculator UI from cached data without re-running terrain / HRRR /
   sounding lookups.

Bonus prod fixes folded in:
- PathShow's elevation chart attribute (data-* → data-profile JSON)
  was crashing the JS hook with 'unexpected character at line 1'.
- Station.changeset now wipes previously-resolved callsign/grid/
  lat/lon when the user types into :input — typing 'AA5' early
  resolved to a wrong location and locked it; subsequent keystrokes
  never re-resolved.
- phx-debounce=600 on the station input so QRZ doesn't get hit on
  every keystroke.
2026-05-03 14:58:16 -05:00

381 lines
12 KiB
Elixir

defmodule MicrowavepropWeb.RoverPlanningLive.Form do
@moduledoc "New / edit form for `/rover-planning` missions."
use MicrowavepropWeb, :live_view
alias Microwaveprop.Accounts.User
alias Microwaveprop.RoverPlanning
alias Microwaveprop.RoverPlanning.Mission
@band_options [
{"902 MHz", 902},
{"1.3 GHz", 1296},
{"2.3 GHz", 2304},
{"3.4 GHz", 3400},
{"5.7 GHz", 5760},
{"10 GHz", 10_000},
{"24 GHz", 24_000},
{"47 GHz", 47_000}
]
@impl true
def mount(params, _session, socket) do
case current_user(socket) do
nil ->
{:ok,
socket
|> put_flash(:error, "Sign in to plan a mission.")
|> push_navigate(to: ~p"/rover-planning")}
%User{} = user ->
{:ok,
socket
|> assign(
page_title: "Rover Mission",
band_options: @band_options,
current_user: user
)
|> apply_action(socket.assigns.live_action, params)}
end
end
defp apply_action(socket, :new, _params) do
# Seed the initial changeset PARAMS (not just the struct) with one
# station row so add_station / remove_station have something to work
# with. Without this seed, cast_assoc treats the first add_station
# click as a replacement of the unseeded default — visually a no-op.
mission = %Mission{}
cs = Mission.changeset(mission, %{"stations" => %{"0" => %{"position" => "0"}}})
assign(socket,
mission: mission,
form: to_form(cs),
mode: :new
)
end
defp apply_action(socket, :edit, %{"id" => id}) do
user = socket.assigns.current_user
case RoverPlanning.get_mission(id) do
nil ->
socket
|> put_flash(:error, "Mission not found.")
|> push_navigate(to: ~p"/rover-planning")
mission ->
if can_modify?(user, mission) do
cs = Mission.changeset(mission, %{})
assign(socket,
mission: mission,
form: to_form(cs),
mode: :edit
)
else
socket
|> put_flash(:error, "You can only edit your own missions.")
|> push_navigate(to: ~p"/rover-planning/#{mission.id}")
end
end
end
@impl true
def handle_event("validate", %{"mission" => params}, socket) do
cs =
socket.assigns.mission
|> Mission.changeset(params)
|> Map.put(:action, :validate)
{:noreply, assign(socket, form: to_form(cs))}
end
def handle_event("add_station", _params, socket) do
params = current_params(socket)
stations = stations_params(socket, params)
next_index = next_station_index(stations)
# Don't include "input" in the new station's params — `used_input?/1`
# treats any field present in params as "used" and would render the
# `validate_required([:input])` error before the user has typed
# anything. Leaving the key out keeps the row pristine until edited.
stations =
Map.put(stations, Integer.to_string(next_index), %{
"position" => Integer.to_string(next_index)
})
cs =
socket.assigns.mission
|> Mission.changeset(Map.put(params, "stations", stations))
|> Map.put(:action, :validate)
{:noreply, assign(socket, form: to_form(cs))}
end
def handle_event("remove_station", %{"index" => index}, socket) do
params = current_params(socket)
stations = socket |> stations_params(params) |> Map.delete(index)
cs =
socket.assigns.mission
|> Mission.changeset(Map.put(params, "stations", stations))
|> Map.put(:action, :validate)
{:noreply, assign(socket, form: to_form(cs))}
end
def handle_event("save", %{"mission" => params}, socket) do
user = socket.assigns.current_user
result =
case socket.assigns.mode do
:new -> RoverPlanning.create_mission(user, params)
:edit -> RoverPlanning.update_mission(user, socket.assigns.mission.id, params)
end
case result do
{:ok, mission} ->
{:noreply,
socket
|> put_flash(:info, "Mission saved — computing path profiles.")
|> push_navigate(to: ~p"/rover-planning/#{mission.id}")}
{:error, :not_found} ->
{:noreply, put_flash(socket, :error, "Mission not found.")}
{:error, %Ecto.Changeset{} = cs} ->
{:noreply, assign(socket, form: to_form(cs))}
end
end
# When the form changeset already carries a "stations" params map
# (every keystroke fires phx-change="validate" which builds one),
# trust it. Otherwise — e.g. the user clicked add/remove BEFORE
# touching any other input on an :edit-mode form — fall back to
# rebuilding the params from the persisted mission's stations so
# `cast_assoc(on_replace: :delete)` doesn't wipe every existing row.
defp stations_params(_socket, %{"stations" => stations}) when is_map(stations) and stations != %{}, do: stations
defp stations_params(socket, _params), do: stations_from_mission(socket.assigns.mission)
defp stations_from_mission(%Mission{stations: list}) when is_list(list) do
list
|> Enum.with_index()
|> Map.new(fn {s, i} ->
{Integer.to_string(i),
%{
"id" => s.id,
"input" => s.input || s.callsign || s.grid,
"callsign" => s.callsign,
"grid" => s.grid,
"lat" => s.lat,
"lon" => s.lon,
"position" => Integer.to_string(s.position || i)
}}
end)
end
defp stations_from_mission(_), do: %{}
defp current_params(socket) do
case socket.assigns.form.source do
%Ecto.Changeset{params: params} when is_map(params) -> params
_ -> %{}
end
end
defp next_station_index(stations) when map_size(stations) == 0, do: 0
defp next_station_index(stations) do
stations
|> Map.keys()
|> Enum.map(fn k ->
case Integer.parse(to_string(k)) do
{n, _} -> n
:error -> -1
end
end)
|> Enum.max()
|> Kernel.+(1)
end
defp current_user(%Phoenix.LiveView.Socket{assigns: assigns}) do
case assigns[:current_scope] do
%{user: %User{} = user} -> user
_ -> nil
end
end
defp can_modify?(%User{is_admin: true}, _), do: true
defp can_modify?(%User{id: id}, %Mission{user_id: id}) when not is_nil(id), do: true
defp can_modify?(_, _), do: false
# Multi-checkbox values come from the form as a list of strings (or
# ints, depending on whether the changeset has cast them yet). Tolerate
# both, falling back to "not checked" for nil / empty bands lists.
defp band_checked?(values, target) when is_list(values) do
Enum.any?(values, fn
v when is_integer(v) -> v == target
v when is_binary(v) -> v == Integer.to_string(target)
_ -> false
end)
end
defp band_checked?(_, _), do: false
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope} max_width="max-w-3xl">
<.header>
{if @mode == :edit, do: "Edit mission", else: "New rover mission"}
<:subtitle>
Stationary stations can be callsigns, Maidenhead grids, or <code>lat, lon</code> pairs.
</:subtitle>
</.header>
<.form
for={@form}
id="mission-form"
phx-change="validate"
phx-submit="save"
class="space-y-4"
>
<.input field={@form[:name]} type="text" label="Mission name" placeholder="DFW August rove" />
<div>
<label class="label">
<span class="label-text">Bands</span>
<span class="label-text-alt text-base-content/60">
Pick one or more — paths recompute per band
</span>
</label>
<%!--
Hidden zero-value field so an unchecked-everywhere submit
still parses as an empty list (Phoenix drops the key
otherwise). Mission.changeset rejects an empty bands_mhz
with a friendly error.
--%>
<input type="hidden" name="mission[bands_mhz][]" value="" />
<div class="flex flex-wrap gap-2">
<label
:for={{label, value} <- @band_options}
class="cursor-pointer flex items-center gap-2 border border-base-300 rounded px-3 py-1 text-sm"
>
<input
type="checkbox"
name="mission[bands_mhz][]"
value={value}
checked={band_checked?(@form[:bands_mhz].value, value)}
class="checkbox checkbox-sm checkbox-primary"
/>
<span>{label}</span>
</label>
</div>
<p :if={@form[:bands_mhz].errors != []} class="text-error text-sm mt-1">
{Phoenix.HTML.html_escape(Enum.map_join(@form[:bands_mhz].errors, ", ", &elem(&1, 0)))}
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<.input
field={@form[:rover_height_ft]}
type="number"
step="any"
label="Rover antenna (ft)"
/>
<.input
field={@form[:station_height_ft]}
type="number"
step="any"
label="Station antenna (ft)"
/>
</div>
<label class="flex items-center gap-2 cursor-pointer">
<input type="hidden" name="mission[only_known_good]" value="false" />
<input
type="checkbox"
name="mission[only_known_good]"
value="true"
checked={Phoenix.HTML.Form.normalize_value("checkbox", @form[:only_known_good].value)}
class="checkbox checkbox-primary"
/>
<span>
Only check against known good locations
<span class="text-xs opacity-70 block">
When checked, paths are computed only against rover-locations marked Good.
</span>
</span>
</label>
<div>
<div class="flex items-center justify-between mb-2">
<h3 class="font-semibold">Stationary stations</h3>
<button
type="button"
phx-click="add_station"
class="btn btn-xs btn-ghost"
>
<.icon name="hero-plus" class="w-4 h-4" /> Add station
</button>
</div>
<.inputs_for :let={s} field={@form[:stations]}>
<div class="card bg-base-200 p-3 mb-2">
<%!--
`items-start` keeps the lat/lon row top-aligned even when the
callsign cell grows (e.g. an error message under the input).
The trash button uses `mt-6` to sit beside the input — matches
the label height so it visually aligns with the input field.
--%>
<div class="grid grid-cols-1 md:grid-cols-2 gap-2 items-start">
<.input
field={s[:input]}
type="text"
label="Callsign / grid / lat,lon"
placeholder="W5LUA or EM13qc or 32.91, -97.06"
phx-debounce="600"
/>
<div class="flex items-start gap-2">
<div class="flex-1">
<.input field={s[:lat]} type="number" step="any" label="Lat" />
</div>
<div class="flex-1">
<.input field={s[:lon]} type="number" step="any" label="Lon" />
</div>
<button
type="button"
phx-click="remove_station"
phx-value-index={s.index}
class="btn btn-ghost btn-sm text-error mt-6"
title="Remove station"
>
<.icon name="hero-trash" class="w-4 h-4" />
</button>
</div>
</div>
<p :if={s[:callsign].value} class="text-xs opacity-70 mt-1 px-1">
Resolved callsign: {s[:callsign].value}
<span :if={s[:grid].value}>· grid {s[:grid].value}</span>
</p>
</div>
</.inputs_for>
<p :if={@form[:stations].errors != []} class="text-error text-sm">
{Phoenix.HTML.html_escape(Enum.map_join(@form[:stations].errors, ", ", &elem(&1, 0)))}
</p>
</div>
<.input field={@form[:notes]} type="textarea" label="Notes" rows="3" />
<div class="flex gap-2">
<button type="submit" class="btn btn-primary">
{if @mode == :edit, do: "Save changes", else: "Create mission"}
</button>
<.link navigate={~p"/rover-planning"} class="btn btn-ghost">Cancel</.link>
</div>
</.form>
</Layouts.app>
"""
end
end