diff --git a/lib/microwaveprop/rover_planning.ex b/lib/microwaveprop/rover_planning.ex
new file mode 100644
index 00000000..6b5009fa
--- /dev/null
+++ b/lib/microwaveprop/rover_planning.ex
@@ -0,0 +1,197 @@
+defmodule Microwaveprop.RoverPlanning do
+ @moduledoc """
+ Context for the `/rover-planning` feature: planned rover missions
+ with stationary endpoints and per-rover-location terrain path
+ profiles. Mutations are scoped to a user (or admin); reads are
+ global.
+
+ After a successful `create_mission/2` or
+ `replace_mission_paths/1`, the context enqueues one
+ `RoverPathProfileWorker` job per (rover-location, station) pair
+ so the show page can render results as they land.
+ """
+
+ import Ecto.Query
+
+ alias Ecto.Multi
+ alias Microwaveprop.Accounts.User
+ alias Microwaveprop.Repo
+ alias Microwaveprop.Rover.Location
+ alias Microwaveprop.RoverPlanning.Mission
+ alias Microwaveprop.RoverPlanning.Path
+ alias Microwaveprop.RoverPlanning.Station
+ alias Microwaveprop.Workers.RoverPathProfileWorker
+
+ @spec list_missions() :: [Mission.t()]
+ def list_missions do
+ Mission
+ |> order_by([m], desc: m.inserted_at)
+ |> preload([:user, :stations])
+ |> Repo.all()
+ end
+
+ @spec get_mission(Ecto.UUID.t()) :: Mission.t() | nil
+ def get_mission(id) when is_binary(id) do
+ case Ecto.UUID.cast(id) do
+ {:ok, uuid} ->
+ Mission
+ |> Repo.get(uuid)
+ |> Repo.preload([:user, stations: from(s in Station, order_by: s.position)])
+
+ :error ->
+ nil
+ end
+ end
+
+ @spec get_mission_with_paths(Ecto.UUID.t()) :: Mission.t() | nil
+ def get_mission_with_paths(id) do
+ case get_mission(id) do
+ nil -> nil
+ mission -> Repo.preload(mission, paths: [:rover_location, :station])
+ end
+ end
+
+ @spec create_mission(User.t(), map()) ::
+ {:ok, Mission.t()} | {:error, Ecto.Changeset.t()}
+ def create_mission(%User{} = user, attrs) do
+ changeset = Mission.changeset(%Mission{user_id: user.id}, attrs)
+
+ case Repo.insert(changeset) do
+ {:ok, mission} ->
+ mission = Repo.preload(mission, [:user, :stations])
+ :ok = enqueue_paths_for(mission)
+ {:ok, mission}
+
+ {:error, _} = error ->
+ error
+ end
+ end
+
+ @spec update_mission(User.t(), Ecto.UUID.t(), map()) ::
+ {:ok, Mission.t()} | {:error, :not_found | Ecto.Changeset.t()}
+ def update_mission(%User{} = user, id, attrs) do
+ case fetch_owned(user, id) do
+ {:ok, mission} ->
+ mission
+ |> Repo.preload(stations: from(s in Station, order_by: s.position))
+ |> Mission.changeset(attrs)
+ |> Repo.update()
+ |> case do
+ {:ok, updated} ->
+ updated = Repo.preload(updated, [:user, :stations], force: true)
+ :ok = replace_mission_paths(updated)
+ {:ok, updated}
+
+ other ->
+ other
+ end
+
+ {:error, :not_found} ->
+ {:error, :not_found}
+ end
+ end
+
+ @spec delete_mission(User.t(), Ecto.UUID.t()) ::
+ {:ok, Mission.t()} | {:error, :not_found}
+ def delete_mission(%User{} = user, id) do
+ case fetch_owned(user, id) do
+ {:ok, mission} -> Repo.delete(mission)
+ {:error, :not_found} -> {:error, :not_found}
+ end
+ end
+
+ @spec list_paths(Mission.t()) :: [Path.t()]
+ def list_paths(%Mission{id: mission_id}) do
+ Repo.all(
+ from(p in Path,
+ where: p.mission_id == ^mission_id,
+ preload: [:rover_location, :station],
+ order_by: [asc: p.station_id, asc: p.rover_location_id]
+ )
+ )
+ end
+
+ @doc """
+ Wipes the existing path matrix for a mission and re-enqueues all
+ pairings. Called after a mission edit when the station list or
+ `only_known_good` toggle could have shifted the matrix.
+ """
+ @spec replace_mission_paths(Mission.t()) :: :ok
+ def replace_mission_paths(%Mission{} = mission) do
+ Repo.delete_all(from(p in Path, where: p.mission_id == ^mission.id))
+ enqueue_paths_for(mission)
+ end
+
+ defp enqueue_paths_for(%Mission{} = mission) do
+ rovers = candidate_rover_locations(mission)
+ stations = mission.stations || []
+
+ pairs =
+ for rover <- rovers, station <- stations do
+ %{rover: rover, station: station}
+ end
+
+ case pairs do
+ [] -> :ok
+ _ -> insert_pending_paths_and_jobs(mission, pairs)
+ end
+ end
+
+ defp candidate_rover_locations(%Mission{only_known_good: true}) do
+ Repo.all(from(l in Location, where: l.status == :ideal))
+ end
+
+ defp candidate_rover_locations(%Mission{only_known_good: false}) do
+ Repo.all(Location)
+ end
+
+ defp insert_pending_paths_and_jobs(mission, pairs) do
+ {:ok, _} =
+ pairs
+ |> Enum.reduce(Multi.new(), fn %{rover: r, station: s}, multi ->
+ attrs = %{
+ mission_id: mission.id,
+ rover_location_id: r.id,
+ station_id: s.id,
+ status: :pending
+ }
+
+ Multi.insert(multi, {:path, r.id, s.id}, Path.changeset(%Path{}, attrs),
+ on_conflict: :nothing,
+ conflict_target: [:mission_id, :rover_location_id, :station_id]
+ )
+ end)
+ |> Repo.transaction()
+
+ enqueue_jobs(mission, pairs)
+ :ok
+ end
+
+ defp enqueue_jobs(mission, pairs) do
+ for %{rover: r, station: s} <- pairs do
+ %{
+ "mission_id" => mission.id,
+ "rover_location_id" => r.id,
+ "station_id" => s.id
+ }
+ |> RoverPathProfileWorker.new()
+ |> Oban.insert()
+ end
+
+ :ok
+ end
+
+ defp fetch_owned(%User{is_admin: true}, id) do
+ case get_mission(id) do
+ nil -> {:error, :not_found}
+ mission -> {:ok, mission}
+ end
+ end
+
+ defp fetch_owned(%User{id: user_id}, id) do
+ case get_mission(id) do
+ %Mission{user_id: ^user_id} = mission -> {:ok, mission}
+ _ -> {:error, :not_found}
+ end
+ end
+end
diff --git a/lib/microwaveprop/rover_planning/mission.ex b/lib/microwaveprop/rover_planning/mission.ex
new file mode 100644
index 00000000..2d292e1e
--- /dev/null
+++ b/lib/microwaveprop/rover_planning/mission.ex
@@ -0,0 +1,71 @@
+defmodule Microwaveprop.RoverPlanning.Mission do
+ @moduledoc """
+ A planned rover mission. Owned by a user but globally visible. Holds
+ the band + height parameters that drive path-profile computation, the
+ list of stationary endpoints (`stations`), and the matrix of computed
+ paths from each rover-location to each station (`paths`).
+
+ `only_known_good` toggles whether the rover endpoint set is the
+ `:ideal` rover-locations (default) or every rover-location.
+ """
+ use Ecto.Schema
+
+ import Ecto.Changeset
+
+ alias Microwaveprop.Accounts.User
+ alias Microwaveprop.RoverPlanning.Path
+ alias Microwaveprop.RoverPlanning.Station
+
+ @primary_key {:id, :binary_id, autogenerate: true}
+ @foreign_key_type :binary_id
+ schema "rover_missions" do
+ field :name, :string
+ field :band_mhz, :integer, default: 10_000
+ field :only_known_good, :boolean, default: true
+ field :rover_height_ft, :float, default: 8.0
+ field :station_height_ft, :float, default: 30.0
+ field :notes, :string
+
+ belongs_to :user, User
+ has_many :stations, Station, foreign_key: :mission_id, on_replace: :delete
+ has_many :paths, Path, foreign_key: :mission_id
+
+ timestamps(type: :utc_datetime)
+ end
+
+ @type t :: %__MODULE__{}
+
+ @castable_fields [
+ :name,
+ :band_mhz,
+ :only_known_good,
+ :rover_height_ft,
+ :station_height_ft,
+ :notes
+ ]
+
+ @spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
+ def changeset(mission, attrs) do
+ mission
+ |> cast(attrs, @castable_fields)
+ |> cast_assoc(:stations, with: &Station.changeset/2, required: true)
+ |> validate_required([:name, :band_mhz])
+ |> validate_length(:name, min: 1, max: 100)
+ |> validate_number(:band_mhz, greater_than: 0)
+ |> validate_number(:rover_height_ft, greater_than_or_equal_to: 0.0)
+ |> validate_number(:station_height_ft, greater_than_or_equal_to: 0.0)
+ |> validate_length(:notes, max: 4000)
+ |> validate_at_least_one_station()
+ end
+
+ defp validate_at_least_one_station(changeset) do
+ case fetch_field(changeset, :stations) do
+ {_source, list} when is_list(list) and list != [] ->
+ kept = Enum.reject(list, fn s -> match?(%Ecto.Changeset{action: :replace}, s) end)
+ if kept == [], do: add_error(changeset, :stations, "must include at least one station"), else: changeset
+
+ _ ->
+ add_error(changeset, :stations, "must include at least one station")
+ end
+ end
+end
diff --git a/lib/microwaveprop/rover_planning/path.ex b/lib/microwaveprop/rover_planning/path.ex
new file mode 100644
index 00000000..7ee149d1
--- /dev/null
+++ b/lib/microwaveprop/rover_planning/path.ex
@@ -0,0 +1,56 @@
+defmodule Microwaveprop.RoverPlanning.Path do
+ @moduledoc """
+ A computed (or pending) path-profile result for one rover-location
+ → station pairing inside a mission. `result` holds the same shape
+ produced by `MicrowavepropWeb.PathLive`'s synchronous compute, so
+ the show page can render it identically.
+ """
+ use Ecto.Schema
+
+ import Ecto.Changeset
+
+ alias Microwaveprop.Rover.Location
+ alias Microwaveprop.RoverPlanning.Mission
+ alias Microwaveprop.RoverPlanning.Station
+
+ @statuses [:pending, :computing, :complete, :failed]
+
+ @primary_key {:id, :binary_id, autogenerate: true}
+ @foreign_key_type :binary_id
+ schema "rover_mission_paths" do
+ field :status, Ecto.Enum, values: @statuses, default: :pending
+ field :result, :map
+ field :error, :string
+ field :computed_at, :utc_datetime
+
+ belongs_to :mission, Mission
+ belongs_to :rover_location, Location
+ belongs_to :station, Station
+
+ timestamps(type: :utc_datetime)
+ end
+
+ @type t :: %__MODULE__{}
+
+ @spec statuses() :: [atom()]
+ def statuses, do: @statuses
+
+ @spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
+ def changeset(path, attrs) do
+ path
+ |> cast(attrs, [
+ :status,
+ :result,
+ :error,
+ :computed_at,
+ :mission_id,
+ :rover_location_id,
+ :station_id
+ ])
+ |> validate_required([:mission_id, :rover_location_id, :station_id, :status])
+ |> validate_inclusion(:status, @statuses)
+ |> unique_constraint([:mission_id, :rover_location_id, :station_id],
+ name: :rover_mission_paths_unique
+ )
+ end
+end
diff --git a/lib/microwaveprop/rover_planning/station.ex b/lib/microwaveprop/rover_planning/station.ex
new file mode 100644
index 00000000..9535710c
--- /dev/null
+++ b/lib/microwaveprop/rover_planning/station.ex
@@ -0,0 +1,86 @@
+defmodule Microwaveprop.RoverPlanning.Station do
+ @moduledoc """
+ A stationary endpoint for a rover mission. The user provides one of:
+ callsign, Maidenhead grid, or `lat, lon`; the changeset normalizes
+ the input into all three when possible (callsigns geocode via
+ `MicrowavepropWeb.LocationResolver`).
+ """
+ use Ecto.Schema
+
+ import Ecto.Changeset
+
+ alias Microwaveprop.RoverPlanning.Mission
+ alias MicrowavepropWeb.LocationResolver
+
+ @primary_key {:id, :binary_id, autogenerate: true}
+ @foreign_key_type :binary_id
+ schema "rover_mission_stations" do
+ field :callsign, :string
+ field :grid, :string
+ field :input, :string
+ field :lat, :float
+ field :lon, :float
+ field :position, :integer, default: 0
+
+ belongs_to :mission, Mission
+
+ timestamps(type: :utc_datetime)
+ end
+
+ @type t :: %__MODULE__{}
+
+ @spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
+ def changeset(station, attrs) do
+ station
+ |> cast(attrs, [:callsign, :grid, :input, :lat, :lon, :position])
+ |> validate_required([:input])
+ |> resolve_input()
+ |> validate_required([:lat, :lon])
+ |> validate_number(:lat, greater_than_or_equal_to: -90.0, less_than_or_equal_to: 90.0)
+ |> validate_number(:lon, greater_than_or_equal_to: -180.0, less_than_or_equal_to: 180.0)
+ end
+
+ # When `input` looks like a callsign or grid, geocode it and populate
+ # the matching field. Skips resolution when the user already provided
+ # explicit lat/lon — they may want a free-form coordinate that doesn't
+ # round-trip through callsign lookup.
+ defp resolve_input(changeset) do
+ cond do
+ not changeset.valid? ->
+ changeset
+
+ get_field(changeset, :lat) && get_field(changeset, :lon) ->
+ changeset
+
+ input = get_field(changeset, :input) ->
+ apply_resolution(changeset, LocationResolver.resolve(String.trim(input)))
+
+ true ->
+ changeset
+ end
+ end
+
+ defp apply_resolution(changeset, {:ok, %{kind: :callsign} = res}) do
+ changeset
+ |> put_change(:callsign, res.label)
+ |> put_change(:grid, Map.get(res, :grid))
+ |> put_change(:lat, res.lat)
+ |> put_change(:lon, res.lon)
+ end
+
+ defp apply_resolution(changeset, {:ok, %{kind: :grid} = res}) do
+ changeset
+ |> put_change(:grid, res.label)
+ |> put_change(:lat, res.lat)
+ |> put_change(:lon, res.lon)
+ end
+
+ defp apply_resolution(changeset, {:ok, %{kind: :coordinates} = res}) do
+ changeset
+ |> put_change(:lat, res.lat)
+ |> put_change(:lon, res.lon)
+ end
+
+ defp apply_resolution(changeset, {:error, msg}), do: add_error(changeset, :input, msg)
+ defp apply_resolution(changeset, :empty), do: add_error(changeset, :input, "is required")
+end
diff --git a/lib/microwaveprop/workers/rover_path_profile_worker.ex b/lib/microwaveprop/workers/rover_path_profile_worker.ex
new file mode 100644
index 00000000..e14a6c09
--- /dev/null
+++ b/lib/microwaveprop/workers/rover_path_profile_worker.ex
@@ -0,0 +1,177 @@
+defmodule Microwaveprop.Workers.RoverPathProfileWorker do
+ @moduledoc """
+ Computes a terrain path profile for one rover-location → station
+ pairing inside a `RoverPlanning.Mission`. Mirrors the synchronous
+ `MicrowavepropWeb.PathLive` compute pipeline (elevation profile +
+ ITU-R P.526 analysis at the mission's band + heights) and stores
+ the result as JSON on the matching `RoverPlanning.Path`.
+
+ The HRRR-along-path step from `/path` is intentionally skipped here:
+ it depends on real-time profile grids that aren't worth pinning to a
+ stored mission result. The rendered show page can re-fetch live
+ weather on demand if needed.
+ """
+ use Oban.Worker,
+ queue: :terrain,
+ max_attempts: 5,
+ unique: [
+ period: 300,
+ keys: [:mission_id, :rover_location_id, :station_id],
+ states: [:available, :scheduled, :executing, :retryable]
+ ]
+
+ import Ecto.Query
+
+ alias Microwaveprop.Geo
+ alias Microwaveprop.Repo
+ alias Microwaveprop.RoverPlanning.Path
+ alias Microwaveprop.Terrain.ElevationClient
+ alias Microwaveprop.Terrain.TerrainAnalysis
+
+ require Logger
+
+ @impl Oban.Worker
+ def backoff(%Oban.Job{attempt: attempt}) do
+ min(60 * Integer.pow(2, attempt - 1), 3600)
+ end
+
+ @impl Oban.Worker
+ def perform(%Oban.Job{
+ args: %{"mission_id" => mission_id, "rover_location_id" => rover_location_id, "station_id" => station_id}
+ }) do
+ case load_path(mission_id, rover_location_id, station_id) do
+ nil ->
+ :ok
+
+ %Path{status: :complete} ->
+ :ok
+
+ %Path{} = path ->
+ run(path)
+ end
+ end
+
+ defp load_path(mission_id, rover_location_id, station_id) do
+ Path
+ |> where([p], p.mission_id == ^mission_id)
+ |> where([p], p.rover_location_id == ^rover_location_id)
+ |> where([p], p.station_id == ^station_id)
+ |> preload([:rover_location, :station, :mission])
+ |> Repo.one()
+ end
+
+ defp run(%Path{rover_location: rover, station: station, mission: mission} = path) do
+ mark_status(path, :computing)
+
+ rover_height_m = (mission.rover_height_ft || 8.0) * 0.3048
+ station_height_m = (mission.station_height_ft || 30.0) * 0.3048
+ freq_ghz = mission.band_mhz / 1000
+ dist_km = Geo.haversine_km(rover.lat, rover.lon, station.lat, station.lon)
+ bearing = Geo.bearing_deg(rover.lat, rover.lon, station.lat, station.lon)
+
+ case ElevationClient.fetch_elevation_profile(
+ rover.lat,
+ rover.lon,
+ station.lat,
+ station.lon,
+ 64,
+ download: true
+ ) do
+ {:ok, profile} ->
+ analysis =
+ TerrainAnalysis.analyse(profile, dist_km, freq_ghz,
+ ant_ht_a: rover_height_m,
+ ant_ht_b: station_height_m
+ )
+
+ store_complete(path, profile, analysis, dist_km, bearing)
+
+ {:error, reason} ->
+ store_failed(path, "elevation profile failed: #{inspect(reason)}")
+ end
+ rescue
+ e ->
+ Logger.error("RoverPathProfileWorker crashed for path=#{path.id}: #{Exception.message(e)}")
+ store_failed(path, "exception: #{Exception.message(e)}")
+ reraise e, __STACKTRACE__
+ end
+
+ defp store_complete(path, profile, analysis, dist_km, bearing) do
+ points =
+ Enum.map(profile, fn p ->
+ %{
+ "lat" => p.lat,
+ "lon" => p.lon,
+ "d" => p.d,
+ "elev" => p.elev,
+ "dist_km" => p.dist_km
+ }
+ end)
+
+ result = %{
+ "distance_km" => dist_km,
+ "bearing_deg" => bearing,
+ "max_elevation_m" => analysis.max_elevation_m,
+ "min_clearance_m" => analysis.min_clearance_m,
+ "diffraction_db" => analysis.diffraction_db,
+ "fresnel_hit_count" => analysis.fresnel_hit_count,
+ "obstructed_count" => analysis.obstructed_count,
+ "verdict" => to_string(analysis.verdict),
+ "sample_count" => length(profile),
+ "path_points" => points
+ }
+
+ path
+ |> Path.changeset(%{
+ status: :complete,
+ result: result,
+ error: nil,
+ computed_at: DateTime.truncate(DateTime.utc_now(), :second)
+ })
+ |> Repo.update()
+ |> case do
+ {:ok, updated} ->
+ broadcast(updated)
+ :ok
+
+ {:error, cs} ->
+ {:error, "store update failed: #{inspect(cs.errors)}"}
+ end
+ end
+
+ defp store_failed(path, message) do
+ path
+ |> Path.changeset(%{
+ status: :failed,
+ error: message,
+ computed_at: DateTime.truncate(DateTime.utc_now(), :second)
+ })
+ |> Repo.update()
+ |> case do
+ {:ok, updated} ->
+ broadcast(updated)
+ {:error, message}
+
+ {:error, cs} ->
+ {:error, "store update failed: #{inspect(cs.errors)}"}
+ end
+ end
+
+ defp mark_status(path, status) do
+ path
+ |> Path.changeset(%{status: status})
+ |> Repo.update()
+ |> case do
+ {:ok, _} -> :ok
+ _ -> :ok
+ end
+ end
+
+ defp broadcast(%Path{mission_id: mission_id} = path) do
+ Phoenix.PubSub.broadcast(
+ Microwaveprop.PubSub,
+ "rover_planning:#{mission_id}",
+ {:rover_path_updated, path.id}
+ )
+ end
+end
diff --git a/lib/microwaveprop_web/live/rover_planning_live.ex b/lib/microwaveprop_web/live/rover_planning_live.ex
new file mode 100644
index 00000000..d55e39b7
--- /dev/null
+++ b/lib/microwaveprop_web/live/rover_planning_live.ex
@@ -0,0 +1,203 @@
+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"""
+
+ <.link
+ navigate={~p"/rover-planning/#{@record.id}"}
+ class="btn btn-xs btn-ghost"
+ >
+ View
+
+ <.link
+ :if={@can_modify?}
+ navigate={~p"/rover-planning/#{@record.id}/edit"}
+ class="btn btn-xs btn-ghost"
+ >
+ Edit
+
+
+
+ """
+ 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}
+
+ """
+ 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|Known good only|
+ end
+
+ defp scope_cell(false) do
+ assigns = %{}
+ ~H|All locations|
+ 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"""
+
+ <.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.
+
+ <: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
+
+
+
+
+
+ Sign in to plan a mission.
+
+
+ <.live_table
+ fields={fields()}
+ filters={filters()}
+ options={@options}
+ streams={@streams}
+ actions={actions_for(@current_scope)}
+ />
+
+ """
+ end
+end
diff --git a/lib/microwaveprop_web/live/rover_planning_live/form.ex b/lib/microwaveprop_web/live/rover_planning_live/form.ex
new file mode 100644
index 00000000..7c70d08e
--- /dev/null
+++ b/lib/microwaveprop_web/live/rover_planning_live/form.ex
@@ -0,0 +1,299 @@
+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
+ alias Microwaveprop.RoverPlanning.Station
+
+ @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
+ mission = %Mission{stations: [%Station{position: 0}]}
+ cs = Mission.changeset(mission, %{})
+
+ 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 = Map.get(params, "stations", %{})
+ next_index = next_station_index(stations)
+
+ stations =
+ Map.put(stations, Integer.to_string(next_index), %{
+ "input" => "",
+ "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 = Map.get(params, "stations", %{})
+ stations = Map.delete(stations, 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
+
+ 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
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+
+ <.header>
+ {if @mode == :edit, do: "Edit mission", else: "New rover mission"}
+ <:subtitle>
+ Stationary stations can be callsigns, Maidenhead grids, or lat, lon pairs.
+
+
+
+ <.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" />
+
+
+ <.input
+ field={@form[:band_mhz]}
+ type="select"
+ label="Band"
+ options={@band_options}
+ />
+ <.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)"
+ />
+
+
+
+
+
+
+
Stationary stations
+
+
+
+ <.inputs_for :let={s} field={@form[:stations]}>
+
+
+ <.input
+ field={s[:input]}
+ type="text"
+ label="Callsign / grid / lat,lon"
+ placeholder="W5LUA or EM13qc or 32.91, -97.06"
+ />
+
+
+ <.input field={s[:lat]} type="number" step="any" label="Lat" />
+
+
+ <.input field={s[:lon]} type="number" step="any" label="Lon" />
+
+
+
+
+
+ Resolved callsign: {s[:callsign].value}
+ · grid {s[:grid].value}
+
+
+
+
+
+ {Phoenix.HTML.html_escape(Enum.map_join(@form[:stations].errors, ", ", &elem(&1, 0)))}
+
+
+
+ <.input field={@form[:notes]} type="textarea" label="Notes" rows="3" />
+
+
+
+ <.link navigate={~p"/rover-planning"} class="btn btn-ghost">Cancel
+
+
+
+ """
+ end
+end
diff --git a/lib/microwaveprop_web/live/rover_planning_live/show.ex b/lib/microwaveprop_web/live/rover_planning_live/show.ex
new file mode 100644
index 00000000..df70c58d
--- /dev/null
+++ b/lib/microwaveprop_web/live/rover_planning_live/show.ex
@@ -0,0 +1,264 @@
+defmodule MicrowavepropWeb.RoverPlanningLive.Show do
+ @moduledoc """
+ `/rover-planning/:id` mission detail. Renders the planned stations
+ and the matrix of computed (or pending) terrain path profiles from
+ every candidate rover-location.
+ """
+ use MicrowavepropWeb, :live_view
+
+ alias Microwaveprop.Accounts.User
+ alias Microwaveprop.Radio.Maidenhead
+ alias Microwaveprop.RoverPlanning
+ alias Microwaveprop.RoverPlanning.Mission
+
+ @impl true
+ def mount(%{"id" => id}, _session, socket) do
+ case RoverPlanning.get_mission_with_paths(id) do
+ nil ->
+ {:ok,
+ socket
+ |> put_flash(:error, "Mission not found.")
+ |> push_navigate(to: ~p"/rover-planning")}
+
+ %Mission{} = mission ->
+ if connected?(socket) do
+ Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "rover_planning:#{mission.id}")
+ end
+
+ {:ok,
+ assign(socket,
+ page_title: "Mission · #{mission.name}",
+ mission: mission,
+ paths: RoverPlanning.list_paths(mission)
+ )}
+ end
+ end
+
+ @impl true
+ def handle_info({:rover_path_updated, _path_id}, socket) do
+ {:noreply, assign(socket, paths: RoverPlanning.list_paths(socket.assigns.mission))}
+ end
+
+ @impl true
+ def handle_event("delete", _params, socket) do
+ case current_user(socket) do
+ %User{} = user ->
+ case RoverPlanning.delete_mission(user, socket.assigns.mission.id) do
+ {:ok, _} ->
+ {:noreply,
+ socket
+ |> put_flash(:info, "Mission deleted.")
+ |> push_navigate(to: ~p"/rover-planning")}
+
+ {: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 current_user(%Phoenix.LiveView.Socket{assigns: assigns}) do
+ case assigns[:current_scope] do
+ %{user: %User{} = user} -> user
+ _ -> nil
+ end
+ end
+
+ defp can_modify?(%{current_scope: %{user: %User{is_admin: true}}}, _), do: true
+
+ defp can_modify?(%{current_scope: %{user: %User{id: id}}}, %Mission{user_id: id}) when not is_nil(id), do: true
+
+ defp can_modify?(_, _), do: false
+
+ defp band_label(mhz) when is_integer(mhz) and mhz >= 1000 do
+ ghz = mhz / 1000
+ if ghz == trunc(ghz), do: "#{trunc(ghz)} GHz", else: "#{Float.round(ghz, 1)} GHz"
+ end
+
+ defp band_label(mhz), do: "#{mhz} MHz"
+
+ defp status_badge(:complete) do
+ assigns = %{}
+ ~H|Complete|
+ end
+
+ defp status_badge(:computing) do
+ assigns = %{}
+ ~H|Computing|
+ end
+
+ defp status_badge(:failed) do
+ assigns = %{}
+ ~H|Failed|
+ end
+
+ defp status_badge(_) do
+ assigns = %{}
+ ~H|Pending|
+ end
+
+ defp verdict_badge("clear") do
+ assigns = %{}
+ ~H|Clear|
+ end
+
+ defp verdict_badge("blocked") do
+ assigns = %{}
+ ~H|Blocked|
+ end
+
+ defp verdict_badge("marginal") do
+ assigns = %{}
+ ~H|Marginal|
+ end
+
+ defp verdict_badge(_), do: ""
+
+ defp progress_summary(paths) do
+ total = length(paths)
+ complete = Enum.count(paths, &(&1.status == :complete))
+ pending = Enum.count(paths, &(&1.status in [:pending, :computing]))
+ failed = Enum.count(paths, &(&1.status == :failed))
+ %{total: total, complete: complete, pending: pending, failed: failed}
+ end
+
+ defp format_distance(km) when is_number(km), do: "#{Float.round(km, 1)} km / #{Float.round(km * 0.621371, 1)} mi"
+
+ defp format_distance(_), do: "—"
+
+ defp format_db(nil), do: "—"
+ defp format_db(value) when is_number(value), do: "#{Float.round(value, 1)} dB"
+
+ defp rover_label(%{rover_location: %{lat: lat, lon: lon}}) when is_number(lat) and is_number(lon) do
+ grid = Maidenhead.from_latlon(lat, lon, 6)
+ "#{grid} (#{Float.round(lat, 4)}, #{Float.round(lon, 4)})"
+ end
+
+ defp rover_label(_), do: "—"
+
+ defp station_label(%{station: %{callsign: c}}) when is_binary(c) and c != "", do: c
+ defp station_label(%{station: %{grid: g}}) when is_binary(g) and g != "", do: g
+
+ defp station_label(%{station: %{lat: lat, lon: lon}}) when is_number(lat) and is_number(lon),
+ do: "#{Float.round(lat, 3)}, #{Float.round(lon, 3)}"
+
+ defp station_label(_), do: "—"
+
+ @impl true
+ def render(assigns) do
+ summary = progress_summary(assigns.paths)
+ assigns = assign(assigns, :summary, summary)
+
+ ~H"""
+
+ <.header>
+ {@mission.name}
+ <:subtitle>
+ {band_label(@mission.band_mhz)} · {length(@mission.stations)} station(s) ·
+ rover {@mission.rover_height_ft}ft / station {@mission.station_height_ft}ft
+
+ <:actions>
+ <.link navigate={~p"/rover-planning"} class="btn btn-ghost btn-sm">
+ <.icon name="hero-arrow-left" class="w-4 h-4" /> All missions
+
+ <.link
+ :if={can_modify?(assigns, @mission)}
+ navigate={~p"/rover-planning/#{@mission.id}/edit"}
+ class="btn btn-ghost btn-sm"
+ >
+ <.icon name="hero-pencil-square" class="w-4 h-4" /> Edit
+
+
+
+
+
+
+
Stationary stations
+
+ -
+ {station.callsign || station.grid || station.input}
+ grid {station.grid}
+
+ {Float.round(station.lat, 4)}, {Float.round(station.lon, 4)}
+
+
+
+
+
+ {@mission.notes}
+
+
+
+ Scope: {if @mission.only_known_good,
+ do: "Known good locations only (status = ideal)",
+ else: "All rover locations"} · Owner: {(@mission.user && @mission.user.callsign) || "—"}
+
+
+
+
+
+
Path profiles
+
+ {@summary.complete}/{@summary.total} complete
+ 0}>· {@summary.pending} pending
+ 0} class="text-error">
+ · {@summary.failed} failed
+
+
+
+
+
+ No paths yet — they'll appear here as the workers run.
+
+
+
+
+
+
+ | Rover location |
+ Station |
+ Status |
+ Distance |
+ Min clearance |
+ Diffraction |
+ Verdict |
+
+
+
+
+ | {rover_label(path)} |
+ {station_label(path)} |
+ {status_badge(path.status)} |
+
+ {if path.result, do: format_distance(path.result["distance_km"]), else: "—"}
+ |
+
+ {if path.result,
+ do: "#{Float.round(path.result["min_clearance_m"] || 0.0, 1)} m",
+ else: "—"}
+ |
+
+ {if path.result, do: format_db(path.result["diffraction_db"]), else: "—"}
+ |
+
+ {if path.result, do: verdict_badge(path.result["verdict"]), else: "—"}
+ |
+
+
+
+
+
+
+ """
+ end
+end
diff --git a/lib/microwaveprop_web/router.ex b/lib/microwaveprop_web/router.ex
index 79eaaead..65068ab6 100644
--- a/lib/microwaveprop_web/router.ex
+++ b/lib/microwaveprop_web/router.ex
@@ -198,6 +198,10 @@ defmodule MicrowavepropWeb.Router do
live "/rover", RoverLive
live "/rover-locations", RoverLocationsLive
live "/rover-locations/:id", RoverLocationsLive.Show
+ live "/rover-planning", RoverPlanningLive
+ live "/rover-planning/new", RoverPlanningLive.Form, :new
+ live "/rover-planning/:id", RoverPlanningLive.Show
+ live "/rover-planning/:id/edit", RoverPlanningLive.Form, :edit
live "/algo", AlgoLive
live "/about", AboutLive
live "/privacy", PrivacyLive
diff --git a/priv/repo/migrations/20260503155617_create_rover_planning.exs b/priv/repo/migrations/20260503155617_create_rover_planning.exs
new file mode 100644
index 00000000..2edee094
--- /dev/null
+++ b/priv/repo/migrations/20260503155617_create_rover_planning.exs
@@ -0,0 +1,65 @@
+defmodule Microwaveprop.Repo.Migrations.CreateRoverPlanning do
+ use Ecto.Migration
+
+ def change do
+ create table(:rover_missions, primary_key: false) do
+ add :id, :binary_id, primary_key: true, null: false
+ add :user_id, references(:users, type: :binary_id, on_delete: :nilify_all), null: false
+ add :name, :string, null: false
+ add :band_mhz, :integer, null: false, default: 10_000
+ add :only_known_good, :boolean, null: false, default: true
+ add :rover_height_ft, :float, null: false, default: 8.0
+ add :station_height_ft, :float, null: false, default: 30.0
+ add :notes, :text
+ timestamps(type: :utc_datetime)
+ end
+
+ create index(:rover_missions, [:user_id])
+ create index(:rover_missions, [:inserted_at])
+
+ create table(:rover_mission_stations, primary_key: false) do
+ add :id, :binary_id, primary_key: true, null: false
+
+ add :mission_id, references(:rover_missions, type: :binary_id, on_delete: :delete_all),
+ null: false
+
+ add :callsign, :string
+ add :grid, :string
+ add :input, :string, null: false
+ add :lat, :float, null: false
+ add :lon, :float, null: false
+ add :position, :integer, null: false, default: 0
+ timestamps(type: :utc_datetime)
+ end
+
+ create index(:rover_mission_stations, [:mission_id])
+
+ create table(:rover_mission_paths, primary_key: false) do
+ add :id, :binary_id, primary_key: true, null: false
+
+ add :mission_id, references(:rover_missions, type: :binary_id, on_delete: :delete_all),
+ null: false
+
+ add :rover_location_id,
+ references(:rover_locations, type: :binary_id, on_delete: :delete_all),
+ null: false
+
+ add :station_id,
+ references(:rover_mission_stations, type: :binary_id, on_delete: :delete_all),
+ null: false
+
+ add :status, :string, null: false, default: "pending"
+ add :result, :map
+ add :error, :text
+ add :computed_at, :utc_datetime
+ timestamps(type: :utc_datetime)
+ end
+
+ create index(:rover_mission_paths, [:mission_id])
+ create index(:rover_mission_paths, [:status])
+
+ create unique_index(:rover_mission_paths, [:mission_id, :rover_location_id, :station_id],
+ name: :rover_mission_paths_unique
+ )
+ end
+end
diff --git a/test/microwaveprop/rover_planning_test.exs b/test/microwaveprop/rover_planning_test.exs
new file mode 100644
index 00000000..6125363a
--- /dev/null
+++ b/test/microwaveprop/rover_planning_test.exs
@@ -0,0 +1,144 @@
+defmodule Microwaveprop.RoverPlanningTest do
+ use Microwaveprop.DataCase, async: true
+
+ alias Microwaveprop.AccountsFixtures
+ alias Microwaveprop.Repo
+ alias Microwaveprop.Rover
+ alias Microwaveprop.RoverPlanning
+ alias Microwaveprop.RoverPlanning.Mission
+ alias Microwaveprop.Terrain.ElevationClient
+
+ setup do
+ # Oban runs inline in test, so RoverPathProfileWorker fires immediately
+ # when the context enqueues jobs. Stub elevation lookups so the worker
+ # can complete without hitting the real API.
+ Req.Test.stub(ElevationClient, fn conn ->
+ params = Plug.Conn.fetch_query_params(conn).query_params
+ lat_count = params["latitude"] |> String.split(",") |> length()
+ Req.Test.json(conn, %{"elevation" => List.duplicate(200.0, lat_count)})
+ end)
+
+ :ok
+ end
+
+ defp create_rover_location(attrs) do
+ user = AccountsFixtures.user_fixture()
+
+ {:ok, loc} =
+ Rover.create_location(
+ user,
+ Map.merge(%{lat: 32.5, lon: -97.5, status: :ideal}, attrs)
+ )
+
+ loc
+ end
+
+ defp valid_attrs(extra \\ %{}) do
+ Map.merge(
+ %{
+ "name" => "Test mission",
+ "band_mhz" => 10_000,
+ "only_known_good" => true,
+ "rover_height_ft" => 8.0,
+ "station_height_ft" => 30.0,
+ "stations" => %{
+ "0" => %{"input" => "EM12kp", "position" => 0}
+ }
+ },
+ extra
+ )
+ end
+
+ describe "create_mission/2" do
+ test "inserts a mission with stations and enqueues a path per (rover × station) pair" do
+ user = AccountsFixtures.user_fixture()
+ _ideal_loc = create_rover_location(%{lat: 32.0, lon: -97.0, status: :ideal})
+ _off_limits = create_rover_location(%{lat: 33.0, lon: -98.0, status: :off_limits})
+
+ assert {:ok, mission} = RoverPlanning.create_mission(user, valid_attrs())
+ assert mission.user_id == user.id
+ assert [station] = mission.stations
+ assert station.grid == "EM12KP"
+ assert is_float(station.lat)
+ assert is_float(station.lon)
+
+ paths = RoverPlanning.list_paths(mission)
+ # only_known_good=true → only the :ideal location pairs with the station.
+ # Oban runs inline in test so the worker has already completed.
+ assert length(paths) == 1
+ assert hd(paths).status in [:complete, :failed]
+ end
+
+ test "with only_known_good=false, all rover-locations get a path entry" do
+ user = AccountsFixtures.user_fixture()
+ _ideal = create_rover_location(%{lat: 32.0, lon: -97.0, status: :ideal})
+ _off = create_rover_location(%{lat: 33.0, lon: -98.0, status: :off_limits})
+
+ attrs = valid_attrs(%{"only_known_good" => false})
+ assert {:ok, mission} = RoverPlanning.create_mission(user, attrs)
+ assert length(RoverPlanning.list_paths(mission)) == 2
+ end
+
+ test "missing stations returns a changeset error" do
+ user = AccountsFixtures.user_fixture()
+ attrs = valid_attrs() |> Map.delete("stations")
+
+ assert {:error, %Ecto.Changeset{} = cs} = RoverPlanning.create_mission(user, attrs)
+ refute cs.valid?
+ assert {_, _} = cs.errors[:stations] || hd(cs.errors)
+ end
+ end
+
+ describe "update_mission/3" do
+ test "owner can update a mission and paths get rebuilt" do
+ user = AccountsFixtures.user_fixture()
+ _ = create_rover_location(%{lat: 32.0, lon: -97.0, status: :ideal})
+ {:ok, mission} = RoverPlanning.create_mission(user, valid_attrs())
+
+ [original] = RoverPlanning.list_paths(mission)
+
+ attrs = valid_attrs(%{"name" => "Updated"})
+ assert {:ok, updated} = RoverPlanning.update_mission(user, mission.id, attrs)
+ assert updated.name == "Updated"
+
+ # Old path was deleted by replace, fresh row inserted with new id.
+ [fresh] = RoverPlanning.list_paths(updated)
+ refute fresh.id == original.id
+ end
+
+ test "non-owner update is denied" do
+ owner = AccountsFixtures.user_fixture()
+ other = AccountsFixtures.user_fixture()
+ _ = create_rover_location(%{lat: 32.0, lon: -97.0, status: :ideal})
+ {:ok, mission} = RoverPlanning.create_mission(owner, valid_attrs())
+
+ assert {:error, :not_found} =
+ RoverPlanning.update_mission(other, mission.id, valid_attrs())
+ end
+
+ test "admin can update any mission" do
+ owner = AccountsFixtures.user_fixture()
+ admin = AccountsFixtures.user_fixture()
+ {:ok, admin} = Microwaveprop.Accounts.admin_update_user(admin, %{is_admin: true})
+ _ = create_rover_location(%{lat: 32.0, lon: -97.0, status: :ideal})
+ {:ok, mission} = RoverPlanning.create_mission(owner, valid_attrs())
+
+ assert {:ok, updated} =
+ RoverPlanning.update_mission(admin, mission.id, valid_attrs(%{"name" => "By admin"}))
+
+ assert updated.name == "By admin"
+ end
+ end
+
+ describe "delete_mission/2" do
+ test "owner can delete; non-owner cannot" do
+ owner = AccountsFixtures.user_fixture()
+ other = AccountsFixtures.user_fixture()
+ {:ok, mission} = RoverPlanning.create_mission(owner, valid_attrs())
+
+ assert {:error, :not_found} = RoverPlanning.delete_mission(other, mission.id)
+ assert {:ok, _} = RoverPlanning.delete_mission(owner, mission.id)
+ refute Repo.get(Mission, mission.id)
+ end
+ end
+end
diff --git a/test/microwaveprop_web/live/contact_map_live_test.exs b/test/microwaveprop_web/live/contact_map_live_test.exs
index e3730597..bcf10662 100644
--- a/test/microwaveprop_web/live/contact_map_live_test.exs
+++ b/test/microwaveprop_web/live/contact_map_live_test.exs
@@ -130,9 +130,7 @@ defmodule MicrowavepropWeb.ContactMapLiveTest do
|> render_submit()
html =
- render_click(
- element(lv, ~s|form:has(input.input-sm) button[phx-click="clear_dates"]|)
- )
+ render_click(element(lv, ~s|form:has(input.input-sm) button[phx-click="clear_dates"]|))
refute html =~ ~s(value="2025-01-01")
refute html =~ ~s(value="2026-01-01")
diff --git a/test/microwaveprop_web/live/rover_planning_live_test.exs b/test/microwaveprop_web/live/rover_planning_live_test.exs
new file mode 100644
index 00000000..c331ad19
--- /dev/null
+++ b/test/microwaveprop_web/live/rover_planning_live_test.exs
@@ -0,0 +1,115 @@
+defmodule MicrowavepropWeb.RoverPlanningLiveTest do
+ use MicrowavepropWeb.ConnCase, async: true
+
+ import Phoenix.LiveViewTest
+
+ alias Microwaveprop.AccountsFixtures
+ alias Microwaveprop.Rover
+ alias Microwaveprop.RoverPlanning
+ alias Microwaveprop.Terrain.ElevationClient
+
+ setup do
+ Req.Test.stub(ElevationClient, fn conn ->
+ params = Plug.Conn.fetch_query_params(conn).query_params
+ lat_count = params["latitude"] |> String.split(",") |> length()
+ Req.Test.json(conn, %{"elevation" => List.duplicate(200.0, lat_count)})
+ end)
+
+ :ok
+ end
+
+ defp create_mission(user, name) do
+ {:ok, _} = Rover.create_location(user, %{lat: 32.0, lon: -97.0, status: :ideal})
+
+ {:ok, mission} =
+ RoverPlanning.create_mission(user, %{
+ "name" => name,
+ "band_mhz" => 10_000,
+ "only_known_good" => true,
+ "rover_height_ft" => 8.0,
+ "station_height_ft" => 30.0,
+ "stations" => %{"0" => %{"input" => "EM12kp", "position" => 0}}
+ })
+
+ mission
+ end
+
+ describe "index" do
+ test "anonymous visitor sees the table and a sign-in prompt", %{conn: conn} do
+ {:ok, _lv, html} = live(conn, ~p"/rover-planning")
+ assert html =~ "Rover Planning"
+ assert html =~ "Sign in"
+ end
+
+ test "logged-in user sees the New mission button", %{conn: conn} do
+ user = AccountsFixtures.user_fixture()
+ conn = log_in_user(conn, user)
+ {:ok, _lv, html} = live(conn, ~p"/rover-planning")
+ assert html =~ "New mission"
+ end
+
+ test "table shows existing missions and links to the show page", %{conn: conn} do
+ user = AccountsFixtures.user_fixture()
+ mission = create_mission(user, "Visible mission")
+
+ {:ok, _lv, html} = live(conn, ~p"/rover-planning")
+ assert html =~ "Visible mission"
+ assert html =~ ~s(href="/rover-planning/#{mission.id}")
+ end
+ end
+
+ describe "show" do
+ test "renders mission details + path table", %{conn: conn} do
+ user = AccountsFixtures.user_fixture()
+ mission = create_mission(user, "Detail mission")
+
+ {:ok, _lv, html} = live(conn, ~p"/rover-planning/#{mission.id}")
+ assert html =~ "Detail mission"
+ assert html =~ "Stationary stations"
+ assert html =~ "Path profiles"
+ end
+
+ test "redirects when mission missing", %{conn: conn} do
+ missing = Ecto.UUID.generate()
+
+ assert {:error, {:live_redirect, %{to: "/rover-planning"}}} =
+ live(conn, ~p"/rover-planning/#{missing}")
+ end
+ end
+
+ describe "form" do
+ test "anonymous user gets redirected to /rover-planning", %{conn: conn} do
+ assert {:error, {:live_redirect, %{to: "/rover-planning"}}} =
+ live(conn, ~p"/rover-planning/new")
+ end
+
+ test "logged-in user can create a mission with a callsign-input station",
+ %{conn: conn} do
+ user = AccountsFixtures.user_fixture()
+ {:ok, _} = Rover.create_location(user, %{lat: 32.0, lon: -97.0, status: :ideal})
+ conn = log_in_user(conn, user)
+
+ {:ok, lv, html} = live(conn, ~p"/rover-planning/new")
+ assert html =~ "Only check against known good locations"
+ assert html =~ ~s(checked)
+
+ {:error, {:live_redirect, %{to: redirect_to}}} =
+ lv
+ |> form("#mission-form",
+ mission: %{
+ name: "From form",
+ band_mhz: "10000",
+ only_known_good: "true",
+ rover_height_ft: "8.0",
+ station_height_ft: "30.0",
+ stations: %{
+ "0" => %{input: "EM12kp"}
+ }
+ }
+ )
+ |> render_submit()
+
+ assert redirect_to =~ ~r{^/rover-planning/[0-9a-f-]+$}
+ end
+ end
+end