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 @@
+
+
Admin stats
+
+
Organisation-wide monthly figures across all chapters, for the selected month range. Totals cover the selected range only.
+
+ <% if @range_error.present? %>
+
<%= @range_error %>
+ <% 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
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
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