diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index 71cbed7d0..3af1249cd 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -23,6 +23,7 @@ $primary: $dark-codebar-blue !default; @import "bootstrap-custom"; +@import "partials/activity_strip"; /* Bootstrap's Reboot sets legends to float: left, which puts the first check box in a fieldset off to the right instead of underneath the legend. This overrides that. */ diff --git a/app/assets/stylesheets/partials/_activity_strip.scss b/app/assets/stylesheets/partials/_activity_strip.scss new file mode 100644 index 000000000..5c823ce8c --- /dev/null +++ b/app/assets/stylesheets/partials/_activity_strip.scss @@ -0,0 +1,8 @@ +// app/assets/stylesheets/partials/_activity_strip.scss +.activity-strip { + .activity-cell { + &-empty { fill: $gray-200; } + &-login_only { fill: $gray-400; } + &-active { fill: $codebar-pink; } + } +} diff --git a/app/components/admin/members/activity_strip_component.html.erb b/app/components/admin/members/activity_strip_component.html.erb new file mode 100644 index 000000000..951769206 --- /dev/null +++ b/app/components/admin/members/activity_strip_component.html.erb @@ -0,0 +1,13 @@ +<%# app/components/admin/members/activity_strip_component.html.erb %> +
+
Activity — last 12 months
+ + <% weeks.each_with_index do |week, i| %> + + <% end %> + +
diff --git a/app/components/admin/members/activity_strip_component.rb b/app/components/admin/members/activity_strip_component.rb new file mode 100644 index 000000000..380b78f16 --- /dev/null +++ b/app/components/admin/members/activity_strip_component.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Admin + module Members + class ActivityStripComponent < ViewComponent::Base + CELL_WIDTH = 8 + CELL_GAP = 4 + + def initialize(weeks:) + super() + @weeks = weeks + end + + private + + attr_reader :weeks + + def title_for(week) + summary = week.counts.map { |key, count| "#{count} #{key.tr('.', ' ')}" }.join(', ') + "Week of #{week.week_start.strftime('%-d %b %Y')}: #{summary.presence || 'no activity'}" + end + + def svg_width + weeks.size * (CELL_WIDTH + CELL_GAP) + end + end + end +end diff --git a/app/controllers/admin/members_controller.rb b/app/controllers/admin/members_controller.rb index a649f6815..4317ed31d 100644 --- a/app/controllers/admin/members_controller.rb +++ b/app/controllers/admin/members_controller.rb @@ -24,9 +24,10 @@ def search end def show - @member = MemberPresenter.new(Member.find(params[:id])) + member = Member.find(params[:id]) + @member = MemberPresenter.new(member) load_attendance_data(@member) - + @activity_weeks = Admin::Members::ActivityStrip.new(member).rows @actions = admin_actions(@member).sort_by(&:created_at).reverse end diff --git a/app/services/admin/members/activity_strip.rb b/app/services/admin/members/activity_strip.rb new file mode 100644 index 000000000..9feacca6b --- /dev/null +++ b/app/services/admin/members/activity_strip.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module Admin + module Members + # Buckets a member's activity log into the 52 ISO weeks ending the current week. + # Sole owner of strip state classification; the component renders, never classifies. + class ActivityStrip + WEEK_COUNT = 52 + LOGIN_ONLY_KEYS = %w[member.login member.logout].freeze + + Row = Struct.new(:week_start, :state, :counts, keyword_init: true) + + def initialize(member, now: Time.zone.now) + @member = member + @now = now + end + + def rows # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + activities = PublicActivity::Activity + .where(owner: @member) + .where(created_at: window_start..@now) + .order(:created_at) + + grouped = activities.group_by { |a| a.created_at.to_date.beginning_of_week.beginning_of_day } + + weeks.map do |week_start| + week_activities = grouped[week_start] || [] + counts = week_activities.map(&:key).tally + state = classify(counts) + + Row.new(week_start:, state:, counts:) + end + end + + private + + def classify(counts) + return :empty if counts.empty? + return :login_only if counts.keys.all? { |key| LOGIN_ONLY_KEYS.include?(key) } + + :active + end + + def weeks + @weeks ||= Array.new(WEEK_COUNT) { |i| current_week_start - (WEEK_COUNT - 1 - i).weeks } + end + + def window_start + @window_start ||= current_week_start - (WEEK_COUNT - 1).weeks + end + + def current_week_start + @current_week_start ||= @now.to_date.beginning_of_week.beginning_of_day + end + end + end +end diff --git a/app/views/admin/members/_profile.html.haml b/app/views/admin/members/_profile.html.haml index 8b6061ee6..3e1725817 100644 --- a/app/views/admin/members/_profile.html.haml +++ b/app/views/admin/members/_profile.html.haml @@ -6,6 +6,9 @@ %span.d-block %p.lead= @member.about_you + - if @member.organiser? + = render Admin::Members::ActivityStripComponent.new(weeks: @activity_weeks) + - if @member.skills.any? .mb-4 %h5 Skills diff --git a/spec/components/admin/members/activity_strip_component_spec.rb b/spec/components/admin/members/activity_strip_component_spec.rb new file mode 100644 index 000000000..172076879 --- /dev/null +++ b/spec/components/admin/members/activity_strip_component_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Admin::Members::ActivityStripComponent, type: :component do + let(:member) { Fabricate(:member) } + let(:now) { Time.zone.local(2026, 9, 2, 12, 0, 0) } + let(:rows) do + Admin::Members::ActivityStrip.new(member, now:).tap do |_strip| + PublicActivity::Activity.create!(owner: member, trackable: member, key: 'member.login', + created_at: now - 1.week, updated_at: now - 1.week) + PublicActivity::Activity.create!(owner: member, trackable: member, key: 'event_invitation.rsvp', + created_at: now - 2.weeks, updated_at: now - 2.weeks) + end.rows + end + + before { render_inline(described_class.new(weeks: rows)) } + + it 'renders 52 cells' do + expect(page).to have_css('rect', count: 52) + end + + it 'renders all three state classes' do + expect(page).to have_css('.activity-cell-empty') + expect(page).to have_css('.activity-cell-login_only') + expect(page).to have_css('.activity-cell-active') + end + + it 'renders a tooltip with the week and counts' do + expect(page).to have_css('rect[title*="event_invitation rsvp"]') + end +end diff --git a/spec/controllers/admin/members_controller_spec.rb b/spec/controllers/admin/members_controller_spec.rb index 05e96c118..14fc9f84e 100644 --- a/spec/controllers/admin/members_controller_spec.rb +++ b/spec/controllers/admin/members_controller_spec.rb @@ -112,47 +112,79 @@ end end - describe 'GET #send_eligibility_email' do - let(:member) { Fabricate(:member) } - let(:admin) { Fabricate(:member) } + describe 'GET #show' do + let(:admin) { Fabricate(:member) } + let(:chapter) { Fabricate(:chapter) } - before do - admin.add_role(:admin) - login_as_admin(admin) - end + before do + admin.add_role(:admin) + login_as_admin(admin) + end - it 'creates an eligibility inquiry' do - expect do - get :send_eligibility_email, params: { member_id: member.id } - end.to change(EligibilityInquiry, :count).by(1) - end + describe 'activity strip' do + render_views - it 'sends an eligibility check email' do - mailer = double(deliver_now: true) - allow(MemberMailer).to receive(:eligibility_check) - .with(member, member.email) - .and_return(mailer) + let(:organiser) { Fabricate(:member) } - get :send_eligibility_email, params: { member_id: member.id } + before { organiser.add_role(:organiser, chapter) } - expect(MemberMailer).to have_received(:eligibility_check) - .with(member, member.email) + it 'renders the strip for organisers' do + get :show, params: { id: organiser.id } + + expect(response.body).to include('activity-strip') end - it 'redirects to the member page' do - get :send_eligibility_email, params: { member_id: member.id } + it 'does not render the strip for non-organisers' do + plain = Fabricate(:member) - expect(response).to redirect_to([:admin, member]) + get :show, params: { id: plain.id } + + expect(response.body).not_to include('activity-strip') end + end + end - context 'when not authenticated' do - before { login(Fabricate(:member)) } + describe 'GET #send_eligibility_email' do + let(:member) { Fabricate(:member) } + let(:admin) { Fabricate(:member) } + + before do + admin.add_role(:admin) + login_as_admin(admin) + end + + it 'creates an eligibility inquiry' do + expect do + get :send_eligibility_email, params: { member_id: member.id } + end.to change(EligibilityInquiry, :count).by(1) + end + + it 'sends an eligibility check email' do + mailer = double(deliver_now: true) + allow(MemberMailer).to receive(:eligibility_check) + .with(member, member.email) + .and_return(mailer) - it 'redirects to login' do get :send_eligibility_email, params: { member_id: member.id } - expect(response).to have_http_status(:found) + expect(MemberMailer).to have_received(:eligibility_check) + .with(member, member.email) + end + + it 'redirects to the member page' do + get :send_eligibility_email, params: { member_id: member.id } + + expect(response).to redirect_to([:admin, member]) + end + + context 'when not authenticated' do + before { login(Fabricate(:member)) } + + it 'redirects to login' do + get :send_eligibility_email, params: { member_id: member.id } + + expect(response).to have_http_status(:found) + end end - end end end diff --git a/spec/services/admin/members/activity_strip_spec.rb b/spec/services/admin/members/activity_strip_spec.rb new file mode 100644 index 000000000..e6af6df0f --- /dev/null +++ b/spec/services/admin/members/activity_strip_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Admin::Members::ActivityStrip do + let(:member) { Fabricate(:member) } + let(:now) { Time.zone.local(2026, 9, 2, 12, 0, 0) } # Wednesday, current week starts Mon 31 Aug + let(:strip) { described_class.new(member, now:) } + + def activity_at(time, key: 'member.login') + PublicActivity::Activity.create!(owner: member, key:, trackable: member, + created_at: time, updated_at: time) + end + + it 'returns 52 rows oldest first' do + rows = strip.rows + + expect(rows.size).to eq(52) + expect(rows.first.week_start).to eq(Time.zone.local(2025, 9, 8)) + expect(rows.last.week_start).to eq(Time.zone.local(2026, 8, 31)) + end + + it 'marks weeks with no rows as empty' do + expect(strip.rows.map(&:state)).to all(eq(:empty)) + end + + it 'marks login-only weeks as login_only' do + activity_at(now - 2.weeks, key: 'member.login') + + expect(strip.rows[-3].state).to eq(:login_only) + end + + it 'marks weeks with any non-login key as active' do + activity_at(now - 2.weeks, key: 'event_invitation.rsvp') + + expect(strip.rows[-3].state).to eq(:active) + end + + it 'counts keys for tooltips' do + activity_at(now - 1.week, key: 'member.login') + activity_at(now - 1.week, key: 'event_invitation.rsvp') + + expect(strip.rows[-2].counts).to eq('member.login' => 1, 'event_invitation.rsvp' => 1) + end + + it 'buckets by ISO week with the boundary at window start' do + activity_at(strip.rows.first.week_start) # exactly at the window edge + activity_at(strip.rows.first.week_start - 1.second) # one second before: outside + + expect(strip.rows.first.state).to eq(:login_only) + end +end