Skip to content
Merged
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
24 changes: 24 additions & 0 deletions app/components/admin/dashboard/health_card_component.html.erb
Original file line number Diff line number Diff line change
@@ -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>
62 changes: 62 additions & 0 deletions app/components/admin/dashboard/health_card_component.rb
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<div class="mb-1">
<strong class="small">Workshops and events timeline</strong>
</div>
<svg viewBox="0 0 1000 122" class="w-100" role="img" aria-label="Workshop and event timeline, 180 days past to 90 days future">
<% # 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) %>
<line x1="<%= tick_x %>" y1="14" x2="<%= tick_x %>" y2="70" stroke="#dee2e6" stroke-width="1" />
<% if (ticks_ago % 2).zero? %>
<text x="<%= tick_x %>" y="86" font-size="9" fill="#6c757d" text-anchor="middle">
<%= (range_start + (ticks_ago * 30).days).to_fs(:short) %>
</text>
<% end %>
<% end %>
<% (1..3).each do |ticks_ahead| %>
<% tick_x = (667 + (ticks_ahead * 333 / 3.0)).round(1) %>
<line x1="<%= tick_x %>" y1="14" x2="<%= tick_x %>" y2="70" stroke="#dee2e6" stroke-width="1" />
<text x="<%= tick_x %>" y="86" font-size="9" fill="#6c757d" text-anchor="middle">
<%= (Time.zone.today + (ticks_ahead * 30).days).to_fs(:short) %>
</text>
<% end %>

<line x1="667" y1="6" x2="667" y2="74" stroke="#212529" stroke-width="2" stroke-dasharray="4 3" />
<text x="667" y="10" font-size="9" fill="#212529" text-anchor="middle">today</text>

<% # single-chapter events, complementary colour (amber) with square-ish markers %>
<% stacked_markers(event_past).each do |m| %>
<% if m[:stack] %>
<text x="<%= m[:x] %>" y="<%= m[:y] - 12 %>" font-size="8" fill="#fd7e14" text-anchor="middle">x<%= m[:stack] %></text>
<% end %>
<rect x="<%= m[:x] - 5 %>" y="<%= m[:y] - 5 %>" width="10" height="10" fill="#fd7e14" class="marker">
<title><%= m[:date].to_date.to_fs(:iso8601) %> (event)</title>
</rect>
<% end %>

<% stacked_markers(event_future).each do |m| %>
<rect x="<%= m[:x] - 5 %>" y="<%= m[:y] - 5 %>" width="10" height="10" fill="white" stroke="#fd7e14" stroke-width="2" class="marker">
<title><%= m[:date].to_date.to_fs(:iso8601) %> (event, planned)</title>
</rect>
<% end %>

<% stacked_markers(past).each do |m| %>
<% if m[:stack] %>
<text x="<%= m[:x] %>" y="<%= m[:y] - 12 %>" font-size="8" fill="#0d6efd" text-anchor="middle">x<%= m[:stack] %></text>
<% end %>
<circle cx="<%= m[:x] %>" cy="<%= m[:y] %>" r="5" fill="#0d6efd" class="marker">
<title><%= m[:date].to_date.to_fs(:iso8601) %></title>
</circle>
<% end %>

<% stacked_markers(future).each do |m| %>
<% if m[:stack] %>
<text x="<%= m[:x] %>" y="<%= m[:y] - 12 %>" font-size="8" fill="#0d6efd" text-anchor="middle">x<%= m[:stack] %></text>
<% end %>
<circle cx="<%= m[:x] %>" cy="<%= m[:y] %>" r="5" fill="white" stroke="#0d6efd" stroke-width="2" class="marker">
<title><%= m[:date].to_date.to_fs(:iso8601) %> (planned)</title>
</circle>
<% 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]) %>
<circle cx="<%= item[:x] %>" cy="108" r="5"
fill="<%= m == 'circle-filled' ? '#0d6efd' : 'white' %>"
stroke="<%= m == 'circle-filled' ? 'none' : '#0d6efd' %>" stroke-width="2" />
<% else %>
<rect x="<%= item[:x] - 5 %>" y="103" width="10" height="10"
fill="<%= m == 'rect-filled' ? '#fd7e14' : 'white' %>"
stroke="<%= m == 'rect-filled' ? 'none' : '#fd7e14' %>" stroke-width="2" />
<% end %>
<text x="<%= item[:x] + 12 %>" y="112" font-size="11" fill="#6c757d"><%= item[:label] %></text>
<% end %>
</svg>

39 changes: 39 additions & 0 deletions app/components/admin/dashboard/workshop_timeline_component.rb
Original file line number Diff line number Diff line change
@@ -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 — <title> 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
8 changes: 8 additions & 0 deletions app/controllers/admin/chapters_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
4 changes: 4 additions & 0 deletions app/models/event.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
67 changes: 67 additions & 0 deletions app/services/admin/dashboard/chapter_health.rb
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions app/views/admin/chapters/show.html.haml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 48 additions & 0 deletions spec/components/admin/dashboard/health_card_component_spec.rb
Original file line number Diff line number Diff line change
@@ -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
Loading