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(nil, _max), do: "—" defp fmt_snr(_min, nil), do: "—" defp fmt_snr(min, max) when min == max, do: "#{min} dB" defp fmt_snr(min, max), do: "#{min}–#{max} dB" defp fmt_callsigns(nil), do: "—" defp fmt_callsigns([]), do: "—" defp fmt_callsigns(calls), do: Enum.join(calls, ", ") @impl true def render(assigns) do ~H""" <.header> PSK Reporter Spots <:subtitle> <%= if @band_filter do %> Last {@limit} {@band_filter} spot aggregates <% else %> Last {@limit} spot aggregates from the MQTT firehose <% end %>
Total spots stored: {Format.number(@total_spots)}
Last Heard (UTC) Band From From Grid To To Grid Distance Spots SNR Modes
{fmt_dt(spot.last_spot_at)} {spot.band} {fmt_callsigns(spot.sender_callsigns)} {spot.sender_grid} {fmt_callsigns(spot.receiver_callsigns)} {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}

<%= 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 %>

""" end end