- <% rows.each do |row| %>
- <% account = row[:account] %>
- <% spark = row[:sparkline_points] %>
- <% spark_max = [ spark.max, 1.0 ].max %>
-
-
- <%= account.name[0]&.upcase %>
-
+ <%# Distribution bar — proportional weight of each account in this goal %>
+ <% if rows.size > 1 && total.positive? %>
+
+ <% rows.each do |row| %>
+ <% next if row[:balance].to_d.zero? %>
+
+ <% end %>
+
-
-
<%= account.name %>
-
<%= account.accountable_type %> · <%= row[:balance_money].format %>
+
+ <% rows.each do |row| %>
+ <% next if row[:balance].to_d.zero? %>
+
+
+
<%= row[:account].name %>
+
<%= percent_for(row[:balance]) %>%
+ <% end %>
+
+ <% end %>
-
+ <%# Per-account detail — avatar / name+type / weight / sparkline / last-30d inflow %>
+
+
+ <% rows.each_with_index do |row, idx| %>
+ <% account = row[:account] %>
+ <% color = Goals::AvatarComponent.color_for(account.name) %>
+ <% spark = row[:sparkline_points] %>
+ <% spark_max = [ spark.max, 1.0 ].max %>
+
+ <%= render Goals::AvatarComponent.new(name: account.name, color: color, size: "sm") %>
-
-
- <%= row[:last_30_money].format %>
-
-
<%= t("goals.show.funding_last_30d") %>
+
+
<%= account.name %>
+
+ <%= accountable_label(account) %> · <%= row[:balance_money].format %>
+
+
+
+ <% if rows.size > 1 %>
+ <%= render "pages/dashboard/group_weight", weight: percent_for(row[:balance]), color: color %>
+ <% else %>
+
+ <% end %>
+
+
+
+
+
+ <%= row[:last_30_money].format(precision: 0) %>
+
+
<%= t("goals.show.funding_last_30d") %>
+
-
- <% end %>
+ <% if idx < rows.size - 1 %>
+ <%= render "shared/ruler", classes: "mx-4" %>
+ <% end %>
+ <% end %>
+
<% end %>
diff --git a/app/components/goals/funding_accounts_breakdown_component.rb b/app/components/goals/funding_accounts_breakdown_component.rb
index 9d323a6e0..cf0c45ecb 100644
--- a/app/components/goals/funding_accounts_breakdown_component.rb
+++ b/app/components/goals/funding_accounts_breakdown_component.rb
@@ -9,18 +9,40 @@ class Goals::FundingAccountsBreakdownComponent < ApplicationComponent
attr_reader :goal
def rows
- @rows ||= goal.linked_accounts.sort_by { |a| -last_30_inflow_for(a) }.map do |account|
- inflow = last_30_inflow_for(account)
+ @rows ||= goal.linked_accounts.sort_by { |a| -a.balance.to_d }.map do |account|
{
account: account,
+ balance: account.balance.to_d,
balance_money: Money.new(account.balance.to_d, goal.currency),
- last_30_money: Money.new(inflow, goal.currency),
- last_30_amount: inflow,
+ last_30_money: Money.new(last_30_inflow_for(account), goal.currency),
sparkline_points: sparkline_for(account)
}
end
end
+ def total
+ @total ||= rows.sum { |r| r[:balance].to_d }
+ end
+
+ def percent_for(balance)
+ return 0 if total.zero?
+ ((balance.to_d / total) * 100).round
+ end
+
+ # Label shown beneath the account name. Prefers the depository subtype
+ # ("Savings", "HSA"…) over the bare accountable_type ("Depository") so the
+ # subline carries useful signal. Falls back to the accountable type's i18n
+ # entry (`accounts.types.*`), and finally to a `titleize` so the row is
+ # never blank if a string is missing.
+ def accountable_label(account)
+ if account.subtype.present?
+ I18n.t("goals.form_stepper.step1.subtypes.#{account.subtype}", default: account.subtype.titleize)
+ else
+ type = account.accountable_type.to_s
+ I18n.t("accounts.types.#{type.underscore}", default: type.titleize)
+ end
+ end
+
private
def last_30_inflow_for(account)
@inflow_cache ||= {}
@@ -28,14 +50,15 @@ class Goals::FundingAccountsBreakdownComponent < ApplicationComponent
net = Entry
.joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'")
.where(account_id: account.id, date: WINDOW_DAYS.days.ago.to_date..Date.current)
- .where.not(transactions: { kind: Transaction::TRANSFER_KINDS })
.where(excluded: false)
.sum(:amount)
(-net.to_d).clamp(0, Float::INFINITY)
end
end
- # 12-bucket weekly sparkline of net non-transfer inflow over 90 days.
+ # 12-bucket weekly sparkline of net inflow over 90 days. Uses the same
+ # transfer-inclusive semantics as Goal#pace — transfers between linked
+ # accounts wash out across the goal but show on each account's sparkline.
def sparkline_for(account)
buckets = 12
bucket_days = (SPARK_WINDOW_DAYS / buckets.to_f).ceil
@@ -46,7 +69,6 @@ class Goals::FundingAccountsBreakdownComponent < ApplicationComponent
net = Entry
.joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'")
.where(account_id: account.id, date: start_at..end_at)
- .where.not(transactions: { kind: Transaction::TRANSFER_KINDS })
.where(excluded: false)
.sum(:amount)
(-net.to_d).clamp(0, Float::INFINITY).to_f
diff --git a/config/locales/views/goals/en.yml b/config/locales/views/goals/en.yml
index a67facb3e..99b86223d 100644
--- a/config/locales/views/goals/en.yml
+++ b/config/locales/views/goals/en.yml
@@ -98,6 +98,10 @@ en:
pledge_just_transferred: I just transferred…
pledge_just_saved: I just saved…
funding_accounts_heading: Funding accounts
+ funding_accounts:
+ empty:
+ heading: No funding accounts linked yet
+ body: Edit the goal to link the depository accounts you save into.
notes: Notes
funding_last_30d: last 30d
pending_pledge: