From 09ca022a39604e99833a5f0974f14749f4a2b4c7 Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Sat, 5 Sep 2026 16:19:48 +0200 Subject: [PATCH 1/7] feat: add single-chapter event scope to Event Events linked to exactly one chapter are a per-chapter signal, unlike multi-chapter events which list dozens of chapters. The scope filters for them so chapter views can use events as a health signal. --- app/models/event.rb | 4 ++++ spec/models/event_spec.rb | 12 ++++++++++++ 2 files changed, 16 insertions(+) diff --git a/app/models/event.rb b/app/models/event.rb index c8ce930ee..19f46231a 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -18,6 +18,10 @@ class Event < ApplicationRecord has_and_belongs_to_many :chapters, join_table: 'chapters_events' has_many :invitations + # Events linked to exactly one chapter — a per-chapter signal, unlike + # multi-chapter events which list dozens of chapters. + scope :single_chapter, -> { left_joins(:chapters).group(:id).having('COUNT(chapters.id) = 1') } + validates :name, :slug, :info, :schedule, :description, presence: true validates :slug, uniqueness: true validate :invitability, if: :invitable? diff --git a/spec/models/event_spec.rb b/spec/models/event_spec.rb index acd67e509..fd6e75209 100644 --- a/spec/models/event_spec.rb +++ b/spec/models/event_spec.rb @@ -105,4 +105,16 @@ expect(event.verified_students.count).to eq(2) end end + + describe '.single_chapter' do + it 'includes only events linked to exactly one chapter' do + single = Fabricate(:event) + single.chapters = [Fabricate(:chapter)] + multi = Fabricate(:event) + multi.chapters = [Fabricate(:chapter), Fabricate(:chapter)] + + expect(described_class.single_chapter).to include(single) + expect(described_class.single_chapter).not_to include(multi) + end + end end From 35619f20402ea0fbdaa7e4c654237e40fa8e3414 Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Sat, 5 Sep 2026 16:20:15 +0200 Subject: [PATCH 2/7] feat: add chapter health service for single chapters Admin::Dashboard::ChapterHealth.row returns one health row for a chapter: status bucket (active/dormant/inactive, mirroring the Chapter Status page classification), workshop recency and countdown, median workshop cadence over the past 180 days, eligible member counts, and organiser count. --- .../admin/dashboard/chapter_health.rb | 68 +++++++++++++ .../admin/dashboard/chapter_health_spec.rb | 96 +++++++++++++++++++ 2 files changed, 164 insertions(+) create mode 100644 app/services/admin/dashboard/chapter_health.rb create mode 100644 spec/services/admin/dashboard/chapter_health_spec.rb diff --git a/app/services/admin/dashboard/chapter_health.rb b/app/services/admin/dashboard/chapter_health.rb new file mode 100644 index 000000000..a0f054360 --- /dev/null +++ b/app/services/admin/dashboard/chapter_health.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +module Admin + module Dashboard + # Health data for one chapter, shown in the Chapter Health card on + # /admin/chapters/:id. Mirrors the classification rules used by + # Admin::ChaptersController#status. + class ChapterHealth + Row = Data.define(:chapter, :bucket, :last_workshop_date, :next_workshop_date, + :days_since_last_workshop, :days_until_next_workshop, + :organiser_count, :eligible_students, :eligible_coaches, + :median_cadence_days) + + class << self + # Health row for one chapter; per-chapter queries are fine on a show page. + # rubocop:disable Metrics/AbcSize, Metrics/MethodLength — one entry per Row field + def row(chapter:) + in_window = chapter.workshops + .exists?(date_and_time: 180.days.ago.beginning_of_day..90.days.from_now) + + Row.new( + chapter:, + bucket: bucket_for(chapter, in_window), + last_workshop_date: last_workshop_date = chapter.workshops.where(date_and_time: ..Time.zone.now) + .maximum(:date_and_time), + next_workshop_date: next_workshop_date = chapter.workshops.today_and_upcoming.minimum(:date_and_time), + days_since_last_workshop: recency_days(last_workshop_date), + days_until_next_workshop: countdown_days(next_workshop_date), + organiser_count: chapter.organisers.count, + eligible_students: chapter.eligible_students.count, + eligible_coaches: chapter.eligible_coaches.count, + median_cadence_days: median_cadence( + chapter.workshops.where(date_and_time: 180.days.ago..Time.zone.now).order(:date_and_time) + .pluck(:date_and_time) + ) + ) + end + # rubocop:enable Metrics/AbcSize, Metrics/MethodLength + + private + + def recency_days(date) + date && ((Time.zone.now - date) / 1.day).floor + end + + def countdown_days(date) + date && ((date - Time.zone.now) / 1.day).ceil + end + + # ponytail: median math kept inline; 2 lines over the AbcSize cap. + def median_cadence(dates) # rubocop:disable Metrics/AbcSize + gaps = dates.sort.each_cons(2).map { |a, b| ((b - a) / 1.day).round } + return nil if gaps.empty? + + mid = gaps.length / 2 + gaps.length.odd? ? gaps[mid] : ((gaps[mid - 1] + gaps[mid]) / 2.0).round + end + + def bucket_for(chapter, in_window) + return :inactive unless chapter.active? + return :active if in_window + + :dormant + end + end + end + end +end diff --git a/spec/services/admin/dashboard/chapter_health_spec.rb b/spec/services/admin/dashboard/chapter_health_spec.rb new file mode 100644 index 000000000..54918b347 --- /dev/null +++ b/spec/services/admin/dashboard/chapter_health_spec.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Admin::Dashboard::ChapterHealth do + describe '.row' do + it 'computes a health row for a single chapter' do + chapter = Fabricate(:chapter) + Fabricate(:workshop, chapter:, date_and_time: 30.days.ago) + member = Fabricate(:member) + member.add_role(:organiser, chapter) + + row = described_class.row(chapter:) + + expect(row.bucket).to eq(:active) + expect(row.organiser_count).to eq(1) + expect(row.median_cadence_days).to be_nil + end + + it 'classifies an idle enabled chapter as dormant' do + chapter = Fabricate(:chapter) + + row = described_class.row(chapter:) + + expect(row.bucket).to eq(:dormant) + expect(row.days_since_last_workshop).to be_nil + end + + it 'classifies a disabled chapter as inactive even with in-window workshops' do + chapter = Fabricate(:chapter, active: false) + Fabricate(:workshop, chapter:, date_and_time: 30.days.ago) + + row = described_class.row(chapter:) + + expect(row.bucket).to eq(:inactive) + end + + it 'counts eligible students and coaches (subscribed, not banned, TOC accepted)' do + chapter = Fabricate(:chapter) + Fabricate(:students, chapter:) + Fabricate(:coaches, chapter:) + + row = described_class.row(chapter:) + + expect(row.eligible_students).to eq(2) + expect(row.eligible_coaches).to eq(2) + end + + it 'exposes the days until the next scheduled workshop' do + chapter = Fabricate(:chapter) + Fabricate(:workshop, chapter:, date_and_time: 10.days.from_now) + + row = described_class.row(chapter:) + + expect(row.days_until_next_workshop).to eq(10) + end + + it 'returns nil days_since_last_workshop with no past workshops' do + chapter = Fabricate(:chapter) + + row = described_class.row(chapter:) + + expect(row.days_since_last_workshop).to be_nil + end + + it 'computes median cadence between workshops held in the past 180 days' do + chapter = Fabricate(:chapter) + Fabricate(:workshop, chapter:, date_and_time: 60.days.ago) + Fabricate(:workshop, chapter:, date_and_time: 30.days.ago) + Fabricate(:workshop, chapter:, date_and_time: 10.days.ago) + + row = described_class.row(chapter:) + + expect(row.median_cadence_days).to eq(25) + end + + it 'returns nil cadence with fewer than two workshops in 180 days' do + chapter = Fabricate(:chapter) + Fabricate(:workshop, chapter:, date_and_time: 30.days.ago) + + row = described_class.row(chapter:) + + expect(row.median_cadence_days).to be_nil + end + + it 'excludes workshops outside 180 days from cadence' do + chapter = Fabricate(:chapter) + Fabricate(:workshop, chapter:, date_and_time: 200.days.ago) + Fabricate(:workshop, chapter:, date_and_time: 30.days.ago) + + row = described_class.row(chapter:) + + expect(row.median_cadence_days).to be_nil + end + end +end From c40261a2855668b327fc07ab144f465f1e75a519 Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Sat, 5 Sep 2026 16:20:45 +0200 Subject: [PATCH 3/7] feat: add workshop and events timeline component SVG timeline for one chapter: 180 days past, 90 days future, today marked at the 2/3 point. Workshops render as blue circles (hollow when planned), single-chapter events as amber squares, same-day workshops stack with an xN count (capped at three markers so they stay in bounds), tooltips show ISO8601 dates. The legend is drawn inside the SVG so it scales with the markers. No JavaScript. --- .../workshop_timeline_component.html.erb | 79 +++++++++++++++++++ .../dashboard/workshop_timeline_component.rb | 39 +++++++++ .../workshop_timeline_component_spec.rb | 74 +++++++++++++++++ 3 files changed, 192 insertions(+) create mode 100644 app/components/admin/dashboard/workshop_timeline_component.html.erb create mode 100644 app/components/admin/dashboard/workshop_timeline_component.rb create mode 100644 spec/components/admin/dashboard/workshop_timeline_component_spec.rb diff --git a/app/components/admin/dashboard/workshop_timeline_component.html.erb b/app/components/admin/dashboard/workshop_timeline_component.html.erb new file mode 100644 index 000000000..d20ed572a --- /dev/null +++ b/app/components/admin/dashboard/workshop_timeline_component.html.erb @@ -0,0 +1,79 @@ +
+ Workshops and events timeline +
+ + <% # 30-day ticks: 7 on the past side (180 days ago .. today), 3 ahead %> + <% (0..6).each do |ticks_ago| %> + <% tick_x = (667 - (ticks_ago * 667 / 6.0)).round(1) %> + + <% if (ticks_ago % 2).zero? %> + + <%= (range_start + (ticks_ago * 30).days).to_fs(:short) %> + + <% end %> + <% end %> + <% (1..3).each do |ticks_ahead| %> + <% tick_x = (667 + (ticks_ahead * 333 / 3.0)).round(1) %> + + + <%= (Time.zone.today + (ticks_ahead * 30).days).to_fs(:short) %> + + <% end %> + + + today + + <% # single-chapter events, complementary colour (amber) with square-ish markers %> + <% stacked_markers(event_past).each do |m| %> + <% if m[:stack] %> + x<%= m[:stack] %> + <% end %> + + <%= m[:date].to_date.to_fs(:iso8601) %> (event) + + <% end %> + + <% stacked_markers(event_future).each do |m| %> + + <%= m[:date].to_date.to_fs(:iso8601) %> (event, planned) + + <% end %> + + <% stacked_markers(past).each do |m| %> + <% if m[:stack] %> + x<%= m[:stack] %> + <% end %> + + <%= m[:date].to_date.to_fs(:iso8601) %> + + <% end %> + + <% stacked_markers(future).each do |m| %> + <% if m[:stack] %> + x<%= m[:stack] %> + <% end %> + + <%= m[:date].to_date.to_fs(:iso8601) %> (planned) + + <% end %> + + <% # legend, inside the SVG so it scales with the axis %> + <% legend = [ + { x: 40, label: 'workshop', marker: 'circle-filled' }, + { x: 240, label: 'planned workshop', marker: 'circle-hollow' }, + { x: 500, label: 'chapter event', marker: 'rect-filled' }, + { x: 740, label: 'planned event', marker: 'rect-hollow' } + ] %> + <% legend.each do |item| %> + <% if %w[circle-filled circle-hollow].include?(m = item[:marker]) %> + + <% else %> + + <% end %> + <%= item[:label] %> + <% end %> + diff --git a/app/components/admin/dashboard/workshop_timeline_component.rb b/app/components/admin/dashboard/workshop_timeline_component.rb new file mode 100644 index 000000000..195382e8a --- /dev/null +++ b/app/components/admin/dashboard/workshop_timeline_component.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Admin + module Dashboard + # Horizontal SVG timeline: 180 days past, 90 days future. SVG viewBox is + # 1000x122; today sits at x=667 (2/3). No JS — provides tooltips. + class WorkshopTimelineComponent < ViewComponent::Base + def initialize(past:, future:, event_past: [], event_future: []) # rubocop:disable Lint/MissingSuper + @past = past.sort + @future = future.sort + @event_past = event_past.sort + @event_future = event_future.sort + end + + private + + attr_reader :past, :future, :event_past, :event_future + + def range_start = (Time.zone.today - 180.days).beginning_of_day + def range_end = (Time.zone.today + 90.days).end_of_day + + def x_for(date) + fraction = (date.to_time - range_start) / (range_end - range_start) + (fraction * 1000).round(1) + end + + MAX_STACK = 3 + + def stacked_markers(dates) + dates.group_by(&:to_date).flat_map do |_day, day_dates| + sorted = day_dates.sort_by(&:to_time) + sorted.first(MAX_STACK).each_with_index.map do |date, i| + { x: x_for(date), y: 40 - (i * 14), date:, stack: sorted.size > 1 ? sorted.size : nil } + end + end + end + end + end +end diff --git a/spec/components/admin/dashboard/workshop_timeline_component_spec.rb b/spec/components/admin/dashboard/workshop_timeline_component_spec.rb new file mode 100644 index 000000000..f1f574868 --- /dev/null +++ b/spec/components/admin/dashboard/workshop_timeline_component_spec.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Admin::Dashboard::WorkshopTimelineComponent, type: :component do + it 'renders a heading and legend' do + render_inline(described_class.new(past: [], future: [])) + + expect(page).to have_text('Workshops and events timeline') + expect(page).to have_text('workshop') + expect(page).to have_text('planned workshop') + expect(page).to have_text('chapter event') + expect(page).to have_text('planned event') + end + + it 'renders today at 2/3 of the axis (180 days past, 90 days future)' do + render_inline(described_class.new(past: [], future: [])) + + expect(page).to have_css("line[stroke-dasharray][x1='667']") + end + + it 'places a filled marker for each held workshop with a date tooltip' do + past = [5.months.ago, 2.months.ago] + + render_inline(described_class.new(past:, future: [])) + + expect(page).to have_css("svg[role='img'] circle.marker", count: 2) + expect(page).to have_css('circle title', text: past.first.to_date.to_fs(:iso8601)) + end + + it 'places planned workshops as hollow markers after today' do + future = [3.weeks.from_now] + + render_inline(described_class.new(past: [], future:)) + + expect(page).to have_css("svg[role='img'] circle.marker[stroke='#0d6efd'][fill='white']", count: 1) + end + + it 'renders single-chapter events as amber squares, hollow when planned' do + render_inline(described_class.new(past: [], future: [], + event_past: [40.days.ago], + event_future: [3.weeks.from_now])) + + expect(page).to have_css("svg[role='img'] rect.marker[fill='#fd7e14']", count: 1) + expect(page).to have_css("svg[role='img'] rect.marker[stroke='#fd7e14'][fill='white']", count: 1) + end + + it 'stacks same-day workshops with an xN count' do + same_day = 2.months.ago + + render_inline(described_class.new(past: [same_day, same_day + 1.hour], future: [])) + + expect(page).to have_css('text', text: 'x2') + expect(page).to have_css("svg[role='img'] circle.marker", count: 2) + end + + it 'caps the stack at three markers so they stay inside the viewBox' do + same_day = 2.months.ago + five = (0..4).map { |i| same_day + i.hours } + + render_inline(described_class.new(past: five, future: [])) + + expect(page).to have_css('text', text: 'x5') + expect(page).to have_css("svg[role='img'] circle.marker", count: 3) + ys = page.all("svg[role='img'] circle.marker").map { |c| c['cy'].to_f } + expect(ys).to all(be_between(0, 122)) + end + + it 'labels every third month' do + render_inline(described_class.new(past: [], future: [])) + + expect(page).to have_css('text', minimum: 6) + end +end From 746369617e3dda2788b0cb4c15674451320cfc16 Mon Sep 17 00:00:00 2001 From: Morgan Roderick <morgan@roderick.dk> Date: Sat, 5 Sep 2026 16:21:10 +0200 Subject: [PATCH 4/7] feat: chapter health card on the admin chapter page Adds a Chapter Health section to /admin/chapters/:id, visible to admins and the chapter's organisers. Admin::Dashboard::HealthCardComponent renders the stat tiles (status badge, previous/next workshop, median cadence, eligible members, organiser count) from a ChapterHealth::Row and yields the workshop and events timeline into the card body. The card sits above Upcoming Workshops in the right column. --- .../dashboard/health_card_component.html.erb | 24 +++++++ .../admin/dashboard/health_card_component.rb | 62 +++++++++++++++++++ .../workshop_timeline_component.html.erb | 1 + app/controllers/admin/chapters_controller.rb | 8 +++ app/views/admin/chapters/show.html.haml | 2 + .../dashboard/health_card_component_spec.rb | 48 ++++++++++++++ .../admin/chapters_controller_spec.rb | 25 ++++++++ 7 files changed, 170 insertions(+) create mode 100644 app/components/admin/dashboard/health_card_component.html.erb create mode 100644 app/components/admin/dashboard/health_card_component.rb create mode 100644 spec/components/admin/dashboard/health_card_component_spec.rb diff --git a/app/components/admin/dashboard/health_card_component.html.erb b/app/components/admin/dashboard/health_card_component.html.erb new file mode 100644 index 000000000..66c5a7329 --- /dev/null +++ b/app/components/admin/dashboard/health_card_component.html.erb @@ -0,0 +1,24 @@ +<div class="card border-info mb-4"> + <div class="card-body"> + <h3>Chapter Health</h3> + <div class="row"> + <% tiles.each do |tile| %> + <div class="col-6 col-md-3 text-center mb-2"> + <div class="small text-muted"><%= tile[:label] %></div> + <% if tile[:label] == 'Status' %> + <span class="badge fs-6 <%= bucket_badge_class %>"><%= health.bucket %></span> + <% elsif tile[:title] %> + <span title="<%= tile[:title] %>"><%= tile[:value] %></span> + <% else %> + <span><%= tile[:value] %></span> + <% end %> + </div> + <% end %> + </div> + <div class="row mt-3"> + <div class="col-12"> + <%= content %> + </div> + </div> + </div> +</div> diff --git a/app/components/admin/dashboard/health_card_component.rb b/app/components/admin/dashboard/health_card_component.rb new file mode 100644 index 000000000..c6875ca7a --- /dev/null +++ b/app/components/admin/dashboard/health_card_component.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module Admin + module Dashboard + # The Chapter Health card on /admin/chapters/:id: status badge, workshop + # recency and countdown, median cadence, eligible members, organisers. + # Yield the timeline (or anything else) into the card body via content. + class HealthCardComponent < ViewComponent::Base + BUCKET_CLASSES = { inactive: 'bg-secondary', dormant: 'bg-warning text-dark', + active: 'bg-success' }.freeze + + def initialize(health:) # rubocop:disable Lint/MissingSuper + @health = health + end + + private + + attr_reader :health + + def bucket_badge_class = BUCKET_CLASSES.fetch(health.bucket) + + def tile(label, value, title: nil) + { label:, value:, title: } + end + + def tiles + [ + tile('Status', health.bucket), + tile('Previous workshop', previous_workshop_value, title: previous_workshop_title), + tile('Next workshop', next_workshop_value), + tile('Cadence (days, median, 180d)', cadence_value), + tile('Eligible members', eligible_members_value), + tile('Organisers', number_value(health.organiser_count)) + ] + end + + def previous_workshop_value + health.days_since_last_workshop ? "#{number_value(health.days_since_last_workshop)} days ago" : 'never' + end + + def previous_workshop_title + health.last_workshop_date&.to_date&.to_fs(:long) + end + + def next_workshop_value + health.days_until_next_workshop ? "#{number_value(health.days_until_next_workshop)} days away" : 'none' + end + + def cadence_value + health.median_cadence_days ? number_value(health.median_cadence_days) : '—' + end + + def eligible_members_value + number_value(health.eligible_students + health.eligible_coaches) + end + + def number_value(value) + number_with_delimiter(value) + end + end + end +end diff --git a/app/components/admin/dashboard/workshop_timeline_component.html.erb b/app/components/admin/dashboard/workshop_timeline_component.html.erb index d20ed572a..5a40204c0 100644 --- a/app/components/admin/dashboard/workshop_timeline_component.html.erb +++ b/app/components/admin/dashboard/workshop_timeline_component.html.erb @@ -77,3 +77,4 @@ <text x="<%= item[:x] + 12 %>" y="112" font-size="11" fill="#6c757d"><%= item[:label] %></text> <% end %> </svg> + diff --git a/app/controllers/admin/chapters_controller.rb b/app/controllers/admin/chapters_controller.rb index 4c472ae29..10ff5fc0a 100644 --- a/app/controllers/admin/chapters_controller.rb +++ b/app/controllers/admin/chapters_controller.rb @@ -27,6 +27,9 @@ def show authorize(@chapter) @workshops = @chapter.workshops.today_and_upcoming + @chapter_health = Admin::Dashboard::ChapterHealth.row(chapter: @chapter) + @past_workshop_dates, @planned_workshop_dates = timeline_dates(@chapter.workshops) + @past_event_dates, @planned_event_dates = timeline_dates(@chapter.events.single_chapter) @sponsors = @chapter.sponsors.uniq @groups = @chapter.groups @subscribers = @chapter.subscriptions.last(20).reverse @@ -126,4 +129,9 @@ def member_emails(chapter, type) end members.distinct.pluck(:email).join("\n") end + + def timeline_dates(collection) + [collection.where(date_and_time: ..Time.zone.now).pluck(:date_and_time), + collection.where(date_and_time: Time.zone.now..90.days.from_now).pluck(:date_and_time)] + end end diff --git a/app/views/admin/chapters/show.html.haml b/app/views/admin/chapters/show.html.haml index 99d594134..44607031a 100644 --- a/app/views/admin/chapters/show.html.haml +++ b/app/views/admin/chapters/show.html.haml @@ -58,6 +58,8 @@ %div.text-muted.small= "Based on #{pluralize(@how_you_found_us.total_responses, 'response')}" .col-12.col-lg-8 + = render Admin::Dashboard::HealthCardComponent.new(health: @chapter_health) do + = render Admin::Dashboard::WorkshopTimelineComponent.new(past: @past_workshop_dates, future: @planned_workshop_dates, event_past: @past_event_dates, event_future: @planned_event_dates) .mb-4.mt-md-4.mt-lg-0 .d-md-flex.justify-content-between.align-items-center %h3 Upcoming Workshops diff --git a/spec/components/admin/dashboard/health_card_component_spec.rb b/spec/components/admin/dashboard/health_card_component_spec.rb new file mode 100644 index 000000000..c591edde4 --- /dev/null +++ b/spec/components/admin/dashboard/health_card_component_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Admin::Dashboard::HealthCardComponent, type: :component do + let(:chapter) { Fabricate(:chapter) } + + it 'renders the six stat tiles with formatted values' do + Fabricate(:workshop, chapter:, date_and_time: 30.days.ago) + health = Admin::Dashboard::ChapterHealth.row(chapter:) + + render_inline(described_class.new(health:)) + + expect(page).to have_text('Chapter Health') + expect(page).to have_css('.badge', text: 'active') + expect(page).to have_text('30 days ago') + expect(page).to have_text('never', count: 0) + expect(page).to have_text('Cadence (days, median, 180d)') + expect(page).to have_text('Organisers') + end + + it 'renders never/none fallbacks for a chapter without workshops' do + health = Admin::Dashboard::ChapterHealth.row(chapter:) + + render_inline(described_class.new(health:)) + + expect(page).to have_css('.badge', text: 'dormant') + expect(page).to have_text('never') + expect(page).to have_text('none') + end + + it 'classifies a disabled chapter as inactive' do + chapter = Fabricate(:chapter, active: false) + health = Admin::Dashboard::ChapterHealth.row(chapter:) + + render_inline(described_class.new(health:)) + + expect(page).to have_css('.badge', text: 'inactive') + end + + it 'renders the yielded content inside the card' do + health = Admin::Dashboard::ChapterHealth.row(chapter:) + + render_inline(described_class.new(health:)) { 'TIMELINE-CONTENT' } + + expect(page).to have_text('TIMELINE-CONTENT') + end +end diff --git a/spec/controllers/admin/chapters_controller_spec.rb b/spec/controllers/admin/chapters_controller_spec.rb index 8ef17ee5c..e0cd62a1c 100644 --- a/spec/controllers/admin/chapters_controller_spec.rb +++ b/spec/controllers/admin/chapters_controller_spec.rb @@ -99,4 +99,29 @@ expect(controller.view_assigns['at_risk_ids']).not_to include(chapter.id) end end + + describe '#show health section' do + render_views + + it 'renders the chapter health section for admins' do + login_as_admin(Fabricate(:member)) + chapter = Fabricate(:chapter) + Fabricate(:workshop, chapter:, date_and_time: 30.days.ago) + + get :show, params: { id: chapter.id } + + expect(response.body).to include('Chapter Health') + expect(response.body).to include('Workshops and events timeline') + end + + it 'shows the health section to chapter organisers too' do + organiser = Fabricate(:chapter_organiser) + login(organiser) + + get :show, params: { id: organiser.organised_chapters.first.id } + + expect(response).to be_successful + expect(response.body).to include('Chapter Health') + end + end end From 362b8952095a83fe0901011f5cb70c89aadfcd60 Mon Sep 17 00:00:00 2001 From: Morgan Roderick <morgan@roderick.dk> Date: Sat, 5 Sep 2026 16:32:25 +0200 Subject: [PATCH 5/7] chore: drop ponytail comment from chapter health service --- app/services/admin/dashboard/chapter_health.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/app/services/admin/dashboard/chapter_health.rb b/app/services/admin/dashboard/chapter_health.rb index a0f054360..863cf1d31 100644 --- a/app/services/admin/dashboard/chapter_health.rb +++ b/app/services/admin/dashboard/chapter_health.rb @@ -47,7 +47,6 @@ def countdown_days(date) date && ((date - Time.zone.now) / 1.day).ceil end - # ponytail: median math kept inline; 2 lines over the AbcSize cap. def median_cadence(dates) # rubocop:disable Metrics/AbcSize gaps = dates.sort.each_cons(2).map { |a, b| ((b - a) / 1.day).round } return nil if gaps.empty? From ea538da6802770ec39be0f434fee3580abf84bf4 Mon Sep 17 00:00:00 2001 From: Morgan Roderick <20321+mroderick@users.noreply.github.com> Date: Tue, 8 Sep 2026 11:46:14 +0200 Subject: [PATCH 6/7] Update spec/components/admin/dashboard/health_card_component_spec.rb Co-authored-by: Olle Jonsson <olle.jonsson@gmail.com> --- spec/components/admin/dashboard/health_card_component_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/components/admin/dashboard/health_card_component_spec.rb b/spec/components/admin/dashboard/health_card_component_spec.rb index c591edde4..050205754 100644 --- a/spec/components/admin/dashboard/health_card_component_spec.rb +++ b/spec/components/admin/dashboard/health_card_component_spec.rb @@ -2,7 +2,7 @@ require 'rails_helper' -RSpec.describe Admin::Dashboard::HealthCardComponent, type: :component do +RSpec.describe Admin::Dashboard::HealthCardComponent do let(:chapter) { Fabricate(:chapter) } it 'renders the six stat tiles with formatted values' do From 66da5b366857305ec1aef4ae4d0add382da894cf Mon Sep 17 00:00:00 2001 From: Morgan Roderick <20321+mroderick@users.noreply.github.com> Date: Tue, 8 Sep 2026 11:46:24 +0200 Subject: [PATCH 7/7] Update spec/components/admin/dashboard/workshop_timeline_component_spec.rb Co-authored-by: Olle Jonsson <olle.jonsson@gmail.com> --- .../admin/dashboard/workshop_timeline_component_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/components/admin/dashboard/workshop_timeline_component_spec.rb b/spec/components/admin/dashboard/workshop_timeline_component_spec.rb index f1f574868..1ed76a458 100644 --- a/spec/components/admin/dashboard/workshop_timeline_component_spec.rb +++ b/spec/components/admin/dashboard/workshop_timeline_component_spec.rb @@ -2,7 +2,7 @@ require 'rails_helper' -RSpec.describe Admin::Dashboard::WorkshopTimelineComponent, type: :component do +RSpec.describe Admin::Dashboard::WorkshopTimelineComponent do it 'renders a heading and legend' do render_inline(described_class.new(past: [], future: []))