From 56e9755a102f2a102a32bdfd26fa6a11b21b0b36 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Tue, 5 May 2026 09:35:08 -0500 Subject: [PATCH] feat(contacts): per-month bar chart on the /contacts page Aggregates contact counts by calendar month summed across every year, rendered as an inline SVG bar chart above the table. Months with no contacts render as a 0-height tick so the x-axis stays continuous at 12 bars. Respects the same scope visibility the table uses, so private contacts a viewer can't see don't leak into the chart counts. Each carries data-month / data-month-count for testability and a for hover. --- .../live/contact_live/index.ex | 91 +++++++++++++++++++ .../live/contact_live_test.exs | 20 ++++ 2 files changed, 111 insertions(+) diff --git a/lib/microwaveprop_web/live/contact_live/index.ex b/lib/microwaveprop_web/live/contact_live/index.ex index 6624ebc1..268868c3 100644 --- a/lib/microwaveprop_web/live/contact_live/index.ex +++ b/lib/microwaveprop_web/live/contact_live/index.ex @@ -50,6 +50,16 @@ defmodule MicrowavepropWeb.ContactLive.Index do ] 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] @@ -60,10 +70,44 @@ defmodule MicrowavepropWeb.ContactLive.Index do socket |> assign(:page_title, "Contacts") |> assign(:total_contacts, total) + |> assign(:monthly_bars, monthly_bars(scope)) + |> assign(:chart_baseline, @chart_baseline) + |> assign(:chart_bar_width, @chart_bar_width) |> assign(:visible_fields, visible_fields_for(scope)) |> assign(:data_provider, {__MODULE__, :visible_query_provider, [scope_token(scope)]})} end + defp monthly_bars(scope) do + by_month = + scope + |> visible_query() + |> 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 + + 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. @@ -193,6 +237,53 @@ defmodule MicrowavepropWeb.ContactLive.Index do </:actions> </.header> + <section class="bg-base-200 rounded-lg p-4 mb-6"> + <h3 class="text-sm font-semibold opacity-70 uppercase tracking-wide mb-3"> + Contacts by month <span class="text-xs font-normal opacity-60">(all years combined)</span> + </h3> + <svg viewBox="0 0 612 220" class="w-full h-48" role="img" aria-label="Monthly contact counts"> + <line + x1="0" + y1={@chart_baseline} + x2="612" + y2={@chart_baseline} + class="stroke-base-content/30" + stroke-width="1" + /> + <%= for bar <- @monthly_bars do %> + <rect + x={bar.x} + y={bar.y} + width={@chart_bar_width} + height={bar.height} + class="fill-primary" + data-month={bar.month} + data-month-count={bar.count} + > + <title>{bar.label}: {bar.count} + + + {bar.label} + + <%= if bar.count > 0 do %> + + {bar.count} + + <% end %> + <% end %> + + + <.live_table fields={@visible_fields} filters={filters()} diff --git a/test/microwaveprop_web/live/contact_live_test.exs b/test/microwaveprop_web/live/contact_live_test.exs index dc57ffac..f4d9e7eb 100644 --- a/test/microwaveprop_web/live/contact_live_test.exs +++ b/test/microwaveprop_web/live/contact_live_test.exs @@ -116,6 +116,26 @@ defmodule MicrowavepropWeb.ContactLiveTest do {:ok, _lv, html2} = live(conn, ~p"/contacts?page=2") assert html2 =~ "Page" end + + test "renders a per-month bar chart that sums across years", %{conn: conn} do + # Two March contacts in different years (2025, 2026) plus one + # July: chart should report Mar=2 and Jul=1. + create_contact(%{qso_timestamp: ~U[2025-03-15 12:00:00Z], station1: "W5MAR"}) + create_contact(%{qso_timestamp: ~U[2026-03-22 12:00:00Z], station1: "K5MAR"}) + create_contact(%{qso_timestamp: ~U[2026-07-10 12:00:00Z], station1: "W5JUL"}) + + {:ok, _lv, html} = live(conn, ~p"/contacts") + + assert html =~ "Contacts by month" + assert html =~ ~s|data-month="3"| + assert html =~ ~s|data-month-count="2"| + assert html =~ ~s|data-month="7"| + assert html =~ ~s|data-month-count="1"| + # Months without contacts still render with a 0 count so the + # x-axis stays continuous. + assert html =~ ~s|data-month="1"| + assert html =~ ~s|data-month-count="0"| + end end describe "Index private visibility" do