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
+
+
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