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

Chapter Health

+
+ <% tiles.each do |tile| %> +
+
<%= tile[:label] %>
+ <% if tile[:label] == 'Status' %> + <%= health.bucket %> + <% elsif tile[:title] %> + <%= tile[:value] %> + <% else %> + <%= tile[:value] %> + <% end %> +
+ <% end %> +
+
+
+ <%= content %> +
+
+
+
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 new file mode 100644 index 000000000..5a40204c0 --- /dev/null +++ b/app/components/admin/dashboard/workshop_timeline_component.html.erb @@ -0,0 +1,80 @@ +
+ 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/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/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/app/services/admin/dashboard/chapter_health.rb b/app/services/admin/dashboard/chapter_health.rb new file mode 100644 index 000000000..863cf1d31 --- /dev/null +++ b/app/services/admin/dashboard/chapter_health.rb @@ -0,0 +1,67 @@ +# 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 + + 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/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..050205754 --- /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 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/components/admin/dashboard/workshop_timeline_component_spec.rb b/spec/components/admin/dashboard/workshop_timeline_component_spec.rb new file mode 100644 index 000000000..1ed76a458 --- /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 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 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 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 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