defmodule MicrowavepropWeb.ContactLive.Index do @moduledoc "`/contacts` table with filter/sort + pagination over QSO records." use MicrowavepropWeb, :live_view use MicrowavepropWeb.LiveTableResource, schema: Microwaveprop.Radio.Contact import Ecto.Query alias Microwaveprop.Accounts.Scope alias Microwaveprop.Accounts.User alias Microwaveprop.Cache alias Microwaveprop.Radio.Contact alias Microwaveprop.Repo @spec table_options() :: map() def table_options do %{ sorting: %{default_sort: [inserted_at: :desc]}, exports: %{formats: [:csv]} } end @spec fields() :: keyword() def fields do [ id: %{label: "ID", hidden: true}, station1: %{label: "Station 1", sortable: true, searchable: true}, grid1: %{label: "Grid 1", sortable: true, searchable: true}, station2: %{label: "Station 2", sortable: true, searchable: true}, grid2: %{label: "Grid 2", sortable: true, searchable: true}, band: %{label: "Band", sortable: true, renderer: &band_cell/1}, mode: %{label: "Mode", sortable: true, searchable: true}, distance_km: %{label: "Distance", sortable: true, renderer: &distance_cell/1}, hrrr_status: %{label: "Enriched", renderer: &enrichment_cell/2}, qso_timestamp: %{label: "QSO (UTC)", sortable: true, renderer: &format_ts/1}, inserted_at: %{label: "Added (UTC)", sortable: true, renderer: &format_ts/1}, private: %{label: "Private", sortable: true, renderer: &private_cell/1} ] end @spec filters() :: list() def filters, do: [] @spec actions() :: keyword() def actions do [ view: fn %{record: contact} -> assigns = %{contact: contact} ~H""" <.link navigate={~p"/contacts/#{@contact.id}"} class="btn btn-xs btn-ghost"> View """ end ] end # Monthly chart geometry. Module attributes so the SVG layout # constants stay in one place; assigns wire the values that the # template renders into the markup. @month_labels ~w(Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec) @chart_max_bar_height 150 @chart_bar_width 36 @chart_bar_pitch 50 @chart_bar_offset 12 @chart_baseline 180 @impl true def mount(_params, _session, socket) do scope = socket.assigns[:current_scope] base_query = visible_query(scope) total = Repo.aggregate(base_query, :count, :id) {:ok, socket |> assign(:page_title, "Contacts") |> assign(:total_contacts, total) |> assign(:monthly_bars, monthly_bars()) |> assign(:chart_baseline, @chart_baseline) |> assign(:chart_bar_width, @chart_bar_width) |> assign(:visible_fields, visible_fields_for(scope))} end @monthly_bars_cache_key {__MODULE__, :monthly_bars} @monthly_bars_ttl_ms 60_000 # Counts every contact in the corpus regardless of viewer scope — # the chart is a public-facing seasonal summary, not a personal # view, so private contacts roll into the per-month totals like # any other. Cached for 60s since data only changes on new inserts. defp monthly_bars do Cache.fetch_or_store(@monthly_bars_cache_key, @monthly_bars_ttl_ms, fn -> by_month = Contact |> where([c], not is_nil(c.qso_timestamp)) |> group_by([c], fragment("EXTRACT(MONTH FROM ?)::int", c.qso_timestamp)) |> select([c], {fragment("EXTRACT(MONTH FROM ?)::int", c.qso_timestamp), count(c.id)}) |> Repo.all() |> Map.new() counts = Enum.map(1..12, fn m -> {m, Map.get(by_month, m, 0)} end) max_count = counts |> Enum.map(&elem(&1, 1)) |> Enum.max() Enum.map(counts, fn {month, count} -> x = (month - 1) * @chart_bar_pitch + @chart_bar_offset height = bar_height(count, max_count) %{ month: month, label: Enum.at(@month_labels, month - 1), count: count, x: x, y: @chart_baseline - height, height: height } end) end) end defp bar_height(_count, 0), do: 0 defp bar_height(count, max_count), do: round(count / max_count * @chart_max_bar_height) # Hide the Private column entirely unless this scope can actually see # at least one private contact — avoids an always-empty column for # visitors and owners who've never flagged one as private. defp visible_fields_for(scope) do if has_viewable_private?(scope) do fields() else Keyword.delete(fields(), :private) end end defp has_viewable_private?(%Scope{user: %User{is_admin: true}}) do Repo.exists?(from(c in Contact, where: c.private == true)) end defp has_viewable_private?(%Scope{user: %User{id: user_id}}) do Repo.exists?(from(c in Contact, where: c.private == true and c.user_id == ^user_id)) end defp has_viewable_private?(_), do: false @doc false # Called by LiveTable to get the base queryable. Must be a # named function because the helper persists MFA across renders. @spec visible_query_provider(scope_token :: term()) :: Ecto.Query.t() def visible_query_provider(scope_token) do scope_token |> scope_from_token() |> visible_query() |> from(as: :resource) end defp visible_query(%Scope{user: %User{is_admin: true}}), do: Contact defp visible_query(%Scope{user: %User{id: user_id}}) do from(c in Contact, where: c.private == false or c.user_id == ^user_id) end defp visible_query(_), do: from(c in Contact, where: c.private == false) # The data_provider MFA is serialized into the socket; a raw %Scope{} # with associations isn't safe to store there. Rehydrate the minimal # scope from the persisted token on each invocation. defp scope_from_token({id, true}), do: %Scope{user: %User{id: id, is_admin: true}} defp scope_from_token({id, false}), do: %Scope{user: %User{id: id, is_admin: false}} defp scope_from_token(nil), do: nil defp private_cell(true) do assigns = %{} ~H""" Yes """ end defp private_cell(_), do: "" @enrichment_fields [ {:hrrr_status, "HRRR"}, {:weather_status, "Weather"}, {:terrain_status, "Terrain"}, {:iemre_status, "IEMRE"} ] @done_statuses [:complete, :unavailable] defp enrichment_cell(_value, contact) do details = Enum.map(@enrichment_fields, fn {field, name} -> {name, Map.get(contact, field)} end) done = Enum.count(details, fn {_, s} -> s in @done_statuses end) failed = Enum.count(details, fn {_, s} -> s == :failed end) {label, class} = cond do done == 4 -> {"Complete", "badge-success"} failed > 0 -> {"Failed", "badge-error"} done > 0 -> {"Partial", "badge-warning"} true -> {"Pending", "badge-ghost"} end tooltip = Enum.map_join(details, ", ", fn {name, status} -> "#{name}: #{status}" end) assigns = %{label: label, class: class, tooltip: tooltip} ~H""" {@label} """ end defp band_cell(nil), do: "" defp band_cell(%Decimal{} = d), do: Decimal.to_string(d, :normal) defp band_cell(other), do: to_string(other) defp distance_cell(nil), do: "" defp distance_cell(km), do: Microwaveprop.Format.distance_km(km) defp format_count(n) when is_integer(n) do n |> Integer.to_string() |> String.reverse() |> String.replace(~r/(\d{3})(?=\d)/, "\\1,") |> String.reverse() end defp format_ts(nil), do: "" defp format_ts(%DateTime{} = dt), do: Calendar.strftime(dt, "%Y-%m-%d %H:%M") defp format_ts(%NaiveDateTime{} = dt), do: Calendar.strftime(dt, "%Y-%m-%d %H:%M") defp format_ts(other), do: to_string(other) @impl true def render(assigns) do ~H""" <.header> Contacts <:subtitle> {format_count(@total_contacts)} total — sort or search by callsign, grid, or mode. <:actions> <.link navigate={~p"/submit"} class="btn btn-primary"> <.icon name="hero-plus" class="w-5 h-5" /> Submit Contact

Contacts by month (all years combined)

<%= for bar <- @monthly_bars do %> {bar.label}: {bar.count} {bar.label} <%= if bar.count > 0 do %> {bar.count} <% end %> <% end %>
<.live_table fields={@visible_fields} filters={filters()} options={@options} streams={@streams} actions={actions()} />
""" end end