From 6ffd1ea5aad0027ed22886f96319834354b514ca Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 10 Feb 2026 22:31:52 +0100 Subject: [PATCH] Update docs and guidelines for statistics feature - CODE_GUIDELINES.md and feature-roadmap.md - Add statistics-page-implementation-plan.md Co-authored-by: Cursor --- CODE_GUIDELINES.md | 4 +- docs/feature-roadmap.md | 4 +- docs/statistics-page-implementation-plan.md | 163 ++++++++++++++++++++ 3 files changed, 168 insertions(+), 3 deletions(-) create mode 100644 docs/statistics-page-implementation-plan.md diff --git a/CODE_GUIDELINES.md b/CODE_GUIDELINES.md index 565cbdd..cc58ca9 100644 --- a/CODE_GUIDELINES.md +++ b/CODE_GUIDELINES.md @@ -118,7 +118,8 @@ lib/ │ ├── mailer.ex # Email mailer │ ├── release.ex # Release tasks │ ├── repo.ex # Database repository -│ └── secrets.ex # Secret management +│ ├── secrets.ex # Secret management +│ └── statistics.ex # Reporting: member/cycle aggregates (counts, sums by year) ├── mv_web/ # Web interface layer │ ├── components/ # UI components │ │ ├── core_components.ex @@ -155,6 +156,7 @@ lib/ │ │ ├── import_export_live.ex # CSV import/export LiveView (mount/events/glue only) │ │ ├── import_export_live/ # Import/Export UI components │ │ │ └── components.ex # custom_fields_notice, template_links, import_form, progress, results +│ │ ├── statistics_live.ex # Statistics page (aggregates, year filter, joins/exits by year) │ │ └── contribution_type_live/ # Contribution types (mock-up) │ ├── auth_overrides.ex # AshAuthentication overrides │ ├── endpoint.ex # Phoenix endpoint diff --git a/docs/feature-roadmap.md b/docs/feature-roadmap.md index 7e28eea..3812598 100644 --- a/docs/feature-roadmap.md +++ b/docs/feature-roadmap.md @@ -291,10 +291,10 @@ #### 10. **Reporting & Analytics** 📊 **Current State:** -- ❌ No reporting features +- ✅ **Statistics page (MVP)** – `/statistics` with active/inactive member counts, joins/exits by year, cycle totals, open amount (2026-02-10) **Missing Features:** -- ❌ Member statistics dashboard +- ❌ Extended member statistics dashboard - ❌ Membership growth charts - ❌ Payment reports - ❌ Custom report builder diff --git a/docs/statistics-page-implementation-plan.md b/docs/statistics-page-implementation-plan.md new file mode 100644 index 0000000..2225ae9 --- /dev/null +++ b/docs/statistics-page-implementation-plan.md @@ -0,0 +1,163 @@ +# 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”, filter `join_date >= first_day_of_year` and `join_date <= last_day_of_year` (same for `exit_date` and for `MembershipFeeCycle.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 and `Enum.group_by(..., :status)` then sum amounts in Elixir. +- Use `Mv.MembershipFees.CalendarCycles` only if needed for interval (e.g. cycle_end); for “cycle in year” the `cycle_start` year 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](lib/mv_web/router.ex)): + +- In the same `ash_authentication_live_session` block where `/members` and `/membership_fee_types` live, add: + - `live "/statistics", StatisticsLive, :index` + +**PagePaths** ([lib/mv_web/page_paths.ex](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](lib/mv/authorization/permission_sets.ex)): + +- Add `"/statistics"` to the `pages` list of **read_only** (e.g. after `"/groups/:slug"`) and to the `pages` list 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](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](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`: use `MvWeb.LiveUserAuth, :live_user_required` and ensure role/permission check (same as other protected LiveViews). In `mount` or `handle_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`, `:suspended` for 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}`; in `handle_event` update `assigns.year` and reload data by calling `Mv.Statistics` again and re-assigning. + +**Data loading:** + +- In `mount` and whenever year changes, call `Mv.Statistics` with `actor: current_actor(socket)` (and optionally `year: @year` where needed). Assign results to socket. Handle errors (e.g. redirect or flash) if a call fails. + +**Layout (sections):** + +1. **Page title:** e.g. “Statistics” (gettext). +2. **Year filter:** One control to select year; applies to “joins/exits” and “contribution sums” for that year. +3. **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_year` for selected year) +4. **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. +5. **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. `div` with `width: #{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: + +1. **Statistics module** + - Add `lib/mv/statistics.ex` with the six functions above and `@moduledoc`. + - Add `test/mv/statistics_test.exs` with tests for each function (use fixtures for members and cycles; pass actor in opts). + - Run tests and fix until green. + +2. **Route and permission** + - Add `live "/statistics", StatisticsLive, :index` in router. + - Add `statistics/0` and `@statistics` in PagePaths. + - Add `/statistics` to page permission logic so read_only, normal_user, admin are allowed and own_data is denied. + - Update `docs/page-permission-route-coverage.md` and add/update plug tests for `/statistics`. + +3. **Sidebar** + - Add Statistics link in sidebar (top-level) with `can_access_page?` and `PagePaths.statistics()`. + +4. **StatisticsLive** + - Create `lib/mv_web/live/statistics_live.ex` with mount, assigns, year param, and data loading from `Mv.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). + +5. **CI and docs** + - Run `just ci-dev` (or project equivalent); fix formatting, Credo, and tests. + - In [docs/feature-roadmap.md](docs/feature-roadmap.md), update “Reporting & Analytics” to reflect that a basic statistics page is implemented (MVP). + - In [CODE_GUIDELINES.md](CODE_GUIDELINES.md), add a short note under a suitable section (e.g. “Reporting” or “LiveView”) that statistics are provided by `Mv.Statistics` and displayed in `StatisticsLive`, if desired. + +--- + +## 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.