From 3a81d05b4ee7231e1a1046d3e6c4888622cbfab3 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sat, 25 Apr 2026 14:36:27 -0500 Subject: [PATCH] perf(skewt): load profile + sounding asynchronously via start_async MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- lib/microwaveprop_web/live/skewt_live.ex | 108 +++++++++++++----- .../live/skewt_live_test.exs | 27 +++-- 2 files changed, 99 insertions(+), 36 deletions(-) diff --git a/lib/microwaveprop_web/live/skewt_live.ex b/lib/microwaveprop_web/live/skewt_live.ex index 124fe6b7..920fbb6e 100644 --- a/lib/microwaveprop_web/live/skewt_live.ex +++ b/lib/microwaveprop_web/live/skewt_live.ex @@ -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 <% end %> + <%= if @loading? do %> +
+ + Resolving location and loading HRRR profile… +
+ <% end %> + <%= if @location do %>
{@location.label} diff --git a/test/microwaveprop_web/live/skewt_live_test.exs b/test/microwaveprop_web/live/skewt_live_test.exs index 5ee77191..229c1d3c 100644 --- a/test/microwaveprop_web/live/skewt_live_test.exs +++ b/test/microwaveprop_web/live/skewt_live_test.exs @@ -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