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
15 changes: 14 additions & 1 deletion app/components/admin/stats/table_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,20 @@ def month_header(month)
end

def cell_value(row, month)
number_with_delimiter(row.cells.fetch(month, 0))
value = row.cells.fetch(month, 0)
return number_with_delimiter(value) unless percentage_row?(row)

"#{number_with_delimiter(value)} (#{percentage_of_total(value, row.total)}%)"
end

def percentage_row?(row)
Admin::Stats::Monthly::RETURNING_LABELS.value?(row.label)
end

def percentage_of_total(value, total)
return 0 if total.zero?

(value.to_f / total * 100).round
end

def total_value(row)
Expand Down
94 changes: 91 additions & 3 deletions app/services/admin/stats/monthly.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ class Monthly
coach_check_ins: 'Coach check-ins',
student_rsvps: 'Student RSVPs',
coach_rsvps: 'Coach RSVPs' }.freeze
RETURNING_LABELS = { returning_students: 'Returning students',
returning_coaches: 'Returning coaches' }.freeze
RETURNING_KEYS = { 'Student' => :returning_students, 'Coach' => :returning_coaches }.freeze
TRANSITION_LABEL = 'Students who became coaches'
SIGN_UP_LABELS = { students: 'New students',
coaches: 'New coaches',
uncategorised: 'Uncategorised',
Expand All @@ -32,21 +36,61 @@ def call(range)
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)]
rows = attendance_rows(months) + sign_up_rows(months) + [workshop_row(months), *transition_rows(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|
tally_attendance(counts, grouped_attendance(months))
ATTENDANCE_LABELS.map { |key, label| row(:attendance, label, months, counts[key]) } + returning_rows(months)
end

def tally_attendance(counts, rows)
rows.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

# A returning member attended at least one workshop in an earlier
# calendar month than the one being counted, so the same-month repeat
# does not count. Distinct per month and role.
def returning_rows(months)
counts = blank_counts(months, RETURNING_LABELS.keys)
grouped_returning(months).each do |month, role, count|
key = @month_keys[month]
next unless key

row_key = RETURNING_KEYS[role]
counts[row_key][key] += count if row_key
end
RETURNING_LABELS.map { |key, label| row(:attendance, label, months, counts[key]) }
end

def grouped_returning(months)
WorkshopInvitation.joins(:workshop)
.where(workshops: { date_and_time: window(months) }, attended: true)
.where(prior_attendance_exists)
.group(month_bucket('workshops.date_and_time'), :role)
.pluck(month_bucket('workshops.date_and_time'), :role,
Arel.sql('COUNT(DISTINCT workshop_invitations.member_id)'))
end

def prior_attendance_exists
Arel.sql(<<~SQL.squish)
EXISTS (
SELECT 1 FROM workshop_invitations AS prior_invitations
INNER JOIN workshops AS prior_workshops ON prior_workshops.id = prior_invitations.workshop_id
WHERE prior_invitations.member_id = workshop_invitations.member_id
AND prior_invitations.attended = TRUE
AND #{month_bucket('prior_workshops.date_and_time')} < #{month_bucket('workshops.date_and_time')}
)
SQL
end

def grouped_attendance(months)
Expand All @@ -69,6 +113,50 @@ def attendance_keys(role, attended, attending)
end
end

# Bucketed by the month of the member's first Coaches subscription.
# Subscriptions are append-only (unsubscribes set discarded_at), so a
# Students subscription created earlier marks the transition. Chapter is
# ignored and a still-kept Students subscription does not disqualify.
def transition_rows(months)
counts = default_cells(months)
grouped_transitions(months).each do |month, count|
key = @month_keys[month]
counts[key] += count if key
end
[row(:transitions, TRANSITION_LABEL, months, counts)]
end

def grouped_transitions(months)
Subscription.from("(#{first_coach_sql}) first_coaches")
.where('first_coaches.first_coach_at BETWEEN ? AND ?',
window(months).begin, window(months).end)
.where(prior_student_exists)
.group(month_bucket('first_coaches.first_coach_at'))
.pluck(month_bucket('first_coaches.first_coach_at'), Arel.sql('COUNT(*)'))
end

def first_coach_sql
<<~SQL.squish
SELECT s.member_id, MIN(s.created_at) AS first_coach_at
FROM subscriptions s
INNER JOIN groups g ON g.id = s.group_id
WHERE g.name = 'Coaches'
GROUP BY s.member_id
SQL
end

def prior_student_exists
Arel.sql(<<~SQL.squish)
EXISTS (
SELECT 1 FROM subscriptions student_subs
INNER JOIN groups student_groups ON student_groups.id = student_subs.group_id
WHERE student_subs.member_id = first_coaches.member_id
AND student_groups.name = 'Students'
AND student_subs.created_at < first_coaches.first_coach_at
)
SQL
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|
Expand Down
7 changes: 4 additions & 3 deletions app/views/admin/stats/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,10 @@
<% 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') %>
<%= render Admin::Stats::TableComponent.new(months: @months, rows: rows_by_section.fetch(:attendance, []), caption: 'Attendance per month') %>
<%= render Admin::Stats::TableComponent.new(months: @months, rows: rows_by_section.fetch(:sign_ups, []), caption: 'New sign-ups per month') %>
<%= render Admin::Stats::TableComponent.new(months: @months, rows: rows_by_section.fetch(:workshops, []), caption: 'Workshops per month') %>
<%= render Admin::Stats::TableComponent.new(months: @months, rows: rows_by_section.fetch(:transitions, []), caption: 'Students who became coaches') %>

<%= link_to 'Download CSV',
admin_stats_path(format: :csv,
Expand Down
2 changes: 1 addition & 1 deletion db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -466,10 +466,10 @@

create_table "subscriptions", id: :serial, force: :cascade do |t|
t.datetime "created_at", precision: nil
t.datetime "discarded_at"
t.integer "group_id"
t.integer "member_id"
t.datetime "updated_at", precision: nil
t.datetime "discarded_at"
t.index ["discarded_at"], name: "index_subscriptions_on_discarded_at"
t.index ["group_id"], name: "index_subscriptions_on_group_id"
t.index ["member_id", "group_id"], name: "index_subscriptions_on_member_id_group_id_active", unique: true, where: "(discarded_at IS NULL)"
Expand Down
30 changes: 30 additions & 0 deletions spec/components/admin/stats/table_component_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,36 @@
expect(page).to have_css('td.text-end', text: '1,241') # totals cell
end

it 'shows each cell as a percentage of the row total in brackets for returning rows' do
returning_row = Admin::Stats::Monthly::Row.new(section: :attendance, label: 'Returning students',
cells: { Date.new(2026, 6, 1) => 1234, Date.new(2026, 7, 1) => 7,
Date.new(2026, 8, 1) => 0 },
total: 1241)

render_inline(described_class.new(months:, rows: [returning_row], caption: 'Attendance'))

expect(page).to have_css('td.text-end', text: '1,234 (99%)')
expect(page).to have_css('td.text-end', text: '7 (1%)')
expect(page).to have_css('td.text-end', text: '0 (0%)')
end

it 'does not show percentages for other metric rows' do
render_inline(described_class.new(months:, rows: [row], caption: 'Attendance'))

expect(page).to have_css('td.text-end', text: '7')
expect(page).to have_no_css('td.text-end', text: '7 (')
end

it 'shows 0% when a returning row has a zero total' do
zero_row = Admin::Stats::Monthly::Row.new(section: :attendance, label: 'Returning coaches',
cells: { Date.new(2026, 6, 1) => 0 }, total: 0)

render_inline(described_class.new(months: [Date.new(2026, 6, 1)], rows: [zero_row], caption: 'Attendance'))

expect(page).to have_css('td.text-end', text: '0 (0%)')
expect(page).to have_css('td.text-end', text: '0') # 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)
Expand Down
130 changes: 130 additions & 0 deletions spec/services/admin/stats/monthly_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,136 @@ def total_for(section, label)
end
end

describe 'returning member rows' do
it 'counts a member attending in a later month as a returning student' 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, 'Returning students', [2026, 5])).to eq(0)
expect(cell(:attendance, 'Returning students', [2026, 6])).to eq(1)
end

it 'counts returning coaches on the coach row' 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: 'Coach')
Fabricate(:attended_workshop_invitation, workshop: june, member:, role: 'Coach')

expect(cell(:attendance, 'Returning coaches', [2026, 6])).to eq(1)
expect(cell(:attendance, 'Returning students', [2026, 6])).to eq(0)
end

it 'counts prior attendance before the selected range as returning (any prior ever)' do
member = Fabricate(:member)
april = Fabricate(:workshop_no_sponsor, date_and_time: Time.zone.local(2026, 4, 8, 18, 30))
may = Fabricate(:workshop_no_sponsor, date_and_time: Time.zone.local(2026, 5, 6, 18, 30))
Fabricate(:attended_workshop_invitation, workshop: april, member:, role: 'Student')
Fabricate(:attended_workshop_invitation, workshop: may, member:, role: 'Student')

expect(cell(:attendance, 'Returning students', [2026, 5])).to eq(1)
end

it 'does not count attendance in the same month as returning' do
member = Fabricate(:member)
first = Fabricate(:workshop_no_sponsor, date_and_time: Time.zone.local(2026, 6, 3, 18, 30))
second = Fabricate(:workshop_no_sponsor, date_and_time: Time.zone.local(2026, 6, 17, 18, 30))
Fabricate(:attended_workshop_invitation, workshop: first, member:, role: 'Student')
Fabricate(:attended_workshop_invitation, workshop: second, member:, role: 'Student')

expect(cell(:attendance, 'Returning students', [2026, 6])).to eq(0)
end

it 'counts a member once per month even with multiple attendances' do
member = Fabricate(:member)
may = Fabricate(:workshop_no_sponsor, date_and_time: Time.zone.local(2026, 5, 6, 18, 30))
june_one = Fabricate(:workshop_no_sponsor, date_and_time: Time.zone.local(2026, 6, 3, 18, 30))
june_two = Fabricate(:workshop_no_sponsor, date_and_time: Time.zone.local(2026, 6, 17, 18, 30))
Fabricate(:attended_workshop_invitation, workshop: may, member:, role: 'Student')
Fabricate(:attended_workshop_invitation, workshop: june_one, member:, role: 'Student')
Fabricate(:attended_workshop_invitation, workshop: june_two, member:, role: 'Student')

expect(cell(:attendance, 'Returning students', [2026, 6])).to eq(1)
end

it 'treats prior attendance in another role as returning' 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: 'Coach')
Fabricate(:attended_workshop_invitation, workshop: june, member:, role: 'Student')

expect(cell(:attendance, 'Returning students', [2026, 6])).to eq(1)
end

it 'leaves the check-in rows counting attendances' 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, 6])).to eq(1)
expect(total_for(:attendance, 'Student check-ins')).to eq(2)
end
end

describe 'student to coach rows' do
let(:students_group) { Fabricate(:group, name: 'Students') }
let(:coaches_group) { Fabricate(:group, name: 'Coaches') }

def subscribe(member, group, at)
Fabricate(:subscription, member:, group:, created_at: at)
end

it 'counts a member in the month of their first coaching subscription' do
member = Fabricate(:member)
subscribe(member, students_group, Time.zone.local(2026, 5, 10))
subscribe(member, coaches_group, Time.zone.local(2026, 6, 10))

expect(cell(:transitions, 'Students who became coaches', [2026, 5])).to eq(0)
expect(cell(:transitions, 'Students who became coaches', [2026, 6])).to eq(1)
expect(total_for(:transitions, 'Students who became coaches')).to eq(1)
end

it 'counts members who kept their student subscription (across chapters)' do
member = Fabricate(:member)
subscribe(member, students_group, Time.zone.local(2026, 5, 10))
subscribe(member, coaches_group, Time.zone.local(2026, 6, 10))

expect(students_group.chapter_id).not_to eq(coaches_group.chapter_id)
expect(cell(:transitions, 'Students who became coaches', [2026, 6])).to eq(1)
end

it 'only counts the first coaching subscription' do
member = Fabricate(:member)
subscribe(member, students_group, Time.zone.local(2026, 4, 10))
subscribe(member, coaches_group, Time.zone.local(2026, 6, 10))
subscribe(member, Fabricate(:group, name: 'Coaches'), Time.zone.local(2026, 7, 10))

expect(cell(:transitions, 'Students who became coaches', [2026, 6])).to eq(1)
expect(cell(:transitions, 'Students who became coaches', [2026, 7])).to eq(0)
end

it 'does not count coaches who never had a student subscription' do
member = Fabricate(:member)
subscribe(member, coaches_group, Time.zone.local(2026, 6, 10))

expect(cell(:transitions, 'Students who became coaches', [2026, 6])).to eq(0)
end

it 'does not count a coaching subscription created before the student one' do
member = Fabricate(:member)
subscribe(member, coaches_group, Time.zone.local(2026, 5, 10))
subscribe(member, students_group, Time.zone.local(2026, 6, 10))

expect(total_for(:transitions, 'Students who became coaches')).to eq(0)
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))
Expand Down
Loading