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..5fac96ec5
--- /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-blue; }
+ }
+}
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..a014b5e7c
--- /dev/null
+++ b/app/components/admin/members/activity_strip_component.html.erb
@@ -0,0 +1,15 @@
+<%# 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..f2a0f4e6d
--- /dev/null
+++ b/app/components/admin/members/activity_strip_component.rb
@@ -0,0 +1,32 @@
+# 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('.', ' ').humanize}" }.join(', ')
+ "#{iso_week(week)} (week of #{week.week_start.strftime('%-d %b %Y')}): #{summary.presence || 'no activity'}"
+ end
+
+ def iso_week(week)
+ week.week_start.strftime('%G-W%V')
+ 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..16d511508 100644
--- a/app/controllers/admin/members_controller.rb
+++ b/app/controllers/admin/members_controller.rb
@@ -24,9 +24,11 @@ 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
+ @activities = PublicActivity::Activity.where(owner: member).order(created_at: :desc).limit(30)
@actions = admin_actions(@member).sort_by(&:created_at).reverse
end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 72c890099..34cb23bc0 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -60,6 +60,25 @@ def sponsorship_level_title(level)
"#{level.humanize} sponsors"
end
+ # Admin-page subject of a member activity: the entity the action was about.
+ # Returns nil when the subject is the member themselves (e.g. logins, bans).
+ def activity_subject(activity)
+ case (trackable = activity.trackable)
+ when WorkshopInvitation then trackable.workshop
+ when Invitation then trackable.event
+ when MeetingInvitation then trackable.meeting
+ when Member then nil
+ else trackable
+ end
+ end
+
+ def activity_subject_label(subject)
+ return "#{subject.chapter.name} #{subject.name}" if subject.is_a?(Group)
+ return subject.full_name if subject.respond_to?(:full_name)
+
+ subject.try(:name) || subject.to_s
+ end
+
private
def humanize_date_with_time(datetime, end_time)
diff --git a/app/services/admin/members/activity_strip.rb b/app/services/admin/members/activity_strip.rb
new file mode 100644
index 000000000..6adfbe5e8
--- /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 = Data.define(:week_start, :state, :counts)
+
+ 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/show.html.haml b/app/views/admin/members/show.html.haml
index d9c25b0cb..e98021b17 100644
--- a/app/views/admin/members/show.html.haml
+++ b/app/views/admin/members/show.html.haml
@@ -29,3 +29,25 @@
.row.mt-4
.col
= link_to 'View all RSVPS', admin_member_events_path(@member), class: 'btn btn-primary btn-block'
+
+ .row.mt-4
+ .col
+ %h3.mt-4 Activity
+ = render Admin::Members::ActivityStripComponent.new(weeks: @activity_weeks)
+
+ %ul.list-unstyled.activity-log.mt-3
+ - @activities.group_by { |a| a.created_at.beginning_of_week.to_date }.each do |week_start, activities|
+ %li
+ %h5.mt-3
+ #{week_start.strftime('%G-W%V')}
+ %span.small.text-muted (week of #{week_start.strftime('%-d %b %Y')})
+ %ul.list-unstyled
+ - activities.each do |activity|
+ %li.pb-2.mb-2
+ = activity.key.tr('.', ' ').humanize
+ - if (subject = activity_subject(activity))
+ %span.text-muted.ms-1
+ = t('admin.members.activity.for', subject: link_to(activity_subject_label(subject), [:admin, subject])).html_safe
+ %span.text-muted.ms-2= l(activity.created_at, format: :activity_log)
+ - if @activities.empty?
+ %li.text-muted No recorded activity yet
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 9cd7385de..729d2b661 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -59,6 +59,7 @@ en:
full_date: "%d %B %Y"
default_date: "%d/%m/%Y"
website_format: "%a, %d %B %Y at %H:%M"
+ activity_log: "%A, %Y-%m-%d %H:%M"
day_in_words: "%a"
day: "%d"
month: "%B"
@@ -658,6 +659,9 @@ en:
admin:
dashboard:
title: "Admin"
+ members:
+ activity:
+ for: "for %{subject}"
workshop:
manage_rsvps:
text: "RSVP Students and Coaches to the workshop. The attendance confirmation email will be triggered and they will be removed from the waiting list."
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..37891aaec
--- /dev/null
+++ b/spec/components/admin/members/activity_strip_component_spec.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe Admin::Members::ActivityStripComponent 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', text: 'Event invitation rsvp')
+ end
+
+ it 'labels each cell with its ISO week' do
+ expect(page).to have_css('rect[aria-label]', count: 52)
+ expect(page).to have_css("rect[aria-label='#{(now - 2.weeks).to_date.beginning_of_week.strftime('%G-W%V')}']")
+ expect(page).to have_css('rect title', text: /^2026-W/)
+ end
+end
diff --git a/spec/controllers/admin/members_controller_spec.rb b/spec/controllers/admin/members_controller_spec.rb
index a3f2182bc..3b3267b8a 100644
--- a/spec/controllers/admin/members_controller_spec.rb
+++ b/spec/controllers/admin/members_controller_spec.rb
@@ -112,6 +112,68 @@
end
end
+ describe 'GET #show' do
+ let(:admin) { Fabricate(:member) }
+ let(:chapter) { Fabricate(:chapter) }
+
+ before do
+ admin.add_role(:admin)
+ login_as_admin(admin)
+ end
+
+ describe 'activity strip' do
+ render_views
+
+ it 'renders the strip for any member' do
+ member = Fabricate(:member)
+
+ get :show, params: { id: member.id }
+
+ expect(response.body).to include('activity-strip')
+ end
+ end
+
+ describe 'activity log' do
+ render_views
+
+ let(:member) { Fabricate(:member) }
+
+ it 'shows the latest 30 activities for any member' do
+ 30.times do |i|
+ MemberActivityRecorder.record(actor: member, key: 'member.login',
+ trackable: member)
+ PublicActivity::Activity.last.update!(created_at: i.days.ago)
+ end
+ MemberActivityRecorder.record(actor: member, key: 'member.logout', trackable: member)
+ PublicActivity::Activity.last.update!(created_at: 31.days.ago)
+
+ get :show, params: { id: member.id }
+
+ within('.activity-log') do
+ expect(page).to have_text('Member login')
+ expect(page).to have_no_text('Member logout')
+ expect(page).to have_no_text('No recorded activity')
+ end
+ end
+
+ it 'shows an empty state for members without activity' do
+ get :show, params: { id: member.id }
+
+ expect(response.body).to include('No recorded activity yet')
+ end
+
+ it 'links to the related entity of an activity' do
+ invitation = Fabricate(:invitation)
+ MemberActivityRecorder.record(actor: invitation.member, key: 'event_invitation.rsvp',
+ trackable: invitation)
+
+ get :show, params: { id: invitation.member.id }
+
+ expect(response.body).to include("href=\"#{admin_event_path(invitation.event)}\"")
+ end
+ end
+ end
+
describe 'GET #send_eligibility_email' do
let(:member) { Fabricate(:member) }
let(:admin) { Fabricate(:member) }
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