feat(map): 7-day outlook strip

Adds a compact seven-card horizontal strip to both mobile and desktop
map sidebars showing the per-day peak score at the viewport center
for the selected band. Cards are color-coded (emerald for 80+,
rose for bad) so the weekend verdict reads at a glance.

- Propagation.daily_outlook_at/3: groups point_forecast entries by
  UTC date, picks each day's peak, returns ascending order
- MapLive mounts with today's outlook for the initial center; the
  select_band handler refreshes it from the viewport midpoint so the
  strip tracks the user's current band and rough location
- Empty-state message covers the case where no extended-horizon
  scores have landed yet (fresh deploy, or before the first GEFS
  run completes)

This is the consumer of the GEFS pipeline — once the cron starts
running and f024-f168 scores accumulate, days 2-7 of the strip
populate automatically.
This commit is contained in:
Graham McIntire 2026-04-18 14:46:44 -05:00
parent 4d0c15e3b8
commit a6af6b115a
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
4 changed files with 161 additions and 0 deletions

View file

@ -322,6 +322,34 @@ defmodule Microwaveprop.Propagation do
end
end
@doc """
Returns the per-day peak score at `(lat, lon)` for the next `:days`
UTC days on `:band_mhz`, in ascending date order. Days with no
available forecast valid_times are omitted.
Backs the 7-day outlook strip on the map turns the hourly forecast
sparkline into a coarse "is Saturday any good" verdict.
"""
@spec daily_outlook_at(float(), float(), keyword()) ::
[%{date: Date.t(), peak_score: non_neg_integer()}]
def daily_outlook_at(lat, lon, opts \\ []) do
band_mhz = Keyword.get(opts, :band_mhz, 10_000)
days = Keyword.get(opts, :days, 7)
today = DateTime.to_date(DateTime.utc_now())
horizon = Date.add(today, days - 1)
band_mhz
|> point_forecast(lat, lon)
|> Enum.group_by(fn entry ->
DateTime.to_date(entry.valid_time)
end)
|> Enum.filter(fn {date, _} -> Date.compare(date, today) != :lt and Date.compare(date, horizon) != :gt end)
|> Enum.map(fn {date, entries} ->
%{date: date, peak_score: entries |> Enum.map(& &1.score) |> Enum.max()}
end)
|> Enum.sort_by(& &1.date, Date)
end
@doc "Get scores across all forecast hours for a single grid point (for sparkline)."
@spec point_forecast(non_neg_integer(), float(), float()) ::
[%{valid_time: DateTime.t(), score: non_neg_integer()}]

View file

@ -87,6 +87,7 @@ defmodule MicrowavepropWeb.MapLive do
outside_conus: outside_conus?(visitor),
grid_visible: false,
radar_visible: false,
daily_outlook: Propagation.daily_outlook_at(center.lat, center.lon, band_mhz: selected_band, days: 7),
deploy_iso: Calendar.strftime(build_ts, "%Y-%m-%d %H:%M UTC"),
deploy_ago: format_deploy_ago(build_ts)
)}
@ -159,6 +160,15 @@ defmodule MicrowavepropWeb.MapLive do
|> LiveStash.stash_assigns([:selected_band, :selected_time])
|> push_event("update_scores", %{scores: scores})
|> push_event("update_band_info", %{band_info: band_info(band)})
|> assign(
:daily_outlook,
Propagation.daily_outlook_at(
socket.assigns.bounds["south"] + (socket.assigns.bounds["north"] - socket.assigns.bounds["south"]) / 2,
socket.assigns.bounds["west"] + (socket.assigns.bounds["east"] - socket.assigns.bounds["west"]) / 2,
band_mhz: band,
days: 7
)
)
|> push_timeline()
{:noreply, socket}
@ -451,6 +461,53 @@ defmodule MicrowavepropWeb.MapLive do
end
end
# Outlook helpers. Day label is the 3-letter weekday ("Sat", "Sun");
# badge class paints the card background based on the peak score so
# the strip reads as a rough heatmap at a glance.
defp outlook_day_label(%Date{} = date), do: Calendar.strftime(date, "%a")
defp outlook_bg_class(score) when is_integer(score) do
cond do
score >= 80 -> "bg-emerald-500/30 border-emerald-400/50"
score >= 65 -> "bg-lime-500/25 border-lime-400/50"
score >= 50 -> "bg-amber-500/25 border-amber-400/50"
score >= 33 -> "bg-orange-500/25 border-orange-400/50"
true -> "bg-rose-500/25 border-rose-400/50"
end
end
defp outlook_bg_class(_), do: "bg-base-300/30 border-base-300/50"
attr :id, :string, required: true
attr :outlook, :list, required: true
defp daily_outlook_strip(assigns) do
~H"""
<div id={@id} class="px-1">
<%= if @outlook == [] do %>
<div class="text-[11px] opacity-60 italic leading-tight">
Extended outlook not yet available.
</div>
<% else %>
<div class="text-[11px] opacity-70 mb-1 leading-tight">7-day outlook</div>
<div class="grid grid-cols-7 gap-1">
<div
:for={entry <- @outlook}
class={[
"flex flex-col items-center justify-center rounded border py-1 text-xs leading-tight",
outlook_bg_class(entry.peak_score)
]}
title={"#{entry.date} peak score: #{entry.peak_score}"}
>
<span class="opacity-80">{outlook_day_label(entry.date)}</span>
<span class="font-semibold tabular-nums">{entry.peak_score}</span>
</div>
</div>
<% end %>
</div>
"""
end
attr :id, :string, required: true
attr :status, :map, required: true
attr :progress, :any, default: nil
@ -629,6 +686,8 @@ defmodule MicrowavepropWeb.MapLive do
</ul>
</div>
<.daily_outlook_strip id="daily-outlook-mobile" outlook={@daily_outlook} />
<div id="panel-extras" class="hidden flex-col gap-2">
<label class="flex items-center gap-2 cursor-pointer px-1">
<input
@ -769,6 +828,8 @@ defmodule MicrowavepropWeb.MapLive do
</ul>
</div>
<.daily_outlook_strip id="daily-outlook-desktop" outlook={@daily_outlook} />
<%!-- Grid toggle --%>
<label class="flex items-center gap-2 cursor-pointer px-1">
<input

View file

@ -0,0 +1,59 @@
defmodule Microwaveprop.Propagation.DailyOutlookTest do
use Microwaveprop.DataCase, async: false
alias Microwaveprop.Propagation
alias Microwaveprop.Propagation.ScoreCache
setup do
ScoreCache.clear()
on_exit(fn -> ScoreCache.clear() end)
:ok
end
describe "daily_outlook_at/3" do
test "returns an empty list when no scores have been written" do
assert Propagation.daily_outlook_at(32.9, -97.0, days: 3) == []
end
test "groups scores by UTC date and picks the peak per day" do
# Use a lat on the grid: snap(32.9) = 32.875 (step 0.125). Write at
# the snapped coords so point_forecast's internal snap-to-grid finds
# the scores on disk.
lat = 32.875
lon = -97.0
band = 10_000
# Day A: 5Z @ 40, 17Z @ 80 → peak 80
# Day B: 5Z @ 60 → peak 60
day_a_1 = ~U[2026-04-20 05:00:00Z]
day_a_2 = ~U[2026-04-20 17:00:00Z]
day_b_1 = ~U[2026-04-21 05:00:00Z]
Propagation.replace_scores(
[%{lat: lat, lon: lon, valid_time: day_a_1, band_mhz: band, score: 40, factors: nil}],
day_a_1
)
Propagation.replace_scores(
[%{lat: lat, lon: lon, valid_time: day_a_2, band_mhz: band, score: 80, factors: nil}],
day_a_2
)
Propagation.replace_scores(
[%{lat: lat, lon: lon, valid_time: day_b_1, band_mhz: band, score: 60, factors: nil}],
day_b_1
)
outlook = Propagation.daily_outlook_at(lat, lon, band_mhz: band, days: 30)
# The helper returns entries in ascending date order.
assert length(outlook) >= 2
day_a = Enum.find(outlook, &(&1.date == ~D[2026-04-20]))
day_b = Enum.find(outlook, &(&1.date == ~D[2026-04-21]))
assert day_a.peak_score == 80
assert day_b.peak_score == 60
end
end
end

View file

@ -109,6 +109,19 @@ defmodule MicrowavepropWeb.MapLiveTest do
end
end
describe "daily outlook strip" do
test "renders the strip in both mobile and desktop sidebars", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/map")
assert html =~ ~s(id="daily-outlook-mobile")
assert html =~ ~s(id="daily-outlook-desktop")
end
test "shows an empty-state message when no outlook data exists", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/map")
assert html =~ "Extended outlook not yet available"
end
end
describe "data timestamp indicator" do
test "renders a 'Data from …' line on both mobile and desktop sidebars", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/map")