From 9f447313c8ab0520a3f58c7426ba04e1b7b30ffe Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Thu, 10 Sep 2026 19:24:50 +0200 Subject: [PATCH 1/3] feat(stats): complete-month range resolver for admin stats Admin::Stats::Range resolves 3/6/12-month presets anchored at the last complete month and inclusive custom start/end month ranges: explicit months take precedence over a preset, future end months clamp to the last complete month, reversed ranges and spans beyond 240 months return an explicit invalid state (the page shows an inline error, the CSV export falls back to the default range), and absent or malformed input falls back to the default 3 complete months. --- app/services/admin/stats/range.rb | 87 ++++++++++++ spec/services/admin/stats/range_spec.rb | 176 ++++++++++++++++++++++++ 2 files changed, 263 insertions(+) create mode 100644 app/services/admin/stats/range.rb create mode 100644 spec/services/admin/stats/range_spec.rb diff --git a/app/services/admin/stats/range.rb b/app/services/admin/stats/range.rb new file mode 100644 index 000000000..fd0db1e99 --- /dev/null +++ b/app/services/admin/stats/range.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +module Admin + module Stats + # Resolves a stats date range into a list of complete calendar months + # (first-of-month Dates) in the app time zone. Presets anchor at the + # last complete month; the current partial month never appears. A + # start month after the end month is an explicit invalid state — the + # caller picks the response (page: inline error, CSV: default range). + class Range + Result = Data.define(:months, :status, :start_month, :end_month) do + def invalid? + status == :invalid + end + end + + PRESETS = { '3' => 3, '6' => 6, '12' => 12 }.freeze + + # Server-side bound on custom ranges: wider spans route to the + # invalid state, reusing the inline-error and CSV-fallback paths. + # session-stored range well inside the cookie store and the table + # readable (20 years). + MAX_SPAN_MONTHS = 240 + + class << self + def resolve(preset: nil, start_month: nil, end_month: nil) + start = parse_month(start_month) + finish = parse_month(end_month) + + return custom_result(start, finish) if start && finish + + default_result(span_for(preset)) + end + + private + + def span_for(preset) + PRESETS[preset.to_s] || 3 + end + + def current_month + Time.zone.today.beginning_of_month + end + + def last_complete_month + current_month.prev_month + end + + def parse_month(value) + return nil if value.blank? + + Date.strptime(value.to_s, '%Y-%m').beginning_of_month + rescue ArgumentError, TypeError + nil + end + + def custom_result(start, finish) + finish = last_complete_month if finish >= current_month + return invalid_result(start, finish) if start > finish + return invalid_result(start, finish) if span_months(start, finish) > MAX_SPAN_MONTHS + + Result.new(months: month_list(start, finish), status: :custom, + start_month: start, end_month: finish) + end + + def invalid_result(start, finish) + Result.new(months: [], status: :invalid, start_month: start, end_month: finish) + end + + def default_result(count) + last = last_complete_month + months = Array.new(count) { |i| last << (count - 1 - i) } + Result.new(months:, status: :default, start_month: nil, end_month: nil) + end + + def month_list(from, to) + span = span_months(from, to) + (0..span).map { |offset| from >> offset } + end + + def span_months(from, to) + (to.year * 12 + to.month) - (from.year * 12 + from.month) + end + end + end + end +end diff --git a/spec/services/admin/stats/range_spec.rb b/spec/services/admin/stats/range_spec.rb new file mode 100644 index 000000000..b6be66e00 --- /dev/null +++ b/spec/services/admin/stats/range_spec.rb @@ -0,0 +1,176 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Admin::Stats::Range, type: :service do + subject(:resolve) do + described_class.resolve(preset:, start_month:, end_month:) + end + + # AE1/AE2 anchor date: 10 Sep 2026. September is the current partial month. + around do |example| + travel_to(Time.zone.local(2026, 9, 10, 12, 0, 0)) { example.run } + end + + def month_list(dates) + dates.map { |d| d.strftime('%Y-%m') } + end + + describe 'presets' do + it 'resolves the 3-month preset as the last 3 complete months (AE1)' do + result = described_class.resolve(preset: '3') + + expect(result.status).to eq(:default) + expect(month_list(result.months)).to eq(%w[2026-06 2026-07 2026-08]) + expect(result.months).to all(be_a(Date)) + end + + it 'excludes the current partial month (AE1)' do + result = described_class.resolve(preset: '3') + + expect(month_list(result.months)).not_to include('2026-09') + end + + it 'resolves the 6-month preset ending at the last complete month' do + result = described_class.resolve(preset: '6') + + expect(month_list(result.months)).to eq(%w[2026-03 2026-04 2026-05 2026-06 2026-07 2026-08]) + end + end + + describe 'default range (AE2)' do + it 'resolves the most recent 3 complete months when nothing is supplied' do + result = described_class.resolve + + expect(result.status).to eq(:default) + expect(month_list(result.months)).to eq(%w[2026-06 2026-07 2026-08]) + end + + it 'falls back to the default range on malformed month values' do + result = described_class.resolve(start_month: 'not-a-month', end_month: '2026-08') + + expect(result.status).to eq(:default) + expect(result.months.length).to eq(3) + expect(month_list(result.months).last).to eq('2026-08') + end + + it 'falls back to the default range when only one custom month is supplied' do + result = described_class.resolve(start_month: '2026-01') + + expect(result.status).to eq(:default) + expect(result.months.length).to eq(3) + end + + it 'falls back to the default range on an unrecognised preset' do + result = described_class.resolve(preset: '7') + + expect(result.status).to eq(:default) + expect(result.months.length).to eq(3) + end + end + + describe 'custom ranges' do + it 'resolves an inclusive start/end range' do + result = described_class.resolve(start_month: '2026-01', end_month: '2026-03') + + expect(result.status).to eq(:custom) + expect(month_list(result.months)).to eq(%w[2026-01 2026-02 2026-03]) + end + + it 'yields a single month when start equals end' do + result = described_class.resolve(start_month: '2026-05', end_month: '2026-05') + + expect(result.status).to eq(:custom) + expect(month_list(result.months)).to eq(%w[2026-05]) + end + + it 'clamps an end month in the current month to the last complete month' do + result = described_class.resolve(start_month: '2026-06', end_month: '2026-09') + + expect(result.status).to eq(:custom) + expect(month_list(result.months)).to eq(%w[2026-06 2026-07 2026-08]) + end + + it 'clamps an end month in the future to the last complete month' do + result = described_class.resolve(start_month: '2026-01', end_month: '2027-04') + + expect(result.status).to eq(:custom) + expect(month_list(result.months).last).to eq('2026-08') + end + + it 'spans a year boundary correctly' do + result = described_class.resolve(start_month: '2025-11', end_month: '2026-02') + + expect(month_list(result.months)).to eq(%w[2025-11 2025-12 2026-01 2026-02]) + end + + it 'spans a past leap-year February correctly' do + result = described_class.resolve(start_month: '2024-01', end_month: '2024-03') + + expect(month_list(result.months)).to eq(%w[2024-01 2024-02 2024-03]) + expect(result.months[1]).to eq(Date.new(2024, 2, 1)) + end + + it 'rejects a start month after the end month' do + result = described_class.resolve(start_month: '2026-08', end_month: '2026-01') + + expect(result.status).to eq(:invalid) + end + + it 'rejects a start month that lands after the clamp (start in a future month)' do + result = described_class.resolve(start_month: '2026-12', end_month: '2027-02') + + expect(result.status).to eq(:invalid) + end + + it 'rejects a start month in the current partial month after the end clamps back' do + result = described_class.resolve(start_month: '2026-09', end_month: '2026-10') + + expect(result.status).to eq(:invalid) + end + + it 'gives explicit start/end months precedence over a preset when both arrive' do + result = described_class.resolve(preset: '3', start_month: '2026-01', end_month: '2026-02') + + expect(result.status).to eq(:custom) + expect(month_list(result.months)).to eq(%w[2026-01 2026-02]) + end + end + + describe 'result shape' do + it 'exposes month dates anchored at the first of the month' do + result = described_class.resolve(preset: '3') + + expect(result.months).to eq([Date.new(2026, 6, 1), Date.new(2026, 7, 1), Date.new(2026, 8, 1)]) + end + + it 'carries the parsed custom months for error display' do + result = described_class.resolve(start_month: '2026-08', end_month: '2026-06') + + expect(result.status).to eq(:invalid) + expect(result.start_month).to eq(Date.new(2026, 8, 1)) + expect(result.end_month).to eq(Date.new(2026, 6, 1)) + end + + it 'routes spans beyond the maximum to the invalid state' do + result = described_class.resolve(start_month: '2000-01', end_month: '2026-08') + + expect(result.status).to eq(:invalid) + expect(result.months).to be_empty + end + + it 'keeps a 20-year custom range valid' do + result = described_class.resolve(start_month: '2006-09', end_month: '2026-08') + + expect(result.status).to eq(:custom) + expect(result.months.size).to eq(240) + end + + it 'exposes nil parsed custom months on the default range' do + result = described_class.resolve + + expect(result.start_month).to be_nil + expect(result.end_month).to be_nil + end + end +end From 1e0c5dd77a63b10e4a4a5ca023253cdacc0080f2 Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Thu, 10 Sep 2026 19:25:01 +0200 Subject: [PATCH 2/3] feat(stats): monthly aggregate service with SQL grouping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admin::Stats::Monthly computes organisation-wide monthly rows for any resolved range entirely in Postgres: attendance (check-ins and RSVPs as independent bases per role), sign-ups by current group membership with distinct-member totals via a single left-join query, and workshop counts — each grouped by calendar month in the app time zone, so cost is independent of row counts. Totals are range totals only. --- app/services/admin/stats/monthly.rb | 142 +++++++++++++++++ spec/services/admin/stats/monthly_spec.rb | 178 ++++++++++++++++++++++ 2 files changed, 320 insertions(+) create mode 100644 app/services/admin/stats/monthly.rb create mode 100644 spec/services/admin/stats/monthly_spec.rb diff --git a/app/services/admin/stats/monthly.rb b/app/services/admin/stats/monthly.rb new file mode 100644 index 000000000..40d8a510c --- /dev/null +++ b/app/services/admin/stats/monthly.rb @@ -0,0 +1,142 @@ +# frozen_string_literal: true + +module Admin + module Stats + # Computes organisation-wide monthly metric rows for a resolved + # Admin::Stats::Range. All aggregation happens in SQL: each metric + # query groups by calendar month (app time zone) and returns a + # handful of rows, so request cost is independent of row counts. + # Attendance counts come from workshop_invitations bucketed by the + # workshop's date_and_time; sign-up rows come from members.created_at + # with role assignment by current group membership and a + # distinct-member total. Totals are range totals only: each totals + # cell sums its row across exactly the range's months. + class Monthly + Row = Data.define(:section, :label, :cells, :total) + Result = Data.define(:months, :rows) + + ATTENDANCE_LABELS = { student_check_ins: 'Student check-ins', + coach_check_ins: 'Coach check-ins', + student_rsvps: 'Student RSVPs', + coach_rsvps: 'Coach RSVPs' }.freeze + SIGN_UP_LABELS = { students: 'New students', + coaches: 'New coaches', + uncategorised: 'Uncategorised', + total: 'Total new members' }.freeze + ROLE_KEYS = { 'Student' => { check_in: :student_check_ins, rsvp: :student_rsvps }, + 'Coach' => { check_in: :coach_check_ins, rsvp: :coach_rsvps } }.freeze + + class << self + def call(range) + months = range.months + return Result.new(months: [], rows: []) if months.empty? + + @month_keys = months.index_by(&:itself) + rows = attendance_rows(months) + sign_up_rows(months) + [workshop_row(months)] + Result.new(months:, rows:) + end + + private + + def attendance_rows(months) + counts = blank_counts(months, ATTENDANCE_LABELS.keys) + grouped_attendance(months).each do |month, role, attended, attending, count| + key = @month_keys[month] + next unless key + + attendance_keys(role, attended, attending).each { |row_key| counts[row_key][key] += count } + end + ATTENDANCE_LABELS.map { |key, label| row(:attendance, label, months, counts[key]) } + end + + def grouped_attendance(months) + scope = WorkshopInvitation.joins(:workshop).where(workshops: { date_and_time: window(months) }) + scope.where(attending: true) + .or(scope.where(attended: true)) + .group(month_bucket('workshops.date_and_time'), :role, :attended, :attending) + .pluck(month_bucket('workshops.date_and_time'), :role, :attended, :attending, + Arel.sql('COUNT(*)')) + end + + # A check-in invitation carries both flags (attending is set when + # attendance is recorded), so it counts in the check-in row AND the + # RSVP row — the rows are independent bases. + def attendance_keys(role, attended, attending) + keys = ROLE_KEYS[role] || {} + [].tap do |selected| + selected << keys[:check_in] if attended && keys[:check_in] + selected << keys[:rsvp] if attending && keys[:rsvp] + end + end + + def sign_up_rows(months) # rubocop:disable Metrics/AbcSize + counts = blank_counts(months, SIGN_UP_LABELS.keys) + grouped_sign_ups(months).each do |month, total, students, coaches, uncategorised| + key = @month_keys[month] + next unless key + + counts[:students][key] += students + counts[:coaches][key] += coaches + counts[:uncategorised][key] += uncategorised + counts[:total][key] += total + end + SIGN_UP_LABELS.map { |key, label| row(:sign_ups, label, months, counts[key]) } + end + + # Left join keeps members with no group subscription in the + # uncategorised row; distinct counts keep dual-group members in + # both role rows but once in the total. + def grouped_sign_ups(months) + Member.where(created_at: window(months)) + .left_joins(:groups) + .group(month_bucket('members.created_at')) + .pluck(month_bucket('members.created_at'), + Arel.sql('COUNT(DISTINCT members.id)'), + Arel.sql("COUNT(DISTINCT CASE WHEN groups.name = 'Students' THEN members.id END)"), + Arel.sql("COUNT(DISTINCT CASE WHEN groups.name = 'Coaches' THEN members.id END)"), + Arel.sql('COUNT(DISTINCT CASE WHEN groups.name IS NULL THEN members.id END)')) + end + + def workshop_row(months) + row(:workshops, 'Workshops', months, workshop_counts(months)) + end + + def workshop_counts(months) + counts = default_cells(months) + Workshop.where(date_and_time: window(months)) + .unscope(:order) + .group(month_bucket('workshops.date_and_time')) + .pluck(month_bucket('workshops.date_and_time'), Arel.sql('COUNT(*)')) + .each do |month, count| + key = @month_keys[month] + counts[key] = count if key + end + counts + end + + def row(section, label, months, counts) + cells = months.index_with { |m| counts.fetch(m, 0) } + Row.new(section:, label:, cells:, total: cells.values.sum) + end + + def blank_counts(months, keys) + keys.index_with { default_cells(months) } + end + + def default_cells(months) + months.index_with { 0 } + end + + def window(months) + months.first.beginning_of_day..months.last.end_of_month.end_of_day + end + + # Calendar-month bucket in the app time zone, cast to a + # first-of-month date so it matches the months list. + def month_bucket(column) + Arel.sql("DATE_TRUNC('month', #{column} AT TIME ZONE '#{Time.zone.tzinfo.identifier}')::date") + end + end + end + end +end diff --git a/spec/services/admin/stats/monthly_spec.rb b/spec/services/admin/stats/monthly_spec.rb new file mode 100644 index 000000000..0a9f76ae1 --- /dev/null +++ b/spec/services/admin/stats/monthly_spec.rb @@ -0,0 +1,178 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Admin::Stats::Monthly do + subject(:result) { described_class.call(range) } + + let(:range) do + Admin::Stats::Range.resolve(start_month: '2026-05', end_month: '2026-07') + end + + def row(section, label) + result.rows.find { |r| r.section == section && r.label == label } + end + + def cell(section, label, month) + row(section, label).cells[Date.new(*month)] + end + + def total_for(section, label) + row(section, label).total + end + + describe 'attendance rows (R3)' do + it 'Covers AE4. counts student RSVPs and check-ins against the workshop month' do + workshop = Fabricate(:workshop_no_sponsor, date_and_time: Time.zone.local(2026, 5, 15, 18, 30)) + 10.times do |i| + invitation = Fabricate(:attending_workshop_invitation, workshop:, role: 'Student') + invitation.update!(attended: true) if i < 7 + end + + expect(cell(:attendance, 'Student RSVPs', [2026, 5])).to eq(10) + expect(cell(:attendance, 'Student check-ins', [2026, 5])).to eq(7) + end + + it 'counts coach attendance on the coach rows' do + workshop = Fabricate(:workshop_no_sponsor, date_and_time: Time.zone.local(2026, 6, 3, 18, 30)) + Fabricate(:attending_workshop_invitation, workshop:, role: 'Coach', attended: true) + + expect(cell(:attendance, 'Coach check-ins', [2026, 6])).to eq(1) + expect(cell(:attendance, 'Coach RSVPs', [2026, 6])).to eq(1) + expect(cell(:attendance, 'Student check-ins', [2026, 6])).to eq(0) + end + + it 'excludes a workshop dated outside the range from every column' do + outside = Fabricate(:workshop_no_sponsor, date_and_time: Time.zone.local(2026, 4, 20, 18, 30)) + Fabricate(:attending_workshop_invitation, workshop: outside, role: 'Student', attended: true) + Fabricate(:workshop_no_sponsor, date_and_time: Time.zone.local(2026, 8, 1, 18, 30)) + + expect(total_for(:attendance, 'Student RSVPs')).to eq(0) + expect(total_for(:workshops, 'Workshops')).to eq(0) + end + + it 'excludes waiting-listed invitations (attending nil) from the RSVP rows' do + workshop = Fabricate(:workshop_no_sponsor, date_and_time: Time.zone.local(2026, 6, 10, 18, 30)) + Fabricate(:workshop_invitation, workshop:, role: 'Student', attending: nil) + + expect(cell(:attendance, 'Student RSVPs', [2026, 6])).to eq(0) + expect(cell(:attendance, 'Student check-ins', [2026, 6])).to eq(0) + end + + it 'excludes invitations with a nil role from both attendance rows' do + workshop = Fabricate(:workshop_no_sponsor, date_and_time: Time.zone.local(2026, 6, 10, 18, 30)) + Fabricate(:attending_workshop_invitation, workshop:, role: nil, attended: true) + + expect(cell(:attendance, 'Student RSVPs', [2026, 6])).to eq(0) + expect(cell(:attendance, 'Coach check-ins', [2026, 6])).to eq(0) + end + + it 'counts attendances, not distinct members' do + member = Fabricate(:member) + may = Fabricate(:workshop_no_sponsor, date_and_time: Time.zone.local(2026, 5, 6, 18, 30)) + june = Fabricate(:workshop_no_sponsor, date_and_time: Time.zone.local(2026, 6, 10, 18, 30)) + Fabricate(:attended_workshop_invitation, workshop: may, member:, role: 'Student') + Fabricate(:attended_workshop_invitation, workshop: june, member:, role: 'Student') + + expect(cell(:attendance, 'Student check-ins', [2026, 5])).to eq(1) + expect(cell(:attendance, 'Student check-ins', [2026, 6])).to eq(1) + expect(total_for(:attendance, 'Student check-ins')).to eq(2) + end + + it 'counts virtual workshops toward the workshop row' do + Fabricate(:virtual_workshop, date_and_time: Time.zone.local(2026, 7, 1, 18, 30)) + + expect(cell(:workshops, 'Workshops', [2026, 7])).to eq(1) + end + end + + describe 'sign-up rows (R4, D8)' do + it 'Covers AE3. assigns a members-only-coaches group member to the coaches row' do + member = Fabricate(:member, created_at: Time.zone.local(2026, 6, 10, 14, 0)) + Fabricate(:subscription, member:, group: Fabricate(:coaches)) + + expect(cell(:sign_ups, 'New coaches', [2026, 6])).to eq(1) + expect(cell(:sign_ups, 'New students', [2026, 6])).to eq(0) + expect(cell(:sign_ups, 'Uncategorised', [2026, 6])).to eq(0) + expect(cell(:sign_ups, 'Total new members', [2026, 6])).to eq(1) + end + + it 'Covers AE6. counts a dual-group member once in the total and in both role rows' do + member = Fabricate(:member, created_at: Time.zone.local(2026, 7, 5, 9, 0)) + Fabricate(:subscription, member:, group: Fabricate(:students)) + Fabricate(:subscription, member:, group: Fabricate(:coaches)) + + expect(cell(:sign_ups, 'New students', [2026, 7])).to eq(1) + expect(cell(:sign_ups, 'New coaches', [2026, 7])).to eq(1) + expect(cell(:sign_ups, 'Total new members', [2026, 7])).to eq(1) + end + + it 'lands a member with no subscriptions in uncategorised and the total' do + Fabricate(:member, created_at: Time.zone.local(2026, 5, 20, 12, 0)) + + expect(cell(:sign_ups, 'Uncategorised', [2026, 5])).to eq(1) + expect(cell(:sign_ups, 'Total new members', [2026, 5])).to eq(1) + end + + it 'counts banned members and members without accepted terms (D8: no status filter)' do + Fabricate(:banned_member, created_at: Time.zone.local(2026, 5, 2, 10, 0)) + Fabricate(:member_without_toc, created_at: Time.zone.local(2026, 5, 3, 10, 0)) + + expect(cell(:sign_ups, 'Total new members', [2026, 5])).to eq(2) + end + + it 'excludes members created outside the range' do + Fabricate(:member, created_at: Time.zone.local(2026, 4, 10, 10, 0)) + Fabricate(:member, created_at: Time.zone.local(2026, 8, 10, 10, 0)) + + expect(total_for(:sign_ups, 'Total new members')).to eq(0) + end + end + + describe 'range totals (R8)' do + it 'sums each row across exactly the range months, never all time' do + workshop = Fabricate(:workshop_no_sponsor, date_and_time: Time.zone.local(2026, 6, 10, 18, 30)) + Fabricate(:attending_workshop_invitation, workshop:, role: 'Student') + Fabricate(:attending_workshop_invitation, workshop:, role: 'Coach') + old_workshop = Fabricate(:workshop_no_sponsor, date_and_time: Time.zone.local(2025, 6, 10, 18, 30)) + Fabricate(:attending_workshop_invitation, workshop: old_workshop, role: 'Student') + Fabricate(:member, created_at: Time.zone.local(2026, 5, 10)) + + aggregate_failures do + result.rows.each do |r| + expect(r.total).to eq(r.cells.values.sum), + "row #{r.label}: total #{r.total} != cell sum #{r.cells.values.sum}" + end + expect(total_for(:attendance, 'Student RSVPs')).to eq(1) + expect(total_for(:workshops, 'Workshops')).to eq(1) + end + end + end + + describe 'range handling' do + it 'includes zero cells for months with no data' do + expect(cell(:workshops, 'Workshops', [2026, 5])).to eq(0) + expect(row(:workshops, 'Workshops').cells.keys).to eq( + [Date.new(2026, 5, 1), Date.new(2026, 6, 1), Date.new(2026, 7, 1)] + ) + end + + it 'returns an empty result for an invalid range' do + invalid = Admin::Stats::Range.resolve(start_month: '2026-07', end_month: '2026-05') + + outcome = described_class.call(invalid) + + expect(outcome.months).to eq([]) + expect(outcome.rows).to eq([]) + end + + it 'aggregates across all chapters (R2, organisation-wide)' do + Fabricate(:workshop_no_sponsor, date_and_time: Time.zone.local(2026, 5, 5, 18, 30)) + other_chapter = Fabricate(:chapter) + Fabricate(:workshop_no_sponsor, chapter: other_chapter, + date_and_time: Time.zone.local(2026, 5, 12, 18, 30)) + + expect(cell(:workshops, 'Workshops', [2026, 5])).to eq(2) + end + end +end From 53342a7f44c8ede8f1c88593a97c8b7225840bda Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Thu, 10 Sep 2026 19:25:18 +0200 Subject: [PATCH 3/3] feat(stats): admin stats page behind admin-only gate with CSV export Admin::StatsController serves the HTML page and CSV export from one action, enforcing the global-admin check itself on top of the admin area's admins-plus-organisers default. The page renders one captioned table per metric group with right-aligned, thousands-separated cells, preset and custom-month GET forms with inline validation, and a Download CSV link built from the resolved range so the export mirrors the displayed table; the admin portal gains a Stats button beside Chapter Status. --- .../admin/stats/table_component.html.erb | 25 +++ app/components/admin/stats/table_component.rb | 34 ++++ app/controllers/admin/stats_controller.rb | 89 +++++++++ app/views/admin/portal/index.html.haml | 1 + app/views/admin/stats/index.html.erb | 43 +++++ config/routes.rb | 2 + .../admin/stats/table_component_spec.rb | 53 +++++ .../admin/stats_controller_spec.rb | 181 ++++++++++++++++++ spec/features/admin/stats_page_spec.rb | 113 +++++++++++ 9 files changed, 541 insertions(+) create mode 100644 app/components/admin/stats/table_component.html.erb create mode 100644 app/components/admin/stats/table_component.rb create mode 100644 app/controllers/admin/stats_controller.rb create mode 100644 app/views/admin/stats/index.html.erb create mode 100644 spec/components/admin/stats/table_component_spec.rb create mode 100644 spec/controllers/admin/stats_controller_spec.rb create mode 100644 spec/features/admin/stats_page_spec.rb diff --git a/app/components/admin/stats/table_component.html.erb b/app/components/admin/stats/table_component.html.erb new file mode 100644 index 000000000..7c891e25d --- /dev/null +++ b/app/components/admin/stats/table_component.html.erb @@ -0,0 +1,25 @@ +
+ + + + + + <% months.each do |month| %> + + <% end %> + + + + + <% rows.each do |row| %> + + + <% months.each do |month| %> + + <% end %> + + + <% end %> + +
<%= caption %>
Metric<%= month_header(month) %>Totals
<%= row.label %><%= cell_value(row, month) %><%= total_value(row) %>
+
diff --git a/app/components/admin/stats/table_component.rb b/app/components/admin/stats/table_component.rb new file mode 100644 index 000000000..9165b20f1 --- /dev/null +++ b/app/components/admin/stats/table_component.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Admin + module Stats + # Renders one metric group's monthly table: one column per month in + # the active range plus a Totals column. Fed plain dates and + # Admin::Stats::Monthly::Row objects. + class TableComponent < ViewComponent::Base + include ActionView::Helpers::NumberHelper + + def initialize(months:, rows:, caption:) # rubocop:disable Lint/MissingSuper + @months = months + @rows = rows + @caption = caption + end + + private + + attr_reader :months, :rows, :caption + + def month_header(month) + month.strftime('%B %Y') + end + + def cell_value(row, month) + number_with_delimiter(row.cells.fetch(month, 0)) + end + + def total_value(row) + number_with_delimiter(row.total) + end + end + end +end diff --git a/app/controllers/admin/stats_controller.rb b/app/controllers/admin/stats_controller.rb new file mode 100644 index 000000000..15989645d --- /dev/null +++ b/app/controllers/admin/stats_controller.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +module Admin + # Serves the organisation-wide monthly stats page and its CSV export + # behind the global-admin check: the admin area also admits organisers, + # so this controller re-checks admin status itself. + class StatsController < Admin::ApplicationController + before_action :authenticate_admin! + + def index + skip_authorization + load_stats + + respond_to do |format| + format.html + format.csv { send_stats_csv } + end + end + + private + + def load_stats + range_result = resolve_range + @range_result = range_result + @months = range_result.months + @rows = Admin::Stats::Monthly.call(range_result).rows + @range_start, @range_end = month_inputs(range_result) + end + + def resolve_range + range_result = Admin::Stats::Range.resolve(**filter_params) + return default_range if range_result.invalid? && request.format.csv? + return page_invalid_range if range_result.invalid? + + session[:admin_stats_months] = range_result.months.map(&:iso8601) unless request.format.csv? + range_result + end + + # The page never silently substitutes an invalid range — it + # re-renders the last valid range (retained in the session) with an + # inline error. CSV instead falls back to the default 3 months. + def page_invalid_range + @range_error = 'The start month must not be after the end month.' + stored = Array(session[:admin_stats_months]).map { |month| Date.parse(month) } + return default_range if stored.blank? + + Admin::Stats::Range::Result.new(months: stored, status: :custom, + start_month: stored.first, end_month: stored.last) + end + + def default_range + Admin::Stats::Range.resolve + end + + def filter_params + return {} unless params.key?(:stats) + + params.expect(stats: %i[preset start_month end_month]).to_h.symbolize_keys + end + + def month_inputs(range_result) + [range_result.start_month, range_result.end_month].map { |month| month&.strftime('%Y-%m') } + end + + def send_stats_csv + send_data stats_csv, filename: "codebar-stats-#{range_span}.csv", + type: 'text/csv', disposition: 'attachment' + end + + def stats_csv + CSV.generate do |out| + out << ['Metric', *@months.map { |month| csv_month(month) }, 'Totals'] + @rows.each { |row| out << csv_row(row) } + end + end + + def csv_month(month) + month.strftime('%Y-%m') + end + + def csv_row(row) + [row.label, *@months.map { |month| row.cells.fetch(month, 0) }, row.total] + end + + def range_span + "#{csv_month(@months.first)}-#{csv_month(@months.last)}" + end + end +end diff --git a/app/views/admin/portal/index.html.haml b/app/views/admin/portal/index.html.haml index cac1ce311..25ec1dcbd 100644 --- a/app/views/admin/portal/index.html.haml +++ b/app/views/admin/portal/index.html.haml @@ -21,6 +21,7 @@ = link_to 'Admin Guide', admin_guide_path, class: 'btn btn-primary btn-lg mb-3' = link_to 'Members Directory', admin_members_path, class: 'btn btn-primary btn-lg mb-3' = link_to 'Chapter Status', status_admin_chapters_path, class: 'btn btn-primary btn-lg mb-3' + = link_to 'Stats', admin_stats_path, class: 'btn btn-primary btn-lg mb-3' %hr .row diff --git a/app/views/admin/stats/index.html.erb b/app/views/admin/stats/index.html.erb new file mode 100644 index 000000000..bb2004d0b --- /dev/null +++ b/app/views/admin/stats/index.html.erb @@ -0,0 +1,43 @@ +<% content_for :title, 'Admin stats' %> + +
+

Admin stats

+ +

Organisation-wide monthly figures across all chapters, for the selected month range. Totals cover the selected range only.

+ + <% if @range_error.present? %> + + <% end %> + + <%= form_tag admin_stats_path, method: :get, class: 'd-inline-block mb-4 me-3' do %> +
+ <% active_class = ->(p) { @range_result.status == :default && @range_result.months.size == p ? ' active' : '' } %> + <%= button_tag '3 months', name: 'stats[preset]', value: 3, class: "btn btn-outline-primary#{active_class.call(3)}" %> + <%= button_tag '6 months', name: 'stats[preset]', value: 6, class: "btn btn-outline-primary#{active_class.call(6)}" %> + <%= button_tag '12 months', name: 'stats[preset]', value: 12, class: "btn btn-outline-primary#{active_class.call(12)}" %> +
+ <% end %> + + <%= form_tag admin_stats_path, method: :get, class: 'd-inline-flex align-items-end gap-2 mb-4' do %> +
+ <%= label_tag :stats_start_month, 'Start month' %> + <%= month_field_tag 'stats[start_month]', @range_start, id: 'stats_start_month', min: Workshop.minimum(:date_and_time)&.strftime('%Y-%m'), class: 'form-control' %> +
+
+ <%= label_tag :stats_end_month, 'End month' %> + <%= month_field_tag 'stats[end_month]', @range_end, id: 'stats_end_month', class: 'form-control' %> +
+ <%= submit_tag 'Apply', class: 'btn btn-primary' %> + <% end %> + + <% rows_by_section = @rows.group_by(&:section) %> + <%= render Admin::Stats::TableComponent.new(months: @months, rows: rows_by_section.fetch(:attendance, []), caption: 'Attendance') %> + <%= render Admin::Stats::TableComponent.new(months: @months, rows: rows_by_section.fetch(:sign_ups, []), caption: 'New sign-ups') %> + <%= render Admin::Stats::TableComponent.new(months: @months, rows: rows_by_section.fetch(:workshops, []), caption: 'Workshops') %> + + <%= link_to 'Download CSV', + admin_stats_path(format: :csv, + stats: { preset: params.dig(:stats, :preset), + start_month: @range_start, end_month: @range_end }.compact), + class: 'btn btn-primary' %> +
\ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index 9114f855b..1703ba314 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -114,6 +114,8 @@ resources :organisers, only: %i[index create destroy], controller: 'chapters/organisers' end + resources :stats, only: [:index] + resources :events, only: %i[new create show edit update] do get 'attendees_emails' post 'invite' diff --git a/spec/components/admin/stats/table_component_spec.rb b/spec/components/admin/stats/table_component_spec.rb new file mode 100644 index 000000000..68f02a76a --- /dev/null +++ b/spec/components/admin/stats/table_component_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Admin::Stats::TableComponent do + let(:months) { [Date.new(2026, 6, 1), Date.new(2026, 7, 1), Date.new(2026, 8, 1)] } + let(:row) do + Admin::Stats::Monthly::Row.new(section: :attendance, label: 'Student check-ins', + cells: { Date.new(2026, 6, 1) => 1234, Date.new(2026, 7, 1) => 7, + Date.new(2026, 8, 1) => 0 }, + total: 1241) + end + + it 'renders the caption above the table' do + render_inline(described_class.new(months: [Date.new(2026, 6, 1)], rows: [row], caption: 'Attendance')) + + expect(page).to have_css('caption.caption-top', text: 'Attendance') + end + + it 'renders the caption, month columns, and the Totals column' do + render_inline(described_class.new(months: [Date.new(2026, 6, 1)], rows: [row], caption: 'Attendance')) + + expect(page).to have_css('caption', text: 'Attendance') + expect(page).to have_css('th', text: 'June 2026') + expect(page).to have_css('th', text: 'Totals') + end + + it 'right-aligns numeric cells and formats them with thousands separators' do + render_inline(described_class.new(months: [Date.new(2026, 6, 1)], rows: [row], caption: 'Attendance')) + + expect(page).to have_css('td.text-end', text: '1,234') + expect(page).to have_css('td.text-end', text: '1,241') # totals cell + end + + it 'renders one table per metric group, with no section banner rows' do + workshops_row = Admin::Stats::Monthly::Row.new(section: :workshops, label: 'Workshops', + cells: { Date.new(2026, 6, 1) => 2 }, total: 2) + + render_inline(described_class.new(months: [Date.new(2026, 6, 1)], rows: [row], caption: 'Attendance')) + attendance_table = page + + render_inline(described_class.new(months: [Date.new(2026, 6, 1)], rows: [workshops_row], caption: 'Workshops')) + + expect(attendance_table).to have_no_selector('td[colspan]') + expect(page).to have_css('caption', text: 'Workshops') + end + + it 'renders zero for a month missing from the row cells' do + render_inline(described_class.new(months: [Date.new(2026, 5, 1)], rows: [row], caption: 'Attendance')) + + expect(page).to have_css('td.text-end', text: '0') + end +end diff --git a/spec/controllers/admin/stats_controller_spec.rb b/spec/controllers/admin/stats_controller_spec.rb new file mode 100644 index 000000000..0ba8ef748 --- /dev/null +++ b/spec/controllers/admin/stats_controller_spec.rb @@ -0,0 +1,181 @@ +require 'rails_helper' + +RSpec.describe Admin::StatsController do + let(:admin) { Fabricate(:member) } + + around { |example| travel_to(Time.zone.local(2026, 9, 10, 12, 0, 0)) { example.run } } + + describe 'GET #index as HTML' do + before { login_as_admin(admin) } + + it 'renders the default most recent 3 complete months (AE2)' do + get :index + + expect(response).to be_successful + months = controller.view_assigns['months'] + expect(months).to eq([Date.new(2026, 6, 1), Date.new(2026, 7, 1), Date.new(2026, 8, 1)]) + end + + it 'renders the 3-month preset anchored at the last complete month (AE1)' do + get :index, params: { stats: { preset: '3' } } + + expect(controller.view_assigns['months']) + .to eq([Date.new(2026, 6, 1), Date.new(2026, 7, 1), Date.new(2026, 8, 1)]) + end + + it 'prefers explicit start/end months over a preset when both are supplied (KTD4)' do + get :index, params: { stats: { preset: '3', start_month: '2026-01', end_month: '2026-02' } } + + expect(controller.view_assigns['months']) + .to eq([Date.new(2026, 1, 1), Date.new(2026, 2, 1)]) + end + + it 'keeps the previous valid range and shows an inline error for a start-after-end range (R7)' do + get :index, params: { stats: { preset: '3' } } + previous = controller.view_assigns['months'] + + get :index, params: { stats: { start_month: '2026-08', end_month: '2026-03' } } + + expect(controller.view_assigns['months']).to eq(previous) + expect(controller.view_assigns['range_result']).not_to be_invalid + expect(controller.view_assigns['range_error']).to be_present + end + + it 'renders the default range with an inline error when the first request is invalid' do + get :index, params: { stats: { start_month: '2026-08', end_month: '2026-03' } } + + expect(controller.view_assigns['months'].size).to eq(3) + expect(controller.view_assigns['range_error']).to be_present + end + + it 'filters unpermitted keys and does not leak them to the service' do + get :index, params: { stats: { preset: '3', hacker_field: 'malicious' }, evil: 'pwn' } + + expect(response).to be_successful + expect(controller.view_assigns['months'].size).to eq(3) + end + + it 'raises for a type-tampered stats parameter (params.expect contract)' do + expect { get :index, params: { stats: 'evil' } } + .to raise_error(ActionController::ParameterMissing) + end + + it 'exposes range inputs for the view' do + get :index, params: { stats: { start_month: '2026-01', end_month: '2026-03' } } + + expect(controller.view_assigns['range_start']).to eq('2026-01') + expect(controller.view_assigns['range_end']).to eq('2026-03') + end + end + + describe 'GET #index as CSV' do + let!(:workshop) do + Fabricate(:workshop, date_and_time: Time.zone.local(2026, 1, 15, 18, 30)) + end + + before do + login_as_admin(admin) + Fabricate(:attending_workshop_invitation, workshop:, role: 'Student') + Fabricate(:attended_workshop_invitation, workshop:, role: 'Coach') + Fabricate(:member, created_at: Time.zone.local(2026, 1, 5, 10, 0, 0)).tap do |member| + member.groups << Fabricate(:students) + end + Fabricate(:member, created_at: Time.zone.local(2026, 2, 9, 10, 0, 0)).tap do |member| + member.groups << Fabricate(:coaches) + end + Fabricate(:member, created_at: Time.zone.local(2026, 3, 1, 10, 0, 0)) + end + + it 'returns a text/csv attachment mirroring the HTML table (AE5)' do + filters = { stats: { start_month: '2026-01', end_month: '2026-03' } } + + get :index, params: filters + months = controller.view_assigns['months'] + expected_rows = controller.view_assigns['rows'] + + get :index, params: filters, format: :csv + + expect(response.media_type).to eq('text/csv') + expect(response.headers['Content-Disposition']).to include('attachment') + expect(response.headers['Content-Disposition']) + .to include('codebar-stats-2026-01-2026-03.csv') + + parsed = CSV.parse(response.body) + expect(parsed.first).to eq(%w[Metric 2026-01 2026-02 2026-03 Totals]) + expect(parsed.size).to eq(expected_rows.size + 1) + + expected_rows.each_with_index do |row, index| + expect(parsed[index + 1]) + .to eq([row.label, *months.map { |m| row.cells[m].to_s }, row.total.to_s]) + end + end + + it 'counts fabricated data through the real service chain' do + get :index, params: { stats: { start_month: '2026-01', end_month: '2026-01' } } + + rows = controller.view_assigns['rows'].index_by(&:label) + expect(rows['Student check-ins'].cells[Date.new(2026, 1, 1)]).to eq(0) + expect(rows['Coach check-ins'].cells[Date.new(2026, 1, 1)]).to eq(1) + expect(rows['Student RSVPs'].cells[Date.new(2026, 1, 1)]).to eq(1) + expect(rows['New students'].cells[Date.new(2026, 1, 1)]).to eq(1) + expect(rows['Total new members'].cells[Date.new(2026, 1, 1)]).to eq(1) + expect(rows['Workshops'].cells[Date.new(2026, 1, 1)]).to eq(1) + end + + it 'falls back to the default 3 months when the supplied range is invalid (R9)' do + get :index, params: { stats: { start_month: '2026-08', end_month: '2026-03' } }, format: :csv + + expect(response).to be_successful + header = CSV.parse(response.body).first + expect(header[1]).to eq('2026-06') + expect(header[-2]).to eq('2026-08') + expect(header.last).to eq('Totals') + end + + it 'clamps a CSV end month in the current month to the last complete month (R9 parity with R7)' do + get :index, params: { stats: { start_month: '2026-06', end_month: '2026-09' } }, format: :csv + + expect(response).to be_successful + header = CSV.parse(response.body).first + expect(header[1]).to eq('2026-06') + expect(header[-2]).to eq('2026-08') + end + + it 'falls back to the default 3 months for malformed months (R9)' do + get :index, params: { stats: { start_month: 'garbage', end_month: 'also-bad' } }, + format: :csv + + expect(response).to be_successful + expect(CSV.parse(response.body).first.size).to eq(5) + end + end + + describe 'denial paths (AE7)' do + let(:chapter) { Fabricate(:chapter) } + let(:organiser) { Fabricate(:member) } + + before { login_as_organiser(organiser, chapter) } + + it 'redirects an organiser from the page to the root' do + get :index + + expect(response).to redirect_to(root_path) + end + + it 'redirects an organiser from the CSV export to the root' do + get :index, format: :csv + + expect(response).to redirect_to(root_path) + end + + it 'redirects a signed-out visitor from either format' do + LoginHelpers::LoginStub.current_user = nil + + get :index + expect(response).to redirect_to(root_path) + + get :index, format: :csv + expect(response).to redirect_to(root_path) + end + end +end diff --git a/spec/features/admin/stats_page_spec.rb b/spec/features/admin/stats_page_spec.rb new file mode 100644 index 000000000..643b20d39 --- /dev/null +++ b/spec/features/admin/stats_page_spec.rb @@ -0,0 +1,113 @@ +require 'rails_helper' + +RSpec.feature 'admin stats page' do + let(:member) { Fabricate(:member) } + + before do + login_as_admin(member) + end + + scenario 'the portal links to the stats page and the default view covers the most recent 3 complete months' do # rubocop:disable RSpec/MultipleExpectations + Fabricate(:workshop_no_sponsor, date_and_time: Time.zone.local(2026, 8, 1, 18, 30)) + + travel_to(Time.zone.local(2026, 9, 10, 12, 0)) do + visit admin_root_path + + expect(page).to have_link('Stats', href: admin_stats_path) + click_link('Stats', href: admin_stats_path) + + expect(page).to have_css('caption', text: 'Attendance') + expect(page).to have_css('caption', text: 'New sign-ups') + expect(page).to have_css('caption', text: 'Workshops') + expect(page).to have_css('th', text: 'August 2026') + expect(page).to have_no_selector('th', text: 'September 2026') + expect(page).to have_no_selector('th', text: 'May 2026') + expect(page).to have_css('.btn-outline-primary.active', text: '3 months') + expect(page).to have_link('Download CSV') + end + end + + scenario 'the 3-month preset re-renders the last three complete months' do + Fabricate(:workshop_no_sponsor, date_and_time: Time.zone.local(2026, 6, 1, 18, 30)) + + travel_to(Time.zone.local(2026, 9, 10, 12, 0)) do + visit admin_stats_path + + click_button '3 months' + + expect(page).to have_css('th', text: 'June 2026') + expect(page).to have_css('th', text: 'July 2026') + expect(page).to have_css('th', text: 'August 2026') + expect(page).to have_no_selector('th', text: 'May 2026') + end + end + + scenario 'a single-month custom range renders one month column with sign-up roles' do + june_member = Fabricate(:member, created_at: Time.zone.local(2026, 6, 10, 14, 0)) + Fabricate(:subscription, member: june_member, group: Fabricate(:coaches)) + + travel_to(Time.zone.local(2026, 9, 10, 12, 0)) do + visit admin_stats_path(stats: { start_month: '2026-06', end_month: '2026-06' }) + + expect(page).to have_css('th', text: 'June 2026') + expect(page).to have_no_selector('th', text: 'May 2026') + expect(page).to have_no_selector('th', text: 'July 2026') + + expect(find('tr', text: 'New coaches')).to have_text('1') + expect(find('tr', text: 'New students')).to have_text('0') + expect(find('tr', text: 'Total new members')).to have_text('1') + + expect(page).to have_link('Download CSV', + href: admin_stats_path(stats: { start_month: '2026-06', end_month: '2026-06' }, + format: :csv)) + end + end + + scenario 'a dual-group member counts once in the total and in both role rows' do + july_member = Fabricate(:member, created_at: Time.zone.local(2026, 7, 5, 9, 0)) + Fabricate(:subscription, member: july_member, group: Fabricate(:coaches)) + Fabricate(:subscription, member: july_member, group: Fabricate(:students)) + + travel_to(Time.zone.local(2026, 9, 10, 12, 0)) do + visit admin_stats_path(stats: { start_month: '2026-07', end_month: '2026-07' }) + + expect(find('tr', text: 'New students')).to have_text('1') + expect(find('tr', text: 'New coaches')).to have_text('1') + expect(find('tr', text: 'Total new members')).to have_text('1') + end + end + + scenario 'a start-after-end submission shows the inline error and keeps the previous range' do + Fabricate(:workshop_no_sponsor, date_and_time: Time.zone.local(2026, 6, 1, 18, 30)) + + travel_to(Time.zone.local(2026, 9, 10, 12, 0)) do + visit admin_stats_path + click_button '3 months' + + fill_in 'Start month', with: '2026-08' + fill_in 'End month', with: '2026-07' + click_button 'Apply' + + expect(page).to have_text('The start month must not be after the end month.') + expect(page).to have_css('th', text: 'June 2026') + expect(page).to have_css('th', text: 'July 2026') + expect(page).to have_css('th', text: 'August 2026') + end + end + + scenario 'row totals equal the sum of the month cells' do + may = Fabricate(:workshop_no_sponsor, date_and_time: Time.zone.local(2026, 5, 6, 18, 30)) + june = Fabricate(:workshop_no_sponsor, date_and_time: Time.zone.local(2026, 6, 10, 18, 30)) + coach = Fabricate(:member) + Fabricate(:attended_workshop_invitation, workshop: may, member: coach, role: 'Coach') + Fabricate(:attended_workshop_invitation, workshop: june, member: coach, role: 'Coach') + Fabricate(:attended_workshop_invitation, workshop: june, role: 'Coach') + + travel_to(Time.zone.local(2026, 9, 10, 12, 0)) do + visit admin_stats_path(stats: { start_month: '2026-05', end_month: '2026-06' }) + + row = find('tr', text: 'Coach check-ins') + expect(row.all('td').map(&:text)).to eq(%w[1 2 3]) + end + end +end