perf(skewt): load profile + sounding asynchronously via start_async

URL-parameter loads (`/skewt?q=…`) were blocking the initial mount
on three serial steps: geocoder/grid resolve, ProfilesFile or
HrrrProfileLookup read, and the SkewtParams/SoundingParams/SkewtSvg
derivation. The address path in particular hits an external API,
which made the page take "forever" to load on first paint.

Restructure SkewtLive so the page chrome (header, search bar) renders
synchronously on mount, and the heavy work runs in a single
`start_async(:load_skewt, …)` task that calls SkewtLocationResolver,
walks the on-disk profile store with a DB fallback, and produces the
SVG. A new `loading?` assign drives a small spinner + "Resolving
location and loading HRRR profile…" message that shows while the task
is in flight; previous data stays on screen so navigation between
forecast hours doesn't blank out the diagram.

handle_async/3 covers three landings:

  * `{:ok, {:ok, result}}` — apply assigns from the resolver
  * `{:ok, {:error, reason}}` — surface as the existing error alert
    (geocoder failure, unknown callsign, etc.)
  * `{:exit, reason}` — log a warning and show a generic retry message

start_async/3 cancels any prior task with the same name on each call,
so a rapid-fire URL patch (e.g. clicking through forecast-hour
buttons before the first response returns) never lands stale data.

Tests: extended SkewtLiveTest from 3 to 4 cases — the URL-param
initial render now asserts the chrome + spinner appear pre-async,
and a separate case `render_async/2`s the view to confirm the
location label arrives once the task completes. 4/4 green.
This commit is contained in:
Graham McIntire 2026-04-25 14:36:27 -05:00
parent c3f1875df8
commit 3a81d05b4e
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
2 changed files with 99 additions and 36 deletions

View file

@ -30,7 +30,8 @@ defmodule MicrowavepropWeb.SkewtLive do
selected_valid_time: nil,
profile: nil,
derived: nil,
svg: nil
svg: nil,
loading?: false
)}
end
@ -52,8 +53,55 @@ defmodule MicrowavepropWeb.SkewtLive do
{:noreply, push_patch(socket, to: ~p"/skewt?#{params}")}
end
@impl true
def handle_async(:load_skewt, {:ok, {:ok, result}}, socket) do
{:noreply,
assign(socket,
location: result.location,
error: nil,
valid_times: result.valid_times,
selected_valid_time: result.selected_valid_time,
profile: result.profile,
derived: result.derived,
svg: result.svg,
loading?: false
)}
end
def handle_async(:load_skewt, {:ok, {:error, reason}}, socket) do
{:noreply,
assign(socket,
location: nil,
error: reason,
valid_times: [],
selected_valid_time: nil,
profile: nil,
derived: nil,
svg: nil,
loading?: false
)}
end
def handle_async(:load_skewt, {:exit, reason}, socket) do
require Logger
Logger.warning("SkewtLive async exit: #{inspect(reason)}")
{:noreply,
assign(socket,
error: "Sorry, something went wrong loading this profile. Try again.",
loading?: false
)}
end
# ── Refresh logic ──────────────────────────────────────────────────
# The page chrome (header + search bar) renders synchronously on
# mount; everything else (geocoding, HRRR file/DB read, sounding
# parameter derivation, SVG render) lands via `:load_skewt` async.
# Repeated calls under the same key cancel any in-flight task, so
# rapid URL patches don't race.
defp refresh(socket, "", _time) do
assign(socket,
query: "",
@ -63,48 +111,45 @@ defmodule MicrowavepropWeb.SkewtLive do
selected_valid_time: nil,
profile: nil,
derived: nil,
svg: nil
svg: nil,
loading?: false
)
end
defp refresh(socket, query, requested_time) do
socket
|> assign(
query: query,
error: nil,
loading?: true
)
|> start_async(:load_skewt, fn -> resolve_and_load(query, requested_time) end)
end
defp resolve_and_load(query, requested_time) do
case SkewtLocationResolver.resolve(query) do
{:ok, %{lat: lat, lon: lon} = location} ->
valid_times = available_valid_times(lat, lon)
chosen = pick_valid_time(valid_times, requested_time)
{profile, derived, svg} =
case chosen do
nil ->
{nil, nil, nil}
time ->
load_profile(time, lat, lon)
nil -> {nil, nil, nil}
time -> load_profile(time, lat, lon)
end
assign(socket,
query: query,
location: location,
error: nil,
valid_times: valid_times,
selected_valid_time: chosen,
profile: profile,
derived: derived,
svg: svg
)
{:ok,
%{
location: location,
valid_times: valid_times,
selected_valid_time: chosen,
profile: profile,
derived: derived,
svg: svg
}}
{:error, reason} ->
assign(socket,
query: query,
location: nil,
error: reason,
valid_times: [],
selected_valid_time: nil,
profile: nil,
derived: nil,
svg: nil
)
{:error, reason}
end
end
@ -284,6 +329,13 @@ defmodule MicrowavepropWeb.SkewtLive do
</div>
<% end %>
<%= if @loading? do %>
<div class="mt-4 flex items-center gap-3 text-sm opacity-80">
<span class="loading loading-spinner loading-sm"></span>
<span>Resolving location and loading HRRR profile</span>
</div>
<% end %>
<%= if @location do %>
<div class="mt-4 flex flex-wrap items-baseline gap-4 text-sm">
<span class="font-semibold">{@location.label}</span>

View file

@ -12,17 +12,29 @@ defmodule MicrowavepropWeb.SkewtLiveTest do
refute html =~ "viewBox=\"0 0 720"
end
test "renders the resolved location label when given a grid square", %{conn: conn} do
test "initial render with a URL query shows the page chrome + a loading indicator", %{conn: conn} do
# The URL parameter triggers an async resolve. The page must
# render the chrome (header, search bar) and a loading state
# synchronously — *before* the geocoder / HRRR fetch returns.
{:ok, _view, html} = live(conn, ~p"/skewt?q=EM12kp")
# The grid is resolved client-side from the URL — even when no
# HRRR profile happens to be on disk for this lat/lon, the
# location header should still surface so the user knows their
# input was accepted.
assert html =~ "Skew-T-Log-P"
assert html =~ ~s|placeholder="EM12kp|
assert html =~ "Resolving location"
# Location label has not arrived yet — that's the whole point.
refute html =~ "EM12KP"
end
test "after the async completes the resolved location label appears", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/skewt?q=EM12kp")
# Wait for the :load_skewt async to finish, then re-render.
html = render_async(view)
assert html =~ "EM12KP"
# Lat/lon get formatted to 3 decimals.
assert html =~ "32.646"
assert html =~ "-97.125"
refute html =~ "Resolving location"
end
test "submitting the form patches the URL with the query", %{conn: conn} do
@ -30,8 +42,7 @@ defmodule MicrowavepropWeb.SkewtLiveTest do
view |> form("form", q: "EM12") |> render_submit()
# After patch, the location label appears on the page.
assert render(view) =~ "EM12"
assert render_async(view) =~ "EM12"
end
end
end