From 857b02999abc431f5e5014adaba8d2821bab27da Mon Sep 17 00:00:00 2001 From: Michael Josephson <30024+mikej@users.noreply.github.com> Date: Thu, 5 Feb 2026 09:54:57 +0000 Subject: [PATCH 1/4] move chapter admin sidebar links inside a card this is a preparatory step before adding a sidebar card that will show a summary of how members found this chapter (based on the responses to the "How did you find out about us" question that's asked during signup). before adding that card we want to ensure all existing sidebar content inside a card. --- app/views/admin/chapters/show.html.haml | 26 +++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/app/views/admin/chapters/show.html.haml b/app/views/admin/chapters/show.html.haml index bf2b90eb4..747d66326 100644 --- a/app/views/admin/chapters/show.html.haml +++ b/app/views/admin/chapters/show.html.haml @@ -23,18 +23,20 @@ %span.badge.bg-warning.text-dark Phone no. not set - if current_user.is_admin? = link_to 'Edit organisers', admin_chapter_organisers_path(@chapter), class: 'btn btn-primary btn-sm' - %ul.nav.flex-column.ms-0.mb-4 - - @groups.each do |group| - %li.nav-item - = link_to [ :admin, group ], class: 'nav-link' do - #{group.name} (#{group.members.count}) - %li.nav-item - = link_to admin_chapter_members_path(@chapter, type: group.name.downcase), class: 'nav-link' do - View #{group.name} emails - %li.nav-item - = link_to 'View all sponsors', admin_sponsors_path, class: 'nav-link' - %li.nav-item - = link_to 'View all workshops', admin_chapter_workshops_path(@chapter), class: 'nav-link' + .card.border-info.mb-4 + .card-body + %ul.nav.flex-column.ms-0.mb-0 + - @groups.each do |group| + %li.nav-item + = link_to [ :admin, group ], class: 'nav-link' do + #{group.name} (#{group.members.count}) + %li.nav-item + = link_to admin_chapter_members_path(@chapter, type: group.name.downcase), class: 'nav-link' do + View #{group.name} emails + %li.nav-item + = link_to 'View all sponsors', admin_sponsors_path, class: 'nav-link' + %li.nav-item + = link_to 'View all workshops', admin_chapter_workshops_path(@chapter), class: 'nav-link' .col-12.col-lg-7.offset-lg-1 .mb-4 From 2894c32e2c1da68b9ce540646352ee53d1229f28 Mon Sep 17 00:00:00 2001 From: Michael Josephson <30024+mikej@users.noreply.github.com> Date: Thu, 5 Feb 2026 10:17:46 +0000 Subject: [PATCH 2/4] tidy spacing around cards at different breakpoints for large: cards are shown in a sidebar for medium: cards are shown next to each other, above the main column for small: cards are shown vertically one on top of the other so we need some horizontal spacing between cards for medium and some vertical spacing between cards for small. --- app/views/admin/chapters/show.html.haml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/admin/chapters/show.html.haml b/app/views/admin/chapters/show.html.haml index 747d66326..38cdded80 100644 --- a/app/views/admin/chapters/show.html.haml +++ b/app/views/admin/chapters/show.html.haml @@ -23,7 +23,7 @@ %span.badge.bg-warning.text-dark Phone no. not set - if current_user.is_admin? = link_to 'Edit organisers', admin_chapter_organisers_path(@chapter), class: 'btn btn-primary btn-sm' - .card.border-info.mb-4 + .card.border-info.my-4.my-md-0.my-lg-4.ms-md-4.ms-lg-0 .card-body %ul.nav.flex-column.ms-0.mb-0 - @groups.each do |group| @@ -39,7 +39,7 @@ = link_to 'View all workshops', admin_chapter_workshops_path(@chapter), class: 'nav-link' .col-12.col-lg-7.offset-lg-1 - .mb-4 + .mb-4.mt-md-4.mt-lg-0 .d-md-flex.justify-content-between.align-items-center %h3 Upcoming Workshops = link_to 'New workshop', new_admin_workshop_path, class: 'btn btn-primary btn-sm' From f0d28db4bb9c944a9f230611ab6cb3d076b0a7ce Mon Sep 17 00:00:00 2001 From: Michael Josephson <30024+mikej@users.noreply.github.com> Date: Thu, 5 Feb 2026 10:24:21 +0000 Subject: [PATCH 3/4] reduce space between sidebar and main column reduce space between sidebar and main column on the chapter admin page offsetting by a column was introducing quite a lot of horizontal space between the two columns so we can reduce this and create more space for the content. --- app/views/admin/chapters/show.html.haml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/admin/chapters/show.html.haml b/app/views/admin/chapters/show.html.haml index 38cdded80..7b03db19a 100644 --- a/app/views/admin/chapters/show.html.haml +++ b/app/views/admin/chapters/show.html.haml @@ -38,7 +38,7 @@ %li.nav-item = link_to 'View all workshops', admin_chapter_workshops_path(@chapter), class: 'nav-link' - .col-12.col-lg-7.offset-lg-1 + .col-12.col-lg-8 .mb-4.mt-md-4.mt-lg-0 .d-md-flex.justify-content-between.align-items-center %h3 Upcoming Workshops From 4a06f906ee0df7ec95cd62c2e91574586d22295f Mon Sep 17 00:00:00 2001 From: Michael Josephson <30024+mikej@users.noreply.github.com> Date: Thu, 5 Feb 2026 10:42:08 +0000 Subject: [PATCH 4/4] add "How members found this chapter" sidebar card add a card to the sidebar on the chapter admin page that shows a summary of the responses to the "How did you find out about us" question that members are asked during signup. reword to use past tense rename variable for clarity --- app/assets/stylesheets/_bootstrap-custom.scss | 2 +- app/controllers/admin/chapters_controller.rb | 1 + app/presenters/how_you_found_us_presenter.rb | 49 +++++++++++++ app/views/admin/chapters/show.html.haml | 15 ++++ spec/features/admin/chapters_spec.rb | 25 +++++++ .../how_you_found_us_presenter_spec.rb | 72 +++++++++++++++++++ 6 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 app/presenters/how_you_found_us_presenter.rb create mode 100644 spec/presenters/how_you_found_us_presenter_spec.rb diff --git a/app/assets/stylesheets/_bootstrap-custom.scss b/app/assets/stylesheets/_bootstrap-custom.scss index 9778c966d..971cb54ec 100644 --- a/app/assets/stylesheets/_bootstrap-custom.scss +++ b/app/assets/stylesheets/_bootstrap-custom.scss @@ -37,7 +37,7 @@ $carousel-control-width: 3rem; @import "bootstrap/pagination"; @import "bootstrap/badge"; @import "bootstrap/alert"; -// @import "bootstrap/progress"; +@import "bootstrap/progress"; @import "bootstrap/list-group"; @import "bootstrap/close"; // @import "bootstrap/toasts"; diff --git a/app/controllers/admin/chapters_controller.rb b/app/controllers/admin/chapters_controller.rb index a6b3d88ef..2d1270bb9 100644 --- a/app/controllers/admin/chapters_controller.rb +++ b/app/controllers/admin/chapters_controller.rb @@ -27,6 +27,7 @@ def show @sponsors = @chapter.sponsors.uniq @groups = @chapter.groups @subscribers = @chapter.subscriptions.last(20).reverse + @how_you_found_us = HowYouFoundUsPresenter.new(@chapter) end def edit diff --git a/app/presenters/how_you_found_us_presenter.rb b/app/presenters/how_you_found_us_presenter.rb new file mode 100644 index 000000000..86ee39e70 --- /dev/null +++ b/app/presenters/how_you_found_us_presenter.rb @@ -0,0 +1,49 @@ +class HowYouFoundUsPresenter + + def initialize(chapter) + @chapter = chapter + end + + def by_percentage + return how_values.to_h { |how| [how, 0] } unless data_present? + + # use the largest remainder algorithm so that percentages are whole + # numbers but always add up to 100 + # https://stackoverflow.com/a/13483710/ + entries = how_values.map do |how| + count = raw_stats.fetch(how, 0) + exact = (count / total_responses.to_f) * 100 + percentage_value = exact.floor + remainder = exact - percentage_value + { how: how, percentage_value: percentage_value, remainder: remainder } + end + + allocated_so_far = entries.sum { |entry| entry[:percentage_value] } + left_to_allocate = 100 - allocated_so_far + + entries + .sort_by { |entry| [-entry[:remainder], entry[:how].to_s] } + .first(left_to_allocate) + .each { |entry| entry[:percentage_value] += 1 } + + entries.to_h { |entry| [entry[:how], entry[:percentage_value]] } + end + + def total_responses + raw_stats.values.sum(0) + end + + def data_present? + total_responses.positive? + end + + private + + def raw_stats + @stats ||= @chapter.members.where.not(how_you_found_us: nil).group(:how_you_found_us).count + end + + def how_values + Member.how_you_found_us.keys + end +end diff --git a/app/views/admin/chapters/show.html.haml b/app/views/admin/chapters/show.html.haml index 7b03db19a..f2f3bc13f 100644 --- a/app/views/admin/chapters/show.html.haml +++ b/app/views/admin/chapters/show.html.haml @@ -23,6 +23,7 @@ %span.badge.bg-warning.text-dark Phone no. not set - if current_user.is_admin? = link_to 'Edit organisers', admin_chapter_organisers_path(@chapter), class: 'btn btn-primary btn-sm' + .card.border-info.my-4.my-md-0.my-lg-4.ms-md-4.ms-lg-0 .card-body %ul.nav.flex-column.ms-0.mb-0 @@ -38,6 +39,20 @@ %li.nav-item = link_to 'View all workshops', admin_chapter_workshops_path(@chapter), class: 'nav-link' + - if @how_you_found_us.data_present? + .card.border-info.my-4.my-md-0.my-lg-4.ms-md-4.ms-lg-0 + .card-body + %h3 How members found this chapter + - @how_you_found_us.by_percentage.each do |(how, percent)| + - label = t("member.details.edit.how_you_found_us_options.#{how}") + .mb-3 + .d-flex.justify-content-between + %div= label + %div #{percent}% + .progress + .progress-bar.bg-primary{ role: 'progressbar', style: "width: #{percent}%", "aria-valuenow": percent, "aria-valuemin": 0, "aria-valuemax": 100 } + %div.text-muted.small= "Based on #{pluralize(@how_you_found_us.total_responses, 'response')}" + .col-12.col-lg-8 .mb-4.mt-md-4.mt-lg-0 .d-md-flex.justify-content-between.align-items-center diff --git a/spec/features/admin/chapters_spec.rb b/spec/features/admin/chapters_spec.rb index 4ebe7f93f..9434a4229 100644 --- a/spec/features/admin/chapters_spec.rb +++ b/spec/features/admin/chapters_spec.rb @@ -119,4 +119,29 @@ expect(page).not_to have_content(coach_email) end end + + context 'how you found us card' do + let(:chapter) { Fabricate(:chapter) } + let(:group) { Fabricate(:group, chapter: chapter) } + + before do + login_as_admin(member) + end + + scenario 'shows the card when there are responses' do + member_with_response = Fabricate(:member, how_you_found_us: :from_a_friend) + Fabricate(:subscription, member: member_with_response, group: group) + + visit admin_chapter_path(chapter) + + expect(page).to have_content('How members found this chapter') + expect(page).to have_content('Based on 1 response') + end + + scenario 'does not show the card when there are no responses' do + visit admin_chapter_path(chapter) + + expect(page).not_to have_content('How members found this chapter') + end + end end diff --git a/spec/presenters/how_you_found_us_presenter_spec.rb b/spec/presenters/how_you_found_us_presenter_spec.rb new file mode 100644 index 000000000..fb522f388 --- /dev/null +++ b/spec/presenters/how_you_found_us_presenter_spec.rb @@ -0,0 +1,72 @@ +RSpec.describe HowYouFoundUsPresenter do + def add_member(group, how) + member = Fabricate(:member, how_you_found_us: how) + Fabricate(:subscription, member: member, group: group) + member + end + + def add_member_without_how(group) + member = Fabricate(:member, how_you_found_us: nil) + Fabricate(:subscription, member: member, group: group) + member + end + + let(:chapter) { Fabricate(:chapter_without_organisers) } + let(:group) { Fabricate(:group, chapter: chapter) } + let(:presenter) { HowYouFoundUsPresenter.new(chapter) } + + describe '#by_percentage' do + it 'returns integer percentages for all enum values in enum order using largest remainder rounding' do + add_member(group, :from_a_friend) + add_member(group, :search_engine) + add_member(group, :search_engine) + add_member(group, :social_media) + add_member(group, :social_media) + add_member(group, :social_media) + + expect(presenter.by_percentage).to eq( + { + 'from_a_friend' => 17, + 'search_engine' => 33, + 'social_media' => 50, + 'codebar_host_or_partner' => 0, + 'other' => 0 + } + ) + expect(presenter.by_percentage.values.sum).to eq(100) + end + + it 'returns all enum values with zeros when there is no data' do + expect(presenter.by_percentage).to eq( + { + 'from_a_friend' => 0, + 'search_engine' => 0, + 'social_media' => 0, + 'codebar_host_or_partner' => 0, + 'other' => 0 + } + ) + end + end + + describe '#total_responses' do + it 'sums the counts' do + add_member(group, :from_a_friend) + add_member(group, :search_engine) + + expect(presenter.total_responses).to eq(2) + end + end + + describe '#data_present?' do + it 'returns true when there are responses' do + add_member(group, :from_a_friend) + + expect(presenter.data_present?).to eq(true) + end + + it 'returns false when there are no responses' do + expect(presenter.data_present?).to eq(false) + end + end +end