diff --git a/lib/microwaveprop_web/live/status_live.ex b/lib/microwaveprop_web/live/status_live.ex index 9cbd2b08..82eeaee6 100644 --- a/lib/microwaveprop_web/live/status_live.ex +++ b/lib/microwaveprop_web/live/status_live.ex @@ -110,8 +110,13 @@ defmodule MicrowavepropWeb.StatusLive do ) end - # "Done" means a narr_profiles row within 0.15° + 30 min of the contact, - # matching `Weather.find_nearest_narr/3` lookup tolerance. + # "Done" means a narr_profiles row within 0.15° of the contact and + # stamped at the floor-snapped 3-hour analysis mark. + # + # NARR analyses live at 00/03/06/09/12/15/18/21 UTC. The fetcher calls + # `NarrClient.snap_to_analysis_hour/1`, which floors qso_timestamp to the + # previous 3-hour mark — so a contact at 18:50Z yields a profile valid at + # 18:00Z. The SQL snap below must stay in sync with that Elixir function. defp count_narr_done do coverage_end = NarrClient.coverage_end() @@ -127,7 +132,8 @@ defmodule MicrowavepropWeb.StatusLive do FROM narr_profiles p WHERE p.lat BETWEEN ((c.pos1->>'lat')::float - 0.15) AND ((c.pos1->>'lat')::float + 0.15) AND p.lon BETWEEN ((c.pos1->>'lon')::float - 0.15) AND ((c.pos1->>'lon')::float + 0.15) - AND p.valid_time BETWEEN (c.qso_timestamp - INTERVAL '30 minutes') AND (c.qso_timestamp + INTERVAL '30 minutes') + AND p.valid_time = date_trunc('hour', c.qso_timestamp) + - make_interval(hours => CAST(EXTRACT(HOUR FROM c.qso_timestamp) AS integer) % 3) ) """, [coverage_end] diff --git a/test/microwaveprop_web/live/status_live_test.exs b/test/microwaveprop_web/live/status_live_test.exs index a512b389..70f568d9 100644 --- a/test/microwaveprop_web/live/status_live_test.exs +++ b/test/microwaveprop_web/live/status_live_test.exs @@ -98,6 +98,44 @@ defmodule MicrowavepropWeb.StatusLiveTest do assert narr_row =~ "0 / 1" end + # NARR analyses are archived at 3-hour marks (00/03/06/09/12/15/18/21 UTC). + # `NarrClient.snap_to_analysis_hour/1` floors the QSO timestamp to the + # previous 3-hour mark before fetch. The status numerator query must mirror + # that snap — a naive ±30 min window around the raw qso_timestamp misses + # the stored profile whenever the contact lands more than 30 min past the + # last analysis hour. + test "NARR progress counts contacts whose qso_timestamp doesn't land on a 3-hour analysis mark", + %{conn: conn} do + {:ok, _} = + %Contact{} + |> Contact.changeset(%{ + station1: "W5OFF", + station2: "K5OFF", + qso_timestamp: ~U[2010-06-15 18:50:00Z], + mode: "CW", + band: Decimal.new("1296"), + grid1: "EM12", + grid2: "EM00", + pos1: %{"lat" => 33.0, "lon" => -97.0}, + pos2: %{"lat" => 30.3, "lon" => -97.7}, + distance_km: Decimal.new("300") + }) + |> Ecto.Changeset.change(%{hrrr_status: :unavailable}) + |> Repo.insert() + + {:ok, _} = + Repo.insert(%NarrProfile{ + valid_time: ~U[2010-06-15 18:00:00Z], + lat: 33.0, + lon: -97.0, + ducting_detected: false + }) + + {:ok, lv, _html} = live(conn, ~p"/status") + narr_row = lv |> element(~s|[data-progress-key="narr"]|) |> render() + assert narr_row =~ "1 / 1" + end + # NARR eligibility is defined by qso_timestamp, not hrrr_status. A # pre-2014 contact whose hrrr_status is still :queued (the ping-pong # window before reconcile flips it) must still be counted — otherwise