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:
parent
1a3dc4fa0a
commit
56e9755a10
2 changed files with 111 additions and 0 deletions
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue