From a6af6b115a427e43236b1e5667775311546d9364 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sat, 18 Apr 2026 14:46:44 -0500 Subject: [PATCH] feat(map): 7-day outlook strip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- lib/microwaveprop/propagation.ex | 28 +++++++++ lib/microwaveprop_web/live/map_live.ex | 61 +++++++++++++++++++ .../propagation/daily_outlook_test.exs | 59 ++++++++++++++++++ test/microwaveprop_web/live/map_live_test.exs | 13 ++++ 4 files changed, 161 insertions(+) create mode 100644 test/microwaveprop/propagation/daily_outlook_test.exs diff --git a/lib/microwaveprop/propagation.ex b/lib/microwaveprop/propagation.ex index 5f75541e..67f5cc77 100644 --- a/lib/microwaveprop/propagation.ex +++ b/lib/microwaveprop/propagation.ex @@ -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()}] diff --git a/lib/microwaveprop_web/live/map_live.ex b/lib/microwaveprop_web/live/map_live.ex index b770b8cb..1b62a5cb 100644 --- a/lib/microwaveprop_web/live/map_live.ex +++ b/lib/microwaveprop_web/live/map_live.ex @@ -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""" +
+ <%= if @outlook == [] do %> +
+ Extended outlook not yet available. +
+ <% else %> +
7-day outlook
+
+
+ {outlook_day_label(entry.date)} + {entry.peak_score} +
+
+ <% end %> +
+ """ + end + attr :id, :string, required: true attr :status, :map, required: true attr :progress, :any, default: nil @@ -629,6 +686,8 @@ defmodule MicrowavepropWeb.MapLive do + <.daily_outlook_strip id="daily-outlook-mobile" outlook={@daily_outlook} /> + + <.daily_outlook_strip id="daily-outlook-desktop" outlook={@daily_outlook} /> + <%!-- Grid toggle --%>