diff --git a/app/controllers/scholarships_controller.rb b/app/controllers/scholarships_controller.rb
index 79ffb1fb9e..5bed82759a 100644
--- a/app/controllers/scholarships_controller.rb
+++ b/app/controllers/scholarships_controller.rb
@@ -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])
diff --git a/app/decorators/organization_decorator.rb b/app/decorators/organization_decorator.rb
index a7d93fb236..6143c7e061 100644
--- a/app/decorators/organization_decorator.rb
+++ b/app/decorators/organization_decorator.rb
@@ -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
diff --git a/app/decorators/scholarship_decorator.rb b/app/decorators/scholarship_decorator.rb
index 8ee11d53c6..840da16940 100644
--- a/app/decorators/scholarship_decorator.rb
+++ b/app/decorators/scholarship_decorator.rb
@@ -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
@@ -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
diff --git a/app/models/organization.rb b/app/models/organization.rb
index 8d7fa66968..f30d9b6656 100644
--- a/app/models/organization.rb
+++ b/app/models/organization.rb
@@ -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
diff --git a/app/models/scholarship.rb b/app/models/scholarship.rb
index 2a570ff269..f4d4687339 100644
--- a/app/models/scholarship.rb
+++ b/app/models/scholarship.rb
@@ -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.
diff --git a/app/views/scholarships/_recipient_row.html.erb b/app/views/scholarships/_recipient_row.html.erb
index d1682cf0e9..611ac495a9 100644
--- a/app/views/scholarships/_recipient_row.html.erb
+++ b/app/views/scholarships/_recipient_row.html.erb
@@ -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 %>
<%= scholarship.training_label %> |
diff --git a/config/features.yml b/config/features.yml
index 1026db33b3..0f76191546 100644
--- a/config/features.yml
+++ b/config/features.yml
@@ -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
diff --git a/docs/adr/0001-organization-affiliation-and-program-status.md b/docs/adr/0001-organization-affiliation-and-program-status.md
index 8fc97cb27a..ef4719a9c0 100644
--- a/docs/adr/0001-organization-affiliation-and-program-status.md
+++ b/docs/adr/0001-organization-affiliation-and-program-status.md
@@ -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
diff --git a/spec/decorators/organization_decorator_spec.rb b/spec/decorators/organization_decorator_spec.rb
index e986be70d9..e9e4c41b10 100644
--- a/spec/decorators/organization_decorator_spec.rb
+++ b/spec/decorators/organization_decorator_spec.rb
@@ -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
@@ -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
diff --git a/spec/decorators/scholarship_decorator_spec.rb b/spec/decorators/scholarship_decorator_spec.rb
index 2975df4520..874af99ec7 100644
--- a/spec/decorators/scholarship_decorator_spec.rb
+++ b/spec/decorators/scholarship_decorator_spec.rb
@@ -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)
diff --git a/spec/models/organization_spec.rb b/spec/models/organization_spec.rb
index ffaa6b3216..67ecf9948d 100644
--- a/spec/models/organization_spec.rb
+++ b/spec/models/organization_spec.rb
@@ -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"))
diff --git a/spec/models/scholarship_spec.rb b/spec/models/scholarship_spec.rb
index 4e4807f7e1..abfce38e4e 100644
--- a/spec/models/scholarship_spec.rb
+++ b/spec/models/scholarship_spec.rb
@@ -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) }