From ffb14cb64f7289c0a00aeb426fae1003af1fe285 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sun, 19 Apr 2026 12:10:35 -0500 Subject: [PATCH] feat(map): auto-advance cursor, full forecast timeline, panel spacing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three UI fixes: 1. MapLive now ticks every 60s and, when the selected_time is still the "Now" slot, advances to the newer latest-past-or-now hour as the clock crosses into it. The URL is patched in place so shared links stay accurate without polluting history. Manually picking a specific past/future hour turns tracking off. 2. point_forecast/3 now reads its valid_times list straight from the on-disk .ntms files (same source the main-map timeline uses), then consults the cache per-hour for a fast score lookup. The old cache- or-store branch could leave the click-to-detail sparkline at 13 hours while the bottom timeline showed 14+; both now line up. 3. Point-detail panel moves from bottom-14 → bottom-24 on desktop so its lower edge clears the forecast-timeline bar (+ max-height tuned to match), fixing the overlap visible in screenshots. --- lib/microwaveprop/propagation.ex | 45 +++----- lib/microwaveprop_web/live/map_live.ex | 61 +++++++++- test/microwaveprop/propagation_test.exs | 56 +++++++-- test/microwaveprop_web/live/map_live_test.exs | 108 ++++++++++++++++++ 4 files changed, 233 insertions(+), 37 deletions(-) diff --git a/lib/microwaveprop/propagation.ex b/lib/microwaveprop/propagation.ex index fc94e33a..3e910f7d 100644 --- a/lib/microwaveprop/propagation.ex +++ b/lib/microwaveprop/propagation.ex @@ -355,40 +355,31 @@ defmodule Microwaveprop.Propagation do {snapped_lat, snapped_lon} = snap_to_grid(lat, lon) now = DateTime.utc_now() - case ScoreCache.valid_times(band_mhz) do - [] -> point_forecast_from_store(band_mhz, snapped_lat, snapped_lon, now) - cached -> point_forecast_from_cache(band_mhz, snapped_lat, snapped_lon, now, cached) - end + # Use the on-disk .ntms file list as the authoritative timeline so + # the chart never falls behind the main-map timeline (which also + # reads the disk). The cache is still consulted per-hour for a + # fast score lookup; a miss falls through to the file. + band_mhz + |> ScoresFile.list_valid_times() + |> forecast_window(now) + |> Enum.map(&point_forecast_entry(band_mhz, &1, snapped_lat, snapped_lon)) + |> Enum.reject(&is_nil/1) end) end - defp point_forecast_from_store(band_mhz, lat, lon, now) do - band_mhz - |> ScoresFile.list_valid_times() - |> forecast_window(now) - |> Enum.map(&point_forecast_entry(&1, band_mhz, lat, lon)) - |> Enum.reject(&is_nil/1) - end + defp point_forecast_entry(band_mhz, valid_time, lat, lon) do + case ScoreCache.fetch_point(band_mhz, valid_time, lat, lon) do + {:ok, score} -> + %{valid_time: valid_time, score: score} - defp point_forecast_entry(valid_time, band_mhz, lat, lon) do - case ScoresFile.read_point(band_mhz, valid_time, lat, lon) do - nil -> nil - score -> %{valid_time: valid_time, score: score} + :miss -> + case ScoresFile.read_point(band_mhz, valid_time, lat, lon) do + nil -> nil + score -> %{valid_time: valid_time, score: score} + end end end - defp point_forecast_from_cache(band_mhz, lat, lon, now, cached_times) do - cached_times - |> forecast_window(now) - |> Enum.map(fn t -> - case ScoreCache.fetch_point(band_mhz, t, lat, lon) do - {:ok, score} -> %{valid_time: t, score: score} - :miss -> nil - end - end) - |> Enum.reject(&is_nil/1) - end - # Select the set of valid_times the forecast chart should render. # Mirrors `available_valid_times`: keep everything from one hour # before now onward so the most recent analysis hour (typically diff --git a/lib/microwaveprop_web/live/map_live.ex b/lib/microwaveprop_web/live/map_live.ex index 720b52e7..518af254 100644 --- a/lib/microwaveprop_web/live/map_live.ex +++ b/lib/microwaveprop_web/live/map_live.ex @@ -28,12 +28,18 @@ defmodule MicrowavepropWeb.MapLive do # visit for an in-progress hourly run, long enough to be cheap. @pipeline_status_refresh_ms 15_000 + # How often to check whether the wall clock has ticked into a new + # forecast hour. 60s is frequent enough to advance within a minute + # of top-of-hour and cheap enough to run for every connected map. + @advance_now_cursor_ms 60_000 + @impl true 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) + Process.send_after(self(), :advance_now_cursor, @advance_now_cursor_ms) end socket = @@ -81,6 +87,7 @@ defmodule MicrowavepropWeb.MapLive do initial_scores_json: Jason.encode!(initial_scores), valid_times: valid_times, selected_time: selected_time, + tracking_now?: tracking_now?(selected_time, valid_times, DateTime.utc_now()), bounds: bounds, initial_center: center, initial_zoom: zoom, @@ -200,6 +207,7 @@ defmodule MicrowavepropWeb.MapLive do socket = socket |> assign(selected_band: band, valid_times: valid_times, selected_time: selected_time) + |> assign(:tracking_now?, tracking_now?(selected_time, valid_times, DateTime.utc_now())) |> LiveStash.stash_assigns([:selected_band, :selected_time]) |> push_event("update_scores", %{scores: scores}) |> push_event("update_band_info", %{band_info: band_info(band)}) @@ -217,6 +225,7 @@ defmodule MicrowavepropWeb.MapLive do socket = socket |> assign(:selected_time, time) + |> assign(:tracking_now?, tracking_now?(time, socket.assigns.valid_times, DateTime.utc_now())) |> push_event("update_scores", %{scores: scores}) |> patch_map_url() @@ -233,7 +242,13 @@ defmodule MicrowavepropWeb.MapLive do def handle_event("set_selected_time", %{"time" => time_str}, socket) do case DateTime.from_iso8601(time_str) do {:ok, time, _} -> - {:noreply, socket |> assign(:selected_time, time) |> patch_map_url()} + socket = + socket + |> assign(:selected_time, time) + |> assign(:tracking_now?, tracking_now?(time, socket.assigns.valid_times, DateTime.utc_now())) + |> patch_map_url() + + {:noreply, socket} _ -> {:noreply, socket} @@ -378,6 +393,32 @@ defmodule MicrowavepropWeb.MapLive do {:noreply, socket} end + def handle_info(:advance_now_cursor, socket) do + Process.send_after(self(), :advance_now_cursor, @advance_now_cursor_ms) + + if socket.assigns.tracking_now? do + new_time = closest_to_now(socket.assigns.valid_times) + + if new_time && socket.assigns.selected_time && + DateTime.after?(new_time, socket.assigns.selected_time) do + scores = Propagation.scores_at(socket.assigns.selected_band, new_time, socket.assigns.bounds) + + socket = + socket + |> assign(:selected_time, new_time) + |> push_event("update_scores", %{scores: scores}) + |> push_timeline() + |> patch_map_url(replace: true) + + {:noreply, socket} + else + {:noreply, socket} + end + else + {:noreply, socket} + end + end + def handle_info(:refresh_pipeline_status, socket) do Process.send_after(self(), :refresh_pipeline_status, @pipeline_status_refresh_ms) @@ -563,6 +604,22 @@ defmodule MicrowavepropWeb.MapLive do end end + @doc false + # True when the map's current selected_time is still the "Now" slot, + # i.e. the user hasn't scrubbed to a specific past/future hour. Used + # by :advance_now_cursor to decide whether to auto-follow the wall + # clock when it ticks into the next hour. + @spec tracking_now?(DateTime.t() | nil, [DateTime.t()], DateTime.t()) :: boolean() + def tracking_now?(nil, _times, _now), do: false + def tracking_now?(_selected, [], _now), do: false + + def tracking_now?(%DateTime{} = selected, times, %DateTime{} = now) do + case cursor_for_now(times, now) do + nil -> false + cursor -> DateTime.compare(selected, cursor) == :eq + end + end + defp score_range_km(score, config) do cond do score >= 80 -> config.exceptional_range_km @@ -830,7 +887,7 @@ defmodule MicrowavepropWeb.MapLive do class={[ "bg-neutral text-neutral-content shadow-lg border border-base-300 overflow-hidden overflow-y-auto z-[1001]", "fixed bottom-0 left-0 right-0 max-h-[50vh] rounded-t-2xl", - "md:absolute md:top-auto md:right-auto md:bottom-14 md:left-3 md:max-h-[70vh] md:w-80 md:rounded-box" + "md:absolute md:top-auto md:right-auto md:bottom-24 md:left-3 md:max-h-[calc(100vh-8rem)] md:w-80 md:rounded-box" ]} style="display:none;" > diff --git a/test/microwaveprop/propagation_test.exs b/test/microwaveprop/propagation_test.exs index db8cd99c..9a5dc18e 100644 --- a/test/microwaveprop/propagation_test.exs +++ b/test/microwaveprop/propagation_test.exs @@ -341,12 +341,24 @@ defmodule Microwaveprop.PropagationTest do end describe "point_forecast/3 with cache" do + # Write a score to both disk and cache. Production always writes + # the store first, then warms the cache — nothing produces a + # cache-only record, so the tests mirror that invariant. + defp seed_point(band_mhz, valid_time, lat, lon, score) do + Propagation.replace_scores( + [%{lat: lat, lon: lon, valid_time: valid_time, band_mhz: band_mhz, score: score, factors: nil}], + valid_time + ) + + ScoreCache.put(band_mhz, valid_time, [%{lat: lat, lon: lon, score: score}]) + end + test "returns forecast timeline from cached scores when warm" do t1 = DateTime.truncate(DateTime.add(DateTime.utc_now(), 3600, :second), :second) t2 = DateTime.add(t1, 3600, :second) - ScoreCache.put(10_000, t1, [%{lat: 32.875, lon: -97.0, score: 70}]) - ScoreCache.put(10_000, t2, [%{lat: 32.875, lon: -97.0, score: 75}]) + seed_point(10_000, t1, 32.875, -97.0, 70) + seed_point(10_000, t2, 32.875, -97.0, 75) result = Propagation.point_forecast(10_000, 32.875, -97.0) @@ -363,9 +375,9 @@ defmodule Microwaveprop.PropagationTest do recent = DateTime.utc_now() |> DateTime.add(-1800, :second) |> DateTime.truncate(:second) future = DateTime.utc_now() |> DateTime.add(3600, :second) |> DateTime.truncate(:second) - ScoreCache.put(10_000, stale, [%{lat: 32.875, lon: -97.0, score: 40}]) - ScoreCache.put(10_000, recent, [%{lat: 32.875, lon: -97.0, score: 60}]) - ScoreCache.put(10_000, future, [%{lat: 32.875, lon: -97.0, score: 80}]) + seed_point(10_000, stale, 32.875, -97.0, 40) + seed_point(10_000, recent, 32.875, -97.0, 60) + seed_point(10_000, future, 32.875, -97.0, 80) result = Propagation.point_forecast(10_000, 32.875, -97.0) @@ -382,8 +394,8 @@ defmodule Microwaveprop.PropagationTest do older = DateTime.utc_now() |> DateTime.add(-6 * 3600, :second) |> DateTime.truncate(:second) newer = DateTime.utc_now() |> DateTime.add(-3 * 3600, :second) |> DateTime.truncate(:second) - ScoreCache.put(10_000, older, [%{lat: 32.875, lon: -97.0, score: 30}]) - ScoreCache.put(10_000, newer, [%{lat: 32.875, lon: -97.0, score: 45}]) + seed_point(10_000, older, 32.875, -97.0, 30) + seed_point(10_000, newer, 32.875, -97.0, 45) result = Propagation.point_forecast(10_000, 32.875, -97.0) assert [%{valid_time: ^newer, score: 45}] = result @@ -391,7 +403,7 @@ defmodule Microwaveprop.PropagationTest do test "snaps coordinates to the nearest grid point" do valid_time = DateTime.truncate(DateTime.add(DateTime.utc_now(), 3600, :second), :second) - ScoreCache.put(10_000, valid_time, [%{lat: 32.875, lon: -97.0, score: 65}]) + seed_point(10_000, valid_time, 32.875, -97.0, 65) # Off-grid query snaps to 32.875, -97.0 result = Propagation.point_forecast(10_000, 32.88, -96.98) @@ -403,6 +415,34 @@ defmodule Microwaveprop.PropagationTest do end end + describe "point_forecast/3 authoritative timeline" do + test "includes hours present on disk but not yet in the cache" do + # Cache has the two earliest valid_times, but a newer forecast + # hour has just landed on disk. The forecast chart should include + # that third hour so it matches the main-map timeline. + t1 = DateTime.utc_now() |> DateTime.truncate(:second) |> Map.merge(%{minute: 0, second: 0}) + t2 = DateTime.add(t1, 3600, :second) + t3 = DateTime.add(t1, 2 * 3600, :second) + + for {t, score} <- [{t1, 50}, {t2, 60}, {t3, 70}] do + Propagation.replace_scores( + [%{lat: 32.875, lon: -97.0, valid_time: t, band_mhz: 10_000, score: score, factors: nil}], + t + ) + end + + ScoreCache.clear() + ScoreCache.put(10_000, t1, [%{lat: 32.875, lon: -97.0, score: 50}]) + ScoreCache.put(10_000, t2, [%{lat: 32.875, lon: -97.0, score: 60}]) + + result = Propagation.point_forecast(10_000, 32.875, -97.0) + times = Enum.map(result, & &1.valid_time) + + assert t3 in times + assert length(result) == 3 + end + end + describe "point_forecast/3 from store" do test "includes recent past valid_times and falls back to the latest when stale" do # Three .ntms files on disk: one 2h old (dropped by cutoff), diff --git a/test/microwaveprop_web/live/map_live_test.exs b/test/microwaveprop_web/live/map_live_test.exs index dc7c5750..63eecb09 100644 --- a/test/microwaveprop_web/live/map_live_test.exs +++ b/test/microwaveprop_web/live/map_live_test.exs @@ -5,6 +5,7 @@ defmodule MicrowavepropWeb.MapLiveTest do alias Microwaveprop.Propagation alias Microwaveprop.Propagation.ScoreCache + alias Phoenix.LiveView.Utils describe "mount" do test "renders full-page map", %{conn: conn} do @@ -224,6 +225,113 @@ defmodule MicrowavepropWeb.MapLiveTest do end end + describe "tracking_now?/2" do + alias MicrowavepropWeb.MapLive + + test "true when selected time equals the latest at-or-before-now slot" do + times = [~U[2026-04-18 18:00:00Z], ~U[2026-04-18 19:00:00Z], ~U[2026-04-18 20:00:00Z]] + now = ~U[2026-04-18 19:35:00Z] + # closest_to_now at 19:35 with these slots is 19:00. + assert MapLive.tracking_now?(~U[2026-04-18 19:00:00Z], times, now) == true + end + + test "false when selected time is an earlier historical slot" do + times = [~U[2026-04-18 18:00:00Z], ~U[2026-04-18 19:00:00Z], ~U[2026-04-18 20:00:00Z]] + now = ~U[2026-04-18 19:35:00Z] + assert MapLive.tracking_now?(~U[2026-04-18 18:00:00Z], times, now) == false + end + + test "false when selected time is a future forecast slot" do + times = [~U[2026-04-18 18:00:00Z], ~U[2026-04-18 19:00:00Z], ~U[2026-04-18 20:00:00Z]] + now = ~U[2026-04-18 19:35:00Z] + assert MapLive.tracking_now?(~U[2026-04-18 20:00:00Z], times, now) == false + end + + test "false when valid_times is empty" do + assert MapLive.tracking_now?(~U[2026-04-18 19:00:00Z], [], ~U[2026-04-18 19:00:00Z]) == false + end + + test "false when selected_time is nil" do + times = [~U[2026-04-18 19:00:00Z]] + assert MapLive.tracking_now?(nil, times, ~U[2026-04-18 19:00:00Z]) == false + end + end + + describe "advance_now_cursor handler" do + setup do + ScoreCache.clear() + on_exit(fn -> ScoreCache.clear() end) + :ok + end + + test "advances selected_time to the newly-past hour when tracking now", %{conn: conn} do + # Seed two adjacent hours so there's somewhere to advance to. The + # later of the two is the one we want the cursor to snap to once + # the clock ticks past it. + previous_hour = + DateTime.utc_now() |> DateTime.truncate(:second) |> Map.merge(%{minute: 0, second: 0}) |> DateTime.add(-1, :hour) + + current_hour = DateTime.add(previous_hour, 3600, :second) + + for t <- [previous_hour, current_hour] do + Propagation.replace_scores( + [%{lat: 33.0, lon: -97.0, valid_time: t, band_mhz: 10_000, score: 50, factors: nil}], + t + ) + end + + {:ok, lv, _html} = live(conn, ~p"/map") + + # Force the LV onto the previous hour and mark it as tracking_now? + # so the tick handler has a clear advance opportunity. + :sys.replace_state(lv.pid, fn state -> + socket = + state.socket + |> Utils.assign(:selected_time, previous_hour) + |> Utils.assign(:tracking_now?, true) + + %{state | socket: socket} + end) + + send(lv.pid, :advance_now_cursor) + _ = render(lv) + + assigns = :sys.get_state(lv.pid).socket.assigns + assert assigns.selected_time == current_hour + end + + test "leaves selected_time alone when the user is on a specific hour", %{conn: conn} do + previous_hour = + DateTime.utc_now() |> DateTime.truncate(:second) |> Map.merge(%{minute: 0, second: 0}) |> DateTime.add(-1, :hour) + + current_hour = DateTime.add(previous_hour, 3600, :second) + + for t <- [previous_hour, current_hour] do + Propagation.replace_scores( + [%{lat: 33.0, lon: -97.0, valid_time: t, band_mhz: 10_000, score: 50, factors: nil}], + t + ) + end + + {:ok, lv, _html} = live(conn, ~p"/map") + + :sys.replace_state(lv.pid, fn state -> + socket = + state.socket + |> Utils.assign(:selected_time, previous_hour) + |> Utils.assign(:tracking_now?, false) + + %{state | socket: socket} + end) + + send(lv.pid, :advance_now_cursor) + _ = render(lv) + + assigns = :sys.get_state(lv.pid).socket.assigns + assert assigns.selected_time == previous_hour + end + end + describe "format_data_timestamp/1" do alias MicrowavepropWeb.MapLive