From 7a10bc8949acee190a0a0d14162b56f9bff1d902 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Sun, 23 Aug 2026 20:55:30 -0400 Subject: [PATCH 1/3] Judge each scholarship's program status at its own training MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The New / Ongoing / Reinstated column judged every award against today, via Organization#program_status → Affiliation#active?. So a 2018 award showed the program's status *now*: an org that has since lapsed read Reinstate on a row where it was plainly Ongoing at that training, and the value moved silently as affiliations changed underneath it. Anchor each row on the start date of the training it paid for instead. That means routing through FacilitatorProgramStatus, which ADR-0001 D4 already names as the one rule for this word — the scholarship index was the last surface computing it ad hoc, and the only one that could disagree with the event reports and org profile chips about the same program on the same day. Hover text names the anchor and the reasoning. An award with no registration behind it (grant-first, or funding a CE registration) keeps the year anchor, labelled "no event in view". Organization#program_status had no other caller, so it goes. Co-Authored-By: Claude Opus 5 (1M context) --- app/controllers/scholarships_controller.rb | 9 ++- app/decorators/organization_decorator.rb | 4 +- app/decorators/scholarship_decorator.rb | 35 ++++++----- app/models/organization.rb | 13 ----- app/models/scholarship.rb | 13 +++++ .../scholarships/_recipient_row.html.erb | 3 +- config/features.yml | 15 +++++ ...nization-affiliation-and-program-status.md | 11 +++- .../decorators/organization_decorator_spec.rb | 9 +-- spec/decorators/scholarship_decorator_spec.rb | 58 +++++++++++++++++++ spec/models/organization_spec.rb | 28 --------- spec/models/scholarship_spec.rb | 17 ++++++ 12 files changed, 147 insertions(+), 68 deletions(-) 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..116e62caad 100644 --- a/config/features.yml +++ b/config/features.yml @@ -70,6 +70,21 @@ 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: events + 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. Previously every row was judged against today, so a past + award drifted as the program's affiliations changed. It uses the same rule as + the event reports and org profile chips, and hovering explains the verdict and + the date it was judged on. + 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) } From 4d4834d5c03dbde5b47e6b1072a62e586592dada Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Sun, 23 Aug 2026 21:18:04 -0400 Subject: [PATCH 2/3] Trim scholarship feature summary under the 300-char limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seed summary exceeded Feature's 300-char maximum, so import! raised RecordInvalid — failing the catalog seed-validity spec and returning 422 from POST /features/import. Co-Authored-By: Claude Opus 4.8 (1M context) --- config/features.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/config/features.yml b/config/features.yml index 116e62caad..3b89966f07 100644 --- a/config/features.yml +++ b/config/features.yml @@ -78,10 +78,8 @@ 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. Previously every row was judged against today, so a past - award drifted as the program's affiliations changed. It uses the same rule as - the event reports and org profile chips, and hovering explains the verdict and - the date it was judged on. + 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." From 86f056dc2321de2f554b798217898b18543c033d Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Sun, 23 Aug 2026 21:29:59 -0400 Subject: [PATCH 3/3] File the scholarship status feature under Scholarships & grants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The entry describes the scholarship index, so it belongs beside the other scholarship features on /features — filed under Events & trainings it was invisible to anyone filtering by the area it actually ships in. Co-Authored-By: Claude Opus 5 (1M context) --- config/features.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/features.yml b/config/features.yml index 3b89966f07..0f76191546 100644 --- a/config/features.yml +++ b/config/features.yml @@ -71,7 +71,7 @@ - "Add ?admin=true to the affiliation editor URL to reveal the registration picker." - name: "Scholarship program status is judged at its own training" - area: events + area: scholarships display_status: admin_facing released_on: 2026-08-23 action_path: "/scholarships"