Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/assets/stylesheets/application.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
8 changes: 8 additions & 0 deletions app/assets/stylesheets/partials/_activity_strip.scss
Original file line number Diff line number Diff line change
@@ -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; }
}
}
15 changes: 15 additions & 0 deletions app/components/admin/members/activity_strip_component.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<%# app/components/admin/members/activity_strip_component.html.erb %>
<div class="activity-strip">
<h5>Activity — last 12 months</h5>
<svg viewBox="0 0 <%= svg_width %> 32" width="100%" height="32"
role="img" aria-label="Weekly activity, last 12 months">
<% weeks.each_with_index do |week, i| %>
<rect x="<%= i * (CELL_WIDTH + CELL_GAP) %>" y="0"
width="<%= CELL_WIDTH %>" height="32" rx="2"
class="activity-cell activity-cell-<%= week.state %>"
aria-label="<%= iso_week(week) %>">
<title><%= title_for(week) %></title>
</rect>
<% end %>
</svg>
</div>
32 changes: 32 additions & 0 deletions app/components/admin/members/activity_strip_component.rb
Original file line number Diff line number Diff line change
@@ -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
6 changes: 4 additions & 2 deletions app/controllers/admin/members_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
19 changes: 19 additions & 0 deletions app/helpers/application_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
57 changes: 57 additions & 0 deletions app/services/admin/members/activity_strip.rb
Original file line number Diff line number Diff line change
@@ -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
22 changes: 22 additions & 0 deletions app/views/admin/members/show.html.haml
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 4 additions & 0 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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."
Expand Down
38 changes: 38 additions & 0 deletions spec/components/admin/members/activity_strip_component_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# 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
114 changes: 88 additions & 26 deletions spec/controllers/admin/members_controller_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -112,47 +112,109 @@
end
end

describe 'GET #send_eligibility_email' do
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) }
let(:admin) { Fabricate(:member) }

before do
admin.add_role(:admin)
login_as_admin(admin)
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 'creates an eligibility inquiry' do
expect do
get :send_eligibility_email, params: { member_id: member.id }
end.to change(EligibilityInquiry, :count).by(1)
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 '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 'links to the related entity of an activity' do
invitation = Fabricate(:invitation)
MemberActivityRecorder.record(actor: invitation.member, key: 'event_invitation.rsvp',
trackable: invitation)

get :send_eligibility_email, params: { member_id: member.id }
get :show, params: { id: invitation.member.id }

expect(MemberMailer).to have_received(:eligibility_check)
.with(member, member.email)
expect(response.body).to include("href=\"#{admin_event_path(invitation.event)}\"")
end
end
end

it 'redirects to the member page' do
get :send_eligibility_email, params: { member_id: member.id }
describe 'GET #send_eligibility_email' do
let(:member) { Fabricate(:member) }
let(:admin) { Fabricate(:member) }

expect(response).to redirect_to([:admin, member])
end
before do
admin.add_role(:admin)
login_as_admin(admin)
end

context 'when not authenticated' do
before { login(Fabricate(:member)) }
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
Loading