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 %>