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

+ 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()} 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