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
1 change: 1 addition & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,7 @@ PLATFORMS
arm-linux-musl
arm64-darwin-23
arm64-darwin-24
arm64-darwin-25
x86_64-linux
x86_64-linux-gnu
x86_64-linux-musl
Expand Down
33 changes: 33 additions & 0 deletions app/javascript/controllers/one_time_amount_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Controller } from "@hotwired/stimulus"

const currency = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
})

// Keeps a one-time giving amount within the scenario's remaining giving budget.
// Sets a custom-validity message so the browser blocks submission (via native
// constraint validation, regardless of number-input support) and shows a
// friendly error without closing the modal.
export default class extends Controller {
static values = { max: Number }

connect() {
this.element.addEventListener("input", this.validate)
this.validate()
}

disconnect() {
this.element.removeEventListener("input", this.validate)
}

validate = () => {
const overMaxValue = this.element.value !== "" && Number(this.element.value) > this.maxValue
this.element.setCustomValidity(overMaxValue ? this.message : "")
}

get message() {
return `Enter an amount of ${currency.format(this.maxValue)} or less — that's your remaining one-time giving budget.`
}
}
26 changes: 24 additions & 2 deletions app/models/allocation/one_time.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,36 @@ def share_percentage
(amount.to_i / total.to_f * 100).round
end

# The most this allocation can be set to while staying within the scenario's
# total giving budget. Returns nil when no budget is set (the server imposes
# no cap there either). Never drops below the allocation's own amount so
# pre-existing over-allocated data stays editable. Used by the view helper so
# the slider cap and the server validator stay in sync.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we are asserting this context in the tests, so we may be good to remove these comments.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Something I noticed when working with AI agents is that they do pay attention to these breadcrumbs, so lately I've been kind of leaving them in there...provided that they update these when they change this logic. But I can remove this for now.

def max_amount(scenario = self.scenario)
remaining = budget_remaining(scenario)
return if remaining.nil?

[ remaining, amount.to_i ].max
end

private

def within_total_giving_amount
return if amount.blank? || scenario&.total_giving_amount.blank?

others = scenario.one_time_allocations.where.not(id: id).sum(:amount)
if others + amount > scenario.total_giving_amount
remaining = budget_remaining(scenario)
return if remaining.nil?

if amount > remaining
others = scenario.total_giving_amount - remaining
errors.add(:amount, "would bring one-time giving to #{others + amount}, over the total giving amount of #{scenario.total_giving_amount.to_i}")
end
end

def budget_remaining(scenario = self.scenario)
return nil if scenario.blank? || scenario.total_giving_amount.blank?

others = scenario.one_time_allocations.where.not(id: id).sum(:amount)
scenario.total_giving_amount - others
end
end
5 changes: 3 additions & 2 deletions app/views/scenarios/_allocation_modal.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,14 @@
class: "allocation-slider block mt-2" %>
</div>
<% else %>
<% max_amount = allocation.max_amount(scenario) if klass == Allocation::OneTime %>
<div class="mt-6">
<%= label_tag "allocation_amount_#{suffix}", "Amount", class: "block text-sm text-ink-soft" %>
<div class="relative mt-1">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-ink-faint">$</span>
<%= number_field_tag "allocation[amount]", allocation.amount, id: "allocation_amount_#{suffix}", min: 0, step: "1", required: true, placeholder: "0",
<%= number_field_tag "allocation[amount]", allocation.amount, id: "allocation_amount_#{suffix}", min: 0, max: max_amount, step: "1", required: true, placeholder: "0",
inputmode: "numeric",
data: { controller: "integer-input", action: "input->integer-input#transform" },
data: max_amount.present? ? { controller: "integer-input one-time-amount", action: "input->integer-input#transform", one_time_amount_max_value: max_amount } : { controller: "integer-input", action: "input->integer-input#transform" },
class: "block w-full rounded-md border border-line bg-surface pl-7 pr-3 py-2 shadow-sm focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/10" %>
</div>
</div>
Expand Down
8 changes: 8 additions & 0 deletions test/controllers/allocations_controller_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,14 @@ class AllocationsControllerTest < ActionDispatch::IntegrationTest
assert_match Allocation::GreatestCommunityNeed::DESCRIPTION, response.body
end

test "caps the one-time amount field at the remaining giving budget" do
# scenario total is 10000 and education_grant fixture already allocates 5000.
get scenario_url(@scenario)
assert_response :success
assert_match %r{id="allocation_amount_one_time"[^>]*max="5000"}, response.body
assert_match %r{data-one-time-amount-max-value="5000"}, 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
Expand Down
30 changes: 30 additions & 0 deletions test/models/allocation/one_time_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,34 @@ class Allocation::OneTimeTest < ActiveSupport::TestCase
allocation = Allocation::OneTime.new(amount: 500, scenario: scenarios(:two_boston))
assert_equal 0, allocation.share_percentage
end

test "max_amount is the remaining budget for a new allocation" do
scenario = scenarios(:one_arlington)
allocation = Allocation::OneTime.new

assert_equal 5000, allocation.max_amount(scenario)
end

test "max_amount excludes the allocation's own amount when editing" do
scenario = scenarios(:one_arlington)
allocation = allocations(:education_grant)

assert_equal 10000, allocation.max_amount(scenario)
end

test "max_amount never drops below the allocation's own amount" do
scenario = scenarios(:one_arlington)
scenario.update!(total_giving_amount: 4000)
allocation = allocations(:education_grant)

assert_equal 5000, allocation.max_amount(scenario)
end

test "max_amount is nil when no total giving amount is set" do
scenario = scenarios(:two_boston)
scenario.update!(total_giving_amount: nil)
allocation = Allocation::OneTime.new

assert_nil allocation.max_amount(scenario)
end
end