Skip to content
Draft
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-pink; }
}
}
13 changes: 13 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,13 @@
<%# 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 %>"
title="<%= title_for(week) %>" />
<% end %>
</svg>
</div>
28 changes: 28 additions & 0 deletions app/components/admin/members/activity_strip_component.rb
Original file line number Diff line number Diff line change
@@ -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
5 changes: 3 additions & 2 deletions app/controllers/admin/members_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

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
3 changes: 3 additions & 0 deletions app/views/admin/members/_profile.html.haml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 32 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,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
88 changes: 60 additions & 28 deletions spec/controllers/admin/members_controller_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
52 changes: 52 additions & 0 deletions spec/services/admin/members/activity_strip_spec.rb
Original file line number Diff line number Diff line change
@@ -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
Loading