fix(goals/funding-widget): switch sparkline to bars + shared scale

The shared-scale fix alone wasn't enough — a single outlier bucket
on one account compressed every other row to invisibility, and the
interpolated line between sparse non-zero buckets painted fake
"event triangles" between actual data points.

Switch from a stroked path to per-bucket bars:
- 12 rects per row, x = `i * 8 + 1`, width 6, 2px gap between.
- Bar height = `(value / shared_max) * 24`, floored at 1 unit so a
  non-zero bucket is always visible even when an outlier elsewhere
  dominates the scale.
- Empty buckets render nothing — no fake baseline, no interpolated
  trough.
- Bars grounded at `y = 28` (bottom of viewBox), so "zero" is
  implicit and the eye reads upward from a stable floor.
- Shared `spark_max` across every account's bars (the component
  method introduced for the line version stays — that part of the
  diagnosis was right, it just needed a chart type that handled the
  scale honestly).

Net read: the column-chart-on-each-row layout matches "12 weeks of
deposits into this account" much more directly than a sparkline
ever did, and outlier-vs-modest-but-steady contributions are both
legible at a glance.
This commit is contained in:
Guillem Arias
2026-05-14 20:33:08 +02:00
parent 3b2a1fc828
commit 815fb9d8fa
2 changed files with 19 additions and 5 deletions

View File

@@ -39,7 +39,7 @@
<% account = row[:account] %>
<% color = Goals::AvatarComponent.color_for(account.name) %>
<% spark = row[:sparkline_points] %>
<% spark_max = [ spark.max, 1.0 ].max %>
<% 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">
<%= render Goals::AvatarComponent.new(name: account.name, color: color, size: "sm") %>
@@ -57,10 +57,12 @@
<% 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" />
<% 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>
<div class="text-right">

View File

@@ -29,6 +29,18 @@ 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