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.Pskr.SpotHourly
alias Microwaveprop.Repo alias Microwaveprop.Repo
require Logger
@limit 100 @limit 100
@refresh_ms 60_000 @refresh_ms 60_000
@ -26,6 +28,7 @@ defmodule MicrowavepropWeb.PskrSpotsLive do
limit: @limit, limit: @limit,
total_spots: nil, total_spots: nil,
band_counts: [], band_counts: [],
band_filter: nil,
spots_empty: spots == [] spots_empty: spots == []
) )
|> stream(:spots, spots, reset: true)} |> stream(:spots, spots, reset: true)}
@ -48,15 +51,35 @@ defmodule MicrowavepropWeb.PskrSpotsLive do
@impl true @impl true
def handle_info(:refresh_spots, socket) do def handle_info(:refresh_spots, socket) do
_ = schedule_refresh() _ = 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() spots = fetch_recent_spots()
{:noreply, {:noreply,
socket socket
|> assign( |> assign(band_filter: nil, spots_empty: spots == [])
total_spots: fetch_total_spots(),
band_counts: fetch_band_counts(),
spots_empty: spots == []
)
|> stream(:spots, spots, reset: true)} |> stream(:spots, spots, reset: true)}
end end
@ -64,11 +87,13 @@ defmodule MicrowavepropWeb.PskrSpotsLive do
Process.send_after(self(), :refresh_spots, @refresh_ms) Process.send_after(self(), :refresh_spots, @refresh_ms)
end end
defp fetch_recent_spots do defp fetch_recent_spots(band \\ nil) do
SpotHourly query = from(s in SpotHourly, order_by: [desc: s.last_spot_at], limit: @limit)
|> order_by(desc: :last_spot_at)
|> limit(@limit) query =
|> Repo.all() if band, do: from(s in query, where: s.band == ^band), else: query
Repo.all(query)
end end
defp fetch_total_spots do 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"> <Layouts.app flash={@flash} current_scope={@current_scope} max_width="max-w-none">
<.header> <.header>
PSK Reporter Spots 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> </.header>
<div class="flex flex-wrap items-baseline gap-x-6 gap-y-1 text-sm mb-4"> <div class="flex flex-wrap items-baseline gap-x-6 gap-y-1 text-sm mb-4">
<span class="font-semibold"> <span class="font-semibold">
Total spots stored: <span class="font-mono">{Format.number(@total_spots)}</span> Total spots stored: <span class="font-mono">{Format.number(@total_spots)}</span>
</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="badge badge-sm mr-1">{band}</span>
<span class="font-mono">{Format.number(count)}</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>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
@ -152,8 +201,12 @@ defmodule MicrowavepropWeb.PskrSpotsLive do
</table> </table>
</div> </div>
<p :if={@spots_empty && @total_spots == 0} class="text-sm text-base-content/50 italic mt-4"> <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. No PSK Reporter spots received yet. The MQTT listener is running spots will appear here as they arrive.
<% end %>
</p> </p>
</Layouts.app> </Layouts.app>
""" """

View file

@ -187,4 +187,108 @@ defmodule MicrowavepropWeb.PskrSpotsLiveTest do
assert html =~ "GRID6" assert html =~ "GRID6"
end end
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 end