prop/lib/microwaveprop_web/live/rover_planning_live.ex
Graham McIntire d1a5442afb
feat(rover-planning): /rover-planning page with path-profile worker
Adds a new globally-scoped, owner-mutable rover-mission tracker:
- /rover-planning paginated table (LiveTable) with View/Edit/Delete
- /rover-planning/new + /:id/edit form: name, band, antenna heights,
  notes, "only check against known good locations" toggle (default on),
  and a dynamic list of stationary stations entered as callsigns,
  Maidenhead grids, or lat,lon pairs (Station changeset geocodes via
  LocationResolver, lat/lon stays editable after add)
- /rover-planning/:id show page renders the station list, scope, and a
  matrix of computed path profiles (distance, min clearance, diffraction,
  verdict) populated as the worker completes each pairing

After save, RoverPlanning enqueues one RoverPathProfileWorker job per
(rover-location × station) pairing. The worker mirrors PathLive's
synchronous compute (ElevationClient + TerrainAnalysis at the mission's
band + heights) and stores the result on the matching path row.
PubSub broadcast on completion lets the show page live-refresh.

Admins can edit/delete any mission; owners can edit their own.
2026-05-03 11:04:11 -05:00

203 lines
5.4 KiB
Elixir

defmodule MicrowavepropWeb.RoverPlanningLive do
@moduledoc """
`/rover-planning` paginated table of planned rover missions.
Globally visible, owner-mutable; admins can edit/delete any mission.
"""
use MicrowavepropWeb, :live_view
use MicrowavepropWeb.LiveTableResource, schema: Microwaveprop.RoverPlanning.Mission
alias Microwaveprop.Accounts.User
alias Microwaveprop.RoverPlanning
def table_options do
%{sorting: %{default_sort: [inserted_at: :desc]}}
end
def fields do
[
id: %{label: "ID", hidden: true},
user_id: %{label: "Owner", hidden: true},
name: %{label: "Mission", sortable: true, searchable: true, renderer: &name_cell/2},
band_mhz: %{label: "Band", sortable: true, renderer: &band_cell/1},
only_known_good: %{label: "Scope", sortable: true, renderer: &scope_cell/1},
inserted_at: %{label: "Created", sortable: true, renderer: &format_ts/1}
]
end
def filters, do: []
@impl true
def mount(_params, _session, socket) do
{:ok,
assign(socket,
page_title: "Rover Planning"
)}
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
case current_user(socket) do
%User{} = user ->
case RoverPlanning.delete_mission(user, id) do
{:ok, _} ->
{:noreply,
socket
|> put_flash(:info, "Mission deleted.")
|> reload()}
{:error, :not_found} ->
{:noreply, put_flash(socket, :error, "You can only delete your own missions.")}
end
_ ->
{:noreply, put_flash(socket, :error, "Sign in required.")}
end
end
defp reload(socket) do
options = socket.assigns.options
data_provider = socket.assigns[:data_provider]
table_options = get_merged_table_options()
{resources, updated_options} = fetch_resources(options, data_provider)
socket
|> assign_to_socket(resources, table_options)
|> assign(:options, updated_options)
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)
defp scope_user(assigns) do
case assigns[:current_scope] do
%{user: %User{} = user} -> user
_ -> nil
end
end
defp can_modify?(scope, %{user_id: user_id}) do
case scope do
%{user: %User{is_admin: true}} -> true
%{user: %User{id: ^user_id}} when not is_nil(user_id) -> true
_ -> false
end
end
defp can_modify?(_, _), do: false
defp actions_for(scope) do
[
buttons: fn %{record: record} ->
row_actions(record, scope)
end
]
end
defp row_actions(record, scope) do
assigns = %{record: record, can_modify?: can_modify?(scope, record)}
~H"""
<div class="flex gap-2">
<.link
navigate={~p"/rover-planning/#{@record.id}"}
class="btn btn-xs btn-ghost"
>
View
</.link>
<.link
:if={@can_modify?}
navigate={~p"/rover-planning/#{@record.id}/edit"}
class="btn btn-xs btn-ghost"
>
Edit
</.link>
<button
:if={@can_modify?}
type="button"
phx-click="delete"
phx-value-id={@record.id}
data-confirm="Delete this mission?"
class="btn btn-xs btn-ghost text-error"
>
Delete
</button>
</div>
"""
end
defp name_cell(value, %{id: id}) do
assigns = %{id: id, name: value || "(unnamed)"}
~H"""
<.link navigate={~p"/rover-planning/#{@id}"} class="font-semibold hover:underline">
{@name}
</.link>
"""
end
defp band_cell(nil), do: ""
defp band_cell(mhz) when is_integer(mhz) do
if mhz >= 1000 do
ghz = mhz / 1000
label = if ghz == trunc(ghz), do: "#{trunc(ghz)}", else: Float.to_string(Float.round(ghz, 1))
"#{label} GHz"
else
"#{mhz} MHz"
end
end
defp scope_cell(true) do
assigns = %{}
~H|<span class="badge badge-success badge-sm">Known good only</span>|
end
defp scope_cell(false) do
assigns = %{}
~H|<span class="badge badge-info badge-sm">All locations</span>|
end
defp scope_cell(_), do: ""
defp format_ts(nil), do: ""
defp format_ts(%DateTime{} = dt), do: Calendar.strftime(dt, "%Y-%m-%d")
defp format_ts(%NaiveDateTime{} = dt), do: Calendar.strftime(dt, "%Y-%m-%d")
defp format_ts(other), do: to_string(other)
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope} max_width="max-w-6xl">
<.header>
Rover Planning
<:subtitle>
Plan rover missions: enter your stationary stations, pick a band, and
we compute terrain path profiles from every known-good rover location.
</:subtitle>
<:actions>
<.link
:if={current_user(assigns)}
navigate={~p"/rover-planning/new"}
class="btn btn-primary"
>
<.icon name="hero-plus" class="w-5 h-5" /> New mission
</.link>
</:actions>
</.header>
<p :if={is_nil(current_user(assigns))} class="alert alert-info text-sm mb-4">
<a href={~p"/users/log-in"} class="link link-primary">Sign in</a> to plan a mission.
</p>
<.live_table
fields={fields()}
filters={filters()}
options={@options}
streams={@streams}
actions={actions_for(@current_scope)}
/>
</Layouts.app>
"""
end
end