fix(goals/funding-widget): restore DS-aligned per-account breakdown

V2 rebuilt the funding widget around per-account rows + a custom SVG
sparkline, but cut visible signal and DS adherence in the process.
This rebuild restores the V1 affordances and folds in the V2
sparkline as an enhancement.

- Heading regression: `text-lg font-medium` (with total in `text-lg`)
  → `text-sm font-medium` (total inheriting `text-sm`). The section
  heading collapsed to body-copy size and no longer matched the
  Projection heading beside it. Restore both to `text-lg`.

- Avatar regression: V2 hand-rolled
  `w-10 h-10 rounded-full … style="color: white"`. That box (40px)
  matches no `Goals::AvatarComponent` size (sm=24px, md=36px,
  lg=44px), uses `rounded-full` where the DS uses
  `rounded-md/lg/xl/2xl`, and hardcodes white text instead of the
  `text-inverse` token. Render `Goals::AvatarComponent` directly
  at `size: "sm"`.

- Privacy regression: `row[:balance_money]` subline ("Depository ·
  $3,000") wasn't wrapped in `privacy-sensitive`. Blur mode no
  longer hid the balance, while heading total and last-30d value
  on the same row both had the class. Add `privacy-sensitive` to
  the subline.

- Untranslated leak: `<%= account.accountable_type %>` printed the
  raw "Depository" / "Investment" / "Crypto" class string with no
  i18n. Add `accountable_label(account)` on the component that
  prefers the depository subtype ("Savings", "HSA"…) via
  `goals.form_stepper.step1.subtypes.*`, falling back through
  `accounts.types.*` and finally a `titleize`.

- Lost weight signal: V1 had a stacked distribution bar across the
  top, colored legend dots, and a 5-bar weight pill per row.
  Users could see "Account A contributes 60% of balance" at a
  glance. V2 deleted all three. Restore the distribution bar +
  legend + the existing `pages/dashboard/group_weight` partial in
  a `weight` column (skipped when only one account is linked).

- Lost container framing: V1 wrapped rows in
  `bg-container-inset rounded-xl p-1` with `shared/ruler`
  dividers between rows. V2 used `space-y-3` with no container
  and no dividers, leaving rows floating. Restore both.

- Empty state regression: V2's fallback rendered the section
  heading as a body paragraph (`<p>Funding accounts</p>`) inside
  a `p-5 rounded-xl` card — looked like an unfinished widget.
  Replace with a real empty state via `goals.show.funding_accounts.
  empty.heading` + `body` ("Edit the goal to link the depository
  accounts you save into.").

- Row order: V2 sorted by 30-day inflow (which can flatten to
  ties at $0 across rows). Sort by balance instead — the column
  the user is comparing against anyway.

- Pace alignment: drop the transfer-kind exclusion from the
  component's `last_30_inflow_for` and `sparkline_for` so the
  widget reads the same flow as `Goal#pace` (commit B). Internal
  transfers between linked accounts net out per-account here too,
  external transfers count as inflow on the receiving account.

The 12-bucket sparkline still runs 12 queries per account; that
N+1 lands in a follow-up commit alongside the component-level
query collapse.
This commit is contained in:
Guillem Arias
2026-05-14 19:38:06 +02:00
parent 4fbf16e6f7
commit 10b360bb54
3 changed files with 98 additions and 36 deletions

View File

@@ -1,44 +1,80 @@
<% if rows.empty? %>
<p class="text-sm text-secondary"><%= t("goals.show.funding_accounts_heading") %></p>
<div class="text-center py-6">
<p class="text-sm text-secondary"><%= t("goals.show.funding_accounts.empty.heading") %></p>
<p class="text-xs text-subdued mt-1"><%= t("goals.show.funding_accounts.empty.body") %></p>
</div>
<% else %>
<div class="space-y-4">
<h2 class="text-sm font-medium inline-flex items-center gap-1.5">
<h2 class="text-lg font-medium inline-flex items-center gap-1.5">
<%= t("goals.show.funding_accounts_heading") %>
<span class="text-secondary">·</span>
<span class="text-secondary font-medium tabular-nums privacy-sensitive"><%= goal.current_balance_money.format %></span>
<span class="text-secondary font-medium text-lg privacy-sensitive tabular-nums"><%= Money.new(total, goal.currency).format(precision: 0) %></span>
</h2>
<div class="space-y-3">
<% rows.each do |row| %>
<% account = row[:account] %>
<% spark = row[:sparkline_points] %>
<% spark_max = [ spark.max, 1.0 ].max %>
<div class="grid grid-cols-[40px_minmax(0,1.5fr)_minmax(0,1fr)_120px] gap-3 items-center">
<div class="w-10 h-10 rounded-full inline-flex items-center justify-center font-medium text-sm shrink-0"
style="background-color: <%= Goals::AvatarComponent.color_for(account.name) %>; color: white;">
<%= account.name[0]&.upcase %>
</div>
<%# Distribution bar — proportional weight of each account in this goal %>
<% if rows.size > 1 && total.positive? %>
<div class="flex gap-1">
<% rows.each do |row| %>
<% next if row[:balance].to_d.zero? %>
<div class="h-1.5 rounded-sm" style="width: <%= percent_for(row[:balance]) %>%; background-color: <%= Goals::AvatarComponent.color_for(row[:account].name) %>;"></div>
<% end %>
</div>
<div class="min-w-0">
<p class="text-sm font-medium text-primary truncate"><%= account.name %></p>
<p class="text-xs text-subdued tabular-nums"><%= account.accountable_type %> · <%= row[:balance_money].format %></p>
<div class="flex flex-wrap gap-4">
<% rows.each do |row| %>
<% next if row[:balance].to_d.zero? %>
<div class="flex items-center gap-2 text-sm">
<div class="h-2.5 w-2.5 rounded-full" style="background-color: <%= Goals::AvatarComponent.color_for(row[:account].name) %>;"></div>
<p class="text-secondary"><%= row[:account].name %></p>
<p class="text-primary font-mono privacy-sensitive tabular-nums"><%= percent_for(row[:balance]) %>%</p>
</div>
<% end %>
</div>
<% end %>
<svg viewBox="0 0 <%= spark.size * 8 %> 28" preserveAspectRatio="none" class="w-full h-7">
<% xs = spark.each_with_index.map { |_, i| 2 + (i / (spark.size - 1).to_f) * (spark.size * 8 - 4) } %>
<% ys = spark.map { |v| 24 - (v / spark_max) * 20 } %>
<% path = xs.zip(ys).each_with_index.map { |(x, y), i| "#{i.zero? ? "M" : "L"} #{x.round(2)} #{y.round(2)}" }.join(" ") %>
<path d="<%= path %>" fill="none" stroke="<%= Goals::AvatarComponent.color_for(account.name) %>" stroke-width="1.5" stroke-linejoin="round" />
</svg>
<%# Per-account detail — avatar / name+type / weight / sparkline / last-30d inflow %>
<div class="bg-container-inset rounded-xl p-1">
<div class="rounded-lg bg-container">
<% 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 %>
<div class="px-4 py-3 grid grid-cols-[24px_minmax(0,1.5fr)_72px_minmax(60px,1fr)_96px] items-center gap-3">
<%= render Goals::AvatarComponent.new(name: account.name, color: color, size: "sm") %>
<div class="text-right">
<p class="text-sm font-medium text-primary font-mono tabular-nums privacy-sensitive">
<%= row[:last_30_money].format %>
</p>
<p class="text-[10px] text-subdued"><%= t("goals.show.funding_last_30d") %></p>
<div class="min-w-0">
<p class="text-sm font-medium text-primary truncate"><%= account.name %></p>
<p class="text-xs text-subdued tabular-nums privacy-sensitive">
<%= accountable_label(account) %> · <%= row[:balance_money].format %>
</p>
</div>
<% if rows.size > 1 %>
<%= render "pages/dashboard/group_weight", weight: percent_for(row[:balance]), color: color %>
<% else %>
<span></span>
<% end %>
<svg viewBox="0 0 <%= spark.size * 8 %> 28" preserveAspectRatio="none" class="w-full h-7" aria-hidden="true">
<% xs = spark.each_with_index.map { |_, i| 2 + (i / (spark.size - 1).to_f) * (spark.size * 8 - 4) } %>
<% ys = spark.map { |v| 24 - (v / spark_max) * 20 } %>
<% path = xs.zip(ys).each_with_index.map { |(x, y), i| "#{i.zero? ? "M" : "L"} #{x.round(2)} #{y.round(2)}" }.join(" ") %>
<path d="<%= path %>" fill="none" stroke="<%= color %>" stroke-width="1.5" stroke-linejoin="round" />
</svg>
<div class="text-right">
<p class="text-sm font-medium text-primary tabular-nums privacy-sensitive">
<%= row[:last_30_money].format(precision: 0) %>
</p>
<p class="text-[10px] text-subdued"><%= t("goals.show.funding_last_30d") %></p>
</div>
</div>
</div>
<% end %>
<% if idx < rows.size - 1 %>
<%= render "shared/ruler", classes: "mx-4" %>
<% end %>
<% end %>
</div>
</div>
</div>
<% end %>

View File

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

View File

@@ -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: