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
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,8 @@ end

- `EventDashboard` β€” Aggregates per-event dashboard metrics (registrant/org/sector/state/county counts, scholarship totals, payment received/outstanding/total)
- `EventRevenueReport` β€” Cross-event revenue report grouped by calendar year (money in vs org subsidy vs net, projected CE, chart series) for the CEO revenue page
- `EventParticipationReport` β€” Cross-event participation report grouped by calendar year (unique people trained vs attended seats vs per-status outcome counts, chart series) for the events participation page; sibling of `EventRevenueReport`
- `ReportPeriods` β€” Shared module (included by `EventRevenueReport` and `EventParticipationReport`) resolving the reporting-hub period toggle (this year / last year / all time) to a metric scope + label for the summary cards
- `ScholarshipApplication` β€” Gathers one person's scholarship-application answers for an event by field across all their submissions, so answers surface whether captured on a dedicated scholarship form, an embedded registration section, or the registration submission itself (used by the scholarship edit page and the public submission view)
- `WorkshopSearchService` β€” Complex filtering, sorting, pagination with ActionPolicy
- `WorkshopFromIdeaService` β€” Converts WorkshopIdea to Workshop with asset migration
Expand Down
81 changes: 75 additions & 6 deletions app/controllers/events_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ class EventsController < ApplicationController
skip_before_action :authenticate_user!, only: [ :index, :show, :staff ]
skip_before_action :verify_authenticity_token, only: [ :preview ]
before_action :set_event, only: %i[ show edit update destroy preview dashboard sample_ticket background registrants onboarding staff edit_staff update_staff recipients preview_reminder confirm_reminder send_reminder copy_registration_form ]
before_action :set_report_filters, only: %i[ revenue participation statistics ]

def index
authorize!
Expand All @@ -17,14 +18,32 @@ def show
track_view(@event)
end

# Cross-event revenue report across every paid event (those that charge a
# registration fee). The KPI strip leads with the year of the event the CEO
# navigated from (when arriving from a dashboard), otherwise the current year.
# Cross-event revenue report over paid events, grouped by year. Shares the
# event-type + time-period filters with the participation report, and leads the
# KPI strip with the year of the event navigated from (when arriving from a
# dashboard), otherwise the current year.
def revenue
authorize!
@events = Event.paid.order(start_date: :desc).map(&:decorate)
origin_event = Event.find_by(id: params[:event_id]) if params[:event_id].present?
@report = EventRevenueReport.new(@events, featured_year: origin_event&.start_date&.year)
events, selected_year = filtered_report_events(Event.paid)
@report = EventRevenueReport.new(events, featured_year: selected_year || @filter_event&.start_date&.year)
end

# Cross-event participation report: how many people completed each training,
# grouped by year. Scopes to all events by default, narrowable to facilitator
# trainings, and to a single year via the ahoy-style time-period select.
def participation
authorize!
events, selected_year = filtered_report_events(Event.all)
@report = EventParticipationReport.new(events, featured_year: selected_year)
end

# Events statistics hub: the revenue and participation report summaries side by
# side, each linking to its full report.
def statistics
authorize!
@period = params[:period].presence_in(%w[ this_year last_year all_time ]) || "this_year"
@revenue_report = EventRevenueReport.new(report_events(Event.paid))
@participation_report = EventParticipationReport.new(report_events(Event.all))
end

def new
Expand Down Expand Up @@ -424,6 +443,56 @@ def staff_update_return_path
end
end

# Shared filter state for the revenue/participation/statistics report pages: the
# event-type and specific-event filters, plus the event list for the Event
# dropdown.
def set_report_filters
@event_type = params[:event_type].presence_in(%w[ trainings other ])
@filter_event = Event.find_by(id: params[:event_id]) if params[:event_id].present?
# The revenue report only covers paid events, so its Event dropdown lists only
# those; the others list every event.
dropdown_scope = action_name == "revenue" ? Event.paid : Event.all
@filter_events = dropdown_scope.order(start_date: :desc)
end

# Applies the shared report filters (event type, specific event) plus a
# calendar-year time period to `base` (the report's unfiltered relation, e.g.
# Event.paid or Event.all). Sets @year_options and @time_period for the filter
# form, and returns the decorated events plus the selected year (nil for "all
# time").
def filtered_report_events(base)
base = scoped_report_base(base)
@year_options = base.where.not(start_date: nil)
.distinct
.pluck(Arel.sql("YEAR(start_date)"))
.sort
.reverse
@time_period = params[:time_period].presence || "all_time"
selected_year = selected_report_year(@time_period)
events = selected_year ? base.in_year(selected_year) : base
[ events.order(start_date: :desc).map(&:decorate), selected_year ]
end

# Decorated events for a report, scoped by the shared filters, newest first.
def report_events(base)
scoped_report_base(base).order(start_date: :desc).map(&:decorate)
end

# Narrows `base` by the event-type and specific-event filters.
def scoped_report_base(base)
base = base.facilitator_trainings if @event_type == "trainings"
base = base.where(facilitator_training: false) if @event_type == "other"
base = base.where(id: @filter_event.id) if @filter_event
base
end

# The calendar year a report is scoped to: the current year for "this_year", a
# specific year for a "2025"-style value, or nil for "all_time".
def selected_report_year(time_period)
return Date.current.year if time_period == "this_year"
Integer(time_period, exception: false)
end

# The registrations the admin checked on the recipient picker, narrowed to those
# we can actually email. Shared by the confirm interstitial and the send action
# so both operate on exactly the same set.
Expand Down
2 changes: 1 addition & 1 deletion app/helpers/admin_cards_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ def user_content_cards
custom_card("Portal activity", admin_activities_counts_path, icon: "πŸ“Š"),
custom_card("Bookmarks tally", tally_bookmarks_path, icon: "πŸ”–"),
model_card(:quotes, icon: "πŸ’¬", intensity: 100),
custom_card("Events revenue", revenue_events_path, icon: "πŸ“Š", color: :blue),
custom_card("Events statistics", statistics_events_path, icon: "πŸ“Š", color: :blue),
model_card(:scholarships, icon: "πŸŽ“"),
model_card(:notifications, icon: "πŸ””", title: t("communications.title")),
model_card(:story_ideas, icon: "✍🏾️", intensity: 100),
Expand Down
13 changes: 13 additions & 0 deletions app/helpers/event_participation_helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
module EventParticipationHelper
# A small, neutral year-over-year change indicator for a headcount, e.g.
# "β–² 12" / "β–Ό 3". Direction only, uncoloured. Returns nil when there's no prior
# period or no change.
def participation_delta(current_count, prior_count)
return nil if prior_count.nil?
delta = current_count - prior_count
return nil if delta.zero?
arrow = delta.positive? ? "β–²" : "β–Ό"
content_tag(:span, "#{arrow} #{number_with_delimiter(delta.abs)}",
class: "text-xs text-gray-400 tabular-nums")
end
end
2 changes: 2 additions & 0 deletions app/models/event.rb
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ def remote_search_label
scope :facilitator_trainings, -> { where(facilitator_training: true) }
# Events that charge a registration fee (cost_cents may be nil for free ones).
scope :paid, -> { where("cost_cents > 0") }
# Events whose start date falls in the given calendar year.
scope :in_year, ->(year) { where(start_date: Date.new(year).all_year) }

def self.search_by_params(params)
stories = is_a?(ActiveRecord::Relation) ? self : all
Expand Down
15 changes: 15 additions & 0 deletions app/models/event_registration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,15 @@ class EventRegistration < ApplicationRecord
scope :attended, -> { where(status: "attended") }
scope :registrant_ids, ->(ids) { where(registrant_id: ids.to_s.split("-").map(&:to_i)) }
scope :attendance_status, ->(status) { where(status: status) }
# Registrations on facilitator-training events ("trainings") vs everything else
# ("other"); any other value is a no-op so "all events" passes through.
scope :event_type, ->(type) {
case type
when "trainings" then joins(:event).where(events: { facilitator_training: true })
when "other" then joins(:event).where(events: { facilitator_training: false })
else all
end
}
scope :registrant_state, ->(state) {
joins(registrant: :addresses)
.where(addresses: { inactive: false, state: state })
Expand Down Expand Up @@ -257,6 +266,12 @@ def self.search_by_params(params)
if params[:ce_status].present?
registrations = registrations.ce_status(params[:ce_status])
end
if params[:attendance_status].present?
registrations = registrations.attendance_status(params[:attendance_status])
end
if params[:event_type].present?
registrations = registrations.event_type(params[:event_type])
end
registrations
end

Expand Down
12 changes: 12 additions & 0 deletions app/policies/event_policy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,18 @@ def revenue?
admin?
end

# The cross-event participation report aggregates attendance across every
# event, so it's admin-only like the revenue report.
def participation?
admin?
end

# The events statistics hub gathers the cross-event report summaries, so it's
# admin-only like the reports it links to.
def statistics?
admin?
end

def show?
return true if admin?

Expand Down
Loading