Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions app/components/admin/chapter_status/table_component.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<% if rows.any? %>
<section class="mb-5">
<h2 class="mb-1"><%= label %> (<%= rows.size %>)</h2>
<details class="mb-3">
<summary class="small text-muted">What does this mean?</summary>
<div class="small text-muted">
<% if label == 'Active' %>
Chapters with ≥1 workshop in this window. The <span class="badge bg-warning text-dark">At Risk</span> 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 %>
</div>
</details>
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>Chapter</th>
<th>Workshops</th>
<th>Eligible Students</th>
<th>Eligible Coaches</th>
</tr>
</thead>
<tbody>
<% rows.each do |row| %>
<% at_risk = at_risk_ids&.include?(row[:chapter].id) %>
<tr class="<%= 'table-warning' if at_risk %>">
<td>
<%= link_to row[:chapter].name, [:admin, row[:chapter]] %>
<% if at_risk %>
<span class="badge bg-warning text-dark ms-2" title="No workshops in the last <%= months - 1 %> months">At Risk</span>
<% end %>
</td>
<td><%= row[:workshops] %></td>
<td><%= row[:eligible_students] %></td>
<td><%= row[:eligible_coaches] %></td>
</tr>
<% end %>
</tbody>
</table>
</div>
</section>
<% end %>
14 changes: 14 additions & 0 deletions app/components/admin/chapter_status/table_component.rb
Original file line number Diff line number Diff line change
@@ -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
45 changes: 45 additions & 0 deletions app/controllers/admin/chapters_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions app/views/admin/chapters/status.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<% content_for :title, "Chapter Status — last #{@months} months" %>

<div class="container py-4 py-lg-5">
<h1>Chapter Status</h1>

<p class="lead">Chapter activity overview (including workshops scheduled up to 3 months ahead). "Eligible" means subscribed, not banned, and accepted terms.</p>

<%= form_tag status_admin_chapters_path, method: :get, class: 'd-inline-block mb-4' do %>
<div class="btn-group" role="group">
<% 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' } %>
</div>
<% 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) %>
</div>
1 change: 1 addition & 0 deletions app/views/admin/portal/index.html.haml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
21 changes: 21 additions & 0 deletions db/seeds.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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...'
Expand Down
79 changes: 79 additions & 0 deletions spec/controllers/admin/chapters_controller_spec.rb
Original file line number Diff line number Diff line change
@@ -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