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 bf2b90eb4..f2f3bc13f 100644 --- a/app/views/admin/chapters/show.html.haml +++ b/app/views/admin/chapters/show.html.haml @@ -23,21 +23,38 @@ %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' - .col-12.col-lg-7.offset-lg-1 - .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| + %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' + + - 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 %h3 Upcoming Workshops = link_to 'New workshop', new_admin_workshop_path, class: 'btn btn-primary btn-sm' 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