From 37dfed2e9c224a6f6792a411c26dc1a9b5387e2f Mon Sep 17 00:00:00 2001 From: S Mae Beale Date: Sat, 8 Aug 2026 20:09:15 -0400 Subject: [PATCH 01/32] Events IA Phase 1: Live vs On-demand terminology Replace the inconsistent "training"/"non-demand" labels for non-on-demand trainings with a single "Live" term across the scholarship report and form, and add Event.live/.on_demand scopes so the delivery-format split has a canonical query source. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 2 +- app/models/event.rb | 3 +++ app/services/event_scholarship_report.rb | 9 +++++---- app/views/events/_form.html.erb | 2 +- app/views/events/_scholarship_kpis.html.erb | 2 +- app/views/events/_scholarship_summary.html.erb | 2 +- app/views/events/_scholarships_report_table.html.erb | 2 +- spec/models/event_spec.rb | 11 +++++++++++ spec/services/event_scholarship_report_spec.rb | 6 +++--- 9 files changed, 27 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 352c00508..d6c350f5c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -201,7 +201,7 @@ action, or `authorize! :workshop, to: :summary?`). - `TrainingAttendeesRoster` — Cross-event counterpart to `EventDashboard`: builds the per-registrant lookup maps the shared `events/_registrant_roster` partial reads (sector/age/org/status/location/scholarship/CE plus the attended-trainings event column) for a paginated page of people who have attended a facilitator training; backs the `events#training_attendees` index - `TrainingAttendeesBreakdowns` — Aggregate counterpart to `EventDashboard`'s breakdown methods: computes the chart datasets (sectors, age groups, locations, program status, life experiences, settings, organizations, scholarship/CE) over the whole filtered training-attendee population, profile-sourced, for the shared `events/_registrant_breakdowns` partial on the `events#training_attendees` index - `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 -- `EventScholarshipReport` — Cross-event scholarship report grouped by calendar year: scholarship dollars and award counts (funded vs unfunded, via `EventDashboard`) per facilitator training, plus an attended-trainee count split into "Training" (scheduled) vs "On-demand" (`event.on_demand?`). Sibling of `EventRevenueReport`/`EventParticipationReport` (includes `ReportPeriods`); powers the `events#scholarships` report page and the statistics-hub scholarship summary card +- `EventScholarshipReport` — Cross-event scholarship report grouped by calendar year: scholarship dollars and award counts (funded vs unfunded, via `EventDashboard`) per facilitator training, plus an attended-trainee count split into "Live" (scheduled instructor-led) vs "On-demand" (`event.on_demand?`). Sibling of `EventRevenueReport`/`EventParticipationReport` (includes `ReportPeriods`); powers the `events#scholarships` report page and the statistics-hub scholarship summary card - `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 diff --git a/app/models/event.rb b/app/models/event.rb index ae277f908..0b158d5e9 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -94,6 +94,9 @@ def remote_search_label # Events flagged as facilitator trainings (the "TAC" a scholarship recipient # attends). Drives the scholarship index's training column. scope :facilitator_trainings, -> { where(facilitator_training: true) } + # Delivery format: self-paced ("On-demand") vs scheduled instructor-led ("Live"). + scope :on_demand, -> { where(on_demand: true) } + scope :live, -> { where(on_demand: false) } # start_date is a date column, so compare against a date — a Time would be cast # to midnight and drop events starting today. scope :upcoming, -> { where("start_date >= ?", Date.current) } diff --git a/app/services/event_scholarship_report.rb b/app/services/event_scholarship_report.rb index b907dee5e..d1a4143d1 100644 --- a/app/services/event_scholarship_report.rb +++ b/app/services/event_scholarship_report.rb @@ -6,8 +6,9 @@ # Funded vs unfunded follows the app-wide convention (see EventDashboard): # funded = backed by an external grant; unfunded = no grant, or a grant the org # (AWBW) donated to itself. Alongside the money it carries a trainee headcount — -# people who ATTENDED — split by delivery format: scheduled sessions total under -# "Training" and self-paced ones (event.on_demand?) under "On-demand". +# people who ATTENDED — split by delivery format: scheduled instructor-led +# sessions total under "Live" and self-paced ones (event.on_demand?) under +# "On-demand". # # Give it a collection of (decorated) facilitator-training events. class EventScholarshipReport @@ -52,7 +53,7 @@ module Aggregates end # Attendance split by delivery format — these sum to attended_count. - def training_attended_count = columns.reject(&:on_demand?).sum(&:attended_count) + def live_attended_count = columns.reject(&:on_demand?).sum(&:attended_count) def on_demand_attended_count = columns.select(&:on_demand?).sum(&:attended_count) # Distinct scholarship recipients who attended (registration status @@ -60,7 +61,7 @@ def on_demand_attended_count = columns.select(&:on_demand?).sum(&:attended_count # of these trainings counts once. Split by delivery format (a person attending # both formats counts in each split, so the two need not sum to the total). def recipients_attended_count = distinct_attended_recipient_count(columns.map { |column| column.event.id }) - def training_recipients_attended_count = distinct_attended_recipient_count(columns.reject(&:on_demand?).map { |column| column.event.id }) + def live_recipients_attended_count = distinct_attended_recipient_count(columns.reject(&:on_demand?).map { |column| column.event.id }) def on_demand_recipients_attended_count = distinct_attended_recipient_count(columns.select(&:on_demand?).map { |column| column.event.id }) private diff --git a/app/views/events/_form.html.erb b/app/views/events/_form.html.erb index 5a34a7079..11ca9c29c 100644 --- a/app/views/events/_form.html.erb +++ b/app/views/events/_form.html.erb @@ -31,7 +31,7 @@ placeholder: "e.g. TOS205" } %> <%= f.input :facilitator_training, as: :boolean, label: "Facilitator training event", wrapper_html: { class: "mb-0!" } %> - <%= f.input :on_demand, as: :boolean, label: "On-demand (self-paced)", hint: "Trainees count under \"On-demand\" instead of \"Training\" in the scholarships report", wrapper_html: { class: "mb-0!" } %> + <%= f.input :on_demand, as: :boolean, label: "On-demand (self-paced)", hint: "Trainees count under \"On-demand\" instead of \"Live\" in the scholarships report", wrapper_html: { class: "mb-0!" } %> <%= f.input :pre_title, diff --git a/app/views/events/_scholarship_kpis.html.erb b/app/views/events/_scholarship_kpis.html.erb index 4236ccb41..b1023a9ed 100644 --- a/app/views/events/_scholarship_kpis.html.erb +++ b/app/views/events/_scholarship_kpis.html.erb @@ -34,7 +34,7 @@
Registrants attended
<%= number_with_delimiter(featured.attended_count) %>
-
<%= featured.training_attended_count %> training · <%= featured.on_demand_attended_count %> on-demand
+
<%= featured.live_attended_count %> live · <%= featured.on_demand_attended_count %> on-demand
diff --git a/app/views/events/_scholarship_summary.html.erb b/app/views/events/_scholarship_summary.html.erb index 658b08a81..0f0cf8f03 100644 --- a/app/views/events/_scholarship_summary.html.erb +++ b/app/views/events/_scholarship_summary.html.erb @@ -15,7 +15,7 @@
Recipients attended
<%= number_with_delimiter(metrics.recipients_attended_count) %>
-
<%= metrics.training_recipients_attended_count %> non-demand · <%= metrics.on_demand_recipients_attended_count %> on-demand
+
<%= metrics.live_recipients_attended_count %> live · <%= metrics.on_demand_recipients_attended_count %> on-demand
Scholarships awarded
diff --git a/app/views/events/_scholarships_report_table.html.erb b/app/views/events/_scholarships_report_table.html.erb index d0310c4db..da4093a31 100644 --- a/app/views/events/_scholarships_report_table.html.erb +++ b/app/views/events/_scholarships_report_table.html.erb @@ -85,7 +85,7 @@
Attended breakdown: - <%= report.training_attended_count %> training + <%= report.live_attended_count %> live · <%= report.on_demand_attended_count %> on-demand
diff --git a/spec/models/event_spec.rb b/spec/models/event_spec.rb index 5526cbfa7..dc8af7c9d 100644 --- a/spec/models/event_spec.rb +++ b/spec/models/event_spec.rb @@ -70,6 +70,17 @@ end end + describe ".live and .on_demand" do + it "partitions events by delivery format" do + live = create(:event, on_demand: false) + on_demand = create(:event, on_demand: true) + expect(Event.live).to include(live) + expect(Event.live).not_to include(on_demand) + expect(Event.on_demand).to include(on_demand) + expect(Event.on_demand).not_to include(live) + end + end + describe ".upcoming" do it "includes an event starting today" do # start_date is a date column, so comparing against a time-of-day would diff --git a/spec/services/event_scholarship_report_spec.rb b/spec/services/event_scholarship_report_spec.rb index 9fd47b810..9b687815e 100644 --- a/spec/services/event_scholarship_report_spec.rb +++ b/spec/services/event_scholarship_report_spec.rb @@ -118,8 +118,8 @@ def report_for(events, **opts) let(:group) { report.years.first } - it "totals attended trainees under Training (scheduled) vs On-demand" do - expect(group.training_attended_count).to eq(2) + it "totals attended trainees under Live (scheduled) vs On-demand" do + expect(group.live_attended_count).to eq(2) expect(group.on_demand_attended_count).to eq(3) end @@ -160,7 +160,7 @@ def report_for(events, **opts) end it "splits by delivery format (a cross-format person counts in each)" do - expect(report.training_recipients_attended_count).to eq(1) + expect(report.live_recipients_attended_count).to eq(1) expect(report.on_demand_recipients_attended_count).to eq(1) end end From 0f4f186838012018e9335655fba483ca52fb19e4 Mon Sep 17 00:00:00 2001 From: S Mae Beale Date: Sat, 8 Aug 2026 20:24:13 -0400 Subject: [PATCH 02/32] Events IA Phase 2: canonical scopes/constants for duplicated stat logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapse the many mirrored spellings of the shared event-stat rules into single model-level sources, so per-event and cross-event views can't drift: - EventRegistration.attended_facilitator_trainings (was spelled 4 ways across the controller and the training-attendees services) - Scholarship.externally_funded/.org_subsidized + Grant.self_donated_ids for the funded/unfunded split (consumed by EventDashboard, EventRevenueFigures, and the EventRegistration funder scopes) - Affiliation::STATUSES, #status_on, .with_status (Ruby rule in the roster + SQL rule in the controller, now one source with an executable agreement spec) - Address::US_STATE_ABBREVIATIONS (deduped from two copies) - EventRegistration.status_counts_by_event (shared by the dashboard and the participation report) Behavior-preserving except one intentional fix: the EventRegistration funder filter now treats an AWBW self-donated grant as org-subsidized (unfunded), matching EventDashboard — previously it split on grant presence alone. Pinned by a new .funder spec. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/controllers/events_controller.rb | 26 ++------- app/models/address.rb | 8 +++ app/models/affiliation.rb | 29 ++++++++++ app/models/event_registration.rb | 55 ++++++++++--------- app/models/grant.rb | 7 +++ app/models/scholarship.rb | 6 ++ app/services/event_dashboard.rb | 23 ++------ app/services/event_participation_report.rb | 8 +-- app/services/event_revenue_figures.rb | 8 +-- app/services/training_attendees_breakdowns.rb | 15 +---- app/services/training_attendees_roster.rb | 12 +--- spec/models/affiliation_spec.rb | 31 +++++++++++ spec/models/event_registration_spec.rb | 40 ++++++++++++++ 13 files changed, 170 insertions(+), 98 deletions(-) diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index 813bd5530..cd35815f2 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -589,9 +589,7 @@ def selected_report_year(time_period) # filters (event, year) constrain which attended registrations qualify; the rest # filter the people. Distinct via the id subquery, so joins never duplicate rows. def filtered_training_attendees - registrations = EventRegistration.attended - .joins(:event) - .where(events: { facilitator_training: true }) + registrations = EventRegistration.attended_facilitator_trainings registrations = registrations.where(events: { id: params[:event_id] }) if params[:event_id].present? registrations = registrations.where("YEAR(events.start_date) = ?", params[:event_year]) if params[:event_year].present? @@ -652,7 +650,7 @@ def person_school_district_ids(district) # Attended facilitator-training registrations (any registrant) — the basis for # the org/scholarship/CE drill-in filters. def attended_training_registrations - EventRegistration.attended.joins(:event).where(events: { facilitator_training: true }) + EventRegistration.attended_facilitator_trainings end # Person ids with the given org linked on one of their attended trainings. @@ -710,23 +708,9 @@ def training_ce_person_ids end # Person ids with at least one affiliation in the given status (Active / Pending - # / Inactive), matching TrainingAttendeesRoster#affiliation_status. + # / Inactive). def person_affiliation_status_ids(status) - today = Date.current - scope = - case status - when "Active" - Affiliation.where(inactive: false) - .where("affiliations.start_date IS NULL OR affiliations.start_date <= ?", today) - .where("affiliations.end_date IS NULL OR affiliations.end_date >= ?", today) - when "Pending" - Affiliation.where(inactive: false).where("affiliations.start_date > ?", today) - when "Inactive" - Affiliation.where("affiliations.inactive = ? OR affiliations.end_date < ?", true, today) - else - return Person.none - end - scope.select(:person_id) + Affiliation.with_status(status).select(:person_id) end def person_address_ids(state: nil, county: nil) @@ -741,7 +725,7 @@ def person_address_ids(state: nil, county: nil) def set_training_attendee_filter_options @training_events = Event.facilitator_trainings.order(start_date: :desc) @training_years = @training_events.filter_map { |event| event.start_date&.year }.uniq.sort.reverse - attended_person_ids = Person.where(id: EventRegistration.attended.joins(:event).where(events: { facilitator_training: true }).select(:registrant_id)) + attended_person_ids = Person.where(id: EventRegistration.attended_facilitator_trainings.select(:registrant_id)) @training_sectors = Sector.where(id: SectorableItem.where(sectorable_type: "Person", sectorable_id: attended_person_ids).select(:sector_id)).order(:name) addresses = Address.active.where(addressable_type: "Person", addressable_id: attended_person_ids) @training_states = addresses.where.not(state: [ nil, "" ]).distinct.pluck(:state).sort diff --git a/app/models/address.rb b/app/models/address.rb index 22fed5734..f0100d65c 100644 --- a/app/models/address.rb +++ b/app/models/address.rb @@ -2,6 +2,14 @@ class Address < ApplicationRecord LOCALITIES = [ "LA City", "LA County", "Southern CA", "Northern CA", "Central CA", "Orange County", "Outside CA", "Outside USA", "Unknown" ] CONTACT_TYPES = [ nil, "work", "personal", "mailing", "unknown" ].freeze + # USPS abbreviations for the 50 states, DC, and the US territories the atlas + # draws — the whitelist behind every "States" breakdown, so international + # regions (e.g. "ON", "England") are excluded (they belong to the Countries map). + US_STATE_ABBREVIATIONS = %w[ + AL AK AZ AR CA CO CT DE DC FL GA HI ID IL IN IA KS KY LA ME MD MA MI MN MS MO + MT NE NV NH NJ NM NY NC ND OH OK OR PA RI SC SD TN TX UT VT VA WA WV WI WY + PR GU VI AS MP + ].freeze belongs_to :addressable, polymorphic: true, touch: true # Affiliations that point to this address as their organization address. Nullify diff --git a/app/models/affiliation.rb b/app/models/affiliation.rb index b35069b5d..08cd1bb1a 100644 --- a/app/models/affiliation.rb +++ b/app/models/affiliation.rb @@ -4,6 +4,9 @@ class Affiliation < ApplicationRecord # (both treat exactly "Facilitator" as canonical). FACILITATOR_TITLE = "Facilitator".freeze + # Status taxonomy shown/filtered on the attendees index, in display order. + STATUSES = %w[ Active Pending Inactive ].freeze + belongs_to :organization, inverse_of: :affiliations belongs_to :person, touch: true # Which of the organization's addresses this person is affiliated with (optional). @@ -39,6 +42,24 @@ class Affiliation < ApplicationRecord # TRIM mirrors the in-memory #facilitator? strip so stray whitespace still matches. scope :facilitators, -> { where("BINARY TRIM(title) = ?", "Facilitator") } + # Affiliations whose #status_on(date) equals the given status, expressed in SQL + # so it composes as a subquery (e.g. person-id narrowing). Kept in lock-step with + # #status_on by an executable agreement spec. + scope :with_status, ->(status, on: Date.current) { + case status + when "Active" + where(inactive: false) + .where("affiliations.start_date IS NULL OR affiliations.start_date <= ?", on) + .where("affiliations.end_date IS NULL OR affiliations.end_date >= ?", on) + when "Pending" + where(inactive: false).where("affiliations.start_date > ?", on) + when "Inactive" + where("affiliations.inactive = ? OR affiliations.end_date < ?", true, on) + else + none + end + } + before_validation :skip_if_duplicate before_save :set_inactive_from_dates after_save :sync_organization_status_with_affiliations @@ -62,6 +83,14 @@ def active? !inactive? && (end_date.nil? || end_date >= Date.current) end + # This affiliation's status as of a date: Inactive (flagged or ended), Pending + # (future start), otherwise Active. The in-memory twin of the .with_status scope. + def status_on(date = Date.current) + return "Inactive" if inactive? || (end_date && end_date < date) + return "Pending" if start_date && start_date > date + "Active" + end + def name "#{person.name}" if person end diff --git a/app/models/event_registration.rb b/app/models/event_registration.rb index bd1a50f2d..19c6b9838 100644 --- a/app/models/event_registration.rb +++ b/app/models/event_registration.rb @@ -78,6 +78,10 @@ class EventRegistration < ApplicationRecord scope :active, -> { where(status: ACTIVE_STATUSES) } scope :inactive, -> { where(status: INACTIVE_STATUSES) } scope :attended, -> { where(status: "attended") } + # Attended registrations on facilitator-training events — the population behind + # the cross-event attendees index and its breakdowns. Chain .where(registrant_id:) + # to narrow to a page of people. + scope :attended_facilitator_trainings, -> { attended.joins(:event).where(events: { facilitator_training: true }) } scope :registrant_ids, ->(ids) { where(registrant_id: ids.to_s.split("-").map(&:to_i)) } scope :attendance_status, ->(status) { status == "other" ? where.not(status: NAMED_OUTCOME_STATUSES) : where(status: status) @@ -166,32 +170,19 @@ class EventRegistration < ApplicationRecord else all end } - scope :with_funded_scholarship, -> { - where(<<~SQL.squish) - EXISTS ( - SELECT 1 FROM allocations - INNER JOIN scholarships ON scholarships.id = allocations.source_id - WHERE allocations.allocatable_type = 'EventRegistration' - AND allocations.allocatable_id = event_registrations.id - AND allocations.source_type = 'Scholarship' - AND scholarships.grant_id IS NOT NULL - ) - SQL - } - scope :with_unfunded_scholarship, -> { - where(<<~SQL.squish) - EXISTS ( - SELECT 1 FROM allocations - INNER JOIN scholarships ON scholarships.id = allocations.source_id - WHERE allocations.allocatable_type = 'EventRegistration' - AND allocations.allocatable_id = event_registrations.id - AND allocations.source_type = 'Scholarship' - AND scholarships.grant_id IS NULL - ) - SQL - } - # Funding source of a registrant's scholarship. "awbw" = org-subsidized (a - # scholarship drawn from no grant); "external" = grant-funded (drawn from an + # Funded vs org-subsidized follow the app-wide split (Scholarship.externally_funded + # / .org_subsidized): a grant AWBW funded itself counts as subsidy, not + # external funding, so it lands in the unfunded set alongside grant-less awards. + scope :with_funded_scholarship, -> { where(id: scholarship_allocatable_ids(Scholarship.externally_funded)) } + scope :with_unfunded_scholarship, -> { where(id: scholarship_allocatable_ids(Scholarship.org_subsidized)) } + # EventRegistration ids that a scholarship in the given relation is allocated to. + def self.scholarship_allocatable_ids(scholarships) + Allocation + .where(allocatable_type: "EventRegistration", source_type: "Scholarship", source_id: scholarships.select(:id)) + .select(:allocatable_id) + end + # Funding source of a registrant's scholarship. "awbw" = org-subsidized (no + # grant, or a grant AWBW funded itself); "external" = grant-funded (drawn from an # external funder's grant). scope :funder, ->(value) { case value @@ -384,6 +375,18 @@ class EventRegistration < ApplicationRecord .distinct } + # { event_id => { status => count } } from a single grouped query, so callers + # never round-trip per event. The single-event dashboard reads its own event's + # slice; the participation report reads every event's at once. + def self.status_counts_by_event(event_ids) + where(event_id: event_ids) + .group(:event_id, :status) + .count + .each_with_object({}) do |((event_id, status), count), map| + (map[event_id] ||= {})[status] = count + end + end + def self.search_by_params(params) registrations = is_a?(ActiveRecord::Relation) ? self : all if params[:registrant_id].present? diff --git a/app/models/grant.rb b/app/models/grant.rb index 6058f40ae..0f6f4a23e 100644 --- a/app/models/grant.rb +++ b/app/models/grant.rb @@ -14,6 +14,13 @@ class Grant < ApplicationRecord scope :by_deadline, -> { order(Arel.sql("funds_allocation_deadline IS NULL, funds_allocation_deadline ASC")) } + # Ids of grants the org (AWBW) donated to itself — treated as org subsidy, not + # external funding, by the scholarship funding split. Empty when the AWBW org + # isn't on file, collapsing the split back to grant-present vs grant-absent. + def self.self_donated_ids + where(donor: Organization.awbw).ids + end + # Total scholarship draws against a grant, as a correlated subquery. Used by the # funds scopes so they stay flat WHERE clauses — no GROUP BY/HAVING, which would # break will_paginate's total_entries count on the paginated index. diff --git a/app/models/scholarship.rb b/app/models/scholarship.rb index 3fcedb971..1011f21fe 100644 --- a/app/models/scholarship.rb +++ b/app/models/scholarship.rb @@ -19,6 +19,12 @@ class Scholarship < ApplicationRecord scope :completed, -> { where(tasks_completed: true) } scope :agreement_signed, -> { where.not(agreement_signed_at: nil) } + # Funding split (the app-wide convention, mirrored by EventDashboard and + # EventRevenueFigures): externally funded = backed by a grant whose funder isn't + # the org itself; org-subsidized = no grant, or a grant AWBW funded itself. + scope :externally_funded, -> { where.not(grant_id: [ nil, *Grant.self_donated_ids ]) } + scope :org_subsidized, -> { where(grant_id: [ nil, *Grant.self_donated_ids ]) } + # Scholarships from grants a given funder (Person/Organization) gave — the # "funder" filter. A blank funder matches nothing. scope :from_funder, ->(funder) { where(grant_id: Grant.where(funder: funder).select(:id)) } diff --git a/app/services/event_dashboard.rb b/app/services/event_dashboard.rb index 571232401..3c28715c3 100644 --- a/app/services/event_dashboard.rb +++ b/app/services/event_dashboard.rb @@ -47,7 +47,7 @@ def registrants # Every registration status for this event, counted in one query. def registration_status_counts - @registration_status_counts ||= event.event_registrations.group(:status).count + @registration_status_counts ||= EventRegistration.status_counts_by_event([ event.id ]).fetch(event.id, {}) end # Count of registrations in a single status. @@ -923,22 +923,13 @@ def settings_registrant_ids_by_category private - # USPS abbreviations for the 50 states, DC, and the territories the US atlas - # draws. The States breakdown only shows these, so international registrants' - # regions (e.g. "ON", "England") are excluded — those belong to the Countries map. - US_STATE_ABBREVIATIONS = %w[ - AL AK AZ AR CA CO CT DE DC FL GA HI ID IL IN IA KS KY LA ME MD MA MI MN MS MO - MT NE NV NH NJ NM NY NC ND OH OK OR PA RI SC SD TN TX UT VT VA WA WV WI WY - PR GU VI AS MP - ].freeze - # Active registrant addresses whose state is a recognized US state/territory — # the source for every States figure (count card, choropleth, and drill-in). def us_state_addresses Address .active .where(addressable_type: "Person", addressable_id: registrant_ids) - .where("UPPER(addresses.state) IN (?)", US_STATE_ABBREVIATIONS) + .where("UPPER(addresses.state) IN (?)", Address::US_STATE_ABBREVIATIONS) end def district_addresses @@ -1179,18 +1170,12 @@ def funder_grant_ids # Externally funded = backed by a grant whose funder isn't the org itself. def funded_scholarships - scholarships.where.not(grant_id: [ nil, *awbw_grant_ids ]) + scholarships.externally_funded end # Org-subsidized = no grant, or a grant the org (AWBW) donated to itself. def unfunded_scholarships - scholarships.where(grant_id: [ nil, *awbw_grant_ids ]) - end - - # Ids of grants the org donated to itself; empty when the AWBW org isn't on - # file, collapsing the split back to grant-present vs grant-absent. - def awbw_grant_ids - @awbw_grant_ids ||= Grant.where(funder: Organization.awbw).ids + scholarships.org_subsidized end # [ [ organization_id, registrant_id ], ... ] from the organizations linked on diff --git a/app/services/event_participation_report.rb b/app/services/event_participation_report.rb index b8f685c45..5739ebef5 100644 --- a/app/services/event_participation_report.rb +++ b/app/services/event_participation_report.rb @@ -289,13 +289,7 @@ def years_by_value # { event_id => { status => count } } from one grouped query, so no per-event # round trip and no Ruby readiness pass. def status_counts_by_event - @status_counts_by_event ||= EventRegistration - .where(event_id: event_ids) - .group(:event_id, :status) - .count - .each_with_object({}) do |((event_id, status), count), map| - (map[event_id] ||= {})[status] = count - end + @status_counts_by_event ||= EventRegistration.status_counts_by_event(event_ids) end # { year => distinct attended people } in one grouped distinct query. A nil diff --git a/app/services/event_revenue_figures.rb b/app/services/event_revenue_figures.rb index adeb1efdd..49aa14b48 100644 --- a/app/services/event_revenue_figures.rb +++ b/app/services/event_revenue_figures.rb @@ -77,13 +77,13 @@ def breakdown_for(event) private # A grant counts as external funding only when it exists and the org didn't - # donate it to itself (AWBW) — matching EventDashboard#funded_scholarships. + # donate it to itself (AWBW) — the in-memory form of Scholarship.externally_funded. def external_grant?(grant_id) - grant_id.present? && !awbw_grant_ids.include?(grant_id) + grant_id.present? && !self_donated_grant_ids.include?(grant_id) end - def awbw_grant_ids - @awbw_grant_ids ||= Grant.where(funder: Organization.awbw).ids.to_set + def self_donated_grant_ids + @self_donated_grant_ids ||= Grant.self_donated_ids.to_set end def figures_by_event_id diff --git a/app/services/training_attendees_breakdowns.rb b/app/services/training_attendees_breakdowns.rb index 4b36f5f84..5c6107fbf 100644 --- a/app/services/training_attendees_breakdowns.rb +++ b/app/services/training_attendees_breakdowns.rb @@ -6,14 +6,6 @@ # Sourced from each person's profile (sectors, age groups, categories, addresses) # and from the orgs/scholarships/CE tied to their attended-training registrations. class TrainingAttendeesBreakdowns - # US states/territories the atlas draws — international regions are excluded from - # the States breakdown (they belong to Countries), mirroring EventDashboard. - US_STATE_ABBREVIATIONS = %w[ - AL AK AZ AR CA CO CT DE DC FL GA HI ID IL IN IA KS KY LA ME MD MA MI MN MS MO - MT NE NV NH NJ NM NY NC ND OH OK OR PA RI SC SD TN TX UT VT VA WA WV WI WY - PR GU VI AS MP - ].freeze - def initialize(people) @people = people end @@ -62,7 +54,7 @@ def all_age_group_counts def state_counts @state_counts ||= active_addresses - .where("UPPER(addresses.state) IN (?)", US_STATE_ABBREVIATIONS) + .where("UPPER(addresses.state) IN (?)", Address::US_STATE_ABBREVIATIONS) .group(:state) .distinct .count(:addressable_id) @@ -179,9 +171,8 @@ def person_ids def training_registration_ids @training_registration_ids ||= EventRegistration - .attended - .joins(:event) - .where(events: { facilitator_training: true }, registrant_id: person_ids) + .attended_facilitator_trainings + .where(registrant_id: person_ids) .pluck(:id) end diff --git a/app/services/training_attendees_roster.rb b/app/services/training_attendees_roster.rb index 6f45c5642..a8e590e82 100644 --- a/app/services/training_attendees_roster.rb +++ b/app/services/training_attendees_roster.rb @@ -10,7 +10,7 @@ class TrainingAttendeesRoster # Affiliation status taxonomy shown in the index's Affiliation status column and # offered as a filter, in display order. - AFFILIATION_STATUSES = %w[ Active Pending Inactive ].freeze + AFFILIATION_STATUSES = Affiliation::STATUSES def initialize(people) @people = people.to_a @@ -143,10 +143,8 @@ def ce_source_registration_by_registrant def attended_training_registrations @attended_training_registrations ||= EventRegistration - .attended + .attended_facilitator_trainings .where(registrant_id: people.map(&:id)) - .joins(:event) - .where(events: { facilitator_training: true }) .includes(:event) .to_a end @@ -195,12 +193,8 @@ def linked_org_ids_by_registrant end end - # One affiliation's status: Inactive (flagged or ended), Pending (future start), - # otherwise Active. def affiliation_status(affiliation) - return "Inactive" if affiliation.inactive? || (affiliation.end_date && affiliation.end_date < Date.current) - return "Pending" if affiliation.start_date && affiliation.start_date > Date.current - "Active" + affiliation.status_on end def program_status_by_organization diff --git a/spec/models/affiliation_spec.rb b/spec/models/affiliation_spec.rb index c237ba0b3..4c464b552 100644 --- a/spec/models/affiliation_spec.rb +++ b/spec/models/affiliation_spec.rb @@ -200,6 +200,37 @@ end end + describe 'status (#status_on and .with_status)' do + let!(:active_open) { create(:affiliation, start_date: Date.current.prev_year, end_date: nil) } + let!(:active_span) { create(:affiliation, start_date: Date.current.prev_year, end_date: Date.current.next_year) } + let!(:pending) { create(:affiliation, start_date: Date.current.next_year, end_date: nil) } + let!(:ended) { create(:affiliation, start_date: Date.current.prev_year(2), end_date: Date.current.prev_year) } + let!(:no_dates) { create(:affiliation, start_date: nil, end_date: nil) } + + it 'exposes the taxonomy in display order' do + expect(Affiliation::STATUSES).to eq(%w[ Active Pending Inactive ]) + end + + it '#status_on classifies by flag and dates' do + expect(active_open.reload.status_on).to eq("Active") + expect(active_span.reload.status_on).to eq("Active") + expect(pending.reload.status_on).to eq("Pending") + expect(ended.reload.status_on).to eq("Inactive") + expect(no_dates.reload.status_on).to eq("Active") + end + + it '.with_status returns exactly the rows whose #status_on matches (SQL ↔ Ruby agree)' do + Affiliation::STATUSES.each do |status| + expected = Affiliation.all.select { |a| a.status_on == status }.map(&:id).sort + expect(Affiliation.with_status(status).ids.sort).to eq(expected), "mismatch for #{status}" + end + end + + it '.with_status is empty for an unknown status' do + expect(Affiliation.with_status("bogus")).to be_empty + end + end + describe '#set_inactive_from_dates' do let(:op) { create(:affiliation, inactive: false, end_date: nil) } diff --git a/spec/models/event_registration_spec.rb b/spec/models/event_registration_spec.rb index 3efd911b0..382d0f85d 100644 --- a/spec/models/event_registration_spec.rb +++ b/spec/models/event_registration_spec.rb @@ -197,6 +197,35 @@ def registration_with_scholarship end end + describe ".attended_facilitator_trainings" do + it "returns only attended registrations on facilitator-training events" do + training = create(:event, facilitator_training: true) + other = create(:event, facilitator_training: false) + attended = create(:event_registration, event: training, status: "attended") + no_show = create(:event_registration, event: training, status: "no_show") + other_event = create(:event_registration, event: other, status: "attended") + + results = EventRegistration.attended_facilitator_trainings + expect(results).to include(attended) + expect(results).not_to include(no_show, other_event) + end + end + + describe ".status_counts_by_event" do + it "returns { event_id => { status => count } } across the given events" do + e1 = create(:event) + e2 = create(:event) + create(:event_registration, event: e1, status: "attended") + create(:event_registration, event: e1, status: "attended") + create(:event_registration, event: e1, status: "no_show") + create(:event_registration, event: e2, status: "registered") + + counts = EventRegistration.status_counts_by_event([ e1.id, e2.id ]) + expect(counts[e1.id]).to eq("attended" => 2, "no_show" => 1) + expect(counts[e2.id]).to eq("registered" => 1) + end + end + describe ".registrant_ids" do it "returns registrations for the registrants in a hyphenated id list" do person_a = create(:person) @@ -437,6 +466,17 @@ def registration_with_scholarship expect(results).not_to include(scholarship_reg, incomplete_scholarship_reg, paid_reg, unpaid_reg) end + it "counts a grant AWBW donated to itself as org-subsidized ('awbw'), not funded" do + # Matches EventDashboard's funded/unfunded split: a self-donation is subsidy. + awbw = create(:organization, name: "A Window Between Worlds") + self_donated_reg = create(:event_registration, event: event) + subsidy = create(:scholarship, recipient: self_donated_reg.registrant, grant: create(:grant, donor: awbw), amount_cents: 1000) + create(:allocation, source: subsidy, allocatable: self_donated_reg, amount: 1000) + + expect(EventRegistration.funder("awbw")).to include(self_donated_reg) + expect(EventRegistration.funder("donor")).not_to include(self_donated_reg) + end + it "returns an unfiltered relation for unknown values" do expect(EventRegistration.funder("bogus")).to include(funded_reg, scholarship_reg, paid_reg, unpaid_reg) end From 9a7b97c55bc2a9ff503ede7ad4c86193cddb702c Mon Sep 17 00:00:00 2001 From: S Mae Beale Date: Sat, 8 Aug 2026 20:29:11 -0400 Subject: [PATCH 03/32] Events IA Phase 3: batched EventScholarshipFigures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cross-event scholarship report built one EventDashboard per event (each ~8 queries) just to read that event's funded/unfunded dollars, counts, and attended headcount. Replace it with EventScholarshipFigures — a batched loader modeled on EventRevenueFigures that computes every event's figures in a fixed number of grouped queries and mirrors the dashboard's funded/unfunded split (parity spec holds them together). The optional funder: narrowing carries over. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 3 +- app/services/event_scholarship_figures.rb | 110 ++++++++++++++++++ app/services/event_scholarship_report.rb | 28 +++-- .../event_scholarship_figures_spec.rb | 109 +++++++++++++++++ 4 files changed, 237 insertions(+), 13 deletions(-) create mode 100644 app/services/event_scholarship_figures.rb create mode 100644 spec/services/event_scholarship_figures_spec.rb diff --git a/AGENTS.md b/AGENTS.md index d6c350f5c..01146cc51 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -197,11 +197,12 @@ action, or `authorize! :workshop, to: :summary?`). - `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, CE fees, chart series) for the CEO revenue page - `EventRevenueFigures` — Batch-loads the per-event money components `EventRevenueReport` rows are built from (registration payments/outstanding, funded/unfunded scholarships, discounts, CE paid/outstanding) in a fixed number of grouped queries; mirrors the `EventDashboard` definitions +- `EventScholarshipFigures` — Batch-loads the per-event scholarship figures `EventScholarshipReport` columns are built from (funded/unfunded dollars + counts, attended count) in a fixed number of grouped queries; optional `funder:` narrows to a donor's grants. Mirrors the `EventDashboard` funded/unfunded split, replacing the one-dashboard-per-event it used to build - `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` - `TrainingAttendeesRoster` — Cross-event counterpart to `EventDashboard`: builds the per-registrant lookup maps the shared `events/_registrant_roster` partial reads (sector/age/org/status/location/scholarship/CE plus the attended-trainings event column) for a paginated page of people who have attended a facilitator training; backs the `events#training_attendees` index - `TrainingAttendeesBreakdowns` — Aggregate counterpart to `EventDashboard`'s breakdown methods: computes the chart datasets (sectors, age groups, locations, program status, life experiences, settings, organizations, scholarship/CE) over the whole filtered training-attendee population, profile-sourced, for the shared `events/_registrant_breakdowns` partial on the `events#training_attendees` index - `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 -- `EventScholarshipReport` — Cross-event scholarship report grouped by calendar year: scholarship dollars and award counts (funded vs unfunded, via `EventDashboard`) per facilitator training, plus an attended-trainee count split into "Live" (scheduled instructor-led) vs "On-demand" (`event.on_demand?`). Sibling of `EventRevenueReport`/`EventParticipationReport` (includes `ReportPeriods`); powers the `events#scholarships` report page and the statistics-hub scholarship summary card +- `EventScholarshipReport` — Cross-event scholarship report grouped by calendar year: scholarship dollars and award counts (funded vs unfunded, via `EventScholarshipFigures`) per facilitator training, plus an attended-trainee count split into "Live" (scheduled instructor-led) vs "On-demand" (`event.on_demand?`). Sibling of `EventRevenueReport`/`EventParticipationReport` (includes `ReportPeriods`); powers the `events#scholarships` report page and the statistics-hub scholarship summary card - `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 diff --git a/app/services/event_scholarship_figures.rb b/app/services/event_scholarship_figures.rb new file mode 100644 index 000000000..bcde85ebb --- /dev/null +++ b/app/services/event_scholarship_figures.rb @@ -0,0 +1,110 @@ +# Per-event scholarship figures for the cross-event scholarship report, loaded for +# every event at once in a handful of grouped queries instead of one EventDashboard +# (and its ~8 queries) per event. +# +# Mirrors EventDashboard's funded/unfunded split (Scholarship.externally_funded / +# .org_subsidized) and its active-registrations-only scope, so a report column and +# that event's dashboard can't disagree — a parity spec holds the two together. +# +# Pass funder: (a Person/Organization) to narrow to scholarships drawn from that +# donor's grants, matching EventDashboard.new(event, scholarship_donor:). +class EventScholarshipFigures + Figures = Struct.new( + :funded_cents, + :unfunded_cents, + :funded_count, + :unfunded_count, + :attended_count, + keyword_init: true + ) do + def scholarship_cents = funded_cents + unfunded_cents + def scholarship_count = funded_count + unfunded_count + end + + EMPTY = Figures.new( + funded_cents: 0, + unfunded_cents: 0, + funded_count: 0, + unfunded_count: 0, + attended_count: 0 + ).freeze + + def initialize(events, funder: nil) + @events = events.to_a + @funder = funder + end + + def for(event) + figures_by_event_id.fetch(event.id, EMPTY) + end + + private + + def figures_by_event_id + @figures_by_event_id ||= @events.to_h { |event| [ event.id, build(event) ] } + end + + def build(event) + registration_ids = registration_ids_by_event.fetch(event.id, []) + rows = registration_ids.flat_map { |id| scholarship_rows_by_registration.fetch(id, []) } + funded, unfunded = rows.partition { |grant_id, _amount| external_grant?(grant_id) } + + Figures.new( + funded_cents: funded.sum { |_grant_id, amount| amount }, + unfunded_cents: unfunded.sum { |_grant_id, amount| amount }, + funded_count: funded.size, + unfunded_count: unfunded.size, + attended_count: attended_counts_by_event.fetch(event.id, 0) + ) + end + + # A grant counts as external funding only when it exists and the org didn't + # donate it to itself (AWBW) — the in-memory form of Scholarship.externally_funded. + def external_grant?(grant_id) + grant_id.present? && !self_donated_grant_ids.include?(grant_id) + end + + def self_donated_grant_ids + @self_donated_grant_ids ||= Grant.self_donated_ids.to_set + end + + # { event_id => [ registration_id, ... ] } across every event, active only — + # matching EventDashboard, which counts scholarships on active registrations. + def registration_ids_by_event + @registration_ids_by_event ||= EventRegistration + .active + .where(event_id: @events.map(&:id)) + .pluck(:event_id, :id) + .each_with_object({}) { |(event_id, id), map| (map[event_id] ||= []) << id } + end + + def registration_ids + @registration_ids ||= registration_ids_by_event.values.flatten + end + + # { registration_id => [ [ grant_id, amount_cents ], ... ] }, optionally narrowed + # to the funder's grants. + def scholarship_rows_by_registration + @scholarship_rows_by_registration ||= begin + scope = Scholarship + .joins(:allocation) + .where(allocations: { allocatable_type: "EventRegistration", allocatable_id: registration_ids }) + scope = scope.where(grant_id: funder_grant_ids) if @funder + scope + .pluck(Arel.sql("allocations.allocatable_id"), :grant_id, :amount_cents) + .each_with_object({}) { |(registration_id, grant_id, amount), map| (map[registration_id] ||= []) << [ grant_id, amount ] } + end + end + + # Ids of grants the funder gave — empty (so nothing matches) when they gave none. + def funder_grant_ids + @funder_grant_ids ||= Grant.where(donor: @funder).ids + end + + # { event_id => attended registration count } from one grouped status query. + def attended_counts_by_event + @attended_counts_by_event ||= EventRegistration + .status_counts_by_event(@events.map(&:id)) + .transform_values { |counts| counts.fetch("attended", 0) } + end +end diff --git a/app/services/event_scholarship_report.rb b/app/services/event_scholarship_report.rb index d1a4143d1..0f2c3d0dc 100644 --- a/app/services/event_scholarship_report.rb +++ b/app/services/event_scholarship_report.rb @@ -12,19 +12,20 @@ # # Give it a collection of (decorated) facilitator-training events. class EventScholarshipReport - # One training's column. Sources every figure from EventDashboard so the - # funded/unfunded and attendance conventions can't drift from the dashboard. - Column = Struct.new(:event, :dashboard, keyword_init: true) do - def funded_cents = dashboard.funded_scholarship_cents - def unfunded_cents = dashboard.unfunded_scholarship_cents - def scholarship_cents = funded_cents + unfunded_cents - - def funded_count = dashboard.funded_scholarship_count - def unfunded_count = dashboard.unfunded_scholarship_count - def scholarship_count = funded_count + unfunded_count + # One training's column. Sources every figure from EventScholarshipFigures, which + # mirrors EventDashboard's funded/unfunded and attendance conventions (held by a + # parity spec) but loads all events in a fixed number of queries. + Column = Struct.new(:event, :figures, keyword_init: true) do + def funded_cents = figures.funded_cents + def unfunded_cents = figures.unfunded_cents + def scholarship_cents = figures.scholarship_cents + + def funded_count = figures.funded_count + def unfunded_count = figures.unfunded_count + def scholarship_count = figures.scholarship_count # Trainees who fully attended (registration status "attended"). - def attended_count = dashboard.attendance_count_for("attended") + def attended_count = figures.attended_count # Per-recipient scholarship breakdown for the row's expander: the funded / # unfunded recipients (name-sorted Person records) and their dollars keyed by @@ -90,7 +91,10 @@ def initialize(events, current_year: Date.current.year, featured_year: nil, fund end def columns - @columns ||= @events.map { |event| Column.new(event: event, dashboard: EventDashboard.new(event, scholarship_funder: @funder)) } + @columns ||= begin + figures = EventScholarshipFigures.new(@events, funder: @funder) + @events.map { |event| Column.new(event: event, figures: figures.for(event)) } + end end def any? diff --git a/spec/services/event_scholarship_figures_spec.rb b/spec/services/event_scholarship_figures_spec.rb new file mode 100644 index 000000000..67184c42f --- /dev/null +++ b/spec/services/event_scholarship_figures_spec.rb @@ -0,0 +1,109 @@ +require "rails_helper" + +RSpec.describe EventScholarshipFigures do + let(:event) { create(:event, cost_cents: 10_000) } + let(:person1) { create(:person) } + let(:person2) { create(:person) } + let(:person3) { create(:person) } + + let!(:reg1) { create(:event_registration, event: event, registrant: person1, status: "attended") } + let!(:reg2) { create(:event_registration, event: event, registrant: person2, status: "attended") } + + before do + funded = create(:scholarship, recipient: person1, amount_cents: 4_000, grant: create(:grant)) + create(:allocation, source: funded, allocatable: reg1, amount: 4_000) + + unfunded = create(:scholarship, recipient: person2, amount_cents: 2_000, grant: nil) + create(:allocation, source: unfunded, allocatable: reg2, amount: 2_000) + + # A scholarship on a cancelled registration must be ignored (inactive). + cancelled = create(:event_registration, event: event, registrant: person3, status: "cancelled") + ignored = create(:scholarship, recipient: person3, amount_cents: 9_000, grant: create(:grant)) + create(:allocation, source: ignored, allocatable: cancelled, amount: 9_000) + end + + subject(:figures) { described_class.new([ event ]).for(event) } + + it "splits scholarship dollars and counts into funded vs unfunded" do + expect(figures.funded_cents).to eq(4_000) + expect(figures.unfunded_cents).to eq(2_000) + expect(figures.funded_count).to eq(1) + expect(figures.unfunded_count).to eq(1) + expect(figures.scholarship_cents).to eq(6_000) + expect(figures.scholarship_count).to eq(2) + end + + it "counts attended registrations (cancelled excluded)" do + expect(figures.attended_count).to eq(2) + end + + it "counts an AWBW self-donated grant as unfunded, matching the dashboard" do + awbw = create(:organization, name: "A Window Between Worlds") + reg = create(:event_registration, event: event, registrant: create(:person), status: "attended") + subsidy = create(:scholarship, recipient: reg.registrant, amount_cents: 1_000, grant: create(:grant, donor: awbw)) + create(:allocation, source: subsidy, allocatable: reg, amount: 1_000) + + figures = described_class.new([ event ]).for(event) + expect(figures.funded_cents).to eq(4_000) + expect(figures.unfunded_cents).to eq(3_000) + expect(figures.unfunded_count).to eq(2) + end + + describe "funder narrowing" do + it "counts only scholarships drawn from the given funder's grants" do + funder = create(:organization, name: "Community Trust") + reg = create(:event_registration, event: event, registrant: create(:person), status: "attended") + award = create(:scholarship, recipient: reg.registrant, amount_cents: 5_000, grant: create(:grant, donor: funder)) + create(:allocation, source: award, allocatable: reg, amount: 5_000) + + figures = described_class.new([ event ], funder: funder).for(event) + expect(figures.funded_cents).to eq(5_000) + expect(figures.unfunded_cents).to eq(0) + expect(figures.scholarship_count).to eq(1) + end + + it "counts nothing for a funder who gave no grants" do + funder = create(:organization, name: "Empty Fund") + figures = described_class.new([ event ], funder: funder).for(event) + expect(figures.scholarship_cents).to eq(0) + expect(figures.scholarship_count).to eq(0) + end + end + + # The report and a single event's dashboard must never disagree. + it "matches the event's dashboard" do + dashboard = EventDashboard.new(event) + + expect(figures.funded_cents).to eq(dashboard.funded_scholarship_cents) + expect(figures.unfunded_cents).to eq(dashboard.unfunded_scholarship_cents) + expect(figures.funded_count).to eq(dashboard.funded_scholarship_count) + expect(figures.unfunded_count).to eq(dashboard.unfunded_scholarship_count) + expect(figures.attended_count).to eq(dashboard.attendance_count_for("attended")) + end + + it "returns zeros for an event with no registrations" do + other = create(:event, cost_cents: 10_000) + expect(described_class.new([ event, other ]).for(other)).to eq(described_class::EMPTY) + end + + it "loads every event in a fixed number of queries" do + events = [ event ] + Array.new(3) do + other = create(:event) + reg = create(:event_registration, event: other, registrant: create(:person), status: "attended") + award = create(:scholarship, recipient: reg.registrant, amount_cents: 1_000, grant: create(:grant)) + create(:allocation, source: award, allocatable: reg, amount: 1_000) + other + end + + queries = 0 + counter = ->(_name, _start, _finish, _id, payload) { queries += 1 unless payload[:name].to_s.match?(/SCHEMA|TRANSACTION/) } + ActiveSupport::Notifications.subscribed(counter, "sql.active_record") do + loader = described_class.new(events) + events.each { |e| loader.for(e) } + end + + # 3 batch component queries + 2 constant queries classifying AWBW-donated + # grants as subsidy (the org lookup + its grant ids), independent of event count. + expect(queries).to eq(5) + end +end From 909ad33e37c7dc0d4cb0fdb26725b3e2746de465 Mon Sep 17 00:00:00 2001 From: S Mae Beale Date: Sat, 8 Aug 2026 20:45:51 -0400 Subject: [PATCH 04/32] Events IA Phase 7: cross-event Reports hub + Attendees, owner-scoped Rename the cross-event report suite to its new vocabulary and open it to event owners for their own event: - Routes/actions/views: statistics -> reports (the hub), training_attendees -> attendees; Turbo frame ids and view filenames renamed in lockstep (attendees_results / attendees_charts). No redirects for old URLs (internal admin tool). - Authorization: the whole-org view stays admin-only (cross_event_reports?), but a single-event slice (event_id filter, from the per-event Reports/Roster tabs) is visible to that event's owner too (event_reports?). A before_action resolves event_id to the Event and authorizes against it. - Event-scoped eyebrows: when event_id is present, the reports/attendees pages return to that event's dashboard. - Entry points, helper names (hub_to_report_params etc.), labels, return_to tokens, and AGENTS.md updated throughout. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 6 +- .../event_registrations_controller.rb | 4 +- app/controllers/events_controller.rb | 54 ++++++++------ app/helpers/admin_cards_helper.rb | 2 +- app/helpers/events_helper.rb | 20 +++--- app/policies/event_policy.rb | 36 +++------- app/services/event_scholarship_report.rb | 2 +- app/services/report_periods.rb | 2 +- app/views/event_registrations/edit.html.erb | 4 +- ...unt.html.erb => _attendees_count.html.erb} | 6 +- ...ch.html.erb => _attendees_search.html.erb} | 8 +-- ....html.erb => _attendees_skeleton.html.erb} | 0 app/views/events/_event_filter.html.erb | 2 +- app/views/events/_event_type_filter.html.erb | 2 +- .../events/_participation_summary.html.erb | 10 +-- .../events/_registrant_breakdowns.html.erb | 2 +- app/views/events/_registrant_roster.html.erb | 4 +- app/views/events/_revenue_summary.html.erb | 4 +- .../events/_scholarship_summary.html.erb | 4 +- app/views/events/_time_period_filter.html.erb | 2 +- app/views/events/attendees.html.erb | 24 +++++++ app/views/events/attendees_charts.html.erb | 6 ++ ...ts.html.erb => attendees_results.html.erb} | 8 +-- app/views/events/index.html.erb | 4 +- app/views/events/participation.html.erb | 4 +- app/views/events/recipients.html.erb | 4 +- .../{statistics.html.erb => reports.html.erb} | 8 ++- app/views/events/revenue.html.erb | 2 +- app/views/events/scholarships.html.erb | 2 +- app/views/events/training_attendees.html.erb | 22 ------ .../events/training_attendees_charts.html.erb | 6 -- config/routes.rb | 4 +- spec/requests/admin/home_spec.rb | 6 +- ...ng_attendees_spec.rb => attendees_spec.rb} | 70 +++++++++++++------ spec/requests/events_spec.rb | 32 ++++----- spec/views/page_bg_class_alignment_spec.rb | 4 +- 36 files changed, 204 insertions(+), 176 deletions(-) rename app/views/events/{_training_attendees_count.html.erb => _attendees_count.html.erb} (69%) rename app/views/events/{_training_attendees_search.html.erb => _attendees_search.html.erb} (92%) rename app/views/events/{_training_attendees_skeleton.html.erb => _attendees_skeleton.html.erb} (100%) create mode 100644 app/views/events/attendees.html.erb create mode 100644 app/views/events/attendees_charts.html.erb rename app/views/events/{training_attendees_results.html.erb => attendees_results.html.erb} (91%) rename app/views/events/{statistics.html.erb => reports.html.erb} (86%) delete mode 100644 app/views/events/training_attendees.html.erb delete mode 100644 app/views/events/training_attendees_charts.html.erb rename spec/requests/events/{training_attendees_spec.rb => attendees_spec.rb} (75%) diff --git a/AGENTS.md b/AGENTS.md index 01146cc51..0dc8ab337 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -199,10 +199,10 @@ action, or `authorize! :workshop, to: :summary?`). - `EventRevenueFigures` — Batch-loads the per-event money components `EventRevenueReport` rows are built from (registration payments/outstanding, funded/unfunded scholarships, discounts, CE paid/outstanding) in a fixed number of grouped queries; mirrors the `EventDashboard` definitions - `EventScholarshipFigures` — Batch-loads the per-event scholarship figures `EventScholarshipReport` columns are built from (funded/unfunded dollars + counts, attended count) in a fixed number of grouped queries; optional `funder:` narrows to a donor's grants. Mirrors the `EventDashboard` funded/unfunded split, replacing the one-dashboard-per-event it used to build - `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` -- `TrainingAttendeesRoster` — Cross-event counterpart to `EventDashboard`: builds the per-registrant lookup maps the shared `events/_registrant_roster` partial reads (sector/age/org/status/location/scholarship/CE plus the attended-trainings event column) for a paginated page of people who have attended a facilitator training; backs the `events#training_attendees` index -- `TrainingAttendeesBreakdowns` — Aggregate counterpart to `EventDashboard`'s breakdown methods: computes the chart datasets (sectors, age groups, locations, program status, life experiences, settings, organizations, scholarship/CE) over the whole filtered training-attendee population, profile-sourced, for the shared `events/_registrant_breakdowns` partial on the `events#training_attendees` index +- `TrainingAttendeesRoster` — Cross-event counterpart to `EventDashboard`: builds the per-registrant lookup maps the shared `events/_registrant_roster` partial reads (sector/age/org/status/location/scholarship/CE plus the attended-trainings event column) for a paginated page of people who have attended a facilitator training; backs the `events#attendees` index +- `TrainingAttendeesBreakdowns` — Aggregate counterpart to `EventDashboard`'s breakdown methods: computes the chart datasets (sectors, age groups, locations, program status, life experiences, settings, organizations, scholarship/CE) over the whole filtered training-attendee population, profile-sourced, for the shared `events/_registrant_breakdowns` partial on the `events#attendees` index - `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 -- `EventScholarshipReport` — Cross-event scholarship report grouped by calendar year: scholarship dollars and award counts (funded vs unfunded, via `EventScholarshipFigures`) per facilitator training, plus an attended-trainee count split into "Live" (scheduled instructor-led) vs "On-demand" (`event.on_demand?`). Sibling of `EventRevenueReport`/`EventParticipationReport` (includes `ReportPeriods`); powers the `events#scholarships` report page and the statistics-hub scholarship summary card +- `EventScholarshipReport` — Cross-event scholarship report grouped by calendar year: scholarship dollars and award counts (funded vs unfunded, via `EventScholarshipFigures`) per facilitator training, plus an attended-trainee count split into "Live" (scheduled instructor-led) vs "On-demand" (`event.on_demand?`). Sibling of `EventRevenueReport`/`EventParticipationReport` (includes `ReportPeriods`); powers the `events#scholarships` report page and the reports-hub scholarship summary card - `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 diff --git a/app/controllers/event_registrations_controller.rb b/app/controllers/event_registrations_controller.rb index 3477ed867..2441233e7 100644 --- a/app/controllers/event_registrations_controller.rb +++ b/app/controllers/event_registrations_controller.rb @@ -104,7 +104,7 @@ def update when "ticket" then redirect_to registration_ticket_path(@event_registration.slug), notice: notice, status: :see_other when "preview_reminder" then redirect_to preview_reminder_event_path(@event_registration.event), notice: notice, status: :see_other when "onboarding" then redirect_to helpers.onboarding_event_row_path(@event_registration.event, @event_registration.id), notice: notice, status: :see_other - when "training_attendees" then redirect_to training_attendees_events_path, notice: notice, status: :see_other + when "attendees" then redirect_to attendees_events_path, notice: notice, status: :see_other when "recipients" then redirect_to recipients_event_path(@event_registration.event, anchor: "shout-outs"), notice: notice, status: :see_other else # No explicit origin: keep admins in the management context (the @@ -302,7 +302,7 @@ def destroy case params[:return_to] when "registrants" then redirect_to registrants_event_path(event) when "onboarding" then redirect_to onboarding_event_path(event) - when "training_attendees" then redirect_to training_attendees_events_path + when "attendees" then redirect_to attendees_events_path else redirect_to event_registrations_path end end diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index cd35815f2..6cc319f4b 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -3,7 +3,10 @@ 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 feature_recipient_shoutout preview_reminder confirm_reminder send_reminder copy_registration_form ] - before_action :set_report_filters, only: %i[ revenue participation statistics scholarships ] + before_action :set_report_filters, only: %i[ revenue participation reports scholarships ] + # The cross-event report suite is admin-only for the whole-org view, but a + # single-event slice (event_id filter) is visible to that event's owner too. + before_action :authorize_report!, only: %i[ revenue participation reports scholarships attendees ] def index authorize! @@ -32,7 +35,6 @@ def show # KPI strip with the year of the event navigated from (when arriving from a # dashboard), otherwise the current year. def revenue - authorize! events, selected_year = filtered_report_events(Event.paid) @report = EventRevenueReport.new(events, featured_year: selected_year || @filter_event&.start_date&.year) end @@ -41,15 +43,14 @@ def revenue # 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, participation and scholarship report - # summaries side by side, each linking to its full report. - def statistics - authorize! + # Reports hub: the revenue, participation and scholarship report summaries side + # by side, each linking to its full report. Pre-filterable to a single event via + # event_id (the per-event "Reports" tab links here scoped to its event). + def reports @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)) @@ -58,10 +59,9 @@ def statistics # Cross-event scholarship report: scholarship dollars and award counts (funded # vs unfunded) per facilitator training, grouped by year, with an attended- - # trainee count split into Training vs On-demand. Sibling of the revenue and - # participation reports; admin-only. + # trainee count split into Live vs On-demand. Sibling of the revenue and + # participation reports. def scholarships - authorize! events, selected_year = filtered_report_events(Event.facilitator_trainings) @report = EventScholarshipReport.new(events, featured_year: selected_year, funder: @filter_funder) end @@ -69,27 +69,27 @@ def scholarships # Cross-event index of everyone who has attended a facilitator training, deduped # to one row per person. Lazy-loaded like the people index: the frame request # builds the filtered/paginated page and its roster; the full request renders the - # shell (header, filters, skeleton). - def training_attendees - authorize! + # shell (header, filters, skeleton). Pre-filterable to a single event via event_id + # (the per-event "Roster" tab links here scoped to its event). + def attendees unless turbo_frame_request? set_training_attendee_filter_options - return render :training_attendees + return render :attendees end people = filtered_training_attendees # The charts frame is loaded lazily (only when the user reveals it), so the # expensive cross-event breakdowns run solely on that request. - if turbo_frame_request_id == "training_attendees_charts" + if turbo_frame_request_id == "attendees_charts" @breakdowns = TrainingAttendeesBreakdowns.new(people) - return render :training_attendees_charts + return render :attendees_charts end per_page = params[:number_of_items_per_page].presence || 25 @count_display = people.count @people = people.paginate(page: params[:page], per_page: per_page) @roster = TrainingAttendeesRoster.new(@people) - render :training_attendees_results + render :attendees_results end def new @@ -522,9 +522,21 @@ def staff_update_return_path end end - # Shared filter state for the revenue/participation/statistics/scholarships - # report pages: the event-type, specific-event and abbreviation-search filters, - # plus the event list for the Event dropdown. + # Authorize the cross-event report suite. Scoped to a single event (event_id + # filter, from the per-event Reports/Roster tabs), that event's owner may view + # it (event_reports?); the unfiltered whole-org view stays admin-only. + def authorize_report! + @filter_event ||= Event.find_by(id: params[:event_id]) if params[:event_id].present? + if @filter_event + authorize! @filter_event, to: :event_reports? + else + authorize! to: :cross_event_reports? + end + end + + # Shared filter state for the revenue/participation/reports/scholarships report + # pages: the event-type, specific-event and abbreviation-search 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? @@ -594,7 +606,7 @@ def filtered_training_attendees registrations = registrations.where("YEAR(events.start_date) = ?", params[:event_year]) if params[:event_year].present? scope = Person.where(id: registrations.select(:registrant_id)) - # Dash-joined person ids, e.g. from the statistics-hub participation totals. + # Dash-joined person ids, e.g. from the reports-hub participation totals. scope = scope.where(id: params[:registrant_ids].to_s.split("-")) if params[:registrant_ids].present? scope = scope.search_by_params({ contact_info: params[:contact_info] }) if params[:contact_info].present? scope = scope.where(id: person_sector_ids(params[:sector])) if params[:sector].present? diff --git a/app/helpers/admin_cards_helper.rb b/app/helpers/admin_cards_helper.rb index f54a306bf..133f035fd 100644 --- a/app/helpers/admin_cards_helper.rb +++ b/app/helpers/admin_cards_helper.rb @@ -30,7 +30,7 @@ def user_content_cards custom_card("Portal activity", admin_activities_counts_path, icon: "📊"), custom_card("Bookmarks tally", tally_bookmarks_path, icon: "🔖"), model_card(:notifications, icon: "🔔", title: t("communications.title")), - custom_card("Events statistics", statistics_events_path, icon: "📊", color: :blue), + custom_card("Reports", reports_events_path, icon: "📊", color: :blue), model_card(:scholarships, icon: "🎓"), model_card(:topic_subscriptions, icon: "✉️", intensity: 100, title: "Subscriptions"), model_card(:story_ideas, icon: "✍🏾️", intensity: 100), diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb index fc7d35e47..516221bde 100644 --- a/app/helpers/events_helper.rb +++ b/app/helpers/events_helper.rb @@ -140,37 +140,37 @@ def onboarding_toggle_entries(event) end end - # The statistics hub and the full revenue/participation reports share the + # The reports hub and the full revenue/participation reports share the # event-type and specific-event filters directly, but express the time window # differently: the hub's `period` select offers this_year/last_year/all_time, # while the reports use `time_period` where a specific window is the calendar # year "YYYY". These helpers translate a page's active filters into the query # params for its cross-link so filters carry across — and back — between the - # summaries' "Full report" links and the reports' "Events statistics" eyebrow. + # summaries' "Full report" links and the reports' "← Reports" eyebrow. - # Statistics hub filters → full report query params. - def statistics_to_report_params + # Reports hub filters → full report query params. + def hub_to_report_params { return_to: params[:return_to], event_type: params[:event_type].presence, event_id: params[:event_id].presence, - time_period: statistics_period_to_time_period(@period) + time_period: hub_period_to_time_period(@period) }.compact end - # Full report filters → statistics hub query params. - def report_to_statistics_params + # Full report filters → reports hub query params. + def report_to_hub_params { return_to: params[:return_to], event_type: params[:event_type].presence, event_id: params[:event_id].presence, - period: time_period_to_statistics_period(@time_period) + period: time_period_to_hub_period(@time_period) }.compact end # "last_year" becomes the prior calendar year (reports name specific years # "YYYY"); this_year/all_time pass through unchanged. - def statistics_period_to_time_period(period) + def hub_period_to_time_period(period) return (Date.current.year - 1).to_s if period == "last_year" period end @@ -178,7 +178,7 @@ def statistics_period_to_time_period(period) # Inverse: the prior year maps back to "last_year", the current year to # "this_year"; any other specific year has no hub equivalent, so fall back to # "all_time". - def time_period_to_statistics_period(time_period) + def time_period_to_hub_period(time_period) case time_period when "this_year", "all_time" then time_period when (Date.current.year - 1).to_s then "last_year" diff --git a/app/policies/event_policy.rb b/app/policies/event_policy.rb index decc78ab4..dedda42e6 100644 --- a/app/policies/event_policy.rb +++ b/app/policies/event_policy.rb @@ -13,35 +13,19 @@ def search? admin? end - # The cross-event revenue report aggregates money across every event, so it's - # admin-only. - def revenue? + # The cross-event report suite (revenue, participation, scholarships, the + # reports hub, and the attendees index) aggregates across every event, so the + # whole-org view is admin-only. + def cross_event_reports? 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 - - # The scholarship report aggregates scholarship money and award counts across - # every training, so it's admin-only like the revenue report. - def scholarships? - admin? - end - - # The cross-event index of everyone who has attended a facilitator training - # aggregates registrants across every event, so it's admin-only like the - # participation report it's reached from. - def training_attendees? - admin? + # A single-event slice of those reports — reached from the per-event Reports and + # Roster tabs, which pass event_id — is visible to that event's owner too. The + # controller resolves event_id to the Event and authorizes against it here, so + # owner? has a real record to check. + def event_reports? + admin? || owner? end def show? diff --git a/app/services/event_scholarship_report.rb b/app/services/event_scholarship_report.rb index 0f2c3d0dc..176e87e64 100644 --- a/app/services/event_scholarship_report.rb +++ b/app/services/event_scholarship_report.rb @@ -1,7 +1,7 @@ # Scholarship report: scholarship dollars and award counts (funded vs unfunded) # per facilitator training, grouped by calendar year. The sibling of # EventRevenueReport / EventParticipationReport — same year-grouped shape and -# statistics-hub period card, but counting scholarships. +# reports-hub period card, but counting scholarships. # # Funded vs unfunded follows the app-wide convention (see EventDashboard): # funded = backed by an external grant; unfunded = no grant, or a grant the org diff --git a/app/services/report_periods.rb b/app/services/report_periods.rb index 1d26c8d9a..491e95ce8 100644 --- a/app/services/report_periods.rb +++ b/app/services/report_periods.rb @@ -1,4 +1,4 @@ -# Resolves the statistics-hub period toggle ("this_year" | "last_year" | +# Resolves the reports-hub period toggle ("this_year" | "last_year" | # "all_time") to a metric scope + display label for a report's summary card. # Included by EventRevenueReport and EventParticipationReport, which each supply # an empty_year_group for a year that has no events (so the card shows zeros). diff --git a/app/views/event_registrations/edit.html.erb b/app/views/event_registrations/edit.html.erb index 6b9d52be1..618c69a00 100644 --- a/app/views/event_registrations/edit.html.erb +++ b/app/views/event_registrations/edit.html.erb @@ -18,8 +18,8 @@ <%= link_to onboarding_event_row_path(@event_registration.event, @event_registration.id), class: "text-sm text-gray-500 hover:text-gray-700" do %> Onboarding <% end %> - <% elsif params[:return_to] == "training_attendees" %> - <%= link_to training_attendees_events_path, class: "text-sm text-gray-500 hover:text-gray-700" do %> + <% elsif params[:return_to] == "attendees" %> + <%= link_to attendees_events_path, class: "text-sm text-gray-500 hover:text-gray-700" do %> Training attendees <% end %> <% elsif params[:return_to] == "recipients" %> diff --git a/app/views/events/_training_attendees_count.html.erb b/app/views/events/_attendees_count.html.erb similarity index 69% rename from app/views/events/_training_attendees_count.html.erb rename to app/views/events/_attendees_count.html.erb index 8a57d4629..6594b2d65 100644 --- a/app/views/events/_training_attendees_count.html.erb +++ b/app/views/events/_attendees_count.html.erb @@ -1,6 +1,6 @@ -<%# Header + count for the training-attendees index. Rendered on the shell (no - count yet) and replaced out-of-band by the results frame once the page loads. %> -
+<%# Header + count for the attendees index. Rendered on the shell (no count yet) + and replaced out-of-band by the results frame once the page loads. %> +

Training attendees diff --git a/app/views/events/_training_attendees_search.html.erb b/app/views/events/_attendees_search.html.erb similarity index 92% rename from app/views/events/_training_attendees_search.html.erb rename to app/views/events/_attendees_search.html.erb index 270ce57eb..b46594c5d 100644 --- a/app/views/events/_training_attendees_search.html.erb +++ b/app/views/events/_attendees_search.html.erb @@ -3,8 +3,8 @@ rides along so the eyebrow keeps pointing back to wherever the admin arrived from. %> <% select_class = "w-full bg-white border border-gray-300 rounded-lg px-3 py-2 focus:ring-blue-500 focus:border-blue-500 has-[option:first-child:checked]:text-gray-400" %> -<%= form_tag(training_attendees_events_path, method: :get, - data: { controller: "collection", turbo_frame: "training_attendees_results" }, +<%= form_tag(attendees_events_path, method: :get, + data: { controller: "collection", turbo_frame: "attendees_results" }, autocomplete: "off", class: "rounded-xl border border-gray-200 bg-white p-4 shadow-sm mb-6") do %> <%= hidden_field_tag :return_to, params[:return_to] %> @@ -47,7 +47,7 @@
<%= label_tag :affiliation_status, "Affiliation status", class: "block text-xs font-semibold uppercase text-gray-500 tracking-wide mb-1" %> <%= select_tag :affiliation_status, - options_for_select(TrainingAttendeesRoster::AFFILIATION_STATUSES, params[:affiliation_status]), + options_for_select(Affiliation::STATUSES, params[:affiliation_status]), include_blank: "All statuses", class: select_class, onchange: "this.form.submit();" %>
@@ -69,7 +69,7 @@

- <%= link_to "Clear filters", training_attendees_events_path, + <%= link_to "Clear filters", attendees_events_path, class: "btn btn-utility-outline", data: { action: "collection#clearAndSubmit" } %>
diff --git a/app/views/events/_training_attendees_skeleton.html.erb b/app/views/events/_attendees_skeleton.html.erb similarity index 100% rename from app/views/events/_training_attendees_skeleton.html.erb rename to app/views/events/_attendees_skeleton.html.erb diff --git a/app/views/events/_event_filter.html.erb b/app/views/events/_event_filter.html.erb index 7186f841a..8910e085e 100644 --- a/app/views/events/_event_filter.html.erb +++ b/app/views/events/_event_filter.html.erb @@ -1,4 +1,4 @@ -<%# Specific-event filter shared by the revenue/participation/statistics report +<%# Specific-event filter shared by the revenue/participation/reports report forms. Scopes the report(s) to the chosen event. `all_label` overrides the blank option (revenue lists paid events only). %>
diff --git a/app/views/events/_event_type_filter.html.erb b/app/views/events/_event_type_filter.html.erb index 2ccc6ec3d..753e8adb9 100644 --- a/app/views/events/_event_type_filter.html.erb +++ b/app/views/events/_event_type_filter.html.erb @@ -1,5 +1,5 @@ <%# Event-type filter (all events / trainings / other) shared by the - revenue/participation/statistics report forms. `all_label` overrides the + revenue/participation/reports report forms. `all_label` overrides the blank option (revenue lists paid events only). %>
diff --git a/app/views/events/_participation_summary.html.erb b/app/views/events/_participation_summary.html.erb index 16ae1558d..d2112d0e8 100644 --- a/app/views/events/_participation_summary.html.erb +++ b/app/views/events/_participation_summary.html.erb @@ -1,10 +1,10 @@ -<%# Compact participation headline for the statistics hub, linking to the full +<%# Compact participation headline for the reports hub, linking to the full report. Expects `report` (an EventParticipationReport) and `period` (its resolved PeriodScope: #label, #year and #metrics). %>

Participation

- <%= link_to "Full report →", participation_events_path(statistics_to_report_params), + <%= link_to "Full report →", participation_events_path(hub_to_report_params), class: "text-xs font-medium #{DomainTheme.text_class_for(:events, intensity: 700)} hover:underline" %>
@@ -23,7 +23,7 @@
Registrations
<%= link_to number_with_delimiter(metrics.active_registration_count), - training_attendees_events_path(registrant_ids: active_ids.join("-").presence || "0", return_to: "statistics"), + attendees_events_path(registrant_ids: active_ids.join("-").presence || "0", event_id: params[:event_id].presence, return_to: "reports"), class: "text-xl font-bold text-gray-900 hover:underline" %> <% if metrics.inactive_registration_count.positive? %>(<%= number_with_delimiter(metrics.inactive_registration_count) %> inactive)<% end %>
@@ -37,12 +37,12 @@
People attended
<%= link_to number_with_delimiter(metrics.unique_people), - training_attendees_events_path(registrant_ids: attended_ids.join("-").presence || "0", return_to: "statistics"), + attendees_events_path(registrant_ids: attended_ids.join("-").presence || "0", event_id: params[:event_id].presence, return_to: "reports"), class: "text-xl font-bold text-blue-700 hover:underline" %>
<%# Each side links to the attended registrants of that event type. %>
- <%= link_to "#{number_with_delimiter(people[:trainings])} trainings", training_attendees_events_path(event_year: period.year, return_to: "statistics"), class: "hover:underline" %> + <%= link_to "#{number_with_delimiter(people[:trainings])} trainings", attendees_events_path(event_year: period.year, event_id: params[:event_id].presence, return_to: "reports"), class: "hover:underline" %> · <%= link_to "#{number_with_delimiter(people[:non_trainings])} other", event_registrations_path(event_type: "other", attendance_status: "attended", event_year: period.year), class: "hover:underline" %>
diff --git a/app/views/events/_registrant_breakdowns.html.erb b/app/views/events/_registrant_breakdowns.html.erb index 187723c0b..18f6a0027 100644 --- a/app/views/events/_registrant_breakdowns.html.erb +++ b/app/views/events/_registrant_breakdowns.html.erb @@ -16,7 +16,7 @@ registrant list (by named filter or by the registrant_ids behind the row); in :index context we re-apply this index's filters plus the row's own dimension. %> <% filter_params = index_context ? request.query_parameters.except("page", "controller", "action") : {} %> -<% index_path = ->(params) { training_attendees_events_path(filter_params.merge(params)) } %> +<% index_path = ->(params) { attendees_events_path(filter_params.merge(params)) } %> <% by_ids = ->(ids) { registrants_event_path(event, registrant_ids: ids.join("-")) } %> <% primary_sector_data = data.primary_sectors.map { |s| [ s.name, data.primary_sector_counts.fetch(s.id, 0) ] }.sort_by { |_, count| -count } %> diff --git a/app/views/events/_registrant_roster.html.erb b/app/views/events/_registrant_roster.html.erb index 7f755c426..2a12e8040 100644 --- a/app/views/events/_registrant_roster.html.erb +++ b/app/views/events/_registrant_roster.html.erb @@ -190,7 +190,7 @@
<% if first_registration %>
- <%= link_to edit_event_registration_path(first_registration, return_to: "training_attendees"), class: event_link_class do %> + <%= link_to edit_event_registration_path(first_registration, return_to: "attendees"), class: event_link_class do %> <%= first_registration.event.decorate.compact_label %> <%= first_registration.event.start_date&.strftime("%b %Y") %> <% end %> @@ -203,7 +203,7 @@ <% if registrations.size > 1 %>