feat: clickable band filters on PSK Reporter tab, fix sync blocking in refresh

- Fix handle_info(:refresh_spots) to use start_async instead of blocking
  LiveView process on DB fetches
- Add require Logger to fix pre-existing compiler warning
- Make band counts clickable: clicking a band filters table to that band's
  last 100 spots; clicking again clears the filter
- Highlight active filter badge, show Clear filter link when filtered
- Update subtitle and empty state dynamically based on filter
- Filter persists across auto-refresh (60s)
- Add 5 test cases for band filtering behavior
This commit is contained in:
Graham McInitre 2026-07-20 12:41:18 -05:00
parent b91ad05c78
commit cdf4c75dd5
2 changed files with 172 additions and 15 deletions

View file

@ -8,6 +8,8 @@ defmodule MicrowavepropWeb.PskrSpotsLive do
alias Microwaveprop.Pskr.SpotHourly
alias Microwaveprop.Repo
require Logger
@limit 100
@refresh_ms 60_000
@ -26,6 +28,7 @@ defmodule MicrowavepropWeb.PskrSpotsLive do
limit: @limit,
total_spots: nil,
band_counts: [],
band_filter: nil,
spots_empty: spots == []
)
|> stream(:spots, spots, reset: true)}
@ -48,15 +51,35 @@ defmodule MicrowavepropWeb.PskrSpotsLive do
@impl true
def handle_info(:refresh_spots, socket) do
_ = schedule_refresh()
band = socket.assigns.band_filter
spots = fetch_recent_spots(band)
socket = start_async(socket, :total_spots, fn -> fetch_total_spots() end)
socket = start_async(socket, :band_counts, fn -> fetch_band_counts() end)
{:noreply,
socket
|> assign(spots_empty: spots == [])
|> stream(:spots, spots, reset: true)}
end
@impl true
def handle_event("filter_band", %{"band" => band}, socket) do
band_filter = if socket.assigns.band_filter == band, do: nil, else: band
spots = fetch_recent_spots(band_filter)
{:noreply,
socket
|> assign(band_filter: band_filter, spots_empty: spots == [])
|> stream(:spots, spots, reset: true)}
end
def handle_event("clear_filter", _params, socket) do
spots = fetch_recent_spots()
{:noreply,
socket
|> assign(
total_spots: fetch_total_spots(),
band_counts: fetch_band_counts(),
spots_empty: spots == []
)
|> assign(band_filter: nil, spots_empty: spots == [])
|> stream(:spots, spots, reset: true)}
end
@ -64,11 +87,13 @@ defmodule MicrowavepropWeb.PskrSpotsLive do
Process.send_after(self(), :refresh_spots, @refresh_ms)
end
defp fetch_recent_spots do
SpotHourly
|> order_by(desc: :last_spot_at)
|> limit(@limit)
|> Repo.all()
defp fetch_recent_spots(band \\ nil) do
query = from(s in SpotHourly, order_by: [desc: s.last_spot_at], limit: @limit)
query =
if band, do: from(s in query, where: s.band == ^band), else: query
Repo.all(query)
end
defp fetch_total_spots do
@ -100,17 +125,41 @@ defmodule MicrowavepropWeb.PskrSpotsLive do
<Layouts.app flash={@flash} current_scope={@current_scope} max_width="max-w-none">
<.header>
PSK Reporter Spots
<:subtitle>Last {@limit} spot aggregates from the MQTT firehose</:subtitle>
<:subtitle>
<%= if @band_filter do %>
Last {@limit} <span class="badge badge-sm">{@band_filter}</span> spot aggregates
<% else %>
Last {@limit} spot aggregates from the MQTT firehose
<% end %>
</:subtitle>
</.header>
<div class="flex flex-wrap items-baseline gap-x-6 gap-y-1 text-sm mb-4">
<span class="font-semibold">
Total spots stored: <span class="font-mono">{Format.number(@total_spots)}</span>
</span>
<span :for={{band, count} <- @band_counts} class="text-base-content/70">
<button
:for={{band, count} <- @band_counts}
phx-click="filter_band"
phx-value-band={band}
class={[
"cursor-pointer transition-colors rounded px-1 -mx-1 py-0.5",
if(@band_filter == band,
do: "bg-primary/15 text-primary font-semibold",
else: "hover:bg-base-300 text-base-content/70"
)
]}
>
<span class="badge badge-sm mr-1">{band}</span>
<span class="font-mono">{Format.number(count)}</span>
</span>
</button>
<button
:if={@band_filter}
phx-click="clear_filter"
class="text-xs text-primary hover:underline cursor-pointer"
>
Clear filter
</button>
</div>
<div class="overflow-x-auto">
@ -152,8 +201,12 @@ defmodule MicrowavepropWeb.PskrSpotsLive do
</table>
</div>
<p :if={@spots_empty && @total_spots == 0} 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 :if={@spots_empty && @total_spots != nil} class="text-sm text-base-content/50 italic mt-4">
<%= if @band_filter do %>
No spots found for band {@band_filter}.
<% else %>
No PSK Reporter spots received yet. The MQTT listener is running spots will appear here as they arrive.
<% end %>
</p>
</Layouts.app>
"""

View file

@ -187,4 +187,108 @@ defmodule MicrowavepropWeb.PskrSpotsLiveTest do
assert html =~ "GRID6"
end
end
describe "band filtering" do
test "filtering by band shows only that band's spots", %{conn: conn} do
insert_spot(%{band: "2m", sender_grid: "GRID2M", last_spot_at: now()})
insert_spot(%{band: "70cm", sender_grid: "GRID70", last_spot_at: now()})
{:ok, lv, _html} = live(conn, ~p"/pskreporter")
# Both bands visible initially
html = render(lv)
assert html =~ "GRID2M"
assert html =~ "GRID70"
# Click the 2m badge
html =
lv
|> element("button[phx-value-band=\"2m\"]")
|> render_click()
assert html =~ "GRID2M"
refute html =~ "GRID70"
# Filter badge should be highlighted
assert html =~ "bg-primary/15"
end
test "clicking the active band again clears the filter", %{conn: conn} do
insert_spot(%{band: "2m", sender_grid: "GRID2M", last_spot_at: now()})
insert_spot(%{band: "70cm", sender_grid: "GRID70", last_spot_at: now()})
{:ok, lv, _html} = live(conn, ~p"/pskreporter")
# Filter to 2m
html =
lv
|> element("button[phx-value-band=\"2m\"]")
|> render_click()
refute html =~ "GRID70"
# Click 2m again to clear
html =
lv
|> element("button[phx-value-band=\"2m\"]")
|> render_click()
assert html =~ "GRID2M"
assert html =~ "GRID70"
end
test "clear filter button appears when filter is active", %{conn: conn} do
insert_spot(%{band: "2m", last_spot_at: now()})
{:ok, lv, _html} = live(conn, ~p"/pskreporter")
# No clear filter button initially
refute render(lv) =~ "Clear filter"
# Filter by band
html =
lv
|> element("button[phx-value-band=\"2m\"]")
|> render_click()
assert html =~ "Clear filter"
# Click clear filter
html =
lv
|> element("button", "Clear filter")
|> render_click()
refute html =~ "Clear filter"
end
test "subtitle reflects active filter", %{conn: conn} do
insert_spot(%{band: "70cm", last_spot_at: now()})
{:ok, lv, html} = live(conn, ~p"/pskreporter")
assert html =~ "from the MQTT firehose"
html =
lv
|> element("button[phx-value-band=\"70cm\"]")
|> render_click()
assert html =~ "70cm"
refute html =~ "from the MQTT firehose"
end
test "empty state shows band name when filtered", %{conn: conn} do
insert_spot(%{band: "2m", last_spot_at: now()})
{:ok, lv, _html} = live(conn, ~p"/pskreporter")
html =
lv
|> element("button[phx-value-band=\"70cm\"]")
|> render_click()
assert html =~ "No spots found for band 70cm"
end
end
end