mirror of
https://github.com/we-promise/sure.git
synced 2026-05-30 07:49:01 +00:00
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:
@@ -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 %>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user