fix(map-timeline): never label a future forecast hour as 'Now'

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).
This commit is contained in:
Graham McIntire 2026-04-19 08:09:14 -05:00
parent 6c652ef2d4
commit 28a9eb3a92
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
3 changed files with 63 additions and 7 deletions

View file

@ -1330,9 +1330,16 @@ export const PropagationMap: Record<string, unknown> & {
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) {

View file

@ -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

View file

@ -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