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
25 changes: 25 additions & 0 deletions app/components/admin/stats/table_component.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<div class="table-responsive mb-4">
<table class="table table-sm table-hover align-middle">
<caption class="caption-top text-start"><%= caption %></caption>
<thead>
<tr>
<th scope="col">Metric</th>
<% months.each do |month| %>
<th scope="col" class="text-end"><%= month_header(month) %></th>
<% end %>
<th scope="col" class="text-end">Totals</th>
</tr>
</thead>
<tbody>
<% rows.each do |row| %>
<tr>
<th scope="row"><%= row.label %></th>
<% months.each do |month| %>
<td class="text-end"><%= cell_value(row, month) %></td>
<% end %>
<td class="text-end"><%= total_value(row) %></td>
</tr>
<% end %>
</tbody>
</table>
</div>
34 changes: 34 additions & 0 deletions app/components/admin/stats/table_component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# frozen_string_literal: true

module Admin
module Stats
# Renders one metric group's monthly table: one column per month in
# the active range plus a Totals column. Fed plain dates and
# Admin::Stats::Monthly::Row objects.
class TableComponent < ViewComponent::Base
include ActionView::Helpers::NumberHelper

def initialize(months:, rows:, caption:) # rubocop:disable Lint/MissingSuper
@months = months
@rows = rows
@caption = caption
end

private

attr_reader :months, :rows, :caption

def month_header(month)
month.strftime('%B %Y')
end

def cell_value(row, month)
number_with_delimiter(row.cells.fetch(month, 0))
end

def total_value(row)
number_with_delimiter(row.total)
end
end
end
end
89 changes: 89 additions & 0 deletions app/controllers/admin/stats_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# frozen_string_literal: true

module Admin
# Serves the organisation-wide monthly stats page and its CSV export
# behind the global-admin check: the admin area also admits organisers,
# so this controller re-checks admin status itself.
class StatsController < Admin::ApplicationController
before_action :authenticate_admin!

def index
skip_authorization
load_stats

respond_to do |format|
format.html
format.csv { send_stats_csv }
end
end

private

def load_stats
range_result = resolve_range
@range_result = range_result
@months = range_result.months
@rows = Admin::Stats::Monthly.call(range_result).rows
@range_start, @range_end = month_inputs(range_result)
end

def resolve_range
range_result = Admin::Stats::Range.resolve(**filter_params)
return default_range if range_result.invalid? && request.format.csv?
return page_invalid_range if range_result.invalid?

session[:admin_stats_months] = range_result.months.map(&:iso8601) unless request.format.csv?
range_result
end

# The page never silently substitutes an invalid range — it
# re-renders the last valid range (retained in the session) with an
# inline error. CSV instead falls back to the default 3 months.
def page_invalid_range
@range_error = 'The start month must not be after the end month.'
stored = Array(session[:admin_stats_months]).map { |month| Date.parse(month) }
return default_range if stored.blank?

Admin::Stats::Range::Result.new(months: stored, status: :custom,
start_month: stored.first, end_month: stored.last)
end

def default_range
Admin::Stats::Range.resolve
end

def filter_params
return {} unless params.key?(:stats)

params.expect(stats: %i[preset start_month end_month]).to_h.symbolize_keys
end

def month_inputs(range_result)
[range_result.start_month, range_result.end_month].map { |month| month&.strftime('%Y-%m') }
end

def send_stats_csv
send_data stats_csv, filename: "codebar-stats-#{range_span}.csv",
type: 'text/csv', disposition: 'attachment'
end

def stats_csv
CSV.generate do |out|
out << ['Metric', *@months.map { |month| csv_month(month) }, 'Totals']
@rows.each { |row| out << csv_row(row) }
end
end

def csv_month(month)
month.strftime('%Y-%m')
end

def csv_row(row)
[row.label, *@months.map { |month| row.cells.fetch(month, 0) }, row.total]
end

def range_span
"#{csv_month(@months.first)}-#{csv_month(@months.last)}"
end
end
end
142 changes: 142 additions & 0 deletions app/services/admin/stats/monthly.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
# frozen_string_literal: true

module Admin
module Stats
# Computes organisation-wide monthly metric rows for a resolved
# Admin::Stats::Range. All aggregation happens in SQL: each metric
# query groups by calendar month (app time zone) and returns a
# handful of rows, so request cost is independent of row counts.
# Attendance counts come from workshop_invitations bucketed by the
# workshop's date_and_time; sign-up rows come from members.created_at
# with role assignment by current group membership and a
# distinct-member total. Totals are range totals only: each totals
# cell sums its row across exactly the range's months.
class Monthly
Row = Data.define(:section, :label, :cells, :total)
Result = Data.define(:months, :rows)

ATTENDANCE_LABELS = { student_check_ins: 'Student check-ins',
coach_check_ins: 'Coach check-ins',
student_rsvps: 'Student RSVPs',
coach_rsvps: 'Coach RSVPs' }.freeze
SIGN_UP_LABELS = { students: 'New students',
coaches: 'New coaches',
uncategorised: 'Uncategorised',
total: 'Total new members' }.freeze
ROLE_KEYS = { 'Student' => { check_in: :student_check_ins, rsvp: :student_rsvps },
'Coach' => { check_in: :coach_check_ins, rsvp: :coach_rsvps } }.freeze

class << self
def call(range)
months = range.months
return Result.new(months: [], rows: []) if months.empty?

@month_keys = months.index_by(&:itself)
rows = attendance_rows(months) + sign_up_rows(months) + [workshop_row(months)]
Result.new(months:, rows:)
end

private

def attendance_rows(months)
counts = blank_counts(months, ATTENDANCE_LABELS.keys)
grouped_attendance(months).each do |month, role, attended, attending, count|
key = @month_keys[month]
next unless key

attendance_keys(role, attended, attending).each { |row_key| counts[row_key][key] += count }
end
ATTENDANCE_LABELS.map { |key, label| row(:attendance, label, months, counts[key]) }
end

def grouped_attendance(months)
scope = WorkshopInvitation.joins(:workshop).where(workshops: { date_and_time: window(months) })
scope.where(attending: true)
.or(scope.where(attended: true))
.group(month_bucket('workshops.date_and_time'), :role, :attended, :attending)
.pluck(month_bucket('workshops.date_and_time'), :role, :attended, :attending,
Arel.sql('COUNT(*)'))
end

# A check-in invitation carries both flags (attending is set when
# attendance is recorded), so it counts in the check-in row AND the
# RSVP row — the rows are independent bases.
def attendance_keys(role, attended, attending)
keys = ROLE_KEYS[role] || {}
[].tap do |selected|
selected << keys[:check_in] if attended && keys[:check_in]
selected << keys[:rsvp] if attending && keys[:rsvp]
end
end

def sign_up_rows(months) # rubocop:disable Metrics/AbcSize
counts = blank_counts(months, SIGN_UP_LABELS.keys)
grouped_sign_ups(months).each do |month, total, students, coaches, uncategorised|
key = @month_keys[month]
next unless key

counts[:students][key] += students
counts[:coaches][key] += coaches
counts[:uncategorised][key] += uncategorised
counts[:total][key] += total
end
SIGN_UP_LABELS.map { |key, label| row(:sign_ups, label, months, counts[key]) }
end

# Left join keeps members with no group subscription in the
# uncategorised row; distinct counts keep dual-group members in
# both role rows but once in the total.
def grouped_sign_ups(months)
Member.where(created_at: window(months))
.left_joins(:groups)
.group(month_bucket('members.created_at'))
.pluck(month_bucket('members.created_at'),
Arel.sql('COUNT(DISTINCT members.id)'),
Arel.sql("COUNT(DISTINCT CASE WHEN groups.name = 'Students' THEN members.id END)"),
Arel.sql("COUNT(DISTINCT CASE WHEN groups.name = 'Coaches' THEN members.id END)"),
Arel.sql('COUNT(DISTINCT CASE WHEN groups.name IS NULL THEN members.id END)'))
end

def workshop_row(months)
row(:workshops, 'Workshops', months, workshop_counts(months))
end

def workshop_counts(months)
counts = default_cells(months)
Workshop.where(date_and_time: window(months))
.unscope(:order)
.group(month_bucket('workshops.date_and_time'))
.pluck(month_bucket('workshops.date_and_time'), Arel.sql('COUNT(*)'))
.each do |month, count|
key = @month_keys[month]
counts[key] = count if key
end
counts
end

def row(section, label, months, counts)
cells = months.index_with { |m| counts.fetch(m, 0) }
Row.new(section:, label:, cells:, total: cells.values.sum)
end

def blank_counts(months, keys)
keys.index_with { default_cells(months) }
end

def default_cells(months)
months.index_with { 0 }
end

def window(months)
months.first.beginning_of_day..months.last.end_of_month.end_of_day
end

# Calendar-month bucket in the app time zone, cast to a
# first-of-month date so it matches the months list.
def month_bucket(column)
Arel.sql("DATE_TRUNC('month', #{column} AT TIME ZONE '#{Time.zone.tzinfo.identifier}')::date")
end
end
end
end
end
87 changes: 87 additions & 0 deletions app/services/admin/stats/range.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# frozen_string_literal: true

module Admin
module Stats
# Resolves a stats date range into a list of complete calendar months
# (first-of-month Dates) in the app time zone. Presets anchor at the
# last complete month; the current partial month never appears. A
# start month after the end month is an explicit invalid state — the
# caller picks the response (page: inline error, CSV: default range).
class Range
Result = Data.define(:months, :status, :start_month, :end_month) do
def invalid?
status == :invalid
end
end

PRESETS = { '3' => 3, '6' => 6, '12' => 12 }.freeze

# Server-side bound on custom ranges: wider spans route to the
# invalid state, reusing the inline-error and CSV-fallback paths.
# session-stored range well inside the cookie store and the table
# readable (20 years).
MAX_SPAN_MONTHS = 240

class << self
def resolve(preset: nil, start_month: nil, end_month: nil)
start = parse_month(start_month)
finish = parse_month(end_month)

return custom_result(start, finish) if start && finish

default_result(span_for(preset))
end

private

def span_for(preset)
PRESETS[preset.to_s] || 3
end

def current_month
Time.zone.today.beginning_of_month
end

def last_complete_month
current_month.prev_month
end

def parse_month(value)
return nil if value.blank?

Date.strptime(value.to_s, '%Y-%m').beginning_of_month
rescue ArgumentError, TypeError
nil
end

def custom_result(start, finish)
finish = last_complete_month if finish >= current_month
return invalid_result(start, finish) if start > finish
return invalid_result(start, finish) if span_months(start, finish) > MAX_SPAN_MONTHS

Result.new(months: month_list(start, finish), status: :custom,
start_month: start, end_month: finish)
end

def invalid_result(start, finish)
Result.new(months: [], status: :invalid, start_month: start, end_month: finish)
end

def default_result(count)
last = last_complete_month
months = Array.new(count) { |i| last << (count - 1 - i) }
Result.new(months:, status: :default, start_month: nil, end_month: nil)
end

def month_list(from, to)
span = span_months(from, to)
(0..span).map { |offset| from >> offset }
end

def span_months(from, to)
(to.year * 12 + to.month) - (from.year * 12 + from.month)
end
end
end
end
end
1 change: 1 addition & 0 deletions app/views/admin/portal/index.html.haml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
= link_to 'Admin Guide', admin_guide_path, class: 'btn btn-primary btn-lg mb-3'
= link_to 'Members Directory', admin_members_path, class: 'btn btn-primary btn-lg mb-3'
= link_to 'Chapter Status', status_admin_chapters_path, class: 'btn btn-primary btn-lg mb-3'
= link_to 'Stats', admin_stats_path, class: 'btn btn-primary btn-lg mb-3'
%hr

.row
Expand Down
Loading