- CODE_GUIDELINES.md and feature-roadmap.md - Add statistics-page-implementation-plan.md Co-authored-by: Cursor <cursoragent@cursor.com>
9.5 KiB
Statistics Page – Implementation Plan
Project: Mila – Membership Management System
Feature: Statistics page at /statistics
Scope: MVP only (no export, no optional extensions)
Last updated: 2026-02-10
Decisions (from clarification)
| Topic | Decision |
|---|---|
| Route | /statistics |
| Navigation | Top-level menu (next to Members, Fee Types) |
| Permission | read_only, normal_user, admin (same as member list) |
| Charts | HTML/CSS and SVG only (no Contex, no Chart.js) |
| MVP scope | Minimal: active/inactive, joins/exits per year, contribution sums per year, open amount |
| Open amount | Total unpaid only (no overdue vs. not-yet-due split in MVP) |
Excluded from this plan: Export (CSV/PDF), caching, month/quarter filters, “members per fee type”, “members per group”, and overdue split.
1. Statistics module (Mv.Statistics)
Goal: Central module for all statistics; LiveView only calls this API. Uses Ash reads with actor so policies apply.
Location: lib/mv/statistics.ex (new).
Functions to implement:
| Function | Purpose | Data source |
|---|---|---|
active_member_count(opts) |
Count members with exit_date == nil |
Member read with filter |
inactive_member_count(opts) |
Count members with exit_date != nil |
Member read with filter |
joins_by_year(year, opts) |
Count members with join_date in given year |
Member read, filter by year, count |
exits_by_year(year, opts) |
Count members with exit_date in given year |
Member read, filter by year, count |
cycle_totals_by_year(year, opts) |
For cycles with cycle_start in year: total sum, and sums/counts by status (paid, unpaid, suspended) |
MembershipFeeCycle read (filter by year via cycle_start), aggregate sum(amount) and count per status in Elixir or via Ash aggregates |
open_amount_total(opts) |
Sum of amount for all cycles with status == :unpaid |
MembershipFeeCycle read with filter status == :unpaid, sum(amount) |
All functions accept opts (keyword list) and pass actor: opts[:actor] (and domain: where needed) to Ash calls. No new resources; only read actions on existing Member and MembershipFeeCycle.
Implementation notes:
- Use
Ash.Query.filter(Member, expr(...))for date filters; for “year”, filterjoin_date >= first_day_of_yearandjoin_date <= last_day_of_year(same forexit_dateand forMembershipFeeCycle.cycle_start). - For
cycle_totals_by_year: either multiple Ash reads (one per status) with sum aggregate, or one read of cycles in that year andEnum.group_by(..., :status)then sum amounts in Elixir. - Use
Mv.MembershipFees.CalendarCyclesonly if needed for interval (e.g. cycle_end); for “cycle in year” thecycle_startyear is enough.
Tests: Unit tests in test/mv/statistics_test.exs for each function (with fixtures: members with join_date/exit_date, cycles with cycle_start/amount/status). Use Mv.Helpers.SystemActor.get_system_actor() in tests for Ash read authorization where appropriate.
2. Route and authorization
Router (lib/mv_web/router.ex):
- In the same
ash_authentication_live_sessionblock where/membersand/membership_fee_typeslive, add:live "/statistics", StatisticsLive, :index
PagePaths (lib/mv_web/page_paths.ex):
- Add module attribute
@statistics "/statistics". - Add
def statistics, do: @statistics. - No change to
@admin_page_paths(statistics is top-level).
Page permission (route matrix is driven by lib/mv/authorization/permission_sets.ex):
- Add
"/statistics"to thepageslist of read_only (e.g. after"/groups/:slug") and to thepageslist of normal_user (e.g. after groups entries). admin already has"*"so no change. - own_data must not list
/statistics(so they cannot access it). - Update docs/page-permission-route-coverage.md: add row for
| /statistics | ✗ | ✓ | ✓ | ✓ |. - Add test in
test/mv_web/plugs/check_page_permission_test.exs: read_only and normal_user and admin can access/statistics; own_data cannot.
3. Sidebar
File: lib/mv_web/components/layouts/sidebar.ex.
- In
sidebar_menu, after the “Fee Types” menu item and before the “Administration” block, add a conditional menu item for Statistics:can_access_page?(@current_user, PagePaths.statistics())→ show link.href={~p"/statistics"},icon="hero-chart-bar"(or similar),label={gettext("Statistics")}.
4. Statistics LiveView
Module: MvWeb.StatisticsLive
File: lib/mv_web/live/statistics_live.ex
Mount: :index only.
Behaviour:
on_mount: useMvWeb.LiveUserAuth, :live_user_requiredand ensure role/permission check (same as other protected LiveViews). Inmountorhandle_params, set default selected year to current year (e.g.Date.utc_today().year).- Assigns:
:year(integer),:active_count,:inactive_count,:joins_this_year,:exits_this_year,:cycle_totals(map with keys e.g.:total,:paid,:unpaid,:suspendedfor the selected year),:open_amount_total, and any extra needed for the bar data (e.g. list of%{year: y, joins: j, exits: e}for a small range of years if you show a minimal bar chart). - Year filter: A single select or dropdown for year (e.g. from “first year with data” to current year). On change, send event (e.g.
"set_year") with%{"year" => year}; inhandle_eventupdateassigns.yearand reload data by callingMv.Statisticsagain and re-assigning.
Data loading:
- In
mountand whenever year changes, callMv.Statisticswithactor: current_actor(socket)(and optionallyyear: @yearwhere needed). Assign results to socket. Handle errors (e.g. redirect or flash) if a call fails.
Layout (sections):
- Page title: e.g. “Statistics” (gettext).
- Year filter: One control to select year; applies to “joins/exits” and “contribution sums” for that year.
- Cards (top row):
- Active members (count)
- Inactive members (count)
- Joins in selected year
- Exits in selected year
- Open amount total (sum of all unpaid cycles; format with
MvWeb.Helpers.MembershipFeeHelpers.format_currency/1) - Optionally: “Paid this year” (from
cycle_totals_by_yearfor selected year)
- Contributions for selected year: One section showing for the chosen year: total (Soll), paid, unpaid, suspended (sums and optionally counts). Use simple table or key-value list; no chart required for MVP.
- Joins / Exits by year (simple bar chart): Data: e.g. last 5 or 10 years. For each year, show joins and exits as horizontal bars (HTML/CSS: e.g.
divwithwidth: #{percent}%). Pure HTML/SVG; no external chart library. Use Tailwind/DaisyUI for layout and cards.
Accessibility: Semantic HTML; headings (e.g. h2) for each section; ensure year filter has a label; format numbers in a screen-reader-friendly way (e.g. no purely visual abbreviations without aria-label).
i18n: All user-visible strings via gettext (e.g. “Statistics”, “Active members”, “Inactive members”, “Joins (year)”, “Exits (year)”, “Open amount”, “Contributions for year”, “Total”, “Paid”, “Unpaid”, “Suspended”). Add keys to priv/gettext as needed.
5. Implementation order (tasks)
Execute in this order so that each step is testable:
-
Statistics module
- Add
lib/mv/statistics.exwith the six functions above and@moduledoc. - Add
test/mv/statistics_test.exswith tests for each function (use fixtures for members and cycles; pass actor in opts). - Run tests and fix until green.
- Add
-
Route and permission
- Add
live "/statistics", StatisticsLive, :indexin router. - Add
statistics/0and@statisticsin PagePaths. - Add
/statisticsto page permission logic so read_only, normal_user, admin are allowed and own_data is denied. - Update
docs/page-permission-route-coverage.mdand add/update plug tests for/statistics.
- Add
-
Sidebar
- Add Statistics link in sidebar (top-level) with
can_access_page?andPagePaths.statistics().
- Add Statistics link in sidebar (top-level) with
-
StatisticsLive
- Create
lib/mv_web/live/statistics_live.exwith mount, assigns, year param, and data loading fromMv.Statistics. - Implement UI: title, year filter, cards, contribution section, simple joins/exits bar (HTML).
- Add gettext keys and use them in the template.
- Optionally add a simple LiveView test (e.g. authenticated user sees statistics page and key labels).
- Create
-
CI and docs
- Run
just ci-dev(or project equivalent); fix formatting, Credo, and tests. - In docs/feature-roadmap.md, update “Reporting & Analytics” to reflect that a basic statistics page is implemented (MVP).
- In CODE_GUIDELINES.md, add a short note under a suitable section (e.g. “Reporting” or “LiveView”) that statistics are provided by
Mv.Statisticsand displayed inStatisticsLive, if desired.
- Run
6. Out of scope (not in this plan)
- Export (CSV/PDF).
- Caching (ETS/GenServer/HTTP).
- Month or quarter filters.
- “Members per fee type” or “members per group” statistics.
- Overdue vs. not-yet-due split for open amount.
- Contex or Chart.js.
- New database tables or Ash resources.
These can be added later as separate tasks or follow-up plans.