From bedc325b10301d168c784f900362fc5e9c9b1e8e Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Thu, 25 Jun 2026 19:14:12 +0200 Subject: [PATCH] feat: add chapter status admin page Admin page at /admin/chapters/status showing which chapters are active, dormant, or inactive over a configurable 6 or 12 month window. Each chapter shows workshop count and eligible member counts (not banned, accepted terms, subscribed) per role. Active chapters running no workshops in the last n-1 months are flagged At Risk. The window includes future workshops (3 months ahead) so chapters with upcoming events stay classified as active. Uses ERB + ViewComponent. Also seeds demo chapters for the three status categories: Bournemouth (inactive), South London (dormant), Edinburgh (at-risk). --- .../chapter_status/table_component.html.erb | 45 +++++++++++ .../admin/chapter_status/table_component.rb | 14 ++++ app/controllers/admin/chapters_controller.rb | 45 +++++++++++ app/views/admin/chapters/status.html.erb | 21 +++++ app/views/admin/portal/index.html.haml | 1 + config/routes.rb | 1 + db/seeds.rb | 21 +++++ .../admin/chapters_controller_spec.rb | 79 +++++++++++++++++++ 8 files changed, 227 insertions(+) create mode 100644 app/components/admin/chapter_status/table_component.html.erb create mode 100644 app/components/admin/chapter_status/table_component.rb create mode 100644 app/views/admin/chapters/status.html.erb create mode 100644 spec/controllers/admin/chapters_controller_spec.rb diff --git a/app/components/admin/chapter_status/table_component.html.erb b/app/components/admin/chapter_status/table_component.html.erb new file mode 100644 index 000000000..4c245adb7 --- /dev/null +++ b/app/components/admin/chapter_status/table_component.html.erb @@ -0,0 +1,45 @@ +<% if rows.any? %> +
+

<%= label %> (<%= rows.size %>)

+
+ What does this mean? +
+ <% if label == 'Active' %> + Chapters with ≥1 workshop in this window. The At Risk badge marks chapters with no workshops in the last <%= months - 1 %> months — they may be winding down. + <% elsif label == 'Dormant' %> + Chapters that are enabled but haven't run any workshops in this window. + <% else %> + Chapters that have been disabled. + <% end %> +
+
+
+ + + + + + + + + + + <% rows.each do |row| %> + <% at_risk = at_risk_ids&.include?(row[:chapter].id) %> + + + + + + + <% end %> + +
ChapterWorkshopsEligible StudentsEligible Coaches
+ <%= link_to row[:chapter].name, [:admin, row[:chapter]] %> + <% if at_risk %> + At Risk + <% end %> + <%= row[:workshops] %><%= row[:eligible_students] %><%= row[:eligible_coaches] %>
+
+
+<% end %> diff --git a/app/components/admin/chapter_status/table_component.rb b/app/components/admin/chapter_status/table_component.rb new file mode 100644 index 000000000..45bd6fa62 --- /dev/null +++ b/app/components/admin/chapter_status/table_component.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class Admin::ChapterStatus::TableComponent < ViewComponent::Base + def initialize(label:, rows:, months:, at_risk_ids: nil) # rubocop:disable Lint/MissingSuper + @label = label + @rows = rows + @months = months + @at_risk_ids = at_risk_ids + end + + private + + attr_reader :label, :rows, :months, :at_risk_ids +end diff --git a/app/controllers/admin/chapters_controller.rb b/app/controllers/admin/chapters_controller.rb index f0f5eee8e..ea9be5de9 100644 --- a/app/controllers/admin/chapters_controller.rb +++ b/app/controllers/admin/chapters_controller.rb @@ -46,6 +46,51 @@ def update end end + def status + skip_authorization + @months = [6, 12].include?(params[:months].to_i) ? params[:months].to_i : 6 + period_start = @months.months.ago.beginning_of_day + recent_cutoff = (@months - 1).months.ago.beginning_of_day + + chapters = Chapter.all.index_by(&:id) + + ws_data = Chapter.joins(:workshops) + .where(workshops: { date_and_time: period_start..3.months.from_now }) + .pluck(:chapter_id, 'workshops.date_and_time') + + eligible = Member.not_banned.accepted_toc + .joins(groups: :chapter) + .where(groups: { name: %w[Students Coaches] }) + .group(:chapter_id, 'groups.name') + .count + + ws_by_ch = ws_data.group_by(&:first) + + rows = chapters.map do |ch_id, ch| + ws_dates = (ws_by_ch[ch_id] || []).map(&:second) + ws_count = ws_dates.size + ws_recent = ws_dates.count { |d| d >= recent_cutoff } + { + chapter: ch, + workshops: ws_count, + recent_workshops: ws_recent, + eligible_students: eligible.fetch([ch_id, 'Students'], 0), + eligible_coaches: eligible.fetch([ch_id, 'Coaches'], 0) + } + end + + @active = rows.select { |r| r[:workshops] > 0 } + .sort_by { |r| [-r[:workshops], -(r[:eligible_students] + r[:eligible_coaches])] } + + @dormant = rows.select { |r| r[:workshops] == 0 && r[:chapter].active? } + .sort_by { |r| -(r[:eligible_students] + r[:eligible_coaches]) } + + @inactive = rows.select { |r| !r[:chapter].active? } + .sort_by { |r| -(r[:eligible_students] + r[:eligible_coaches]) } + + @at_risk_ids = @active.select { |r| r[:recent_workshops] == 0 }.map { |r| r[:chapter].id }.to_set + end + def members chapter = Chapter.find(params[:chapter_id]) authorize chapter diff --git a/app/views/admin/chapters/status.html.erb b/app/views/admin/chapters/status.html.erb new file mode 100644 index 000000000..c36a9a082 --- /dev/null +++ b/app/views/admin/chapters/status.html.erb @@ -0,0 +1,21 @@ +<% content_for :title, "Chapter Status — last #{@months} months" %> + +
+

Chapter Status

+ +

Chapter activity overview (including workshops scheduled up to 3 months ahead). "Eligible" means subscribed, not banned, and accepted terms.

+ + <%= form_tag status_admin_chapters_path, method: :get, class: 'd-inline-block mb-4' do %> +
+ <% active_class = ->(m) { @months == m ? ' active' : '' } %> + <%= button_tag '6 months', name: 'months', value: 6, class: "btn btn-outline-primary#{active_class.call(6)}", data: { disable_with: '6 months' } %> + <%= button_tag '12 months', name: 'months', value: 12, class: "btn btn-outline-primary#{active_class.call(12)}", data: { disable_with: '12 months' } %> +
+ <% end %> + + <%= render Admin::ChapterStatus::TableComponent.new(label: 'Active', rows: @active, months: @months, at_risk_ids: @at_risk_ids) %> + + <%= render Admin::ChapterStatus::TableComponent.new(label: 'Dormant', rows: @dormant, months: @months) %> + + <%= render Admin::ChapterStatus::TableComponent.new(label: 'Inactive', rows: @inactive, months: @months) %> +
diff --git a/app/views/admin/portal/index.html.haml b/app/views/admin/portal/index.html.haml index 0ce41ae6d..cac1ce311 100644 --- a/app/views/admin/portal/index.html.haml +++ b/app/views/admin/portal/index.html.haml @@ -20,6 +20,7 @@ = link_to 'Sponsor contacts', admin_contacts_path, class: 'btn btn-primary btn-lg mb-3' = 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' %hr .row diff --git a/config/routes.rb b/config/routes.rb index a0c5e29b4..bc8239af1 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -108,6 +108,7 @@ resources :chapters, only: %i[index new create show edit update] do get :members + get :status, on: :collection resources :workshops, only: [:index] resources :feedback, only: [:index], controller: 'chapters/feedback' resources :organisers, only: %i[index create destroy], controller: 'chapters/organisers' diff --git a/db/seeds.rb b/db/seeds.rb index 5b2b6f587..337aaf7b5 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -29,6 +29,27 @@ Fabricate(:chapter_with_groups, name: name) end + # Chapters for the status page: inactive, dormant, and at-risk + bournemouth = Chapter.find_or_initialize_by(name: 'Bournemouth').tap do |ch| + ch.assign_attributes(active: false, city: 'Bournemouth', email: 'bournemouth@codebar.io', time_zone: 'London') + ch.save!(validate: false) + end + Fabricate(:workshop, chapter: bournemouth, date_and_time: 5.years.ago) + + south_london = Chapter.find_or_initialize_by(name: 'South London').tap do |ch| + ch.assign_attributes(active: true, city: 'London', email: 'south-london@codebar.io', time_zone: 'London') + ch.save!(validate: false) + end + Fabricate(:workshop, chapter: south_london, date_and_time: 3.years.ago) + + edinburgh = Chapter.find_or_initialize_by(name: 'Edinburgh').tap do |ch| + ch.assign_attributes(active: true, city: 'Edinburgh', email: 'edinburgh@codebar.io', time_zone: 'London') + ch.save!(validate: false) + end + Fabricate(:workshop, chapter: edinburgh, date_and_time: 6.months.ago + 1.week) + + chapters += [bournemouth, south_london, edinburgh] + Rails.logger.info 'Creating workshops...' Rails.logger.info 'Creating 1000 past workshops...' diff --git a/spec/controllers/admin/chapters_controller_spec.rb b/spec/controllers/admin/chapters_controller_spec.rb new file mode 100644 index 000000000..01bdea3a3 --- /dev/null +++ b/spec/controllers/admin/chapters_controller_spec.rb @@ -0,0 +1,79 @@ +RSpec.describe Admin::ChaptersController, type: :controller do + let(:admin) { Fabricate(:member) } + + before do + login_as_admin(admin) + end + + describe '#status' do + it 'renders successfully with default 6 months' do + get :status + expect(response).to be_successful + expect(controller.view_assigns['months']).to eq(6) + end + + it 'respects months param' do + get :status, params: { months: '12' } + expect(controller.view_assigns['months']).to eq(12) + end + + it 'falls back to 6 for invalid months' do + get :status, params: { months: '3' } + expect(controller.view_assigns['months']).to eq(6) + end + + it 'classifies chapters correctly' do + get :status + expect(controller.view_assigns['active']).to be_a(Array) + expect(controller.view_assigns['dormant']).to be_a(Array) + expect(controller.view_assigns['inactive']).to be_a(Array) + end + + it 'marks a chapter with a past workshop as active' do + chapter = Fabricate(:chapter_with_groups) + Fabricate(:workshop, chapter: chapter, date_and_time: 2.months.ago) + + get :status + + active_names = controller.view_assigns['active'].map { |r| r[:chapter].name } + expect(active_names).to include(chapter.name) + end + + it 'marks a chapter with only a future workshop as active' do + chapter = Fabricate(:chapter_with_groups) + Fabricate(:workshop, chapter: chapter, date_and_time: 2.months.from_now) + + get :status + + active_names = controller.view_assigns['active'].map { |r| r[:chapter].name } + expect(active_names).to include(chapter.name) + end + + it 'marks an active:false chapter as inactive' do + chapter = Fabricate(:chapter_with_groups, active: false) + + get :status + + inactive_names = controller.view_assigns['inactive'].map { |r| r[:chapter].name } + expect(inactive_names).to include(chapter.name) + end + + it 'flags at-risk chapters with no recent workshops' do + chapter = Fabricate(:chapter_with_groups) + Fabricate(:workshop, chapter: chapter, date_and_time: 6.months.ago + 1.week) + + get :status, params: { months: '6' } + + expect(controller.view_assigns['at_risk_ids']).to include(chapter.id) + end + + it 'does not flag active chapters with recent workshops as at-risk' do + chapter = Fabricate(:chapter_with_groups) + Fabricate(:workshop, chapter: chapter, date_and_time: 1.month.ago) + + get :status, params: { months: '6' } + + expect(controller.view_assigns['at_risk_ids']).not_to include(chapter.id) + end + end +end