diff --git a/app/helpers/scenarios_helper.rb b/app/helpers/scenarios_helper.rb index b63146e..0dad959 100644 --- a/app/helpers/scenarios_helper.rb +++ b/app/helpers/scenarios_helper.rb @@ -4,4 +4,39 @@ module ScenariosHelper def allocation_chart_color(index) CHART_COLORS[index % CHART_COLORS.length] end + + def allocation_pie_gradient(allocations, remaining, total) + # Mirrors the bar chart's flexbox shrink: percentages are only scaled down + # when they overallocate past 100%, never stretched to fill an underallocation. + divisor = [ total, 100 ].max.to_f + cumulative = 0 + stops = allocations.each_with_index.map do |allocation, index| + start = cumulative + cumulative += allocation.percentage.to_f / divisor * 100 + "#{allocation_chart_color(index)} #{start}% #{cumulative}%" + end + stops << "var(--color-line-soft) #{cumulative}% 100%" if remaining.positive? + "conic-gradient(#{stops.join(', ')})" + end + + def remaining_ongoing_percentage(scenario, allocation) + others = scenario.ongoing_allocations.where.not(id: allocation.id).sum(:percentage) + [ 100 - others, 0 ].max + end + + # The slider's max must never sit below the allocation's own current percentage — + # otherwise pre-existing over-allocated data (from before this cap existed) would + # render as if maxed out at the wrong position. It still blocks dragging any higher. + def allocation_slider_max(scenario, allocation) + [ remaining_ongoing_percentage(scenario, allocation), allocation.percentage.to_i ].max + end + + # The slider's CSS fill (--slider-value) is a percentage of the *track*, not of the + # underlying value, so once max drops below 100 it must be rescaled or the colored + # fill and the native thumb position (which follows value/max) drift apart. + def allocation_slider_fill_percent(percentage, max) + return 0 if max.to_i.zero? + + (percentage.to_f / max * 100).round(2) + end end diff --git a/app/javascript/controllers/allocation_slider_controller.js b/app/javascript/controllers/allocation_slider_controller.js index 550c218..04766b5 100644 --- a/app/javascript/controllers/allocation_slider_controller.js +++ b/app/javascript/controllers/allocation_slider_controller.js @@ -15,10 +15,14 @@ export default class extends Controller { update() { const percent = Number(this.inputTarget.value) + const max = Number(this.inputTarget.max) || 100 const dollar = Math.round((percent / 100) * this.ongoingAmountValue) const perpetuity = Math.round(dollar * this.payoutRateValue) - this.inputTarget.style.setProperty("--slider-value", `${percent}%`) + // --slider-value fills the track (0-max), which is distinct from percent + // (0-100) once the slider's max is capped below 100 by remaining headroom. + const fillPercent = max > 0 ? (percent / max) * 100 : 0 + this.inputTarget.style.setProperty("--slider-value", `${fillPercent}%`) this.percentTargets.forEach((el) => (el.textContent = `${percent}%`)) this.dollarTargets.forEach((el) => (el.textContent = currency.format(dollar))) if (this.hasPerpetuityTarget) { diff --git a/app/models/allocation/ongoing.rb b/app/models/allocation/ongoing.rb index 6f83ce1..35335bd 100644 --- a/app/models/allocation/ongoing.rb +++ b/app/models/allocation/ongoing.rb @@ -4,6 +4,7 @@ class Allocation::Ongoing < Allocation validates :percentage, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than_or_equal_to: 100 } + validate :within_ongoing_percentage_total def dollar_amount (percentage.to_i / 100.0 * scenario.ongoing_giving_amount).round @@ -20,4 +21,15 @@ def ongoing? def one_time? false end + + private + + def within_ongoing_percentage_total + return if percentage.blank? || scenario.blank? + + others = scenario.ongoing_allocations.where.not(id: id).sum(:percentage) + if others + percentage > 100 + errors.add(:percentage, "would bring ongoing giving to #{others + percentage}%, over 100%") + end + end end diff --git a/app/views/scenarios/_allocation.html.erb b/app/views/scenarios/_allocation.html.erb index 725336d..1349e47 100644 --- a/app/views/scenarios/_allocation.html.erb +++ b/app/views/scenarios/_allocation.html.erb @@ -1,6 +1,7 @@ <%# locals: (allocation:, scenario:, allocation_counter: 0) %> <% if allocation.ongoing? %> <% color = allocation_chart_color(allocation_counter) %> + <% slider_max = allocation_slider_max(scenario, allocation) %>
<%= form_with url: scenario_allocation_path(scenario, allocation), method: :patch, class: "flex-1 min-w-0 leading-none" do %> - <%= range_field_tag "allocation[percentage]", allocation.percentage, min: 0, max: 100, step: 1, - style: "--slider-color: #{color}; --slider-value: #{allocation.percentage}%", + <%= range_field_tag "allocation[percentage]", allocation.percentage, min: 0, max: slider_max, step: 1, + style: "--slider-color: #{color}; --slider-value: #{allocation_slider_fill_percent(allocation.percentage, slider_max)}%", data: { allocation_slider_target: "input", action: "input->allocation-slider#update change->allocation-slider#save" }, class: "allocation-slider block" %> <% end %> diff --git a/app/views/scenarios/_allocation_modal.html.erb b/app/views/scenarios/_allocation_modal.html.erb index fc53430..0cb898e 100644 --- a/app/views/scenarios/_allocation_modal.html.erb +++ b/app/views/scenarios/_allocation_modal.html.erb @@ -2,7 +2,9 @@ <% klass = allocation.class %> <% editing = allocation.persisted? %> <% suffix = editing ? dom_id(allocation) : klass.model_name.element %> -<% percentage = allocation.percentage || 20 %> +<% remaining_percentage = klass == Allocation::Ongoing ? remaining_ongoing_percentage(scenario, allocation) : nil %> +<% percentage = allocation.percentage || [ 20, remaining_percentage.to_i ].min %> +<% slider_max = klass == Allocation::Ongoing ? allocation_slider_max(scenario, allocation) : nil %> <% color ||= ScenariosHelper::CHART_COLORS.first %> <%= form_with url: (editing ? scenario_allocation_path(scenario, allocation) : scenario_allocations_path(scenario)), method: (editing ? :patch : :post), class: "p-8" do |form| %> @@ -15,11 +17,11 @@
Giving percentage % - 100 % reminder + <%= remaining_percentage %>% remaining

<%= percentage %>%

- <%= range_field_tag "allocation[percentage]", percentage, min: 0, max: 100, step: 1, - style: "--slider-color: #{color}; --slider-value: #{percentage}%", + <%= range_field_tag "allocation[percentage]", percentage, min: 0, max: slider_max, step: 1, + style: "--slider-color: #{color}; --slider-value: #{allocation_slider_fill_percent(percentage, slider_max)}%", data: { allocation_slider_target: "input", action: "input->allocation-slider#update" }, class: "allocation-slider block mt-2" %>
diff --git a/app/views/scenarios/_summary.html.erb b/app/views/scenarios/_summary.html.erb index 897a405..39cdb22 100644 --- a/app/views/scenarios/_summary.html.erb +++ b/app/views/scenarios/_summary.html.erb @@ -24,13 +24,42 @@

<% remaining = [100 - ongoing_total, 0].max %> -
- <% scenario.ongoing_allocations.each_with_index do |allocation, index| %> -
- <% end %> - <% if remaining.positive? %> -
- <% end %> + +
+
+ + +
+ +
+ <% scenario.ongoing_allocations.each_with_index do |allocation, index| %> +
+ <% end %> + <% if remaining.positive? %> +
+ <% end %> +
+ +
    diff --git a/test/controllers/allocations_controller_test.rb b/test/controllers/allocations_controller_test.rb index 4b216d6..7900eae 100644 --- a/test/controllers/allocations_controller_test.rb +++ b/test/controllers/allocations_controller_test.rb @@ -87,6 +87,27 @@ class AllocationsControllerTest < ActionDispatch::IntegrationTest assert flash[:alert].present? end + test "rejects an ongoing allocation that would push the scenario's total over 100%" do + # one_arlington already allocates 30% via the greatest_need fixture. + assert_no_difference -> { @scenario.allocations.count } do + post scenario_allocations_url(@scenario), params: { + allocation: { type: "Allocation::Ongoing", option: "Too much", percentage: 71 } + } + end + assert_redirected_to scenario_path(@scenario) + assert flash[:alert].present? + end + + test "rejects an ongoing update that would push the scenario's total over 100%" do + allocation = allocations(:greatest_need) + patch scenario_allocation_url(@scenario, allocation), params: { + allocation: { percentage: 101 } + } + assert_redirected_to scenario_path(@scenario) + assert flash[:alert].present? + assert_equal 30, allocation.reload.percentage + end + test "updates an ongoing allocation" do allocation = allocations(:greatest_need) patch scenario_allocation_url(@scenario, allocation), params: { diff --git a/test/models/allocation_test.rb b/test/models/allocation_test.rb index e38214d..3b15fde 100644 --- a/test/models/allocation_test.rb +++ b/test/models/allocation_test.rb @@ -42,6 +42,18 @@ class AllocationTest < ActiveSupport::TestCase assert @scenario.one_time_allocations.new(option: "Just fits", amount: 5000).valid? end + test "ongoing allocations cannot push the scenario's total over 100%" do + # greatest_need fixture already allocates 30% in this scenario. + assert_not @scenario.ongoing_allocations.new(option: "Too much", percentage: 71).valid? + assert @scenario.ongoing_allocations.new(option: "Just fits", percentage: 70).valid? + end + + test "updating an ongoing allocation excludes its own prior percentage from the total" do + allocation = allocations(:greatest_need) + assert allocation.update(percentage: 100) + assert_not allocation.update(percentage: 101) + end + test "ongoing dollar_amount is its percentage of the scenario's ongoing giving" do # scenario ongoing giving is 10000 total - 5000 one-time = 5000; greatest_need is 30%. assert_equal 1500, allocations(:greatest_need).dollar_amount