Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
01db4b0
Remove program status from org-wide UI
maebeale Jul 15, 2026
5084684
Show affiliated-since as merged periods; simplify org status to 3 values
maebeale Jul 15, 2026
c377f67
Scope affiliated-since periods to the org form, leaving the person fo…
maebeale Jul 15, 2026
17ac955
Render org affiliated-since server-side only; drop the JS period logic
maebeale Jul 31, 2026
7069592
Show "Program since" (facilitator periods) on org index/show/edit
maebeale Aug 1, 2026
de7a8c9
Add general "Program status" chip + index bucket filter; New→indigo
maebeale Aug 1, 2026
ba32097
Move profile program-status chip assertion to the show page
maebeale Aug 1, 2026
cb09272
Link per-event chips to the event dashboard; gate index status chip t…
maebeale Aug 2, 2026
9768097
Show Program since years in a yellow chip on the org index
maebeale Aug 2, 2026
e595045
Colour the index Program-since chip by status; rename Designationsβ†’Se…
maebeale Aug 2, 2026
c693194
Swap the windows-type index filter for Sector + Age group dropdowns
maebeale Aug 2, 2026
bbdc59a
Aggregate org sector/age-group displays + filters over affiliated people
maebeale Aug 2, 2026
f1af772
Roll up only affiliated people's PRIMARY sector/age group to the org
maebeale Aug 2, 2026
a5a3b7b
Keep the 6 legacy org statuses; bucket them for the program-status UI
maebeale Aug 2, 2026
b0991b6
Rename EventRegistration organization_status scope to organization_li…
maebeale Aug 2, 2026
0f60401
Derive program status from facilitator affiliations, stored status as…
maebeale Aug 2, 2026
9d6c87c
Point per-event program-status chips at the participation report; dro…
maebeale Aug 3, 2026
8e2512a
Use a real stored status name ("Inactive") in search spec
maebeale Aug 3, 2026
fe2670d
Admin-only-blue org index rows (like people); bullet the Program stat…
maebeale Aug 3, 2026
c542fc4
Live-update the org edit form's Program status chip
maebeale Aug 3, 2026
dd881f6
Clarify the Reinstated tooltip: re-engaging after being formerly active
maebeale Aug 3, 2026
8460370
Document org affiliation/program-status semantics (ADR-0001); gate pe…
maebeale Aug 3, 2026
5739567
Live-update the org form's Affiliated-since periods
maebeale Aug 4, 2026
83602db
Fix events-section spec: gate the program-status chip event to trainings
maebeale Aug 6, 2026
42c0311
Reorder per-event program-status chip to "Mon YYYY Β· Status Β· Event"
maebeale Aug 9, 2026
2e1b9aa
Remove fallback to org status
maebeale Aug 15, 2026
227a544
Keep PEOPLE_TAGGINGS from orphaning the AGENCY_TYPES doc comment
maebeale Aug 15, 2026
bdd4be1
Record in ADR-0001 what main's affiliation changes settle
maebeale Aug 15, 2026
c930bac
Stop ai/test aborting when a branch deletes a system spec
maebeale Aug 15, 2026
e89af4e
Render "Art program since" as one value everywhere, at month precision
maebeale Aug 15, 2026
8f33425
Name the participation back-link's org param so it can't filter
maebeale Aug 15, 2026
5d1677b
Make program status one rule anchored on the event's date
maebeale Aug 16, 2026
cd86da6
Trim two verbose decorator display-method comments
maebeale Aug 17, 2026
be51229
Tighten verbose comments across org/program-status code
maebeale Aug 17, 2026
94d936a
Scope org @organization_events through authorized_scope
maebeale Aug 17, 2026
de8080b
Restore affiliations column header on the org edit form
maebeale Aug 18, 2026
ad588b5
Scope org events-section query through authorized_scope
maebeale Aug 18, 2026
ed520f9
Match org affiliation-editor system specs to main's grid fields
maebeale Aug 18, 2026
6cbcfab
Anchor recipients-page program status on the event's own date
maebeale Aug 18, 2026
4acb4ae
Trim comments to the non-obvious, and catalogue the shipped features
maebeale Aug 18, 2026
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
5 changes: 4 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,13 +198,16 @@ action, or `authorize! :workshop, to: :summary?`).

### Business Logic

- `AffiliationPeriods` β€” Merges an organization's affiliation date-intervals into periods, at year precision for "Affiliated since" (e.g. "2010-2012, 2026") or month precision for "Art program since" (e.g. "Aug 2015 – Jun 2018, Feb 2024"); rendered server-side on the org show/index/edit pages, with `affiliation_dates_controller.js` mirroring it only to live-update the edit form
- `EventDashboard` β€” Aggregates per-event dashboard metrics (registrant/org/sector/state/county counts, scholarship totals, payment received/outstanding/total). One population per event β€” `EventRegistration.active` β€” so the money and the people figures always reconcile; "who completed the training" is an attendance figure over that population (`#attended_count`), never a narrower population
- `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
- `FacilitatorProgramStatus` β€” The one rule for an organization's New / Ongoing / Reinstate program status as of a date (its facilitator affiliations, strict `<` on the anchor so the affiliation a training mints isn't prior history). Anchors on the event's start date, falling back to Jan 1 of the current year when no event is in view (`year_anchored?`). Carries the anchor, the month the program went active and its merged facilitator periods, so `#explanation` gives every badge its hover text. Reached via `Organization#facilitator_program_status(as_of:)`; see ADR-0001 D4/D5/D7
- `EventProgramStatusReport` β€” Cross-event program-status report grouped by calendar year: how many organizations were New / Ongoing / Reinstate at each facilitator training, counted both as organization-trainings (summed rows) and as distinct organizations classified at their earliest training. Sibling of `EventRevenueReport`/`EventParticipationReport`/`EventScholarshipReport` (includes `ReportPeriods`); powers the `events#program_statuses` page and the reports-hub program-status card
- `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`
- `AttendeesRoster` β€” 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 events-attended column) for a paginated page of people; backs the `events#attendees` index. Takes `events:` + `registrations:` β€” the index's current filter scopes, already narrowed by `EventPolicy`'s `:reportable` scope β€” so a person's columns show what's in scope rather than their whole history, and never an event the viewer can't see
- `AttendeesBreakdowns` β€” Aggregate counterpart to `EventDashboard`'s breakdown methods: computes the chart datasets (sectors, age groups, locations, program status, life experiences, settings, organizations, scholarship/CE) over an arbitrary people set, profile-sourced, for the shared `events/_registrant_breakdowns` partial. Backs the `events#attendees` index charts (cross-event; `events:` / `registrations:` = the index's current filter scopes, already narrowed by `EventPolicy`'s `:reportable` scope) and the `events#recipients` charts frame (one event's scholarship recipients, `registrations:` = `EventRegistration.active` so it counts them regardless of attendance). Also exposes `*_registrant_ids_by_*` maps mirroring `EventDashboard`'s, so a breakdown row can drill in by person id β€” they regroup rows already loaded for the counts, adding no queries
- `AttendeesBreakdowns` β€” Aggregate counterpart to `EventDashboard`'s breakdown methods: computes the chart datasets (sectors, age groups, locations, program status, life experiences, settings, organizations, scholarship/CE) over an arbitrary people set, profile-sourced, for the shared `events/_registrant_breakdowns` partial. Backs the `events#attendees` index charts (cross-event; `events:` / `registrations:` = the index's current filter scopes, already narrowed by `EventPolicy`'s `:reportable` scope) and the `events#recipients` charts frame (one event's scholarship recipients, `registrations:` = `EventRegistration.active` so it counts them regardless of attendance, `as_of:` = that event's start date so program status reads the same verdict its dashboard does). Also exposes `*_registrant_ids_by_*` maps mirroring `EventDashboard`'s, so a breakdown row can drill in by person id β€” they regroup rows already loaded for the counts, adding no queries
- `AttendeesActiveFilters` β€” Human-readable, removable-chip descriptors for a page's "drill-in" filters (registrant_ids, organization, org city, age group / life experience / setting categories, country, school district, scholarship, CE, payment status, scholarship funding source, sector, state) β€” params that narrow a list without a field in a visible filter form. `CHIP_PARAMS` is the attendees index's set (omitting anything with its own control); pass `chip_params:` for a page with no filter form at all β€” `ROSTER_CHIP_PARAMS` for the per-event roster, `%w[ registrant_ids ]` for scholarship recipients
- `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 reports-hub scholarship summary card
Expand Down
3 changes: 3 additions & 0 deletions ai/test
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ if [ -d spec/system ] && [ -n "$keywords" ]; then
related=$(find spec/system -name "*_spec.rb" | grep -Ei "$pattern" | sort -u || true)
fi
selected=$(printf "%s\n" "$direct" "$related" | grep -E "_spec\.rb$" | sort -u || true)
# A branch that DELETES a system spec still lists it in the diff, and handing rspec
# a path that no longer exists aborts the whole run with a LoadError.
selected=$(printf "%s\n" $selected | while IFS= read -r f; do [ -f "$f" ] && printf "%s\n" "$f"; done || true)

# Non-system suite = every spec dir except system (+ any root-level specs).
spec_targets=$(find spec -mindepth 1 -maxdepth 1 -type d ! -name system | sort)
Expand Down
27 changes: 18 additions & 9 deletions app/controllers/events_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,18 @@ 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 attendance sample_ticket registrants roster onboarding staff edit_staff update_staff recipients preview_reminder confirm_reminder send_reminder copy_registration_form feature_recipient_shoutout ]
before_action :set_report_filters, only: %i[ revenue participation reports scholarships signins ]
before_action :set_report_filters, only: %i[ revenue participation reports scholarships program_statuses signins ]
# The cross-event report suite is visible to admins and event owners alike; what
# differs is the rows, which EventPolicy's :reportable scope narrows to the
# viewer's own events. #attendance is per-event, so it authorizes its own record
# rather than joining this list.
before_action :authorize_report!, only: %i[ revenue participation reports scholarships attendees signins ]
before_action :authorize_report!, only: %i[ revenue participation reports scholarships program_statuses attendees signins ]
# Log a visit to each event page / report. after_action so it only fires once
# the action rendered successfully (authorization inside the actions has passed);
# the turbo_frame_request? / redirect guards skip the lazy results/charts
# sub-requests and the confirm-reminder bounce-back. send_reminder is logged
# inline on a successful send (it always redirects).
after_action :track_page_view, only: %i[ dashboard attendance roster registrants recipients staff onboarding edit preview sample_ticket revenue participation reports scholarships attendees signins confirm_reminder ]
after_action :track_page_view, only: %i[ dashboard attendance roster registrants recipients staff onboarding edit preview sample_ticket revenue participation reports scholarships program_statuses attendees signins confirm_reminder ]

def index
authorize!
Expand Down Expand Up @@ -63,6 +63,7 @@ def reports
@revenue_report = EventRevenueReport.new(report_events(Event.paid))
@participation_report = EventParticipationReport.new(report_events(Event.all))
@scholarship_report = EventScholarshipReport.new(report_events(Event.facilitator_trainings))
@program_status_report = EventProgramStatusReport.new(report_events(Event.facilitator_trainings))
end

# Cross-event scholarship report: scholarship dollars and award counts (funded
Expand All @@ -74,6 +75,13 @@ def scholarships
@report = EventScholarshipReport.new(events, featured_year: selected_year, funder: @filter_funder)
end

# Organizations by program status at each facilitator training, by year β€” the
# annual-reporting figures. Sibling of the other cross-event reports.
def program_statuses
events, selected_year = filtered_report_events(Event.facilitator_trainings)
@report = EventProgramStatusReport.new(events, featured_year: selected_year)
end

# Cross-event index of the people behind event registrations, 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
Expand Down Expand Up @@ -214,7 +222,7 @@ def registrants
scope = scope.funder_name(params[:funder_name]) if params[:funder_name].present?
scope = scope.submission_status(params[:submission_status], @event) if params[:submission_status].present?
scope = scope.registrant_city(params[:city]) if params[:city].present?
scope = scope.organization_status(params[:org_status], @event) if params[:org_status].present?
scope = scope.organization_linking_status(params[:org_status], @event) if params[:org_status].present?
scope = scope.account_status(params[:account_status]) if params[:account_status].present?
scope = scope.registrant_ids(params[:registrant_ids]) if params[:registrant_ids].present?
scope = scope.registrant_state(params[:state]) if params[:state].present?
Expand Down Expand Up @@ -340,7 +348,8 @@ def recipients
recipients = Person.where(id: @dashboard.scholarship_applicant_ids)
@breakdowns = AttendeesBreakdowns.new(recipients,
events: Event.where(id: @event.id),
registrations: EventRegistration.active)
registrations: EventRegistration.active,
as_of: @event.start_date&.to_date)
return render :recipients_charts
end

Expand Down Expand Up @@ -944,14 +953,14 @@ def org_ids_by_city_label
.transform_values { |pairs| pairs.map(&:first) }
end

# Person ids whose linked training org currently has the given facilitator
# program status (new / ongoing / reinstated).
# Anchored the same way the index's own column is: this list spans events, so
# both read as of the start of the current year rather than disagreeing.
def person_program_status_ids(status)
status_sym = status.to_sym
org_ids = Organization
.where(id: EventRegistrationOrganization.where(event_registration_id: attendee_registrations.select(:id)).select(:organization_id))
.includes(:affiliations)
.select { |organization| organization.facilitator_status_on(Date.current) == status_sym }
.select { |organization| organization.facilitator_status_on == status_sym }
.map(&:id)
return Person.none if org_ids.empty?
person_linked_organization_ids(org_ids)
Expand Down Expand Up @@ -1137,7 +1146,7 @@ def onboarding_csv_string
def onboarding_csv_row(registration, cost_required, day_count, include_ce = false)
person = registration.registrant
scholarship = registration.scholarships.first
statuses = registration.program_statuses.map { |status| status.to_s.titleize }.join(", ")
statuses = registration.program_statuses.map(&:label).join(", ")

row = [
person.first_name,
Expand Down
45 changes: 35 additions & 10 deletions app/controllers/organizations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,24 +8,23 @@ def index
if turbo_frame_request?
per_page = params[:number_of_items_per_page].presence || 25
base_scope = authorized_scope(Organization.includes(
:windows_type, :organization_status, :sectors, :addresses,
:organization_status, :sectors, :sectorable_items, :addresses, :affiliations,
{ categorizable_items: { category: :category_type } },
# Feeds the per-row sector and age-group roll-ups.
{ people: Organization::PEOPLE_TAGGINGS },
logo_attachment: :blob
))
filtered = base_scope.search_by_params(params).order(:name)
@organizations_count = filtered.count
@active_people_count = Affiliation.active.where(organization_id: filtered.select(:id)).count("DISTINCT person_id, organization_id")
@organizations = filtered.paginate(page: params[:page], per_page: per_page)
org_ids = @organizations.map(&:id)
@affiliated_since = Affiliation.where(organization_id: org_ids)
.group(:organization_id)
.minimum(:start_date)
@program_since_display = @organizations.to_h { |org| [ org.id, org.decorate.program_since_display ] }
@active_people_counts = Affiliation.active
.where(organization_id: org_ids)
.group(:organization_id)
.distinct
.count(:person_id)
@program_statuses = Organization.program_statuses_by_id(org_ids)

render :organizations_results
else
Expand All @@ -38,15 +37,24 @@ def show
authorize! @organization

if turbo_frame_request? && params[:section] == "events"
events = Event.where(id: @organization.event_registrations.active.select(:event_id))
.includes(:primary_asset)
.order(start_date: :desc)
.paginate(page: params[:page], per_page: 9)
events = authorized_scope(
Event.where(id: @organization.event_registrations.active.select(:event_id))
.includes(:primary_asset)
.order(start_date: :desc)
).paginate(page: params[:page], per_page: 9)
return render partial: "organizations/sections/events", locals: { organization: @organization, events: events }
end

track_view(@organization)

# The admin-only "Program status" block. Trainings only β€” program status is
# meaningless for other events (ADR-0001 D6).
@organization_events = authorized_scope(
Event.where(id: @organization.event_registrations.active.select(:event_id))
.where(facilitator_training: true)
.order(start_date: :desc)
)

workshop_logs = WorkshopLog.where(organization_id: @organization.id)
@month_year_options = workshop_logs.group("DATE_FORMAT(COALESCE(workshop_held_on, created_at, NOW()), '%Y-%m')")
.select("DATE_FORMAT(COALESCE(workshop_held_on, created_at, NOW()), '%Y-%m') AS ym,
Expand Down Expand Up @@ -178,6 +186,18 @@ def set_form_variables
@organization.affiliations.proxy_association.target.replace(sorted)
end

# Drives the edit form's per-event program-status chips. Trainings only β€”
# program status is meaningless for other events (ADR-0001 D6).
@organization_events = if @organization.persisted?
authorized_scope(
Event.where(id: @organization.event_registrations.active.select(:event_id))
.where(facilitator_training: true)
.order(start_date: :desc)
)
else
Event.none
end

@org_categories_grouped = Category
.includes(:category_type)
.published
Expand All @@ -188,7 +208,12 @@ def set_form_variables
end

def set_index_variables
@organization_statuses = OrganizationStatus.all
@sector_options = Sector.published.order(:name).pluck(:name)
@age_group_options = Category.joins(:category_type)
.where(category_types: { name: AgeGroupTaggable::AGE_RANGE_CATEGORY_TYPE })
.published
.order(:position, :name)
.pluck(:name)
end

def populations_served
Expand Down
Loading