diff --git a/lib/microwaveprop_web/live/rover_planning_live/path_show.ex b/lib/microwaveprop_web/live/rover_planning_live/path_show.ex
new file mode 100644
index 00000000..ab9c8339
--- /dev/null
+++ b/lib/microwaveprop_web/live/rover_planning_live/path_show.ex
@@ -0,0 +1,212 @@
+defmodule MicrowavepropWeb.RoverPlanningLive.PathShow do
+ @moduledoc """
+ Renders a single rover-planning Path's cached `result` (terrain
+ analysis + baseline link budget + propagation score). Avoids the
+ live recompute that `/path` would do — values come straight from
+ the row the worker stored. The "Open in Path Calculator" button
+ hands off to the live `/path` flow when the operator wants to see
+ the live HRRR-driven loss budget.
+ """
+ use MicrowavepropWeb, :live_view
+
+ import Ecto.Query
+
+ alias Microwaveprop.Radio.Maidenhead
+ alias Microwaveprop.Repo
+ alias Microwaveprop.RoverPlanning.Mission
+ alias Microwaveprop.RoverPlanning.Path
+
+ @impl true
+ def mount(%{"id" => mission_id, "path_id" => path_id}, _session, socket) do
+ case load_path(mission_id, path_id) do
+ nil ->
+ {:ok,
+ socket
+ |> put_flash(:error, "Path profile not found.")
+ |> push_navigate(to: ~p"/rover-planning/#{mission_id}")}
+
+ %Path{} = path ->
+ {:ok,
+ assign(socket,
+ page_title: "Path · #{path.mission.name}",
+ path: path,
+ mission: path.mission,
+ rover: path.rover_location,
+ station: path.station
+ )}
+ end
+ end
+
+ defp load_path(mission_id, path_id) do
+ with {:ok, mission_uuid} <- Ecto.UUID.cast(mission_id),
+ {:ok, path_uuid} <- Ecto.UUID.cast(path_id) do
+ Path
+ |> where([p], p.id == ^path_uuid and p.mission_id == ^mission_uuid)
+ |> preload([:rover_location, :station, :mission])
+ |> Repo.one()
+ else
+ _ -> nil
+ end
+ end
+
+ 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) when is_integer(mhz), do: "#{mhz} MHz"
+ defp band_label(_), do: "—"
+
+ defp format_distance(km) when is_number(km), do: "#{Float.round(km * 1.0, 1)} km / #{Float.round(km * 0.621371, 1)} mi"
+
+ defp format_distance(_), do: "—"
+
+ defp format_db(nil), do: "—"
+ defp format_db(v) when is_number(v), do: "#{Float.round(v * 1.0, 1)} dB"
+
+ defp format_meters(nil), do: "—"
+ defp format_meters(v) when is_number(v), do: "#{Float.round(v * 1.0, 1)} m"
+
+ defp verdict_label("CLEAR"), do: {"Clear", "badge badge-success"}
+ defp verdict_label("BLOCKED"), do: {"Blocked", "badge badge-error"}
+ defp verdict_label("FRESNEL_PARTIAL"), do: {"Fresnel partial", "badge badge-warning"}
+ defp verdict_label("FRESNEL_MINOR"), do: {"Fresnel minor", "badge badge-info"}
+ defp verdict_label(_), do: {"—", "badge"}
+
+ defp station_endpoint(%{callsign: c}) when is_binary(c) and c != "", do: c
+ defp station_endpoint(%{grid: g}) when is_binary(g) and g != "", do: g
+
+ defp station_endpoint(%{lat: lat, lon: lon}) when is_number(lat) and is_number(lon),
+ do: Maidenhead.from_latlon(lat, lon, 8)
+
+ defp station_endpoint(_), do: ""
+
+ # Live /path link for when the user wants the HRRR-driven recompute
+ # rather than the cached snapshot we render here.
+ defp live_path_url(%Mission{} = mission, %Path{} = path, rover, station) do
+ src = Maidenhead.from_latlon(rover.lat, rover.lon, 10)
+ dst = station_endpoint(station)
+ band = path.band_mhz || mission.band_mhz
+
+ query =
+ URI.encode_query(%{
+ "source" => src,
+ "destination" => dst,
+ "band" => Integer.to_string(band),
+ "src_height_ft" => Float.to_string((mission.rover_height_ft || 8.0) * 1.0),
+ "dst_height_ft" => Float.to_string((mission.station_height_ft || 30.0) * 1.0)
+ })
+
+ "/path?" <> query
+ end
+
+ @impl true
+ def render(assigns) do
+ {verdict_text, verdict_class} =
+ verdict_label((assigns.path.result || %{})["verdict"])
+
+ assigns = assign(assigns, verdict_text: verdict_text, verdict_class: verdict_class)
+
+ ~H"""
+
+ <.header>
+ Path profile
+ <:subtitle>
+ {Maidenhead.from_latlon(@rover.lat, @rover.lon, 10)}
+ → {station_endpoint(@station)}
+ · {band_label(@path.band_mhz || @mission.band_mhz)}
+ {@verdict_text}
+
+ <:actions>
+ <.link
+ navigate={~p"/rover-planning/#{@mission.id}"}
+ class="btn btn-ghost btn-sm"
+ >
+ <.icon name="hero-arrow-left" class="w-4 h-4" /> Back to mission
+
+ <.link
+ href={live_path_url(@mission, @path, @rover, @station)}
+ class="btn btn-ghost btn-sm"
+ >
+ <.icon name="hero-arrow-top-right-on-square" class="w-4 h-4" /> Recompute live
+
+
+
+
+
+ Path is still {@path.status}. Try again in a moment.
+
+
+
+
+
Geometry
+
+
+
- Distance
+ - {format_distance(@path.result["distance_km"])}
+
+
+
- Bearing
+ -
+ {format_db(@path.result["bearing_deg"]) |> String.replace(" dB", "°")}
+
+
+
+
- Min clearance
+ - {format_meters(@path.result["min_clearance_m"])}
+
+
+
- Max elevation
+ - {format_meters(@path.result["max_elevation_m"])}
+
+
+
+
+
+
Cached link budget
+
+
+
- Free-space loss
+ - {format_db(@path.result["free_space_loss_db"])}
+
+
+
- Oxygen absorption
+ - {format_db(@path.result["oxygen_loss_db"])}
+
+
+
- H₂O (baseline 7.5 g/m³)
+ - {format_db(@path.result["humidity_loss_db_baseline"])}
+
+
+
- Diffraction
+ - {format_db(@path.result["diffraction_db"])}
+
+
+
- Total baseline
+ - {format_db(@path.result["total_baseline_loss_db"])}
+
+
+
+ Recompute live for HRRR-driven humidity / rain.
+
+
+
+
+
Propagation forecast
+
+ Cached score (midpoint, mission band):
+
+ {(@path.result["propagation_score"] || "—") |> to_string()}
+
+ / 100
+
+
+ Computed at {(@path.computed_at &&
+ Calendar.strftime(@path.computed_at, "%Y-%m-%d %H:%M UTC")) || "—"}
+
+
+
+
+ """
+ end
+end
diff --git a/lib/microwaveprop_web/live/rover_planning_live/show.ex b/lib/microwaveprop_web/live/rover_planning_live/show.ex
index e08ed031..14fe8dd0 100644
--- a/lib/microwaveprop_web/live/rover_planning_live/show.ex
+++ b/lib/microwaveprop_web/live/rover_planning_live/show.ex
@@ -322,40 +322,12 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Show do
defp stationary_grid(_), do: nil
# Endpoint string used for /path?destination=…. Prefers callsign, then
- # any stored or derived grid (PathLive's LocationResolver re-resolves
- # both back to the same point), falling back to bare coordinates only
- # when nothing else is available.
- defp station_endpoint(%{callsign: c}) when is_binary(c) and c != "", do: c
- defp station_endpoint(%{grid: g}) when is_binary(g) and g != "", do: g
-
- defp station_endpoint(%{lat: lat, lon: lon}) when is_number(lat) and is_number(lon),
- do: Maidenhead.from_latlon(lat, lon, 8)
-
- defp station_endpoint(_), do: ""
-
- # Builds the /path URL pre-filling source (rover-location, max-precision
- # grid), destination (station endpoint), band, and rover/station heights
- # so the user lands on a ready-to-compute Path Calculator screen.
- defp path_url(_mission, %{rover_location: nil}), do: nil
- defp path_url(_mission, %{station: nil}), do: nil
-
- defp path_url(%Mission{} = mission, %{rover_location: %{lat: lat, lon: lon}, station: station} = path)
- when is_number(lat) and is_number(lon) do
- src = Maidenhead.from_latlon(lat, lon, 10)
- dst = station_endpoint(station)
- band = path.band_mhz || mission.band_mhz
-
- query =
- URI.encode_query(%{
- "source" => src,
- "destination" => dst,
- "band" => Integer.to_string(band),
- "src_height_ft" => Float.to_string(mission.rover_height_ft * 1.0),
- "dst_height_ft" => Float.to_string(mission.station_height_ft * 1.0)
- })
-
- "/path?" <> query
- end
+ # Each row links to the CACHED stored-path detail (mission/paths/:id).
+ # That view renders straight from the worker-computed result map, so
+ # the user doesn't pay the full /path recompute cost. /path is still
+ # one click away inside that view for the live HRRR recompute.
+ defp path_url(%Mission{id: mission_id}, %{id: path_id}) when not is_nil(path_id),
+ do: "/rover-planning/#{mission_id}/paths/#{path_id}"
defp path_url(_, _), do: nil
diff --git a/lib/microwaveprop_web/router.ex b/lib/microwaveprop_web/router.ex
index 9cccb9cc..a63859f6 100644
--- a/lib/microwaveprop_web/router.ex
+++ b/lib/microwaveprop_web/router.ex
@@ -203,6 +203,7 @@ defmodule MicrowavepropWeb.Router do
live "/rover-planning/new", RoverPlanningLive.Form, :new
live "/rover-planning/:id", RoverPlanningLive.Show
live "/rover-planning/:id/edit", RoverPlanningLive.Form, :edit
+ live "/rover-planning/:id/paths/:path_id", RoverPlanningLive.PathShow
live "/algo", AlgoLive
live "/about", AboutLive
live "/privacy", PrivacyLive
diff --git a/test/microwaveprop_web/live/rover_planning_live_test.exs b/test/microwaveprop_web/live/rover_planning_live_test.exs
index 199086b4..1b7f47df 100644
--- a/test/microwaveprop_web/live/rover_planning_live_test.exs
+++ b/test/microwaveprop_web/live/rover_planning_live_test.exs
@@ -313,21 +313,21 @@ defmodule MicrowavepropWeb.RoverPlanningLiveTest do
refute html =~ "Diffraction"
end
- test "path rows link to /path with mission params prefilled", %{conn: conn} do
+ test "path rows link to the cached stored-path detail page", %{conn: conn} do
user = AccountsFixtures.user_fixture()
mission = create_mission(user, "Click-through mission")
{:ok, lv, _html} = live(conn, ~p"/rover-planning/#{mission.id}")
- # Each path row carries a phx-click that navigates to /path with
- # the mission's band + heights and the rover/station endpoints
- # filled in. Assert the click handler is present on the
.
+ [path | _] = Repo.all(Path)
rendered = render(lv)
+
+ # Each row navigates to the cached path-detail page so the user
+ # doesn't pay the live /path recompute cost. The live link is
+ # still available from inside that page.
assert rendered =~ "phx-click"
- assert rendered =~ "/path?"
- assert rendered =~ "band=10000"
- assert rendered =~ "src_height_ft=8.0"
- assert rendered =~ "dst_height_ft=30.0"
+ assert rendered =~ "/rover-planning/#{mission.id}/paths/#{path.id}"
+ refute rendered =~ "/path?"
end
test "groups paths by rover location", %{conn: conn} do