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/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/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/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 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