From 9ac275203c3d83556a9f751691aedf85c49fec52 Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 10 Feb 2026 22:31:40 +0100 Subject: [PATCH] Add StatisticsLive: overview, bars by year, pie chart - Summary cards: active/inactive members, open amount - Joins and exits by year (horizontal bars) - Contributions by year: table with stacked bar above amounts - Column order: Paid, Unpaid, Suspended, Total; color dots for legend - All years combined pie chart - LiveView tests --- lib/mv_web/live/statistics_live.ex | 498 ++++++++++++++++++++++ test/mv_web/live/statistics_live_test.exs | 32 ++ 2 files changed, 530 insertions(+) create mode 100644 lib/mv_web/live/statistics_live.ex create mode 100644 test/mv_web/live/statistics_live_test.exs diff --git a/lib/mv_web/live/statistics_live.ex b/lib/mv_web/live/statistics_live.ex new file mode 100644 index 0000000..5fcbc13 --- /dev/null +++ b/lib/mv_web/live/statistics_live.ex @@ -0,0 +1,498 @@ +defmodule MvWeb.StatisticsLive do + @moduledoc """ + LiveView for the statistics page at /statistics. + + Displays aggregated member and membership fee cycle statistics. + """ + use MvWeb, :live_view + + import MvWeb.LiveHelpers, only: [current_actor: 1] + alias Mv.Statistics + alias MvWeb.Helpers.MembershipFeeHelpers + + @impl true + def mount(_params, _session, socket) do + socket = + socket + |> load_statistics() + |> assign(:page_title, gettext("Statistics")) + + {:ok, socket} + end + + @impl true + def handle_params(_params, _uri, socket) do + {:noreply, load_statistics(socket)} + end + + @impl true + def render(assigns) do + ~H""" + + <.header> + {gettext("Statistics")} + <:subtitle>{gettext("Overview from first membership to today")} + + +
+
+
+

+ {gettext("Active members")} +

+

": " <> to_string(@active_count)} + > + {@active_count} +

+
+
+
+
+

+ {gettext("Inactive members")} +

+

": " <> to_string(@inactive_count)} + > + {@inactive_count} +

+
+
+
+
+

+ {gettext("Open amount")} +

+

": " <> MembershipFeeHelpers.format_currency(@open_amount_total)} + > + {MembershipFeeHelpers.format_currency(@open_amount_total)} +

+
+
+
+ +
+
+

+ {gettext("Joins and exits by year")} +

+

+ {gettext("From %{first} to %{last} (relevant years with membership data)", + first: @years |> List.last() |> to_string(), + last: @years |> List.first() |> to_string() + )} +

+ <.joins_exits_bars joins_exits_by_year={@joins_exits_by_year} /> +
+
+ +
+
+

+ {gettext("Contributions by year")} +

+
+
+ <.contributions_bars_by_year + contributions_by_year={@contributions_by_year} + totals_over_all_years={@totals_over_all_years} + /> +
+
+

{gettext("All years combined (pie)")}

+ <.contributions_pie cycle_totals={@totals_over_all_years} /> +

+ + {gettext("Paid")} + + + {gettext("Unpaid")} + + + {gettext("Suspended")} +

+
+
+
+
+
+ """ + end + + attr :joins_exits_by_year, :list, required: true + + defp joins_exits_bars(assigns) do + join_values = Enum.map(assigns.joins_exits_by_year, & &1.joins) + exit_values = Enum.map(assigns.joins_exits_by_year, & &1.exits) + max_joins = max((join_values != [] && Enum.max(join_values)) || 0, 1) + max_exits = max((exit_values != [] && Enum.max(exit_values)) || 0, 1) + + assigns = assign(assigns, :max_joins, max_joins) + assigns = assign(assigns, :max_exits, max_exits) + + ~H""" + + """ + end + + attr :contributions_by_year, :list, required: true + attr :totals_over_all_years, :map, required: true + + defp contributions_bars_by_year(assigns) do + rows = assigns.contributions_by_year + totals = assigns.totals_over_all_years + + all_rows_with_decimals = + Enum.map(rows, fn row -> + %{ + year: row.year, + summary: false, + total: row.total, + paid: row.paid, + unpaid: row.unpaid, + suspended: row.suspended + } + end) ++ + [ + %{ + year: nil, + summary: true, + total: totals.total, + paid: totals.paid, + unpaid: totals.unpaid, + suspended: totals.suspended + } + ] + + max_total = max_decimal(all_rows_with_decimals, :total) + + rows_with_pct = + Enum.map(all_rows_with_decimals, fn row -> + bar_pct = bar_pct(row.total, max_total) + + sum_positive = + Decimal.add(Decimal.add(row.paid, row.unpaid), row.suspended) + + seg_scale = + if Decimal.compare(sum_positive, 0) == :gt, do: sum_positive, else: Decimal.new(1) + + paid_pct = + row.paid |> Decimal.div(seg_scale) |> Decimal.mult(100) |> Decimal.to_float() + + unpaid_pct = + row.unpaid |> Decimal.div(seg_scale) |> Decimal.mult(100) |> Decimal.to_float() + + suspended_pct = + row.suspended |> Decimal.div(seg_scale) |> Decimal.mult(100) |> Decimal.to_float() + + %{ + year: row.year, + summary: row.summary, + total_formatted: MembershipFeeHelpers.format_currency(row.total), + paid_formatted: MembershipFeeHelpers.format_currency(row.paid), + unpaid_formatted: MembershipFeeHelpers.format_currency(row.unpaid), + suspended_formatted: MembershipFeeHelpers.format_currency(row.suspended), + bar_pct: bar_pct, + paid_pct: paid_pct, + unpaid_pct: unpaid_pct, + suspended_pct: suspended_pct + } + end) + + assigns = assign(assigns, :rows, rows_with_pct) + + ~H""" + + """ + end + + defp max_decimal(rows, key) do + Enum.reduce(rows, Decimal.new(0), fn row, acc -> + val = Map.get(row, key) + if Decimal.compare(val, acc) == :gt, do: val, else: acc + end) + end + + defp bar_pct(value, max) do + scale = if Decimal.compare(max, 0) == :gt, do: max, else: Decimal.new(1) + value |> Decimal.div(scale) |> Decimal.mult(100) |> Decimal.to_float() + end + + attr :cycle_totals, :map, required: true + + defp contributions_pie(assigns) do + paid = assigns.cycle_totals.paid + unpaid = assigns.cycle_totals.unpaid + suspended = assigns.cycle_totals.suspended + + sum_positive = Decimal.add(Decimal.add(paid, unpaid), suspended) + scale = if Decimal.compare(sum_positive, 0) == :gt, do: sum_positive, else: Decimal.new(1) + + paid_pct = Decimal.div(paid, scale) |> Decimal.mult(100) |> Decimal.to_float() + unpaid_pct = Decimal.div(unpaid, scale) |> Decimal.mult(100) |> Decimal.to_float() + suspended_pct = Decimal.div(suspended, scale) |> Decimal.mult(100) |> Decimal.to_float() + + # Conic gradient: 0deg = top, clockwise. Success (paid), warning (unpaid), base-300 (suspended) + # Use theme CSS variables (--color-*) so the pie renders in all themes + paid_deg = paid_pct * 3.6 + unpaid_deg = unpaid_pct * 3.6 + + gradient_stops = + "var(--color-success) 0deg, var(--color-success) #{paid_deg}deg, var(--color-warning) #{paid_deg}deg, var(--color-warning) #{paid_deg + unpaid_deg}deg, var(--color-base-300) #{paid_deg + unpaid_deg}deg, var(--color-base-300) 360deg" + + assigns = + assigns + |> assign(:paid_pct, paid_pct) + |> assign(:unpaid_pct, unpaid_pct) + |> assign(:suspended_pct, suspended_pct) + |> assign(:gradient_stops, gradient_stops) + + ~H""" + + """ + end + + defp load_statistics(socket) do + actor = current_actor(socket) + opts = [actor: actor] + + current_year = Date.utc_today().year + first_year = Statistics.first_join_year(opts) || current_year + years = first_year..current_year |> Enum.to_list() |> Enum.reverse() + + active_count = Statistics.active_member_count(opts) + inactive_count = Statistics.inactive_member_count(opts) + open_amount_total = Statistics.open_amount_total(opts) + joins_exits_by_year = build_joins_exits_by_year(years, opts) + contributions_by_year = build_contributions_by_year(years, opts) + totals_over_all_years = sum_cycle_totals(contributions_by_year) + + assign(socket, + years: years, + active_count: active_count, + inactive_count: inactive_count, + open_amount_total: open_amount_total, + joins_exits_by_year: joins_exits_by_year, + contributions_by_year: contributions_by_year, + totals_over_all_years: totals_over_all_years + ) + end + + defp build_joins_exits_by_year(years, opts) do + Enum.map(years, fn y -> + %{ + year: y, + joins: Statistics.joins_by_year(y, opts), + exits: Statistics.exits_by_year(y, opts) + } + end) + end + + defp build_contributions_by_year(years, opts) do + Enum.map(years, fn y -> + totals = Statistics.cycle_totals_by_year(y, opts) + + %{ + year: y, + total: totals.total, + paid: totals.paid, + unpaid: totals.unpaid, + suspended: totals.suspended + } + end) + end + + defp sum_cycle_totals(contributions_by_year) do + Enum.reduce( + contributions_by_year, + %{ + total: Decimal.new(0), + paid: Decimal.new(0), + unpaid: Decimal.new(0), + suspended: Decimal.new(0) + }, + fn row, acc -> + %{ + total: Decimal.add(acc.total, row.total), + paid: Decimal.add(acc.paid, row.paid), + unpaid: Decimal.add(acc.unpaid, row.unpaid), + suspended: Decimal.add(acc.suspended, row.suspended) + } + end + ) + end +end diff --git a/test/mv_web/live/statistics_live_test.exs b/test/mv_web/live/statistics_live_test.exs new file mode 100644 index 0000000..211fe61 --- /dev/null +++ b/test/mv_web/live/statistics_live_test.exs @@ -0,0 +1,32 @@ +defmodule MvWeb.StatisticsLiveTest do + @moduledoc """ + Tests for the Statistics LiveView at /statistics. + """ + use MvWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + + describe "statistics page" do + test "renders statistics page with title and key labels for authenticated user with access", + %{ + conn: conn + } do + {:ok, _view, html} = live(conn, ~p"/statistics") + + assert html =~ "Statistics" + assert html =~ "Active members" + assert html =~ "Open amount" + assert html =~ "Contributions by year" + assert html =~ "Joins and exits by year" + end + + test "page shows overview of all relevant years without year selector", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/statistics") + + # No year dropdown: single select for year should not be present as main control + assert html =~ "Overview" or html =~ "overview" + # table header or legend + assert html =~ "Year" + end + end +end