Skip to content
Merged
81 changes: 81 additions & 0 deletions .impeccable.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# impeccable β€” project design notes

Context and reusable UI patterns for design work in this app (Rails + Tailwind +
Stimulus + Turbo). Read this before building or reshaping UI so new work matches
the house patterns instead of reinventing them.

## Design Context

> Not yet captured. Audience, brand voice, and theme can't be inferred from the
> codebase β€” run `/impeccable teach` to fill this section in before doing
> open-ended design work. The pattern library below is safe to use regardless.

## House UI patterns

### Table view toggles (+ the sliders)

When a table needs to reshape what it shows without leaving the page, we use two
complementary controls, both showcased together in the **events registrants
toolbar** (`app/views/events/_registrants_results.html.erb`) β€” the best example
to copy from.

**1. Segmented display toggle** β€” swaps what the table shows on a reload:
*which rows* (a filter over the dataset) or *which layout variant* renders.
Server-driven: each segment is a `link_to` inside a pill `<nav>` carrying the
state in a query param, so the choice is shareable/bookmarkable. Canonical uses:
the **Active / Inactive** registrant filter (each segment carrying its count),
and the scholarships report's **Separate # & $ / Combined** layout toggle, which
merges a `view` param onto the current filters
(`request.query_parameters.merge("view" => "combined")`) so the controller renders
a different partial (`app/views/events/_scholarships_report.html.erb`). Keep the
param sticky across filter changes with a `hidden_field_tag :view` in the filter
form.

```erb
<nav class="inline-flex rounded-lg bg-gray-100 p-0.5 text-sm font-medium" aria-label="Attendance filter">
<%= link_to registrants_event_path(@event, status_filter: "active"),
data: { turbo_frame: "_top" },
class: "px-3 py-1 rounded-md transition-colors #{selected ? "bg-white text-gray-800 shadow-sm" : "text-gray-500 hover:text-gray-700"}" do %>
Active <span class="text-gray-400">(<%= @active_count %>)</span>
<% end %>
<%# …Inactive segment… %>
</nav>
```

- Track `bg-gray-100 p-0.5`; **selected** segment `bg-white text-gray-800
shadow-sm`, **unselected** `text-gray-500 hover:text-gray-700`; counts in
`text-gray-400`.
- Prefer this when the states are mutually exclusive and the choice should be
shareable/bookmarkable (it lives in a query param).

**2. Slide switch ("the sliders")** β€” toggles *how the table looks* (columns or
cell layout) instantly, client-side via Stimulus. A visually-hidden checkbox
drives a track + knob; the same markup is reused everywhere:

```erb
<label class="inline-flex items-center gap-2 cursor-pointer select-none"
data-controller="column-toggle" data-column-toggle-group-value="organization">
<span class="text-sm text-gray-500">Linked organization</span>
<input type="checkbox" checked class="sr-only"
data-column-toggle-target="toggle" data-action="column-toggle#toggle">
<span class="relative inline-block w-9 h-5">
<span data-column-toggle-target="track" class="absolute inset-0 rounded-full bg-blue-600 transition-colors"></span>
<span data-column-toggle-target="knob" class="absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full shadow transition-transform" style="transform: translateX(16px)"></span>
</span>
</label>
```

- Knob travel is `translateX(16px)` for the `w-9 h-5` track; track goes to
`bg-gray-300` when off and an accent (`bg-blue-600`, or the domain accent) when
on. Keep the checkbox `sr-only` β€” the track/knob are the visible control.
- Backed by the **`column-toggle`** Stimulus controller: it shows/hides the set
of columns whose `data-column-toggle-col` matches the switch's `group` value,
within a `[data-column-toggle-root]` ancestor. Use for optional columns
(registrants: Linked organization, CE).

**Choosing between them:** want the choice shareable/in the URL, or the two
variants differ structurally (different colspans/headers, a different partial) β†’
**segmented nav** with a query param and a full reload. Want it instant and
local, hiding a subset of existing columns β†’ **slide switch** (`column-toggle`).
Match the accent color to the page's domain theme (`DomainTheme`), e.g. fuchsia
for scholarships.
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ end
- `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
- `EventParticipationReport` β€” Cross-event participation report grouped by calendar year (unique people trained vs attended seats vs per-status outcome counts, chart series) for the events participation page; sibling of `EventRevenueReport`
- `ReportPeriods` β€” Shared module (included by `EventRevenueReport` and `EventParticipationReport`) resolving the reporting-hub period toggle (this year / last year / all time) to a metric scope + label for the summary cards
- `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
- `ScholarshipApplication` β€” Gathers one person's scholarship-application answers for an event by field across all their submissions, so answers surface whether captured on a dedicated scholarship form, an embedded registration section, or the registration submission itself (used by the scholarship edit page and the public submission view)
- `WorkshopSearchService` β€” Complex filtering, sorting, pagination with ActionPolicy
- `WorkshopFromIdeaService` β€” Converts WorkshopIdea to Workshop with asset migration
Expand Down
43 changes: 33 additions & 10 deletions app/controllers/events_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ class EventsController < ApplicationController
skip_before_action :authenticate_user!, only: [ :index, :show, :staff ]
skip_before_action :verify_authenticity_token, only: [ :preview ]
before_action :set_event, only: %i[ show edit update destroy preview dashboard sample_ticket background registrants onboarding staff edit_staff update_staff recipients preview_reminder confirm_reminder send_reminder copy_registration_form ]
before_action :set_report_filters, only: %i[ revenue participation statistics ]
before_action :set_report_filters, only: %i[ revenue participation statistics scholarships ]

def index
authorize!
Expand Down Expand Up @@ -37,13 +37,24 @@ def participation
@report = EventParticipationReport.new(events, featured_year: selected_year)
end

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

# 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.
def scholarships
authorize!
events, selected_year = filtered_report_events(Event.facilitator_trainings)
@report = EventScholarshipReport.new(events, featured_year: selected_year, funder: @filter_funder)
end

def new
Expand Down Expand Up @@ -448,15 +459,21 @@ def staff_update_return_path
end
end

# Shared filter state for the revenue/participation/statistics report pages: the
# event-type and specific-event filters, plus the event list for the Event
# dropdown.
# 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.
def set_report_filters
@event_type = params[:event_type].presence_in(%w[ trainings other ])
@filter_event = Event.find_by(id: params[:event_id]) if params[:event_id].present?
# The revenue report only covers paid events, so its Event dropdown lists only
# those; the others list every event.
dropdown_scope = action_name == "revenue" ? Event.paid : Event.all
@event_search = params[:search].presence
@filter_funder = GlobalID::Locator.locate_signed(params[:funder_sgid]) if params[:funder_sgid].present?
# The Event dropdown lists the report's own universe: paid events for revenue,
# facilitator trainings for scholarships, every event otherwise.
dropdown_scope = case action_name
when "revenue" then Event.paid
when "scholarships" then Event.facilitator_trainings
else Event.all
end
@filter_events = dropdown_scope.order(start_date: :desc)
end

Expand All @@ -483,11 +500,17 @@ def report_events(base)
scoped_report_base(base).order(start_date: :desc).map(&:decorate)
end

# Narrows `base` by the event-type and specific-event filters.
# Narrows `base` by the event-type, specific-event and search (abbreviation OR
# title) filters.
def scoped_report_base(base)
base = base.facilitator_trainings if @event_type == "trainings"
base = base.where(facilitator_training: false) if @event_type == "other"
base = base.where(id: @filter_event.id) if @filter_event
if @event_search
like = "%#{Event.sanitize_sql_like(@event_search)}%"
base = base.where("events.abbreviation LIKE :q OR events.title LIKE :q", q: like)
end
base = base.where(id: Scholarship.from_funder(@filter_funder).event_ids) if @filter_funder
base
end

Expand Down
80 changes: 67 additions & 13 deletions app/controllers/scholarships_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,11 @@ class ScholarshipsController < ApplicationController

def index
authorize! Scholarship
# Eager-load everything the grid derives so each row's funder, program,
# location, training, and status cells add no per-row queries:
# * grant β†’ donor for the funder grouping;
# * recipient β†’ affiliations β†’ organization β†’ addresses for program/location/status;
# * recipient β†’ event_registrations β†’ event for the attended-training column.
scholarships = authorized_scope(Scholarship.all).includes(
{ grant: :donor },
{ recipient: [ { affiliations: { organization: :addresses } }, { event_registrations: :event } ] }
)
if params[:recipient_id].present?
scholarships = scholarships.where(recipient_id: params[:recipient_id])
@recipient = Person.find_by(id: params[:recipient_id])
end
set_report_filter_state
scholarships = filtered_scholarships
@funder_groups = ScholarshipsGrouping.new(scholarships).funder_groups
@scholarships_count = scholarships.size
@scholarship_report = EventScholarshipReport.new(report_training_events, featured_year: @selected_year, funder: @filter_funder)
end

def show
Expand Down Expand Up @@ -120,6 +110,70 @@ def toggle_tasks

private

# Filter state for the shared report filter partials (time period, event,
# abbreviation, funder). The Event dropdown and year options list facilitator
# trainings, matching the events scholarship report.
def set_report_filter_state
@filter_event = Event.find_by(id: params[:event_id]) if params[:event_id].present?
@event_search = params[:search].presence
@filter_funder = GlobalID::Locator.locate_signed(params[:funder_sgid]) if params[:funder_sgid].present?
@filter_events = Event.facilitator_trainings.order(start_date: :desc)
@year_options = Event.facilitator_trainings
.where.not(start_date: nil)
.distinct
.pluck(Arel.sql("YEAR(start_date)"))
.sort
.reverse
@time_period = params[:time_period].presence || "all_time"
@selected_year = @time_period == "this_year" ? Date.current.year : Integer(@time_period, exception: false)
end

# The scholarship list, narrowed by recipient, funder, and the event-centric
# filters (which resolve to the events a scholarship was awarded at).
def filtered_scholarships
# Eager-load everything the grid derives so each row's funder, program,
# location, training, and status cells add no per-row queries.
scope = authorized_scope(Scholarship.all).includes(
{ grant: :donor },
{ recipient: [ { affiliations: { organization: :addresses } }, { event_registrations: :event } ] }
)
if params[:recipient_id].present?
scope = scope.where(recipient_id: params[:recipient_id])
@recipient = Person.find_by(id: params[:recipient_id])
end
scope = scope.from_funder(@filter_funder) if @filter_funder
event_ids = filter_event_ids
scope = scope.for_events(event_ids) if event_ids
scope
end

# Event ids matching the year / specific-event / search filters, or nil
# when none are active (so the list isn't restricted by event).
def filter_event_ids
return unless @selected_year || @filter_event || @event_search
scoped_events.select(:id)
end

# Facilitator trainings for the summary report at the top of the index, scoped
# by the same filters (year / event / search / funder), decorated.
def report_training_events
events = scoped_events(Event.facilitator_trainings)
events = events.where(id: Scholarship.from_funder(@filter_funder).event_ids) if @filter_funder
events.order(start_date: :desc).map(&:decorate)
end

# Applies the year / specific-event / search (abbreviation OR title) filters to
# an event scope.
def scoped_events(base = Event.all)
base = base.in_year(@selected_year) if @selected_year
base = base.where(id: @filter_event.id) if @filter_event
if @event_search
like = "%#{Event.sanitize_sql_like(@event_search)}%"
base = base.where("events.abbreviation LIKE :q OR events.title LIKE :q", q: like)
end
base
end

def set_scholarship
@scholarship = Scholarship.find(params[:id])
end
Expand Down
33 changes: 33 additions & 0 deletions app/helpers/events_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,39 @@ def registrants_event_row_path(event_or_id, registration_id)
registrants_event_path(event_or_id, anchor: registrant_row_id(registration_id), highlight: registration_id)
end

# The scholarships report's filter/toggle state, carried through a drill-in so
# its eyebrow can rebuild the exact view (period, event type/id, search, funder,
# split/combined layout, and the report's own origin) the user came from.
REPORT_FILTER_KEYS = %i[ time_period event_type event_id search funder_sgid view return_to ].freeze

# Stable anchor id for a training's row on the scholarships report, so the
# registrants eyebrow can scroll to and highlight the row drilled in from.
def training_report_row_id(event_or_id)
id = event_or_id.respond_to?(:id) ? event_or_id.id : event_or_id
"training-row-#{id}"
end

# Forward: from a scholarships report row into that training's *attended*
# registrants, stamped so the roster's eyebrow returns to the exact row
# (highlight + anchor) with the report's filters/toggle restored.
def attended_registrants_path(event)
registrants_event_path(event,
attendance_status: "attended",
return_to: "scholarships",
return_highlight: event.id,
return_anchor: training_report_row_id(event),
report_filters: params.permit(*REPORT_FILTER_KEYS).to_h.compact_blank)
end

# Back: the registrants eyebrow's path to the scholarships report, restoring the
# carried filters/toggle and highlighting the row the user drilled in from.
def scholarships_report_return_path
filters = params.fetch(:report_filters, ActionController::Parameters.new).permit(*REPORT_FILTER_KEYS)
scholarships_events_path(**filters.to_h.symbolize_keys,
highlight: params[:return_highlight].presence,
anchor: params[:return_anchor].presence)
end

# Stamp a registrants-page link reached from the background dashboard with the
# context its eyebrow needs to send the user back to the exact section they
# drilled in from: return_to marks the origin page, return_anchor the section id
Expand Down
8 changes: 8 additions & 0 deletions app/models/organization.rb
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,14 @@ class Organization < ApplicationRecord
AGENCY_TYPE_OTHER = "Other"
AGENCY_TYPES = [ "501c3/nonprofit", "For-profit", "Government agency", AGENCY_TYPE_OTHER ].freeze

# The organization that runs this app. A grant it donates is the org funding
# itself, so reports count it as subsidy (unfunded), not external funding.
# Identified by name via ORGANIZATION_NAME β€” the only marker available today.
# Not memoized: the record can be created mid-process (seeds, tests).
def self.awbw
find_by(name: ENV.fetch("ORGANIZATION_NAME", "A Window Between Worlds"))
end

# Validations
validates :logo,
content_type: %w[image/png image/jpeg image/webp],
Expand Down
23 changes: 23 additions & 0 deletions app/models/scholarship.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,29 @@ class Scholarship < ApplicationRecord
scope :completed, -> { where(tasks_completed: true) }
scope :agreement_signed, -> { where.not(agreement_signed_at: nil) }

# Scholarships from grants a given donor (Person/Organization) gave β€” the
# "funder" filter. A blank donor matches nothing.
scope :from_funder, ->(donor) { where(grant_id: Grant.where(donor: donor).select(:id)) }

# Scholarships awarded at the given events, via the allocation β†’ event
# registration chain (a scholarship's allocation is on an EventRegistration).
scope :for_events, ->(event_ids) {
registration_ids = EventRegistration.where(event_id: event_ids).select(:id)
source_ids = Allocation
.where(allocatable_type: "EventRegistration", allocatable_id: registration_ids, source_type: "Scholarship")
.select(:source_id)
where(id: source_ids)
}

# Ids of events this relation's scholarships were awarded at β€” for narrowing an
# event report to trainings a funder actually scholarshipped.
def self.event_ids
registration_ids = Allocation
.where(allocatable_type: "EventRegistration", source_type: "Scholarship", source_id: all.select(:id))
.select(:allocatable_id)
EventRegistration.where(id: registration_ids).distinct.pluck(:event_id)
end

# The agreement is signed when a signed-at timestamp is present β€” a single
# source of truth. `agreement_signed` reads/writes as a virtual boolean so the
# admin form checkbox and strong params keep working, stamping or clearing the
Expand Down
7 changes: 7 additions & 0 deletions app/policies/event_policy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ 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

def show?
return true if admin?

Expand Down Expand Up @@ -160,6 +166,7 @@ def google_analytics?
:pre_title,
:pre_date_text,
:facilitator_training,
:on_demand,
:featured,
:start_date, :start_date_date, :start_date_time,
:end_date, :end_date_date, :end_date_time,
Expand Down
Loading