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 %>
+
+
+
+
+
+
+ | Chapter |
+ Workshops |
+ Eligible Students |
+ Eligible Coaches |
+
+
+
+ <% rows.each do |row| %>
+ <% at_risk = at_risk_ids&.include?(row[:chapter].id) %>
+
+ |
+ <%= link_to row[:chapter].name, [:admin, row[:chapter]] %>
+ <% if at_risk %>
+ At Risk
+ <% end %>
+ |
+ <%= row[:workshops] %> |
+ <%= row[:eligible_students] %> |
+ <%= row[:eligible_coaches] %> |
+
+ <% end %>
+
+
+
+
+<% 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