From 93435ae63fefc44530ca8415cf850a6531f16092 Mon Sep 17 00:00:00 2001 From: Mike Dalton Date: Mon, 29 Jun 2026 22:57:01 -0400 Subject: [PATCH 1/2] Add Greatest Community Need ongoing allocation Introduce Allocation::GreatestCommunityNeed, an STI subclass of Allocation::Ongoing that every scenario gets automatically at 0% via a before_create hook (has_one :greatest_community_need). It renders as a distinct, tinted ongoing card with a fixed label/description, is limited to one per scenario, and cannot be edited or deleted from the UI (the controller also blocks its destroy). Co-Authored-By: Claude Opus 4.8 --- app/controllers/allocations_controller.rb | 9 +++- app/models/allocation.rb | 4 ++ .../allocation/greatest_community_need.rb | 31 +++++++++++ app/models/scenario.rb | 9 ++++ app/views/scenarios/_allocation.html.erb | 53 +++++++++++-------- config/initializers/sti_preload.rb | 4 ++ .../allocations_controller_test.rb | 28 +++++++++- test/fixtures/allocations.yml | 4 +- test/models/allocation_test.rb | 25 +++++++++ test/models/scenario_test.rb | 12 +++++ 10 files changed, 153 insertions(+), 26 deletions(-) create mode 100644 app/models/allocation/greatest_community_need.rb create mode 100644 config/initializers/sti_preload.rb diff --git a/app/controllers/allocations_controller.rb b/app/controllers/allocations_controller.rb index 4e7cc70..450f1af 100644 --- a/app/controllers/allocations_controller.rb +++ b/app/controllers/allocations_controller.rb @@ -22,8 +22,13 @@ def update end def destroy - @scenario.allocations.find(params[:id]).destroy - redirect_to scenario_path(@scenario) + allocation = @scenario.allocations.find(params[:id]) + if allocation.greatest_community_need? + redirect_to scenario_path(@scenario), alert: "Greatest Community Need can't be removed." + else + allocation.destroy + redirect_to scenario_path(@scenario) + end end private diff --git a/app/models/allocation.rb b/app/models/allocation.rb index 012867c..e5cc0f8 100644 --- a/app/models/allocation.rb +++ b/app/models/allocation.rb @@ -11,6 +11,10 @@ def display_label allocation_category&.name || option end + def greatest_community_need? + false + end + private def category_or_option_present diff --git a/app/models/allocation/greatest_community_need.rb b/app/models/allocation/greatest_community_need.rb new file mode 100644 index 0000000..b39f009 --- /dev/null +++ b/app/models/allocation/greatest_community_need.rb @@ -0,0 +1,31 @@ +class Allocation::GreatestCommunityNeed < Allocation::Ongoing + LABEL = "Greatest Community Need".freeze + DESCRIPTION = + "Funds go where local nonprofits need them most — trusted to respond to urgent, unmet needs as they arise.".freeze + + after_initialize :apply_defaults + + validate :only_one_per_scenario + + def display_label + LABEL + end + + def greatest_community_need? + true + end + + private + + def apply_defaults + self.option ||= LABEL + self.percentage ||= 0 + end + + def only_one_per_scenario + return if scenario_id.blank? + + clash = self.class.where(scenario_id: scenario_id).where.not(id: id) + errors.add(:base, "Greatest Community Need has already been added") if clash.exists? + end +end diff --git a/app/models/scenario.rb b/app/models/scenario.rb index 93d5f6f..9c51ce0 100644 --- a/app/models/scenario.rb +++ b/app/models/scenario.rb @@ -6,9 +6,12 @@ class Scenario < ApplicationRecord has_many :allocations, dependent: :destroy has_many :ongoing_allocations, class_name: "Allocation::Ongoing" has_many :one_time_allocations, class_name: "Allocation::OneTime" + has_one :greatest_community_need, class_name: "Allocation::GreatestCommunityNeed" validates :name, presence: true + before_create :ensure_greatest_community_need + def ongoing_percentage_total ongoing_allocations.sum { |allocation| allocation.percentage.to_i } end @@ -20,4 +23,10 @@ def one_time_giving_amount def ongoing_giving_amount total_giving_amount.to_i - one_time_giving_amount end + + private + + def ensure_greatest_community_need + build_greatest_community_need + end end diff --git a/app/views/scenarios/_allocation.html.erb b/app/views/scenarios/_allocation.html.erb index a3349f5..725336d 100644 --- a/app/views/scenarios/_allocation.html.erb +++ b/app/views/scenarios/_allocation.html.erb @@ -5,32 +5,41 @@
+ class="rounded-lg border <%= allocation.greatest_community_need? ? "border-brand/30 bg-brand-tint" : "border-line-soft bg-surface" %> px-4 py-3 transition hover:shadow-sm">
-
-

<%= allocation.display_label %>

-

- <%= allocation.preference_categories.map(&:name).join(", ").presence || "No additional preferences" %> -

-
+ <% if allocation.greatest_community_need? %> +
+

<%= allocation.display_label %>

+

<%= Allocation::GreatestCommunityNeed::DESCRIPTION %>

+
+ <% else %> +
+

<%= allocation.display_label %>

+

+ <%= allocation.preference_categories.map(&:name).join(", ").presence || "No additional preferences" %> +

+
+ <% end %>
<%= allocation.percentage %>% <%= number_to_currency(allocation.dollar_amount, precision: 0) %>
- - <%= button_to scenario_allocation_path(allocation.scenario, allocation), method: :delete, - class: "text-ink-faint hover:text-danger cursor-pointer bg-transparent p-0 leading-none", - form_class: "leading-none", aria: { label: "Remove allocation" }, - data: { turbo_confirm: "Remove this allocation?" } do %> - - - + <% unless allocation.greatest_community_need? %> + + <%= button_to scenario_allocation_path(allocation.scenario, allocation), method: :delete, + class: "text-ink-faint hover:text-danger cursor-pointer bg-transparent p-0 leading-none", + form_class: "leading-none", aria: { label: "Remove allocation" }, + data: { turbo_confirm: "Remove this allocation?" } do %> + + + + <% end %> <% end %>
@@ -56,7 +65,9 @@

- <%= render "allocation_modal", allocation: allocation, scenario: scenario, color: color %> + <% unless allocation.greatest_community_need? %> + <%= render "allocation_modal", allocation: allocation, scenario: scenario, color: color %> + <% end %> <% else %>
diff --git a/config/initializers/sti_preload.rb b/config/initializers/sti_preload.rb new file mode 100644 index 0000000..447e772 --- /dev/null +++ b/config/initializers/sti_preload.rb @@ -0,0 +1,4 @@ +# See https://guides.rubyonrails.org/autoloading_and_reloading_constants.html#single-table-inheritance +Rails.application.config.to_prepare do + Allocation::GreatestCommunityNeed +end diff --git a/test/controllers/allocations_controller_test.rb b/test/controllers/allocations_controller_test.rb index 7fe24f0..111b352 100644 --- a/test/controllers/allocations_controller_test.rb +++ b/test/controllers/allocations_controller_test.rb @@ -50,6 +50,24 @@ class AllocationsControllerTest < ActionDispatch::IntegrationTest assert_empty allocation.reload.preference_categories end + test "renders the always-present Greatest Community Need card" do + # one_arlington already has the greatest_need fixture allocation. + get scenario_url(@scenario) + assert_response :success + assert_match Allocation::GreatestCommunityNeed::DESCRIPTION, response.body + end + + test "rejects a duplicate Greatest Community Need allocation" do + # one_arlington already has the greatest_need fixture allocation. + assert_no_difference -> { @scenario.allocations.count } do + post scenario_allocations_url(@scenario), params: { + allocation: { type: "Allocation::GreatestCommunityNeed", percentage: 20 } + } + end + assert_redirected_to scenario_path(@scenario) + assert flash[:alert].present? + end + test "creates a one time allocation" do assert_difference -> { @scenario.allocations.count }, 1 do post scenario_allocations_url(@scenario), params: { @@ -113,9 +131,17 @@ class AllocationsControllerTest < ActionDispatch::IntegrationTest test "destroys an allocation" do assert_difference -> { @scenario.allocations.count }, -1 do + delete scenario_allocation_url(@scenario, allocations(:education_grant)) + end + assert_redirected_to scenario_path(@scenario) + end + + test "cannot delete the Greatest Community Need allocation" do + assert_no_difference -> { @scenario.allocations.count } do delete scenario_allocation_url(@scenario, allocations(:greatest_need)) end assert_redirected_to scenario_path(@scenario) + assert flash[:alert].present? end test "cannot add an allocation to a scenario you do not own" do @@ -140,7 +166,7 @@ class AllocationsControllerTest < ActionDispatch::IntegrationTest test "admin can destroy an allocation on another user's scenario in the same org" do sign_in_as users(:admin) assert_difference -> { @scenario.allocations.count }, -1 do - delete scenario_allocation_url(@scenario, allocations(:greatest_need)) + delete scenario_allocation_url(@scenario, allocations(:education_grant)) end assert_redirected_to scenario_path(@scenario) end diff --git a/test/fixtures/allocations.yml b/test/fixtures/allocations.yml index 004ec68..d299b0d 100644 --- a/test/fixtures/allocations.yml +++ b/test/fixtures/allocations.yml @@ -1,7 +1,7 @@ -# greatest_need uses the free-text option fallback (no category). +# greatest_need is the dedicated Greatest Community Need STI subclass. greatest_need: scenario: one_arlington - type: Allocation::Ongoing + type: Allocation::GreatestCommunityNeed option: Greatest Community Need percentage: 30 diff --git a/test/models/allocation_test.rb b/test/models/allocation_test.rb index 920ddd5..e38214d 100644 --- a/test/models/allocation_test.rb +++ b/test/models/allocation_test.rb @@ -64,4 +64,29 @@ class AllocationTest < ActiveSupport::TestCase assert_not allocations(:greatest_need).one_time? assert allocations(:education_grant).one_time? end + + test "greatest_community_need? is true only for the dedicated subclass" do + assert allocations(:greatest_need).greatest_community_need? + assert_instance_of Allocation::GreatestCommunityNeed, allocations(:greatest_need) + + # A plain ongoing allocation that merely shares the label is NOT the subclass. + look_alike = @scenario.ongoing_allocations.new(percentage: 10, option: "Greatest Community Need") + assert_not look_alike.greatest_community_need? + end + + test "only one Greatest Community Need allocation is allowed per scenario" do + # greatest_need fixture already occupies the slot in this scenario. + duplicate = Allocation::GreatestCommunityNeed.new(scenario: @scenario) + assert_not duplicate.valid? + assert_includes duplicate.errors[:base], "Greatest Community Need has already been added" + + assert Allocation::GreatestCommunityNeed.new(scenario: scenarios(:two_boston)).valid? + end + + test "Greatest Community Need defaults to 0% with a fixed label and needs no category or option" do + gcn = Allocation::GreatestCommunityNeed.new(scenario: scenarios(:two_boston)) + assert_equal 0, gcn.percentage + assert_equal "Greatest Community Need", gcn.display_label + assert gcn.valid? + end end diff --git a/test/models/scenario_test.rb b/test/models/scenario_test.rb index bf3f4e2..7a184ed 100644 --- a/test/models/scenario_test.rb +++ b/test/models/scenario_test.rb @@ -36,4 +36,16 @@ class ScenarioTest < ActiveSupport::TestCase scenario.destroy end end + + test "new scenarios are created with a Greatest Community Need allocation at 0%" do + scenario = users(:one).scenarios.create!( + organization: organizations(:arlington), name: "Fresh plan", total_giving_amount: 1000) + + gcn = scenario.greatest_community_need + assert gcn.present? + assert gcn.greatest_community_need? + assert_equal 0, gcn.percentage + assert_includes scenario.ongoing_allocations, gcn + end + end From 072cf422d39299e156095b2bc27cb962d7b307a1 Mon Sep 17 00:00:00 2001 From: Mike Dalton Date: Mon, 29 Jun 2026 23:10:14 -0400 Subject: [PATCH 2/2] Fix CI: lint blank line and coverage gate Remove the extra blank line at the end of scenario_test.rb flagged by Layout/EmptyLinesAroundClassBody. Guard the STI preload so it only runs where eager loading is off (development and local test). Referencing Allocation::GreatestCommunityNeed during initializer evaluation under CI made SimpleCov report the whole Allocation STI tree as uncovered, dropping line coverage below the 90% gate; eager loading already registers the subclass in production/CI. Co-Authored-By: Claude Opus 4.8 --- config/initializers/sti_preload.rb | 11 +++++++++-- test/models/scenario_test.rb | 1 - 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/config/initializers/sti_preload.rb b/config/initializers/sti_preload.rb index 447e772..53e0fe0 100644 --- a/config/initializers/sti_preload.rb +++ b/config/initializers/sti_preload.rb @@ -1,4 +1,11 @@ +# In production and CI, Rails eager loads every Allocation subclass for us. +# Everywhere else (development and local `bin/rails test`) eager loading is off, +# so this grandchild subclass isn't registered as a descendant when +# Scenario#ongoing_allocations builds its `type IN (...)` query and its rows get +# filtered out. Referencing it in a to_prepare block keeps it loaded. # See https://guides.rubyonrails.org/autoloading_and_reloading_constants.html#single-table-inheritance -Rails.application.config.to_prepare do - Allocation::GreatestCommunityNeed +unless Rails.env.production? || ENV["CI"].present? + Rails.application.config.to_prepare do + Allocation::GreatestCommunityNeed + end end diff --git a/test/models/scenario_test.rb b/test/models/scenario_test.rb index 7a184ed..5fc66ef 100644 --- a/test/models/scenario_test.rb +++ b/test/models/scenario_test.rb @@ -47,5 +47,4 @@ class ScenarioTest < ActiveSupport::TestCase assert_equal 0, gcn.percentage assert_includes scenario.ongoing_allocations, gcn end - end