Show forecast-hour progress on the map pipeline chip

PropagationGridWorker now broadcasts a {:propagation_pipeline_progress,
%{forecast_hour:, valid_time:}} message on the propagation:pipeline
PubSub topic at the start of each process_forecast_hour/4 call.
MapLive subscribes on mount, stashes the last message in the new
:pipeline_progress assign, and clears it on the refresh tick whenever
PipelineStatus flips out of :running.

The chip component consults a new PipelineStatus.running_label/1
helper that formats the payload as "Updating propagation · now" for
f00 and "Updating propagation · +Nh" for f01-f18, falling back to
the base running label until the first progress message arrives.
Covered by unit tests on the formatter and an integration test in
MapLiveTest that seeds an executing grid job, broadcasts progress,
and asserts the rendered chip.
This commit is contained in:
Graham McIntire 2026-04-14 11:12:51 -05:00
parent 89b7d39827
commit 434fca2ad1
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
5 changed files with 135 additions and 13 deletions

View file

@ -36,13 +36,27 @@ defmodule Microwaveprop.Propagation.PipelineStatus do
last_update_at: DateTime.t() | nil
}
@doc """
Format a forecast-progress payload (as broadcast by
`PropagationGridWorker`) into a short chip label for the map.
Returns `nil` when the payload has no `forecast_hour` so callers can
fall back to the base running label from `current/0`.
"""
@spec running_label(map() | nil) :: String.t() | nil
def running_label(%{forecast_hour: 0}), do: "Updating propagation · now"
def running_label(%{forecast_hour: fh}) when is_integer(fh) and fh > 0, do: "Updating propagation · +#{fh}h"
def running_label(_), do: nil
@spec current() :: t()
def current do
case running_worker() do
worker when is_binary(worker) ->
%{
state: :running,
label: running_label(worker),
label: base_running_label(worker),
last_update_at: latest_completed_at()
}
@ -97,9 +111,9 @@ defmodule Microwaveprop.Propagation.PipelineStatus do
end
end
defp running_label(@grid_worker), do: "Updating propagation (HRRR run)"
defp running_label(@asos_worker), do: "Updating propagation (ASOS nudge)"
defp running_label(_), do: "Updating propagation"
defp base_running_label(@grid_worker), do: "Updating propagation (HRRR run)"
defp base_running_label(@asos_worker), do: "Updating propagation (ASOS nudge)"
defp base_running_label(_), do: "Updating propagation"
defp format_age(0), do: "just now"
defp format_age(1), do: "1m"

View file

@ -84,6 +84,14 @@ defmodule Microwaveprop.Workers.PropagationGridWorker do
defp process_forecast_hour(points, run_time, forecast_hour, valid_time) do
label = "f#{String.pad_leading(Integer.to_string(forecast_hour), 2, "0")}"
# Emit progress so the map page's pipeline status chip can show
# which forecast hour is currently being scored ("now", "+1h", …).
Phoenix.PubSub.broadcast(
Microwaveprop.PubSub,
"propagation:pipeline",
{:propagation_pipeline_progress, %{forecast_hour: forecast_hour, valid_time: valid_time}}
)
case timed(label, fn ->
HrrrClient.fetch_grid(points, run_time, forecast_hour: forecast_hour)
end) do

View file

@ -24,6 +24,7 @@ defmodule MicrowavepropWeb.MapLive do
def mount(_params, session, socket) do
if connected?(socket) do
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "propagation:updated")
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "propagation:pipeline")
Process.send_after(self(), :refresh_pipeline_status, @pipeline_status_refresh_ms)
end
@ -31,6 +32,7 @@ defmodule MicrowavepropWeb.MapLive do
socket
|> assign(:initial_utc_clock, Calendar.strftime(DateTime.utc_now(), "%H:%M UTC"))
|> assign(:pipeline_status, PipelineStatus.current())
|> assign(:pipeline_progress, nil)
# LiveStash only persists the keys passed to `stash_assigns/2`
# (selected_band + selected_time). On reconnect the recovered socket has
@ -283,7 +285,24 @@ defmodule MicrowavepropWeb.MapLive do
def handle_info(:refresh_pipeline_status, socket) do
Process.send_after(self(), :refresh_pipeline_status, @pipeline_status_refresh_ms)
{:noreply, assign(socket, :pipeline_status, PipelineStatus.current())}
new_status = PipelineStatus.current()
progress =
if new_status.state == :running do
socket.assigns[:pipeline_progress]
end
socket =
socket
|> assign(:pipeline_status, new_status)
|> assign(:pipeline_progress, progress)
{:noreply, socket}
end
def handle_info({:propagation_pipeline_progress, progress}, socket) do
{:noreply, assign(socket, :pipeline_progress, progress)}
end
@impl true
@ -362,14 +381,17 @@ defmodule MicrowavepropWeb.MapLive do
attr :id, :string, required: true
attr :status, :map, required: true
attr :progress, :any, default: nil
defp pipeline_status_chip(assigns) do
assigns = assign(assigns, :display_label, chip_display_label(assigns.status, assigns.progress))
~H"""
<div
id={@id}
data-pipeline-state={@status.state}
class="flex items-start gap-1.5 text-xs px-1 py-1 leading-tight"
title={@status.label}
title={@display_label}
>
<%= case @status.state do %>
<% :running -> %>
@ -377,21 +399,27 @@ defmodule MicrowavepropWeb.MapLive do
name="hero-arrow-path"
class="size-3.5 shrink-0 mt-0.5 text-info motion-safe:animate-spin"
/>
<span class="opacity-90 break-words min-w-0">{@status.label}</span>
<span class="opacity-90 break-words min-w-0">{@display_label}</span>
<% :idle -> %>
<span class="size-2 rounded-full bg-success inline-block shrink-0 mt-[5px]"></span>
<span class="opacity-70 break-words min-w-0">{@status.label}</span>
<span class="opacity-70 break-words min-w-0">{@display_label}</span>
<% :stale -> %>
<span class="size-2 rounded-full bg-warning inline-block shrink-0 mt-[5px]"></span>
<span class="opacity-80 break-words min-w-0">{@status.label}</span>
<span class="opacity-80 break-words min-w-0">{@display_label}</span>
<% :unknown -> %>
<span class="size-2 rounded-full bg-base-content/30 inline-block shrink-0 mt-[5px]"></span>
<span class="opacity-60 break-words min-w-0">{@status.label}</span>
<span class="opacity-60 break-words min-w-0">{@display_label}</span>
<% end %>
</div>
"""
end
defp chip_display_label(%{state: :running} = status, progress) do
PipelineStatus.running_label(progress) || status.label
end
defp chip_display_label(status, _progress), do: status.label
@impl true
def render(assigns) do
~H"""
@ -453,7 +481,11 @@ defmodule MicrowavepropWeb.MapLive do
</div>
</div>
<.pipeline_status_chip id="pipeline-status-mobile" status={@pipeline_status} />
<.pipeline_status_chip
id="pipeline-status-mobile"
status={@pipeline_status}
progress={@pipeline_progress}
/>
<div class="dropdown">
<div tabindex="0" role="button" class="btn btn-sm w-full justify-between">
@ -723,7 +755,11 @@ defmodule MicrowavepropWeb.MapLive do
</ul>
<div class="border-t border-base-300 pt-2">
<.pipeline_status_chip id="pipeline-status-desktop" status={@pipeline_status} />
<.pipeline_status_chip
id="pipeline-status-desktop"
status={@pipeline_status}
progress={@pipeline_progress}
/>
</div>
</div>
</div>

View file

@ -140,4 +140,24 @@ defmodule Microwaveprop.Propagation.PipelineStatusTest do
assert status.state == :unknown
end
end
describe "running_label/1" do
test "forecast hour 0 renders as 'now'" do
assert PipelineStatus.running_label(%{forecast_hour: 0}) ==
"Updating propagation · now"
end
test "positive forecast hours render as +Nh" do
assert PipelineStatus.running_label(%{forecast_hour: 1}) ==
"Updating propagation · +1h"
assert PipelineStatus.running_label(%{forecast_hour: 18}) ==
"Updating propagation · +18h"
end
test "returns nil when no forecast hour is provided" do
assert PipelineStatus.running_label(nil) == nil
assert PipelineStatus.running_label(%{}) == nil
end
end
end

View file

@ -1,5 +1,5 @@
defmodule MicrowavepropWeb.MapLiveTest do
use MicrowavepropWeb.ConnCase, async: true
use MicrowavepropWeb.ConnCase, async: false
import Phoenix.LiveViewTest
@ -107,5 +107,49 @@ defmodule MicrowavepropWeb.MapLiveTest do
# With a clean test DB no pipeline workers have ever run.
assert html =~ ~s(data-pipeline-state="unknown")
end
test "shows the current forecast hour while PropagationGridWorker runs", %{conn: conn} do
# Put a grid-worker row in executing state so PipelineStatus.current/0
# reports :running. Bypassing Oban's normal insert keeps the SQL
# sandbox from running the worker inline.
now = DateTime.utc_now()
Microwaveprop.Repo.insert!(
struct!(Oban.Job, %{
state: "executing",
queue: "propagation",
worker: "Microwaveprop.Workers.PropagationGridWorker",
args: %{},
attempt: 1,
max_attempts: 3,
inserted_at: now,
scheduled_at: now,
attempted_at: now
})
)
{:ok, lv, html} = live(conn, ~p"/map")
# Before any progress message arrives the chip uses the base label.
assert html =~ "Updating propagation"
assert html =~ ~s(data-pipeline-state="running")
Phoenix.PubSub.broadcast(
Microwaveprop.PubSub,
"propagation:pipeline",
{:propagation_pipeline_progress, %{forecast_hour: 3, valid_time: ~U[2026-04-14 18:00:00Z]}}
)
html = render(lv)
assert html =~ "+3h"
Phoenix.PubSub.broadcast(
Microwaveprop.PubSub,
"propagation:pipeline",
{:propagation_pipeline_progress, %{forecast_hour: 0, valid_time: ~U[2026-04-14 15:00:00Z]}}
)
html = render(lv)
assert html =~ "now"
end
end
end