diff --git a/lib/microwaveprop_web/live/pskr_spots_live.ex b/lib/microwaveprop_web/live/pskr_spots_live.ex new file mode 100644 index 00000000..acd1b060 --- /dev/null +++ b/lib/microwaveprop_web/live/pskr_spots_live.ex @@ -0,0 +1,85 @@ +defmodule MicrowavepropWeb.PskrSpotsLive do + @moduledoc "`/pskreporter` — recent PSK Reporter spot aggregates." + use MicrowavepropWeb, :live_view + + import Ecto.Query + + alias Microwaveprop.Format + alias Microwaveprop.Pskr.SpotHourly + alias Microwaveprop.Repo + + @limit 100 + + @impl true + def mount(_params, _session, socket) do + spots = fetch_recent_spots() + + {:ok, + assign(socket, + page_title: "PSK Reporter Spots", + spots: spots + )} + end + + defp fetch_recent_spots do + SpotHourly + |> order_by(desc: :last_spot_at) + |> limit(@limit) + |> Repo.all() + end + + defp fmt_dt(nil), do: "—" + defp fmt_dt(dt), do: Calendar.strftime(dt, "%Y-%m-%d %H:%M UTC") + + defp fmt_snr(min, max) when min == max, do: "#{min} dB" + defp fmt_snr(min, max), do: "#{min}–#{max} dB" + + @impl true + def render(assigns) do + ~H""" + + <.header> + PSK Reporter Spots + <:subtitle>Last {@limit} spot aggregates from the MQTT firehose + + +
+ + + + + + + + + + + + + + + <%= for spot <- @spots do %> + + + + + + + + + + + <% end %> + +
Last Heard (UTC)BandFrom GridTo GridDistanceSpotsSNRModes
{fmt_dt(spot.last_spot_at)}{spot.band}{spot.sender_grid}{spot.receiver_grid}{Format.distance_km(spot.distance_km)}{Format.number(spot.spot_count)}{fmt_snr(spot.min_snr_db, spot.max_snr_db)} + {mode} +
+
+ +

+ No PSK Reporter spots received yet. The MQTT listener is running — spots will appear here as they arrive. +

+
+ """ + end +end diff --git a/lib/microwaveprop_web/router.ex b/lib/microwaveprop_web/router.ex index 372dd69f..b3b0cd96 100644 --- a/lib/microwaveprop_web/router.ex +++ b/lib/microwaveprop_web/router.ex @@ -269,6 +269,7 @@ defmodule MicrowavepropWeb.Router do live "/algo", AlgoLive live "/about", AboutLive live "/privacy", PrivacyLive + live "/pskreporter", PskrSpotsLive # Beacons: public read + submit (pending admin approval) live "/beacons", BeaconLive.Index, :index diff --git a/test/microwaveprop_web/live/pskr_spots_live_test.exs b/test/microwaveprop_web/live/pskr_spots_live_test.exs new file mode 100644 index 00000000..2efbcf1c --- /dev/null +++ b/test/microwaveprop_web/live/pskr_spots_live_test.exs @@ -0,0 +1,152 @@ +defmodule MicrowavepropWeb.PskrSpotsLiveTest do + use MicrowavepropWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + + alias Microwaveprop.Repo + + defp now, do: DateTime.utc_now() + defp dump_uuid, do: Ecto.UUID.dump!(Ecto.UUID.generate()) + + defp insert_spot(attrs) do + defaults = %{ + hour_utc: now(), + band: "2m", + sender_grid: "EM12KL", + receiver_grid: "DM43ST", + spot_count: 1 + } + + fields = Map.merge(defaults, attrs) + + Repo.query!( + """ + INSERT INTO pskr_spots_hourly + (id, hour_utc, band, sender_grid, receiver_grid, + sender_lat, sender_lon, receiver_lat, receiver_lon, + distance_km, spot_count, max_snr_db, min_snr_db, + modes, first_spot_at, last_spot_at, + inserted_at, updated_at) + VALUES + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $17) + """, + [ + dump_uuid(), + fields.hour_utc, + fields.band, + fields.sender_grid, + fields.receiver_grid, + Map.get(fields, :sender_lat, 32.5), + Map.get(fields, :sender_lon, -96.5), + Map.get(fields, :receiver_lat, 33.5), + Map.get(fields, :receiver_lon, -110.5), + Map.get(fields, :distance_km, 1_200.0), + fields.spot_count, + Map.get(fields, :max_snr_db, 5), + Map.get(fields, :min_snr_db, 0), + Map.get(fields, :modes, ["FT8"]), + Map.get(fields, :first_spot_at, fields.hour_utc), + Map.get(fields, :last_spot_at, fields.hour_utc) + ] + ) + end + + describe "page rendering" do + test "renders the pskr spots page", %{conn: conn} do + {:ok, _lv, html} = live(conn, ~p"/pskreporter") + assert html =~ "PSK Reporter Spots" + end + + test "sets page title", %{conn: conn} do + {:ok, _lv, html} = live(conn, ~p"/pskreporter") + assert html =~ "PSK Reporter Spots" + end + + test "shows empty state when no spots exist", %{conn: conn} do + {:ok, _lv, html} = live(conn, ~p"/pskreporter") + assert html =~ "No PSK Reporter spots received yet" + end + end + + describe "with spots in the database" do + test "displays spot grid and count data in table", %{conn: conn} do + insert_spot(%{sender_grid: "EM12KL", receiver_grid: "DM43ST", spot_count: 42}) + + {:ok, _lv, html} = live(conn, ~p"/pskreporter") + + assert html =~ "EM12KL" + assert html =~ "DM43ST" + assert html =~ "42" + end + + test "displays band as badge", %{conn: conn} do + insert_spot(%{band: "70cm"}) + + {:ok, _lv, html} = live(conn, ~p"/pskreporter") + + assert html =~ "70cm" + end + + test "displays SNR range", %{conn: conn} do + insert_spot(%{min_snr_db: -5, max_snr_db: 12}) + + {:ok, _lv, html} = live(conn, ~p"/pskreporter") + + assert html =~ "-5–12 dB" + end + + test "displays single SNR when min equals max", %{conn: conn} do + insert_spot(%{min_snr_db: 3, max_snr_db: 3}) + + {:ok, _lv, html} = live(conn, ~p"/pskreporter") + + assert html =~ "3 dB" + refute html =~ "3–3 dB" + end + + test "displays modes", %{conn: conn} do + insert_spot(%{modes: ["FT8", "FT4"]}) + + {:ok, _lv, html} = live(conn, ~p"/pskreporter") + + assert html =~ "FT8" + assert html =~ "FT4" + end + + test "shows most recent spots first", %{conn: conn} do + t1 = ~U[2026-07-01 12:00:00Z] + t2 = ~U[2026-07-01 13:00:00Z] + + insert_spot(%{sender_grid: "EARLIER", last_spot_at: t1, hour_utc: t1}) + insert_spot(%{sender_grid: "LATER", last_spot_at: t2, hour_utc: t2}) + + {:ok, _lv, html} = live(conn, ~p"/pskreporter") + + # LATER should appear before EARLIER in the HTML + assert String.contains?(html, "LATER") + assert String.contains?(html, "EARLIER") + assert String.index(html, "LATER") < String.index(html, "EARLIER") + end + + test "limits to 100 most recent spots", %{conn: conn} do + # Insert 105 spots — only the 100 most recent should render + for i <- 1..105 do + t = DateTime.add!(now(), i - 105, :second) + insert_spot(%{sender_grid: "GRID#{i}", last_spot_at: t, hour_utc: t}) + end + + {:ok, _lv, html} = live(conn, ~p"/pskreporter") + + # The oldest 5 should be excluded (GRID1–GRID5 have the earliest timestamps) + refute html =~ "GRID1" + refute html =~ "GRID2" + refute html =~ "GRID3" + refute html =~ "GRID4" + refute html =~ "GRID5" + + # The most recent 100 should be present + assert html =~ "GRID105" + assert html =~ "GRID6" + end + end +end