feat(map): render chrome immediately, load initial scores async

Two-stage mount splits the propagation map's first paint: the static
HTTP render still fetches scores synchronously so SEO/noscript shipping
gets real data baked into `data-scores`, but the websocket-connected
mount defers the potentially-slow fetch to `start_async/3`. Chrome
paints immediately; scores stream in via an `update_scores` push_event
once `handle_async(:initial_scores, ...)` resolves.

A small overlay (spinner on `:loading`, retry button on `:failed`)
uses `<.async_result>` so users can see state and recover from a
transient fetch error.
This commit is contained in:
Graham McIntire 2026-04-21 14:18:43 -05:00
parent 52a32032a3
commit ef4e63aef4
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
2 changed files with 150 additions and 2 deletions

View file

@ -7,6 +7,7 @@ defmodule MicrowavepropWeb.MapLive do
alias Microwaveprop.Propagation.BandConfig
alias Microwaveprop.Propagation.PipelineStatus
alias Microwaveprop.Terrain.Viewshed
alias Phoenix.LiveView.AsyncResult
require Logger
@ -70,7 +71,25 @@ defmodule MicrowavepropWeb.MapLive do
visitor = visitor_location(session)
bounds = bounds_around(center)
initial_scores = Propagation.scores_at(selected_band, selected_time, bounds)
# Two-stage mount: on the static HTTP render we still fetch scores
# synchronously so SEO/noscript/first-paint have real data baked into
# `data-scores`. On the subsequent websocket-connected mount we defer
# the potentially-slow score fetch to start_async/3 and let the chrome
# paint immediately, backfilling via `update_scores` push_event once
# the fetch resolves.
{initial_scores_json, initial_scores_assign, socket} =
if connected?(socket) do
socket =
start_async(socket, :initial_scores, fn ->
Propagation.scores_at(selected_band, selected_time, bounds)
end)
{"[]", AsyncResult.loading(), socket}
else
scores = Propagation.scores_at(selected_band, selected_time, bounds)
{Jason.encode!(scores), AsyncResult.ok(scores), socket}
end
# preload_forecast runs on the first map_bounds event instead of
# here — mount only knows a hardcoded fallback bounding box, so
@ -85,7 +104,8 @@ defmodule MicrowavepropWeb.MapLive do
page_title: "Propagation Prediction Map",
bands: bands,
selected_band: selected_band,
initial_scores_json: Jason.encode!(initial_scores),
initial_scores_json: initial_scores_json,
initial_scores: initial_scores_assign,
valid_times: valid_times,
selected_time: selected_time,
tracking_now?: tracking_now?(selected_time, valid_times, DateTime.utc_now()),
@ -342,6 +362,22 @@ defmodule MicrowavepropWeb.MapLive do
{:noreply, socket}
end
def handle_event("retry_initial_scores", _params, socket) do
band = socket.assigns.selected_band
time = socket.assigns.selected_time
bounds = socket.assigns.bounds
current = socket.assigns.initial_scores
socket =
socket
|> assign(:initial_scores, AsyncResult.loading(current))
|> start_async(:initial_scores, fn ->
Propagation.scores_at(band, time, bounds)
end)
{:noreply, socket}
end
@impl true
def handle_info(:preload_forecast, socket) do
band = socket.assigns.selected_band
@ -444,6 +480,23 @@ defmodule MicrowavepropWeb.MapLive do
end
@impl true
def handle_async(:initial_scores, {:ok, scores}, socket) do
current = socket.assigns.initial_scores
socket =
socket
|> assign(:initial_scores, AsyncResult.ok(current, scores))
|> push_event("update_scores", %{scores: scores})
{:noreply, socket}
end
def handle_async(:initial_scores, {:exit, reason}, socket) do
Logger.warning("Initial score fetch failed: #{inspect(reason)}")
current = socket.assigns.initial_scores
{:noreply, assign(socket, :initial_scores, AsyncResult.failed(current, reason))}
end
def handle_async(:viewshed, {:ok, result}, socket) do
points = Enum.map(result.boundary, fn p -> %{lat: p.lat, lon: p.lon} end)
Logger.info("Viewshed complete: #{length(points)} boundary points")
@ -782,6 +835,37 @@ defmodule MicrowavepropWeb.MapLive do
>
</div>
<%!-- Async loading state for the first score fetch. The map chrome
renders immediately; this overlay shows a spinner while scores
stream in over the websocket and surfaces a retry button if the
fetch fails. --%>
<.async_result :let={_scores} assign={@initial_scores}>
<:loading>
<div
id="initial-scores-loading"
role="status"
aria-live="polite"
class="absolute top-2 right-2 z-[1002] bg-neutral text-neutral-content shadow-lg rounded-box border border-base-300 px-3 py-2 text-xs flex items-center gap-2"
>
<.icon name="hero-arrow-path" class="size-3.5 motion-safe:animate-spin" />
<span>Loading propagation scores</span>
</div>
</:loading>
<:failed :let={_reason}>
<div
id="initial-scores-failed"
role="alert"
class="absolute top-2 right-2 z-[1002] bg-error text-error-content shadow-lg rounded-box px-3 py-2 text-xs flex items-center gap-2"
>
<.icon name="hero-exclamation-triangle" class="size-3.5" />
<span>Couldn't load scores.</span>
<button type="button" class="btn btn-xs btn-ghost" phx-click="retry_initial_scores">
Retry
</button>
</div>
</:failed>
</.async_result>
<%!-- Mobile-only floating controls --%>
<div
id="map-controls"

View file

@ -1,10 +1,12 @@
defmodule MicrowavepropWeb.MapLiveTest do
use MicrowavepropWeb.ConnCase, async: false
import Phoenix.ConnTest, only: [get: 2]
import Phoenix.LiveViewTest
alias Microwaveprop.Propagation
alias Microwaveprop.Propagation.ScoreCache
alias Phoenix.LiveView.AsyncResult
alias Phoenix.LiveView.Utils
describe "mount" do
@ -52,6 +54,63 @@ defmodule MicrowavepropWeb.MapLiveTest do
end
end
describe "async initial scores" do
setup do
ScoreCache.clear()
on_exit(fn -> ScoreCache.clear() end)
:ok
end
test "disconnected HTTP render returns 200 with a data-scores payload", %{conn: conn} do
valid_time =
DateTime.utc_now()
|> DateTime.truncate(:second)
|> Map.put(:minute, 0)
|> Map.put(:second, 0)
Propagation.replace_scores(
[%{lat: 33.0, lon: -97.0, valid_time: valid_time, band_mhz: 10_000, score: 42, factors: nil}],
valid_time
)
conn = get(conn, ~p"/map")
assert conn.status == 200
body = Phoenix.ConnTest.html_response(conn, 200)
# data-scores is baked into the static HTTP render so first paint
# ships real data even without a websocket connection.
assert body =~ ~s(data-scores=)
refute body =~ ~s(data-scores="[]")
end
test "connected mount resolves @initial_scores to an :ok AsyncResult", %{conn: conn} do
valid_time =
DateTime.utc_now()
|> DateTime.truncate(:second)
|> Map.put(:minute, 0)
|> Map.put(:second, 0)
Propagation.replace_scores(
[%{lat: 33.0, lon: -97.0, valid_time: valid_time, band_mhz: 10_000, score: 77, factors: nil}],
valid_time
)
{:ok, lv, _html} = live(conn, ~p"/map")
# The websocket mount kicks off start_async(:initial_scores, ...) and
# pushes `update_scores` once the fetch resolves.
assert_push_event(lv, "update_scores", %{scores: scores})
assert is_list(scores)
# Force a render so the AsyncResult assignment is flushed, then
# inspect the socket's assigns directly.
_ = render(lv)
async_result = :sys.get_state(lv.pid).socket.assigns.initial_scores
assert %AsyncResult{ok?: true} = async_result
end
end
describe "CONUS-only banner" do
@banner_copy "This data set currently covers the continental United States only."
@ -396,6 +455,11 @@ defmodule MicrowavepropWeb.MapLiveTest do
{:ok, lv, _html} = live(conn, ~p"/map")
# The connected mount kicks off an async initial-score fetch that
# emits its own `update_scores` push_event. Drain it here so the
# assertion below observes the propagation_updated delivery only.
assert_push_event(lv, "update_scores", %{scores: _initial_scores})
# Simulate a new forecast-hour compute: rewrite the .ntms file on
# disk to the updated score, but do *not* touch the cache. This
# reproduces the race where the cache_refresh message has not yet