diff --git a/lib/mv/statistics.ex b/lib/mv/statistics.ex
index e11606e..b3a9a50 100644
--- a/lib/mv/statistics.ex
+++ b/lib/mv/statistics.ex
@@ -121,9 +121,14 @@ defmodule Mv.Statistics do
MembershipFeeCycle
|> Ash.Query.filter(expr(cycle_start >= ^first_day and cycle_start <= ^last_day))
- opts_with_domain = Keyword.put(opts, :domain, MembershipFees)
+ query = maybe_filter_by_fee_type(query, opts)
+ # Only pass actor and domain to Ash.read; fee_type_id is only for our filter above
+ opts_for_read =
+ opts
+ |> Keyword.drop([:fee_type_id])
+ |> Keyword.put(:domain, MembershipFees)
- case Ash.read(query, opts_with_domain) do
+ case Ash.read(query, opts_for_read) do
{:ok, cycles} -> cycle_totals_from_cycles(cycles)
{:error, _} -> zero_cycle_totals()
end
@@ -158,6 +163,24 @@ defmodule Mv.Statistics do
}
end
+ defp maybe_filter_by_fee_type(query, opts) do
+ case Keyword.get(opts, :fee_type_id) do
+ nil ->
+ query
+
+ id when is_binary(id) ->
+ # Only apply filter for valid UUID strings (e.g. from form/URL)
+ if Ecto.UUID.cast(id) != :error do
+ Ash.Query.filter(query, expr(membership_fee_type_id == ^id))
+ else
+ query
+ end
+
+ id ->
+ Ash.Query.filter(query, expr(membership_fee_type_id == ^id))
+ end
+ end
+
@doc """
Returns the sum of amount for all cycles with status :unpaid.
"""
@@ -167,9 +190,14 @@ defmodule Mv.Statistics do
MembershipFeeCycle
|> Ash.Query.filter(expr(status == :unpaid))
- opts_with_domain = Keyword.put(opts, :domain, MembershipFees)
+ query = maybe_filter_by_fee_type(query, opts)
- case Ash.read(query, opts_with_domain) do
+ opts_for_read =
+ opts
+ |> Keyword.drop([:fee_type_id])
+ |> Keyword.put(:domain, MembershipFees)
+
+ case Ash.read(query, opts_for_read) do
{:ok, cycles} ->
Enum.reduce(cycles, Decimal.new(0), fn c, acc -> Decimal.add(acc, c.amount) end)
diff --git a/lib/mv_web/live/statistics_live.ex b/lib/mv_web/live/statistics_live.ex
index 5fcbc13..edd416b 100644
--- a/lib/mv_web/live/statistics_live.ex
+++ b/lib/mv_web/live/statistics_live.ex
@@ -8,23 +8,49 @@ defmodule MvWeb.StatisticsLive do
import MvWeb.LiveHelpers, only: [current_actor: 1]
alias Mv.Statistics
+ alias Mv.MembershipFees.MembershipFeeType
alias MvWeb.Helpers.MembershipFeeHelpers
@impl true
def mount(_params, _session, socket) do
socket =
socket
- |> load_statistics()
|> assign(:page_title, gettext("Statistics"))
+ |> assign(:selected_fee_type_id, nil)
+ |> load_fee_types()
+ |> load_statistics()
{:ok, socket}
end
@impl true
- def handle_params(_params, _uri, socket) do
- {:noreply, load_statistics(socket)}
+ def handle_params(params, uri, socket) do
+ # Query params: after push_patch, params may not include query string in some cases;
+ # always derive from URI as well so fee_type_id is reliable.
+ uri_query = if uri, do: URI.decode_query(URI.parse(uri).query || ""), else: %{}
+ fee_type_id = params["fee_type_id"] || uri_query["fee_type_id"]
+ fee_type_id = normalize_fee_type_id(fee_type_id)
+
+ socket =
+ socket
+ |> assign(:selected_fee_type_id, fee_type_id)
+ |> load_statistics()
+
+ {:noreply, socket}
end
+ defp normalize_fee_type_id(nil), do: nil
+ defp normalize_fee_type_id(""), do: nil
+
+ defp normalize_fee_type_id(id) when is_binary(id) do
+ case String.trim(id) do
+ "" -> nil
+ trimmed -> trimmed
+ end
+ end
+
+ defp normalize_fee_type_id(_), do: nil
+
@impl true
def render(assigns) do
~H"""
@@ -34,94 +60,104 @@ defmodule MvWeb.StatisticsLive do
<:subtitle>{gettext("Overview from first membership to today")}
-
-
-
-
- {gettext("Active members")}
-
-
": " <> to_string(@active_count)}
- >
- {@active_count}
-
+
+ {gettext("Members")}
+
+
+
+
+ {gettext("Active members")}
+
+
": " <> to_string(@active_count)}
+ >
+ {@active_count}
+
+
+
+
+
+
+ {gettext("Inactive members")}
+
+
": " <> to_string(@inactive_count)}
+ >
+ {@inactive_count}
+
+
-
-
- {gettext("Inactive members")}
-
-
": " <> to_string(@inactive_count)}
- >
- {@inactive_count}
+
+
{gettext("Member numbers by year")}
+
+ {gettext("From %{first} to %{last} (relevant years with membership data)",
+ first: @years |> List.last() |> to_string(),
+ last: @years |> List.first() |> to_string()
+ )}
+ <.member_numbers_table joins_exits_by_year={@joins_exits_by_year} />
-
-
-
- {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")}
-
+
+
+ {gettext("Contributions")}
+
+
+
+
+
{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")}
+
+
@@ -130,70 +166,117 @@ defmodule MvWeb.StatisticsLive do
"""
end
+ @impl true
+ def handle_event("change_fee_type", %{"fee_type_id" => ""}, socket) do
+ {:noreply, push_patch(socket, to: ~p"/statistics")}
+ end
+
+ def handle_event("change_fee_type", %{"fee_type_id" => id}, socket) do
+ {:noreply, push_patch(socket, to: ~p"/statistics?fee_type_id=#{id}")}
+ 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)
+ defp member_numbers_table(assigns) do
+ rows = assigns.joins_exits_by_year
+ total_activity = Enum.map(rows, fn r -> r.joins + r.exits end)
+ max_total = (total_activity != [] && Enum.max(total_activity)) || 1
- assigns = assign(assigns, :max_joins, max_joins)
- assigns = assign(assigns, :max_exits, max_exits)
+ rows_with_pct =
+ Enum.map(rows, fn row ->
+ sum = row.joins + row.exits
+
+ bar_pct =
+ if max_total > 0 and sum > 0, do: Float.round(sum / max_total * 100, 1), else: 0
+
+ seg_scale = max(sum, 1)
+ joins_pct = row.joins / seg_scale * 100
+ exits_pct = row.exits / seg_scale * 100
+
+ %{
+ year: row.year,
+ joins: row.joins,
+ exits: row.exits,
+ bar_pct: bar_pct,
+ joins_pct: Float.round(joins_pct, 1),
+ exits_pct: Float.round(exits_pct, 1)
+ }
+ end)
+
+ assigns = assign(assigns, :rows, rows_with_pct)
~H"""
-
-
-
- {gettext("Joins")}
-
-
-
- {gettext("Exits")}
-
-
-
- <%= for item <- @joins_exits_by_year do %>
-
-
{item.year}
-
-
-
{item.joins}
-
+
+
+
+ | {gettext("Year")} |
+ {gettext("Joins")} |
+ {gettext("Exits")} |
+
+
+
+ <%= for row <- @rows do %>
+
+ |
+ {row.year}
+ |
+
+
-
- |
+
+ |
+
+ {row.joins}
+
-
-
-
-
-
- <% end %>
-
+
+
+ |
+
+
+ {row.exits}
+
+
+
+ |
+
+ <% end %>
+
+
"""
end
@@ -426,26 +509,40 @@ defmodule MvWeb.StatisticsLive do
"""
end
+ defp load_fee_types(socket) do
+ actor = current_actor(socket)
+
+ fee_types =
+ MembershipFeeType
+ |> Ash.Query.sort(name: :asc)
+ |> Ash.read!(domain: Mv.MembershipFees, actor: actor)
+
+ assign(socket, :membership_fee_types, fee_types)
+ end
+
defp load_statistics(socket) do
actor = current_actor(socket)
- opts = [actor: actor]
+ fee_type_id = socket.assigns[:selected_fee_type_id]
+ # Member stats must never depend on fee type (only contributions do)
+ opts_member = [actor: actor]
+
+ opts_contributions =
+ [actor: actor] ++ if fee_type_id, do: [fee_type_id: fee_type_id], else: []
current_year = Date.utc_today().year
- first_year = Statistics.first_join_year(opts) || current_year
+ first_year = Statistics.first_join_year(opts_member) || 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)
+ active_count = Statistics.active_member_count(opts_member)
+ inactive_count = Statistics.inactive_member_count(opts_member)
+ joins_exits_by_year = build_joins_exits_by_year(years, opts_member)
+ contributions_by_year = build_contributions_by_year(years, opts_contributions)
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
diff --git a/test/mv_web/live/statistics_live_test.exs b/test/mv_web/live/statistics_live_test.exs
index 211fe61..8681bb8 100644
--- a/test/mv_web/live/statistics_live_test.exs
+++ b/test/mv_web/live/statistics_live_test.exs
@@ -15,9 +15,9 @@ defmodule MvWeb.StatisticsLiveTest do
assert html =~ "Statistics"
assert html =~ "Active members"
- assert html =~ "Open amount"
+ assert html =~ "Unpaid"
assert html =~ "Contributions by year"
- assert html =~ "Joins and exits by year"
+ assert html =~ "Member numbers by year"
end
test "page shows overview of all relevant years without year selector", %{conn: conn} do