fix(goals/funding-widget): per-account balance trajectory area chart

Bars communicated "events," not "where the account level sits." A
sparse-deposit account painted three thin bars at the bottom and
looked dead. An account with a single big deposit dominated every
other row's scale.

Swap to the same visual language as the projection chart on
goals#show — filled area below a stroked line — but one chart per
linked account, rendering that account's actual balance trajectory
over the last 90 days.

Mechanics:

- New `trajectory_map` on the component pulls every `balances` row
  for every linked account in one query
  (`Balance.where(account_id: account_ids, date: 90d..today)`).
  Result is grouped per account and resampled to 24 points by a
  single-pass forward walk that carry-forwards the most-recent
  balance at-or-before each anchor date. O(rows + samples), not
  O(rows × samples).
- Per-row Y-scale: baseline 0 (when the account has ever held a
  positive balance), ceiling = max balance × 1.05. The chart reads
  as "how full was this account over time" rather than "how dramatic
  is the shape." Flat-at-$5k accounts paint near the top; growing
  $200 → $500 accounts climb from 40% to top.
- Filled area at `opacity: 0.18` in the account color + stroked line
  at full opacity on top — same treatment as the projection chart's
  saved series.
- Grid track for the chart column widened from `minmax(60px, 1fr)`
  to `minmax(80px, 1fr)` so the curve has enough horizontal room
  to read.

Removed `shared_spark_max` + `sparkline_map` + the bucketed inflow
sparkline machinery. Per-row scale is correct here — magnitude
already lives in the weight pill on the left and the "$X last 30d"
column on the right; the chart's job is shape.
This commit is contained in:
Guillem Arias
2026-05-14 20:41:01 +02:00
parent 4a8f6557e6
commit 75a3632119
2 changed files with 63 additions and 58 deletions

View File

@@ -38,9 +38,21 @@
<% rows.each_with_index do |row, idx| %>
<% account = row[:account] %>
<% color = Goals::AvatarComponent.color_for(account.name) %>
<% spark = row[:sparkline_points] %>
<% spark_max = shared_spark_max %>
<div class="px-4 py-3 grid grid-cols-[24px_minmax(0,1.5fr)_72px_minmax(60px,1fr)_96px] items-center gap-3">
<% traj = row[:trajectory_points] %>
<% traj_max = (traj.max || 0).to_f %>
<% traj_min = (traj.min || 0).to_f %>
<%# Anchor at 0 when this account has had any positive balance, so the
filled area reads as "money in the account". For flat-at-zero
accounts fall back to min..max so the line stays visible. %>
<% baseline = traj_max.positive? ? 0.0 : traj_min %>
<% ceiling = [ traj_max * 1.05, baseline + 1.0 ].max %>
<% range = ceiling - baseline %>
<% n = traj.size %>
<% xs = traj.each_with_index.map { |_, i| (i / (n - 1).to_f) * 100 } %>
<% ys = traj.map { |v| 26 - ((v.to_f - baseline) / range) * 22 } %>
<% line_path = xs.zip(ys).each_with_index.map { |(x, y), i| "#{i.zero? ? "M" : "L"} #{x.round(2)} #{y.round(2)}" }.join(" ") %>
<% area_path = line_path + " L #{xs.last.round(2)} 28 L #{xs.first.round(2)} 28 Z" %>
<div class="px-4 py-3 grid grid-cols-[24px_minmax(0,1.5fr)_72px_minmax(80px,1fr)_96px] items-center gap-3">
<%= render Goals::AvatarComponent.new(name: account.name, color: color, size: "sm") %>
<div class="min-w-0">
@@ -56,13 +68,9 @@
<span></span>
<% end %>
<svg viewBox="0 0 <%= spark.size * 8 %> 28" preserveAspectRatio="none" class="w-full h-7" aria-hidden="true">
<% spark.each_with_index do |v, i| %>
<% next if v.to_f.zero? %>
<% raw_h = (v.to_f / spark_max) * 24 %>
<% bar_h = [ raw_h, 1.0 ].max %>
<rect x="<%= i * 8 + 1 %>" y="<%= (28 - bar_h).round(2) %>" width="6" height="<%= bar_h.round(2) %>" fill="<%= color %>" rx="0.5" />
<% end %>
<svg viewBox="0 0 100 28" preserveAspectRatio="none" class="w-full h-7" aria-hidden="true">
<path d="<%= area_path %>" fill="<%= color %>" opacity="0.18" />
<path d="<%= line_path %>" fill="none" stroke="<%= color %>" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round" />
</svg>
<div class="text-right">

View File

@@ -1,6 +1,7 @@
class Goals::FundingAccountsBreakdownComponent < ApplicationComponent
WINDOW_DAYS = 30
SPARK_WINDOW_DAYS = 90
TRAJECTORY_WINDOW_DAYS = 90
TRAJECTORY_SAMPLES = 24
def initialize(goal:)
@goal = goal
@@ -15,7 +16,7 @@ class Goals::FundingAccountsBreakdownComponent < ApplicationComponent
balance: account.balance.to_d,
balance_money: Money.new(account.balance.to_d, goal.currency),
last_30_money: Money.new(last_30_inflow_for(account), goal.currency),
sparkline_points: sparkline_points_for(account)
trajectory_points: trajectory_for(account)
}
end
end
@@ -29,18 +30,6 @@ class Goals::FundingAccountsBreakdownComponent < ApplicationComponent
((balance.to_d / total) * 100).round
end
# Shared Y-max across every account's sparkline so per-row amplitudes
# are comparable at a glance. Without this each row scaled to its own
# max and a $50 bump on an orange account rendered as tall as a $400
# weekly peak on a pink one — the eye reads them as equal contribution
# when the weight pills already say they aren't.
def shared_spark_max
@shared_spark_max ||= begin
points = rows.flat_map { |r| r[:sparkline_points] }
[ points.max.to_f, 1.0 ].max
end
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
@@ -56,11 +45,8 @@ class Goals::FundingAccountsBreakdownComponent < ApplicationComponent
end
private
SPARK_BUCKETS = 12
# Single grouped query across every linked account for the last-30-day
# inflow column. The V2 implementation hit one query per account in
# the row loop; this collapses to one.
# Net 30-day inflow per account in one grouped query. Powers the right-hand
# "$X last 30d" column.
def last_30_inflow_for(account)
last_30_inflow_map[account.id] || 0
end
@@ -80,45 +66,56 @@ class Goals::FundingAccountsBreakdownComponent < ApplicationComponent
end
end
# 12-bucket weekly sparkline of net inflow over 90 days per account, all
# in one grouped query. Bucket index counts back from today
# (`(CURRENT_DATE - entries.date) / bucket_days`); bucket 0 is the
# newest 8-day window, bucket 11 is the oldest. Each row in the
# returned per-account array is in oldest → newest order so the SVG
# path reads left → right naturally. Uses the same transfer-inclusive
# semantics as Goal#pace.
def sparkline_points_for(account)
sparkline_map[account.id] || Array.new(SPARK_BUCKETS, 0.0)
# 24-sample balance trajectory per account over the last 90 days. Drives
# the per-row filled-area chart — same conceptual shape as the projection
# chart on goals#show, just per linked account. We pull every Balance row
# in the window in one query and, for each anchor date in the sample grid,
# carry-forward the most-recent balance at-or-before that anchor.
def trajectory_for(account)
trajectory_map[account.id] || Array.new(TRAJECTORY_SAMPLES, 0.0)
end
def sparkline_map
@sparkline_map ||= begin
def trajectory_map
@trajectory_map ||= begin
account_ids = goal.linked_accounts.map(&:id)
return {} if account_ids.empty?
bucket_days = (SPARK_WINDOW_DAYS / SPARK_BUCKETS.to_f).ceil
rows = Balance
.where(account_id: account_ids, date: TRAJECTORY_WINDOW_DAYS.days.ago.to_date..Date.current)
.order(account_id: :asc, date: :asc)
.pluck(:account_id, :date, :balance)
bucket_expr = Arel.sql(
"LEAST(GREATEST((CURRENT_DATE - entries.date) / #{bucket_days.to_i}, 0), #{SPARK_BUCKETS - 1})"
)
rows = Entry
.joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'")
.where(account_id: account_ids, date: SPARK_WINDOW_DAYS.days.ago.to_date..Date.current)
.where(excluded: false)
.group(:account_id, bucket_expr)
.sum(:amount)
result = Hash.new { |h, k| h[k] = Array.new(SPARK_BUCKETS, 0.0) }
rows.each do |(account_id, sql_idx), net|
idx = (SPARK_BUCKETS - 1) - sql_idx.to_i
result[account_id][idx] = (-net.to_d).clamp(0, Float::INFINITY).to_f
grouped = rows.group_by(&:first)
account_ids.each_with_object({}) do |aid, h|
h[aid] = sample_trajectory(grouped[aid] || [])
end
result
end
rescue StandardError => e
Rails.logger.error("Sparkline map for goal #{goal.id} failed: #{e.class}: #{e.message}")
Rails.logger.error("Trajectory map for goal #{goal.id} failed: #{e.class}: #{e.message}")
Sentry.capture_exception(e) if defined?(Sentry)
{}
end
# Walk forward through sorted rows once, advancing the cursor as the
# anchor date passes each row's date. O(rows + samples) instead of
# O(rows × samples) reverse-find.
def sample_trajectory(rows)
return Array.new(TRAJECTORY_SAMPLES, 0.0) if rows.empty?
sorted = rows.sort_by { |r| r[1] }
step = TRAJECTORY_WINDOW_DAYS / (TRAJECTORY_SAMPLES - 1).to_f
cursor = 0
last_balance = sorted.first[2].to_f
Array.new(TRAJECTORY_SAMPLES) do |i|
anchor = (TRAJECTORY_WINDOW_DAYS - (step * i)).days.ago.to_date
while cursor < sorted.length && sorted[cursor][1] <= anchor
last_balance = sorted[cursor][2].to_f
cursor += 1
end
last_balance
end
end
end