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
35 changes: 35 additions & 0 deletions app/helpers/scenarios_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 5 additions & 1 deletion app/javascript/controllers/allocation_slider_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
12 changes: 12 additions & 0 deletions app/models/allocation/ongoing.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
5 changes: 3 additions & 2 deletions app/views/scenarios/_allocation.html.erb
Original file line number Diff line number Diff line change
@@ -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) %>
<div data-controller="dialog">
<div data-controller="allocation-slider"
data-allocation-slider-ongoing-amount-value="<%= scenario.ongoing_giving_amount %>"
Expand Down Expand Up @@ -46,8 +47,8 @@

<div class="mt-3 flex items-center gap-4">
<%= 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 %>
Expand Down
10 changes: 6 additions & 4 deletions app/views/scenarios/_allocation_modal.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -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 %>
<dialog data-dialog-target="dialog" data-action="click->dialog#backdropClose" class="m-auto w-full max-w-lg rounded-2xl p-0 shadow-lg backdrop:bg-[rgba(20,18,14,0.48)]">
<%= form_with url: (editing ? scenario_allocation_path(scenario, allocation) : scenario_allocations_path(scenario)), method: (editing ? :patch : :post), class: "p-8" do |form| %>
Expand All @@ -15,11 +17,11 @@
<div class="mt-6" data-controller="allocation-slider">
<div class="flex items-center justify-between">
<span class="text-sm text-ink-soft">Giving percentage %</span>
<span class="text-sm text-ink-faint">100 % reminder</span>
<span class="text-sm text-ink-faint"><%= remaining_percentage %>% remaining</span>
</div>
<p class="mt-1 text-lg font-medium text-ink" data-allocation-slider-target="percent"><%= percentage %>%</p>
<%= 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" %>
</div>
Expand Down
43 changes: 36 additions & 7 deletions app/views/scenarios/_summary.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,42 @@
</p>

<% remaining = [100 - ongoing_total, 0].max %>
<div class="mt-4 flex h-3 gap-1 overflow-hidden rounded-full">
<% scenario.ongoing_allocations.each_with_index do |allocation, index| %>
<div style="width: <%= allocation.percentage %>%; background-color: <%= allocation_chart_color(index) %>"></div>
<% end %>
<% if remaining.positive? %>
<div class="bg-line-soft" style="width: <%= remaining %>%"></div>
<% end %>

<div data-controller="tabs"
data-tabs-active-tab-class="bg-accent text-white"
data-tabs-inactive-tab-class="text-ink-soft hover:text-ink">
<div class="mt-4 inline-flex items-center gap-1 rounded-full border border-line bg-surface-soft p-1">
<button type="button" data-action="tabs#switch" data-tabs-type-param="bar"
data-tabs-target="tab" data-type="bar" data-active="true"
class="flex items-center gap-1.5 rounded-full bg-accent px-3 py-1 text-xs font-medium text-white transition">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 13.5h3.5v6.75H4v-6.75Zm6.25-4.5h3.5v11.25h-3.5V9Zm6.25-4.5H20v15.75h-3.5V4.5Z" />
</svg>
Bar
</button>
<button type="button" data-action="tabs#switch" data-tabs-type-param="pie"
data-tabs-target="tab" data-type="pie"
class="flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium text-ink-soft transition hover:text-ink">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 6a7.5 7.5 0 1 0 7.5 7.5h-7.5V6Z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 10.5H21A7.5 7.5 0 0 0 13.5 3v7.5Z" />
</svg>
Pie
</button>
</div>

<div data-tabs-target="panel" data-type="bar" class="mt-4 flex h-3 gap-1 overflow-hidden rounded-full">
<% scenario.ongoing_allocations.each_with_index do |allocation, index| %>
<div style="width: <%= allocation.percentage %>%; background-color: <%= allocation_chart_color(index) %>"></div>
<% end %>
<% if remaining.positive? %>
<div class="bg-line-soft" style="width: <%= remaining %>%"></div>
<% end %>
</div>

<div data-tabs-target="panel" data-type="pie" class="mt-4 hidden">
<div class="mx-auto h-40 w-40 rounded-full" style="background: <%= allocation_pie_gradient(scenario.ongoing_allocations, remaining, ongoing_total) %>"></div>
</div>
</div>

<ul class="mt-4 space-y-3">
Expand Down
21 changes: 21 additions & 0 deletions test/controllers/allocations_controller_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
12 changes: 12 additions & 0 deletions test/models/allocation_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading