From 28a9eb3a9285bcbd68b443eeffa373f65b8ddecd Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sun, 19 Apr 2026 08:09:14 -0500 Subject: [PATCH] fix(map-timeline): never label a future forecast hour as 'Now' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both the Elixir initial-cursor pick and the JS timeline renderer used nearest-by-absolute-distance, so any wall-clock time past :30 snapped the 'Now' label onto the next top-of-hour forecast — up to ~30 min in the future. Switch to the latest valid_time at or before wall-clock, with a fallback to the earliest time when every slot is future (shouldn't happen for HRRR, safe default). --- assets/js/propagation_map_hook.ts | 13 +++++-- lib/microwaveprop_web/live/map_live.ex | 22 +++++++++--- test/microwaveprop_web/live/map_live_test.exs | 35 +++++++++++++++++++ 3 files changed, 63 insertions(+), 7 deletions(-) diff --git a/assets/js/propagation_map_hook.ts b/assets/js/propagation_map_hook.ts index bfe7a8c5..2442a779 100644 --- a/assets/js/propagation_map_hook.ts +++ b/assets/js/propagation_map_hook.ts @@ -1330,9 +1330,16 @@ export const PropagationMap: Record & { return { ...t, dt, isSelected, isPast, offsetH, idx, label: "" } }) - // "Now" = the item closest to current time - const closestIdx = items.reduce((best, item, i) => - Math.abs(item.dt.getTime() - now.getTime()) < Math.abs(items[best].dt.getTime() - now.getTime()) ? i : best, 0) + // "Now" = the latest forecast hour at or before wall-clock. Picking + // by absolute distance rounds up past the half-hour and labels a + // future slot "Now" (e.g. at 18:35 UTC, 19:00 is 25 min away vs. + // 18:00 at 35 min). Fall back to the earliest slot if they're all + // in the future. + const nowMs = now.getTime() + const pastOrNow = items.filter(t => t.dt.getTime() <= nowMs) + const closestIdx = pastOrNow.length > 0 + ? items.indexOf(pastOrNow.reduce((latest, t) => t.dt.getTime() > latest.dt.getTime() ? t : latest)) + : items.reduce((earliest, t, i) => items[earliest].dt.getTime() < t.dt.getTime() ? earliest : i, 0) items.forEach((t, i) => { if (i === closestIdx) { diff --git a/lib/microwaveprop_web/live/map_live.ex b/lib/microwaveprop_web/live/map_live.ex index 1b62a5cb..80aa283c 100644 --- a/lib/microwaveprop_web/live/map_live.ex +++ b/lib/microwaveprop_web/live/map_live.ex @@ -424,11 +424,25 @@ defmodule MicrowavepropWeb.MapLive do "#{hours}h ago" end - defp closest_to_now([]), do: nil + defp closest_to_now(times), do: cursor_for_now(times, DateTime.utc_now()) - defp closest_to_now(times) do - now = DateTime.utc_now() - Enum.min_by(times, fn t -> abs(DateTime.diff(t, now)) end) + @doc false + # Picks the forecast valid_time the map should display as "Now". + # The rule is "latest valid_time at or before `now`" — a nearest-by- + # absolute-distance pick can land on a future hour (e.g. at 18:35 UTC, + # 19:00 is 25 min away vs. 18:00 at 35 min, so nearest would label + # 19:00 "Now"). Falls back to the earliest time only if every slot is + # in the future, which HRRR analysis + forecast lists shouldn't produce. + @spec cursor_for_now([DateTime.t()], DateTime.t()) :: DateTime.t() | nil + def cursor_for_now([], _now), do: nil + + def cursor_for_now(times, %DateTime{} = now) do + past_or_now = Enum.filter(times, &(DateTime.compare(&1, now) != :gt)) + + case past_or_now do + [] -> Enum.min_by(times, &DateTime.to_unix/1) + past -> Enum.max_by(past, &DateTime.to_unix/1) + end end defp score_range_km(score, config) do diff --git a/test/microwaveprop_web/live/map_live_test.exs b/test/microwaveprop_web/live/map_live_test.exs index 78b305ea..bb338932 100644 --- a/test/microwaveprop_web/live/map_live_test.exs +++ b/test/microwaveprop_web/live/map_live_test.exs @@ -132,6 +132,41 @@ defmodule MicrowavepropWeb.MapLiveTest do end end + describe "cursor_for_now/2" do + alias MicrowavepropWeb.MapLive + + test "returns nil for an empty list" do + assert MapLive.cursor_for_now([], DateTime.utc_now()) == nil + end + + test "picks the latest time at or before now — never future" do + # Valid times at 18:00, 19:00, 20:00 UTC. Wall clock 19:35. + # Nearest-by-abs would round up to 20:00 (25 min future); + # floor picks 19:00 (35 min past). + 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.cursor_for_now(times, now) == ~U[2026-04-18 19:00:00Z] + end + + test "matches exact hour when now is on the hour" do + times = [~U[2026-04-18 18:00:00Z], ~U[2026-04-18 19:00:00Z]] + assert MapLive.cursor_for_now(times, ~U[2026-04-18 19:00:00Z]) == ~U[2026-04-18 19:00:00Z] + end + + test "falls back to the earliest time when every slot is in the future" do + times = [~U[2026-04-18 20:00:00Z], ~U[2026-04-18 21:00:00Z]] + now = ~U[2026-04-18 19:00:00Z] + assert MapLive.cursor_for_now(times, now) == ~U[2026-04-18 20:00:00Z] + end + + test "ignores list order" do + times = [~U[2026-04-18 20:00:00Z], ~U[2026-04-18 18:00:00Z], ~U[2026-04-18 19:00:00Z]] + now = ~U[2026-04-18 19:35:00Z] + assert MapLive.cursor_for_now(times, now) == ~U[2026-04-18 19:00:00Z] + end + end + describe "format_data_timestamp/1" do alias MicrowavepropWeb.MapLive