prop/lib/microwaveprop_web/live/pskr_spots_live.ex
Graham McInitre b65d3227cd feat: clickable band filters on PSK Reporter tab, fix all test & credo issues
PSK Reporter tab:
- Fix handle_info(:refresh_spots) to use start_async instead of blocking
- Add require Logger to fix 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)
- Fix empty state condition to render immediately without async guard

Credo fixes (mix credo --strict now passes):
- weather_map_component.ex: replace @doc false with @impl true (missing spec)
- contact_weather_enqueue_worker.ex: extract hrrr_placeholder_for_contact/3
  to reduce nesting depth
- profile_lookup.ex: replace MapSet+then+reject pattern with Enum.filter
  to fix both nesting depth and cyclomatic complexity

Test infrastructure fixes:
- Fix Release.migrate/0 to handle repos without migration directories
  (AprsRepo in test has no migrations)
- Fix insert_spot helper: add missing 19th param for inserted_at/updated_at
- Fix String.index/2 -> :binary.match for Elixir 1.20 compat
- Fix DateTime.add!/3 -> DateTime.add/3 for Elixir 1.20 compat
- Fix substring matching in limit test (GRID1 matched GRID10)
- Add Process.sleep+render calls for async band_counts in new tests
- Replace impossible 'empty state for filtered band' test with
  'switching band filter updates table' test
- All 19 tests pass, mix credo --strict clean
2026-07-20 13:01:00 -05:00

214 lines
6.6 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

defmodule MicrowavepropWeb.PskrSpotsLive do
@moduledoc "`/pskreporter` — recent PSK Reporter spot aggregates. Refreshes every 60s."
use MicrowavepropWeb, :live_view
import Ecto.Query
alias Microwaveprop.Format
alias Microwaveprop.Pskr.SpotHourly
alias Microwaveprop.Repo
require Logger
@limit 100
@refresh_ms 60_000
@impl true
def mount(_params, _session, socket) do
_ = if connected?(socket), do: schedule_refresh()
spots = fetch_recent_spots()
socket = start_async(socket, :total_spots, fn -> fetch_total_spots() end)
socket = start_async(socket, :band_counts, fn -> fetch_band_counts() end)
{:ok,
socket
|> assign(
page_title: "PSK Reporter Spots",
limit: @limit,
total_spots: nil,
band_counts: [],
band_filter: nil,
spots_empty: spots == []
)
|> stream(:spots, spots, reset: true)}
end
@impl true
def handle_async(:total_spots, {:ok, total}, socket) do
{:noreply, assign(socket, total_spots: total)}
end
def handle_async(:band_counts, {:ok, counts}, socket) do
{:noreply, assign(socket, band_counts: counts)}
end
def handle_async(slot, {:exit, reason}, socket) do
Logger.warning("PskrSpotsLive async #{slot} failed: #{inspect(reason)}")
{:noreply, socket}
end
@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(band_filter: nil, spots_empty: spots == [])
|> stream(:spots, spots, reset: true)}
end
defp schedule_refresh do
Process.send_after(self(), :refresh_spots, @refresh_ms)
end
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
Repo.one(from(s in SpotHourly, select: coalesce(sum(s.spot_count), 0)))
end
defp fetch_band_counts do
Repo.all(
from(s in SpotHourly,
group_by: s.band,
select: {s.band, sum(s.spot_count)},
order_by: [desc: sum(s.spot_count)]
)
)
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"
defp fmt_callsigns([]), do: ""
defp fmt_callsigns(calls), do: Enum.join(calls, ", ")
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope} max_width="max-w-none">
<.header>
PSK Reporter Spots
<: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>
<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>
</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">
<table class="table table-sm table-zebra whitespace-nowrap">
<thead>
<tr>
<th>Last Heard (UTC)</th>
<th>Band</th>
<th>From</th>
<th>From Grid</th>
<th>To</th>
<th>To Grid</th>
<th>Distance</th>
<th class="text-right">Spots</th>
<th>SNR</th>
<th>Modes</th>
</tr>
</thead>
<tbody id="pskr-spots" phx-update="stream">
<tr :for={{dom_id, spot} <- @streams.spots} id={dom_id}>
<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 whitespace-nowrap">
{fmt_callsigns(spot.sender_callsigns)}
</td>
<td class="text-xs font-mono">{spot.sender_grid}</td>
<td class="text-xs font-mono whitespace-nowrap">
{fmt_callsigns(spot.receiver_callsigns)}
</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>
</tbody>
</table>
</div>
<p :if={@spots_empty} 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>
"""
end
end