mirror of
https://github.com/we-promise/sure.git
synced 2026-05-29 23:39:03 +00:00
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:
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user