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) }