feat: add /pskreporter page showing last 100 PSK Reporter spots

This commit is contained in:
Graham McInitre 2026-07-15 13:30:34 -05:00
parent a8ecee32b0
commit d76c36d25f
3 changed files with 238 additions and 0 deletions

View file

@ -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"""
<Layouts.app flash={@flash} current_scope={@current_scope}>
<.header>
PSK Reporter Spots
<:subtitle>Last {@limit} spot aggregates from the MQTT firehose</:subtitle>
</.header>
<div class="overflow-x-auto -mx-2 px-2">
<table class="table table-sm table-zebra whitespace-nowrap">
<thead>
<tr>
<th>Last Heard (UTC)</th>
<th>Band</th>
<th>From Grid</th>
<th>To Grid</th>
<th>Distance</th>
<th class="text-right">Spots</th>
<th>SNR</th>
<th>Modes</th>
</tr>
</thead>
<tbody>
<%= for spot <- @spots do %>
<tr>
<td class="text-xs font-mono">{fmt_dt(spot.last_spot_at)}</td>
<td class="text-xs"><span class="badge badge-sm">{spot.band}</span></td>
<td class="text-xs font-mono">{spot.sender_grid}</td>
<td class="text-xs font-mono">{spot.receiver_grid}</td>
<td class="text-xs font-mono">{Format.distance_km(spot.distance_km)}</td>
<td class="text-xs text-right font-mono">{Format.number(spot.spot_count)}</td>
<td class="text-xs font-mono">{fmt_snr(spot.min_snr_db, spot.max_snr_db)}</td>
<td class="text-xs">
<span :for={mode <- spot.modes} class="badge badge-xs mr-0.5">{mode}</span>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<p :if={@spots == []} class="text-sm text-base-content/50 italic mt-4">
No PSK Reporter spots received yet. The MQTT listener is running spots will appear here as they arrive.
</p>
</Layouts.app>
"""
end
end

View file

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

View file

@ -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 =~ "-512 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 =~ "33 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 (GRID1GRID5 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