diff --git a/app/components/admin/stats/table_component.rb b/app/components/admin/stats/table_component.rb index 9165b20f1..33a201d4e 100644 --- a/app/components/admin/stats/table_component.rb +++ b/app/components/admin/stats/table_component.rb @@ -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) diff --git a/app/services/admin/stats/monthly.rb b/app/services/admin/stats/monthly.rb index b574e174e..7432f26b2 100644 --- a/app/services/admin/stats/monthly.rb +++ b/app/services/admin/stats/monthly.rb @@ -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', @@ -32,7 +36,7 @@ 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 @@ -40,13 +44,53 @@ def call(range) 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) @@ -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| diff --git a/app/views/admin/stats/index.html.erb b/app/views/admin/stats/index.html.erb index bb2004d0b..bc7d63748 100644 --- a/app/views/admin/stats/index.html.erb +++ b/app/views/admin/stats/index.html.erb @@ -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, diff --git a/db/schema.rb b/db/schema.rb index fa1431a2d..fef408fd9 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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)" diff --git a/spec/components/admin/stats/table_component_spec.rb b/spec/components/admin/stats/table_component_spec.rb index 07be23ca0..bddba81a2 100644 --- a/spec/components/admin/stats/table_component_spec.rb +++ b/spec/components/admin/stats/table_component_spec.rb @@ -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) diff --git a/spec/services/admin/stats/monthly_spec.rb b/spec/services/admin/stats/monthly_spec.rb index 05e26765f..ae106aab9 100644 --- a/spec/services/admin/stats/monthly_spec.rb +++ b/spec/services/admin/stats/monthly_spec.rb @@ -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))