Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions app/controllers/scholarships_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -145,10 +145,15 @@ def set_report_filter_state
# 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.
# location, training, and status cells add no per-row queries. The program's
# own affiliations back the status column (FacilitatorProgramStatus reads the
# loaded association); the allocation is loaded for its allocatable_type/_id
# alone, since Scholarship#event resolves the training through the recipient's
# already-loaded registrations rather than the allocatable itself.
scope = authorized_scope(Scholarship.all).includes(
{ grant: :funder },
{ recipient: [ { affiliations: { organization: :addresses } }, { event_registrations: :event } ] }
:allocation,
{ recipient: [ { affiliations: { organization: [ :addresses, :affiliations ] } }, { event_registrations: :event } ] }
)
if params[:recipient_id].present?
scope = scope.where(recipient_id: params[:recipient_id])
Expand Down
4 changes: 2 additions & 2 deletions app/decorators/organization_decorator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,9 @@ def self.program_status_classes(status)
end

# Compact single-letter program-status badge (N / O / R) with the full label as
# a tooltip. Defaults to this org's own #program_status; pass a precomputed
# a tooltip. Defaults to this org's own year-anchored status; pass a precomputed
# status on list pages (index / dashboard) to avoid loading affiliations per row.
def program_status_badge(status = object.program_status)
def program_status_badge(status = object.facilitator_program_status)
key = self.class.program_status_key(status)
return unless key

Expand Down
35 changes: 19 additions & 16 deletions app/decorators/scholarship_decorator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,20 +26,30 @@ def program_location
program&.program_location.presence || "β€”"
end

# New / Ongoing / Reinstate relative to this recipient; blank when there's no
# program to assess.
# The program's New / Ongoing / Reinstated verdict, anchored on the start date of
# the training this award paid for β€” so each row is judged at its own event
# rather than at one page-wide date. Falls back to the year anchor when the award
# has no registration behind it. One rule for every surface (ADR-0001 D4).
def facilitator_program_status
return @facilitator_program_status if defined?(@facilitator_program_status)

@facilitator_program_status = program&.facilitator_program_status(as_of: object.event&.start_date)
end

def program_status
program&.program_status(object.recipient).presence || "β€”"
facilitator_program_status&.label.presence || "β€”"
end

# Tailwind pill classes for the program-status badge.
def program_status_classes
case program&.program_status(object.recipient)
when "Ongoing" then program_pill(:program_ongoing)
when "New" then program_pill(:program_new)
when "Reinstate" then program_pill(:program_reinstated)
else "bg-gray-50 text-gray-500 border-gray-200"
end
OrganizationDecorator.program_status_classes(facilitator_program_status&.status) ||
"bg-gray-50 text-gray-500 border-gray-200"
end

# Hover text naming the anchor date and the reasoning, worded exactly like the
# org profile's per-event chips.
def program_status_explanation
facilitator_program_status&.explanation
end

# The facilitator-training event(s) the recipient attended ("TAC"); titles
Expand Down Expand Up @@ -95,11 +105,4 @@ def agreement_status_badge(all_states: false, prefix: false, icon_size: "text-xs
h.render "shared/badge", label: label, classes: agreement_status_classes,
icon: [ agreement_status_icon, icon_size ].compact_blank.join(" ")
end

private

# Program-status pill classes for a DomainTheme program key (bg-50/text-700/border-200).
def program_pill(key)
"#{DomainTheme.bg_class_for(key)} #{DomainTheme.text_class_for(key, intensity: 700)} #{DomainTheme.border_class_for(key, intensity: 200)}"
end
end
13 changes: 0 additions & 13 deletions app/models/organization.rb
Original file line number Diff line number Diff line change
Expand Up @@ -213,19 +213,6 @@ def program_location
[ first_active.city, first_active.state ].compact_blank.join(", ").presence
end

# This org's program status relative to a scholarship recipient (the
# New/Ongoing/Reinstate column on the scholarship index): Reinstate = lapsed,
# Ongoing = has facilitators beyond this recipient, New = none prior. In-memory
# facilitator-affiliation heuristic, to reuse a preloaded association.
def program_status(recipient = nil)
facilitators = affiliations.select(&:facilitator?)
return "New" if facilitators.empty?
return "Reinstate" if facilitators.none?(&:active?)

prior = recipient ? facilitators.reject { |a| a.person_id == recipient.id } : facilitators
prior.any? ? "Ongoing" : "New"
end

def type_name
"#{name} #{ " (#{windows_type.short_name})" if windows_type}"
end
Expand Down
13 changes: 13 additions & 0 deletions app/models/scholarship.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,19 @@ class Scholarship < ApplicationRecord
# Declined awards drop out of every total.
scope :not_declined, -> { where.not(agreement_response_status: "declined") }

# The training this award paid for: the event behind the registration its
# allocation funds. Nil for a grant-first award (no allocation yet) or one
# funding a CE registration / membership invoice instead of a training.
# Resolved through the recipient's own registrations (guaranteed to include this
# one by recipient_must_match_allocation_registrant) so a list page that preloads
# event_registrations β†’ event pays no per-row query; walking allocatable.event
# would, since a polymorphic preload can't reach the event.
def event
return nil unless allocation&.allocatable_type == "EventRegistration"

recipient&.event_registrations&.find { |registration| registration.id == allocation.allocatable_id }&.event
end

# 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.
Expand Down
3 changes: 2 additions & 1 deletion app/views/scholarships/_recipient_row.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
<% else %>
<%= render "shared/badge",
label: scholarship.program_status,
classes: scholarship.program_status_classes %>
classes: scholarship.program_status_classes,
title: scholarship.program_status_explanation %>
<% end %>
</td>
<td class="px-4 py-2 text-sm text-gray-600 whitespace-nowrap"><%= scholarship.training_label %></td>
Expand Down
13 changes: 13 additions & 0 deletions config/features.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,19 @@
pro_tips:
- "Add ?admin=true to the affiliation editor URL to reveal the registration picker."

- name: "Scholarship program status is judged at its own training"
area: scholarships
display_status: admin_facing
released_on: 2026-08-23
action_path: "/scholarships"
summary: >-
The New / Ongoing / Reinstated column on the scholarship index is now judged
as of the training each award paid for, so two awards on the same page can
read differently. It uses the same rule as the event reports and org profile
chips, and hovering explains the verdict and the date it judged.
pro_tips:
- "An award with no registration behind it falls back to the start of the current year β€” the hover text says so."

- name: "Activity log: readable details column"
area: reporting
display_status: admin_facing
Expand Down
11 changes: 9 additions & 2 deletions docs/adr/0001-organization-affiliation-and-program-status.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,15 @@ facilitator affiliations by a single class, `FacilitatorProgramStatus`:
`Organization#facilitator_program_status(as_of:)` returns that object;
`#facilitator_status_on(date)` is the bare symbol for counting and filtering.
Nothing else classifies a program β€” the dashboard breakdown, the onboarding
matrix, the rosters, the org profile/edit chips and the annual report all call
this one method, so they cannot disagree.
matrix, the rosters, the org profile/edit chips, the **scholarship index** and
the annual report all call this one method, so they cannot disagree.

**Each row picks its own anchor.** The scholarship index anchors each award on
the start date of the training it paid for (`Scholarship#event`, via
allocation β†’ event registration), so two awards listed side by side are judged
at their own events rather than at one page-wide "now". An award with no
registration behind it (grant-first, or funding a CE registration) falls back to
the year anchor, which `#explanation` labels "no event in view".

The status object also carries **why**: the anchor date, the month the program
went (or last was) active, and the full facilitator history as merged periods
Expand Down
9 changes: 5 additions & 4 deletions spec/decorators/organization_decorator_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@
expect(described_class.program_status_classes(:new)).to include("indigo")
expect(described_class.program_status_classes(:ongoing)).to include("blue")
expect(described_class.program_status_classes(:reinstated)).to include("purple")
# Organization#program_status returns "Reinstate" (no trailing d).
# Tolerates either spelling of the word, however a caller phrases it.
expect(described_class.program_status_classes("Reinstate")).to include("purple")
end

Expand All @@ -180,11 +180,12 @@
expect(badge).to have_css("span[title='Ongoing']", text: "O")
end

it "defaults to the organization's own program status" do
create(:affiliation, organization: organization, person: create(:person), title: "Facilitator")
it "defaults to the organization's own year-anchored program status" do
create(:affiliation, organization: organization, person: create(:person), title: "Facilitator",
start_date: 3.years.ago.to_date)

badge = Capybara.string(organization.reload.decorate.program_status_badge)
expect(badge).to have_css("span[title='Ongoing']", text: "O")
expect(badge).to have_css("span[title^='Ongoing']", text: "O")
end

it "is nil for a blank status" do
Expand Down
58 changes: 58 additions & 0 deletions spec/decorators/scholarship_decorator_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,64 @@
end
end

describe "program status, anchored on the training the award paid for" do
let(:org) { create(:organization, name: "Prevail") }

def award_at(training, recipient_person = recipient)
registration = create(:event_registration, event: training, registrant: recipient_person)
scholarship = create(:scholarship, recipient: recipient_person)
create(:allocation, source: scholarship, allocatable: registration, amount: 0)
scholarship.reload.decorate
end

it "is Ongoing when the program was already facilitating at that training" do
training = create(:event, facilitator_training: true, start_date: Date.new(2025, 6, 1))
create(:affiliation, person: recipient, organization: org, title: "Facilitator", start_date: Date.new(2020, 1, 1))

expect(award_at(training).program_status).to eq("Ongoing")
end

it "is New when the only facilitator affiliation starts after that training" do
training = create(:event, facilitator_training: true, start_date: Date.new(2025, 6, 1))
create(:affiliation, person: recipient, organization: org, title: "Facilitator", start_date: Date.new(2025, 9, 1))

expect(award_at(training).program_status).to eq("New")
end

it "is Reinstated when the program had lapsed by that training" do
training = create(:event, facilitator_training: true, start_date: Date.new(2025, 6, 1))
create(:affiliation, person: create(:person), organization: org, title: "Facilitator",
start_date: Date.new(2015, 1, 1), end_date: Date.new(2018, 1, 1))
create(:affiliation, person: recipient, organization: org, title: "Facilitator", start_date: Date.new(2025, 6, 1))

expect(award_at(training).program_status).to eq("Reinstated")
end

it "judges two awards at their own trainings, not at one page-wide date" do
create(:affiliation, person: recipient, organization: org, title: "Facilitator", start_date: Date.new(2020, 3, 1))
early = create(:event, facilitator_training: true, start_date: Date.new(2018, 6, 1))
late = create(:event, facilitator_training: true, start_date: Date.new(2024, 6, 1))

expect(award_at(early).program_status).to eq("New")
expect(award_at(late).program_status).to eq("Ongoing")
end

it "falls back to the year anchor when no registration backs the award" do
create(:affiliation, person: recipient, organization: org, title: "Facilitator", start_date: Date.new(2020, 3, 1))
scholarship = create(:scholarship, recipient: recipient).decorate

expect(scholarship.program_status).to eq("Ongoing")
expect(scholarship.program_status_explanation).to include("no event in view")
end

it "explains the verdict against the training's own start date" do
training = create(:event, facilitator_training: true, start_date: Date.new(2025, 6, 1))
create(:affiliation, person: recipient, organization: org, title: "Facilitator", start_date: Date.new(2020, 1, 1))

expect(award_at(training).program_status_explanation).to include("Jun 1, 2025", "event start date")
end
end

describe "training column" do
it "lists the attended facilitator-training event" do
training = create(:event, title: "TAC251", facilitator_training: true)
Expand Down
28 changes: 0 additions & 28 deletions spec/models/organization_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -408,34 +408,6 @@ def version = Organization.find(organization.id).rollup_cache_version
end
end

describe "#program_status" do
let(:org) { create(:organization) }
let(:recipient) { create(:person) }

it "is New when the recipient is the org's only facilitator" do
create(:affiliation, person: recipient, organization: org, title: "Facilitator")

expect(org.reload.program_status(recipient)).to eq("New")
end

it "is Ongoing when another facilitator already serves the org" do
create(:affiliation, person: recipient, organization: org, title: "Facilitator")
create(:affiliation, person: create(:person), organization: org, title: "Facilitator")

expect(org.reload.program_status(recipient)).to eq("Ongoing")
end

it "is Reinstate when the org's facilitator affiliations have all lapsed" do
create(:affiliation, person: recipient, organization: org, title: "Facilitator", end_date: 1.year.ago.to_date)

expect(org.reload.program_status(recipient)).to eq("Reinstate")
end

it "is New when the org has no facilitator affiliations" do
expect(org.program_status(recipient)).to eq("New")
end
end

describe ".awbw" do
it "finds the org named by ORGANIZATION_NAME" do
awbw = create(:organization, name: ENV.fetch("ORGANIZATION_NAME", "A Window Between Worlds"))
Expand Down
17 changes: 17 additions & 0 deletions spec/models/scholarship_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,23 @@
it { is_expected.to have_one(:allocation).dependent(:destroy) }
end

describe "#event" do
let(:person) { create(:person) }

it "is the event behind the registration the allocation funds" do
training = create(:event, title: "TAC251", facilitator_training: true)
registration = create(:event_registration, event: training, registrant: person)
scholarship = create(:scholarship, recipient: person)
create(:allocation, source: scholarship, allocatable: registration, amount: 0)

expect(scholarship.reload.event).to eq(training)
end

it "is nil for an award with no allocation behind it" do
expect(create(:scholarship, recipient: person).event).to be_nil
end
end

describe "validations" do
it { is_expected.to validate_numericality_of(:amount_cents).is_greater_than_or_equal_to(0) }

Expand Down