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 <rect> carries data-month / data-month-count for testability
and a <title> for hover.
This commit is contained in:
Graham McIntire 2026-05-05 09:35:08 -05:00
parent 1a3dc4fa0a
commit 56e9755a10
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
2 changed files with 111 additions and 0 deletions

View file

@ -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}</title>
</rect>
<text
x={bar.x + @chart_bar_width / 2}
y={@chart_baseline + 16}
text-anchor="middle"
class="fill-base-content text-xs"
>
{bar.label}
</text>
<%= if bar.count > 0 do %>
<text
x={bar.x + @chart_bar_width / 2}
y={bar.y - 4}
text-anchor="middle"
class="fill-base-content text-xs"
>
{bar.count}
</text>
<% end %>
<% end %>
</svg>
</section>
<.live_table
fields={@visible_fields}
filters={filters()}

View file

@ -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