prop/lib/microwaveprop_web/live/rover_planning_live.ex
Graham McInitre b91ad05c78 deps: update all dependencies to latest versions
- Relax constraints: live_stash ~> 0.2 → ~> 1.0, nx ~> 0.9 → ~> 0.13,
  sutra_ui ~> 0.3.0 → ~> 0.4.0
- Within-constraint bumps: exla 0.12→0.13, live_table 0.4.1→0.4.2,
  phoenix_live_view 1.2.6→1.2.7, postgrex 0.22.2→0.22.3, req 0.6.2→0.6.3,
  stream_data 1.3→1.4, plus transitive (castore, circular_buffer, ecto,
  mint, plug)
- Breaking: live_table 0.4.2 removed get_merged_table_options/0 — replaced
  with LiveTable.TableConfig.get_table_options(table_options()) in
  RoverLocationsLive and RoverPlanningLive
- igniter stays at ~> 0.7 (pinned by live_table ~> 0.7.0)
2026-07-20 09:59:13 -05:00

215 lines
6 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
alias Microwaveprop.RoverPlanning.Mission
@spec table_options() :: map()
def table_options do
%{sorting: %{default_sort: [inserted_at: :desc]}}
end
@spec fields() :: keyword()
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: "Bands", sortable: true, renderer: &bands_cell/2},
only_known_good: %{label: "Scope", sortable: true, renderer: &scope_cell/1},
inserted_at: %{label: "Created", sortable: true, renderer: &format_ts/1}
]
end
@spec filters() :: list()
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 = LiveTable.TableConfig.get_table_options(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
# Renders the full bands list (multi-band missions can have several;
# the bound column is `band_mhz` so the table can still sort by the
# primary band, but the cell shows every band the matrix scores).
defp bands_cell(_value, record) do
case Mission.bands(struct(Mission, Map.take(record, [:band_mhz, :bands_mhz]))) do
[] -> ""
[single] -> band_label(single)
list -> Enum.map_join(list, " · ", &band_label/1)
end
end
defp band_label(mhz) when is_integer(mhz) and mhz >= 1000 do
ghz = mhz / 1000
label = if ghz == trunc(ghz), do: "#{trunc(ghz)}", else: Float.to_string(Float.round(ghz, 1))
"#{label} GHz"
end
defp band_label(mhz) when is_integer(mhz), do: "#{mhz} MHz"
defp band_label(_), do: ""
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