feat(savings_goals): replace hero card with KPI strip + differentiate empty states

P1: drop the sparkline + the single mixed hero. Hero became 3 separate
KPI cards (Contributed last 30d, Needs this month, Goals on track),
matching the Transactions page pattern. Each KPI answers a question the
user opens the page asking — saving rate, this-month action, overall
health.

P3: empty state copy + CTA now reflect the reason it is empty. Search
returns 0 → "No goals match X" + Clear search. Chip set to non-all → "No
goals match this filter" + Show all. Both → both reasons + both
buttons.

Drop: total_savings_balance, savings_balance_series,
savings_balance_30d_delta on Family (no other consumers).
Add: Family#contribution_velocity(range:).
This commit is contained in:
Guillem Arias
2026-05-11 14:14:37 +02:00
parent 69c45d4714
commit 8ba6cbcdc8
6 changed files with 198 additions and 208 deletions

View File

@@ -15,7 +15,7 @@ class SavingsGoalsController < ApplicationController
@linkable_account_count = Current.family.accounts.where(accountable_type: "Depository").visible.count
@savings_accounts = Current.family.savings_subtype_accounts
@account_goal_counts = goal_count_per_account(@savings_accounts)
@hero = hero_payload(all_goals)
@kpi = kpi_payload(@active_goals)
@show_search = @active_goals.size > 6
end
@@ -155,21 +155,41 @@ class SavingsGoalsController < ApplicationController
end
end
def hero_payload(all_goals)
def kpi_payload(active_goals)
family = Current.family
currency = family.primary_currency_code
total_savings = family.total_savings_balance
saved_toward_goals = all_goals.sum { |g| g.current_balance.to_d }
delta = family.savings_balance_30d_delta
today = Date.current
velocity_30d = family.contribution_velocity(range: (today - 30)..today)
velocity_prior_30d = family.contribution_velocity(range: (today - 60)..(today - 31))
delta_amount = velocity_30d - velocity_prior_30d
delta_percent = velocity_prior_30d.zero? ? nil : ((delta_amount / velocity_prior_30d) * 100).round(1)
velocity_direction = if delta_amount.positive? then :up
elsif delta_amount.negative? then :down
else :flat
end
behind = active_goals.select { |g| g.status == :behind }
on_track = active_goals.select { |g| g.status == :on_track }
no_date = active_goals.select { |g| g.status == :no_target_date }
paused = active_goals.select(&:paused?)
needs = behind.sum { |g| g.monthly_target_amount.to_d }
{
currency: currency,
total_savings_money: Money.new(total_savings, currency),
saved_toward_goals_money: Money.new(saved_toward_goals, currency),
accounts_count: family.savings_subtype_accounts.size,
active_goals_count: @counts["active"].to_i,
delta: delta,
delta_amount_money: Money.new(delta[:amount].abs, currency),
sparkline_series: family.savings_balance_series(days: 30)
velocity_30d: velocity_30d,
velocity_30d_money: Money.new(velocity_30d.abs, currency),
velocity_prior_30d_money: Money.new(velocity_prior_30d, currency),
velocity_30d_sign: velocity_direction == :down ? "" : (velocity_direction == :up ? "+" : ""),
velocity_delta_amount_money: Money.new(delta_amount.abs, currency),
velocity_delta_percent: delta_percent,
velocity_direction: velocity_direction,
needs_this_month_money: Money.new(needs, currency),
behind_count: behind.size,
on_track_count: on_track.size,
no_date_count: no_date.size,
paused_count: paused.size,
active_total: active_goals.size
}
end

View File

@@ -5,8 +5,24 @@ import { Controller } from "@hotwired/stimulus";
// and data-goal-status; the controller toggles `.hidden` on cards
// based on the active query/chip.
export default class extends Controller {
static targets = ["input", "chip", "card", "empty", "grid", "count"];
static values = { status: { type: String, default: "all" } };
static targets = [
"input",
"chip",
"card",
"empty",
"emptyCopy",
"emptyClearSearch",
"emptyClearFilter",
"grid",
"count",
];
static values = {
status: { type: String, default: "all" },
emptyQuery: { type: String, default: "" },
emptyFilter: { type: String, default: "" },
emptyBoth: { type: String, default: "" },
emptyDefault: { type: String, default: "" },
};
connect() {
this.syncChipState();
@@ -38,6 +54,46 @@ export default class extends Controller {
if (this.hasCountTarget) {
this.countTarget.textContent = visible;
}
this.updateEmptyState(visible, query, active);
}
updateEmptyState(visible, query, active) {
if (visible > 0 || !this.hasEmptyCopyTarget) return;
const rawQuery = this.hasInputTarget ? this.inputTarget.value.trim() : "";
const hasQuery = rawQuery.length > 0;
const hasFilter = active !== "all";
let copy;
if (hasQuery && hasFilter) {
copy = this.emptyBothValue.replace("__QUERY__", rawQuery);
} else if (hasQuery) {
copy = this.emptyQueryValue.replace("__QUERY__", rawQuery);
} else if (hasFilter) {
copy = this.emptyFilterValue;
} else {
copy = this.emptyDefaultValue;
}
this.emptyCopyTarget.textContent = copy;
if (this.hasEmptyClearSearchTarget) {
this.emptyClearSearchTarget.classList.toggle("hidden", !hasQuery);
}
if (this.hasEmptyClearFilterTarget) {
this.emptyClearFilterTarget.classList.toggle("hidden", !hasFilter);
}
}
clearSearch() {
if (this.hasInputTarget) {
this.inputTarget.value = "";
this.inputTarget.focus();
}
this.filter();
}
clearFilter() {
this.statusValue = "all";
this.syncChipState();
this.filter();
}
selectChip(event) {

View File

@@ -1,110 +0,0 @@
import { Controller } from "@hotwired/stimulus";
import * as d3 from "d3";
// Sparkline area chart for the savings hero card. Tiny, axis-less,
// labelless — just the green line + soft area fill + end dot.
// Data: [{ date: "YYYY-MM-DD", value: Number }, ...]
export default class extends Controller {
static values = { series: Array };
connect() {
this._draw();
this._resize = this._draw.bind(this);
window.addEventListener("resize", this._resize);
}
disconnect() {
window.removeEventListener("resize", this._resize);
}
_draw() {
const root = this.element;
root.innerHTML = "";
const series = (this.seriesValue || []).map((p) => ({
date: new Date(p.date),
value: Number(p.value || 0),
}));
if (series.length < 2) return;
const width = root.clientWidth || 600;
const height = root.clientHeight || 140;
if (width <= 0 || height <= 0) return;
const margin = { top: 8, right: 12, bottom: 4, left: 8 };
const innerWidth = width - margin.left - margin.right;
const innerHeight = height - margin.top - margin.bottom;
const x = d3
.scaleTime()
.domain(d3.extent(series, (d) => d.date))
.range([margin.left, margin.left + innerWidth]);
const yMin = Math.min(...series.map((d) => d.value));
const yMax = Math.max(...series.map((d) => d.value));
// Don't clamp to 0 — savings totals can be negative under certain
// demo / edge conditions, and clamping pushes the line off-canvas.
const range = yMax - yMin;
const padding = range > 0 ? range * 0.15 : Math.abs(yMax) * 0.05 || 1;
const y = d3
.scaleLinear()
.domain([yMin - padding, yMax + padding])
.range([margin.top + innerHeight, margin.top]);
const svg = d3
.select(root)
.append("svg")
.attr("width", width)
.attr("height", height)
.attr("viewBox", `0 0 ${width} ${height}`)
.attr("preserveAspectRatio", "none");
const gradId = `sparkline-fill-${Math.random().toString(36).slice(2, 8)}`;
const defs = svg.append("defs");
const grad = defs
.append("linearGradient")
.attr("id", gradId)
.attr("x1", 0).attr("y1", 0).attr("x2", 0).attr("y2", 1);
grad.append("stop").attr("offset", "0%").attr("stop-color", "var(--color-green-500)").attr("stop-opacity", 0.18);
grad.append("stop").attr("offset", "100%").attr("stop-color", "var(--color-green-500)").attr("stop-opacity", 0);
const area = d3
.area()
.x((d) => x(d.date))
.y0(margin.top + innerHeight)
.y1((d) => y(d.value))
.curve(d3.curveMonotoneX);
const line = d3
.line()
.x((d) => x(d.date))
.y((d) => y(d.value))
.curve(d3.curveMonotoneX);
svg
.append("path")
.datum(series)
.attr("fill", `url(#${gradId})`)
.attr("d", area);
svg
.append("path")
.datum(series)
.attr("fill", "none")
.attr("stroke", "var(--color-green-600)")
.attr("stroke-width", 2)
.attr("stroke-linejoin", "round")
.attr("stroke-linecap", "round")
.attr("d", line);
const last = series[series.length - 1];
svg
.append("circle")
.attr("cx", x(last.date))
.attr("cy", y(last.value))
.attr("r", 4)
.attr("fill", "var(--color-green-600)")
.attr("stroke", "var(--bg-container)")
.attr("stroke-width", 2);
}
}

View File

@@ -54,51 +54,11 @@ class Family < ApplicationRecord
end
end
# Sum of current balances across savings_subtype_accounts, in the
# family's primary currency. Multi-currency accounts are summed
# naively (FX out of scope for the v1 hero card).
def total_savings_balance
savings_subtype_accounts.sum { |a| a.balance.to_d }
end
# Returns [{ date: "YYYY-MM-DD", value: Float }, ...] running daily
# totals over the trailing `days` window across savings_subtype_accounts.
# Uses each account's recorded balances; falls back to the current
# balance for any day that has no recorded snapshot.
def savings_balance_series(days: 30)
accs = savings_subtype_accounts
return [] if accs.empty?
start_date = days.days.ago.to_date
end_date = Date.current
balances_by_account = Balance
.where(account_id: accs.map(&:id), date: start_date..end_date)
.order(:date)
.group_by(&:account_id)
(start_date..end_date).map do |date|
total = accs.sum do |account|
snapshots = balances_by_account[account.id] || []
snapshot = snapshots.reverse.find { |b| b.date <= date }
(snapshot&.balance || account.balance).to_d
end
{ date: date.to_s, value: total.to_f }
end
end
# 30-day delta on total savings balance, with arrow + percent helpers
# for the hero card. { amount:, percent:, direction: } where direction
# is :up / :down / :flat.
def savings_balance_30d_delta
series = savings_balance_series(days: 30)
return { amount: 0, percent: 0, direction: :flat } if series.size < 2
first = series.first[:value].to_d
last = series.last[:value].to_d
diff = last - first
pct = first.zero? ? 0 : ((diff / first) * 100).round(1)
dir = diff.positive? ? :up : (diff.negative? ? :down : :flat)
{ amount: diff, percent: pct.to_f, direction: dir }
# Sum of contribution amounts within the given date range, returned as
# a BigDecimal in the family's primary currency. Powers the savings
# goals "Contributed · last 30d" KPI.
def contribution_velocity(range:)
savings_contributions.where(contributed_at: range).sum(:amount).to_d
end
has_many :llm_usages, dependent: :destroy

View File

@@ -7,39 +7,64 @@
<% if @counts["all"].zero? && @savings_accounts.empty? %>
<%= render "empty_state", linkable_account_count: @linkable_account_count %>
<% else %>
<%# Hero card %>
<section class="bg-container rounded-xl shadow-border-xs p-7 grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.6fr)] gap-6 items-stretch min-h-[220px]">
<div class="flex flex-col">
<p class="text-xs text-secondary"><%= t(".hero.total_in_savings") %></p>
<p class="text-4xl font-medium text-primary tabular-nums mt-1 privacy-sensitive"><%= @hero[:total_savings_money].format %></p>
<% delta = @hero[:delta] %>
<% if delta[:direction] != :flat %>
<p class="text-xs <%= delta[:direction] == :up ? "text-success" : "text-destructive" %> mt-1 tabular-nums">
<%= t("savings_goals.index.hero.delta_#{delta[:direction]}", amount: @hero[:delta_amount_money].format, percent: delta[:percent].abs) %>
<%# KPI strip %>
<section class="grid grid-cols-1 md:grid-cols-3 gap-3">
<%# Velocity %>
<div class="bg-container rounded-xl shadow-border-xs px-5 py-5">
<p class="text-[11px] font-medium uppercase tracking-wide text-secondary"><%= t(".kpi.velocity_label") %></p>
<p class="text-3xl font-medium text-primary tabular-nums mt-2 privacy-sensitive">
<%= @kpi[:velocity_30d_sign] %><%= @kpi[:velocity_30d_money].format %>
</p>
<% if @kpi[:velocity_direction] == :flat %>
<p class="text-xs text-secondary mt-1 tabular-nums">
<% if @kpi[:velocity_prior_30d_money].zero? && @kpi[:velocity_30d_money].zero? %>
<%= t(".kpi.velocity_delta_zero_base") %>
<% else %>
<%= t(".kpi.velocity_delta_flat") %>
<% end %>
</p>
<% elsif @kpi[:velocity_delta_percent].nil? %>
<p class="text-xs text-success mt-1 tabular-nums"><%= t(".kpi.velocity_delta_zero_base") %></p>
<% else %>
<p class="text-xs <%= @kpi[:velocity_direction] == :up ? "text-success" : "text-destructive" %> mt-1 tabular-nums">
<%= t(".kpi.velocity_delta_#{@kpi[:velocity_direction]}", percent: @kpi[:velocity_delta_percent].abs) %>
</p>
<% end %>
<div class="grid grid-cols-3 gap-6 mt-auto pt-6">
<div>
<p class="text-xs text-secondary"><%= t(".hero.accounts") %></p>
<p class="text-lg font-medium text-primary mt-1 tabular-nums"><%= @hero[:accounts_count] %></p>
</div>
<div>
<p class="text-xs text-secondary"><%= t(".hero.active_goals") %></p>
<p class="text-lg font-medium text-primary mt-1 tabular-nums"><%= @hero[:active_goals_count] %></p>
</div>
<div>
<p class="text-xs text-secondary"><%= t(".hero.saved_toward_goals") %></p>
<p class="text-lg font-medium text-primary mt-1 tabular-nums privacy-sensitive"><%= @hero[:saved_toward_goals_money].format %></p>
</div>
</div>
</div>
<% if @hero[:sparkline_series].size >= 2 %>
<div class="h-full min-h-[200px]"
data-controller="savings-sparkline"
data-savings-sparkline-series-value="<%= @hero[:sparkline_series].to_json %>"></div>
<% end %>
<%# Needs this month %>
<div class="bg-container rounded-xl shadow-border-xs px-5 py-5">
<p class="text-[11px] font-medium uppercase tracking-wide text-secondary"><%= t(".kpi.needs_this_month_label") %></p>
<p class="text-3xl font-medium text-primary tabular-nums mt-2 privacy-sensitive"><%= @kpi[:needs_this_month_money].format %></p>
<p class="text-xs text-secondary mt-1">
<% if @kpi[:behind_count].zero? %>
<%= t(".kpi.needs_this_month_zero_sub") %>
<% else %>
<%= t(".kpi.needs_this_month_sub", count: @kpi[:behind_count]) %>
<% end %>
</p>
</div>
<%# On-track count %>
<div class="bg-container rounded-xl shadow-border-xs px-5 py-5">
<p class="text-[11px] font-medium uppercase tracking-wide text-secondary"><%= t(".kpi.on_track_label") %></p>
<p class="text-3xl font-medium text-primary tabular-nums mt-2">
<%= t(".kpi.on_track_value", on_track: @kpi[:on_track_count], total: @kpi[:active_total]) %>
</p>
<p class="text-xs text-secondary mt-1">
<%
parts = []
parts << t(".kpi.on_track_sub_parts.behind", count: @kpi[:behind_count]) if @kpi[:behind_count].positive?
parts << t(".kpi.on_track_sub_parts.no_date", count: @kpi[:no_date_count]) if @kpi[:no_date_count].positive?
parts << t(".kpi.on_track_sub_parts.paused", count: @kpi[:paused_count]) if @kpi[:paused_count].positive?
%>
<% if parts.any? %>
<%= parts.join(" · ") %>
<% else %>
<%= t(".kpi.on_track_sub_all_good") %>
<% end %>
</p>
</div>
</section>
<%# Accounts section %>
@@ -58,7 +83,11 @@
<% end %>
<%# Goals section %>
<section data-controller="savings-goals-filter">
<section data-controller="savings-goals-filter"
data-savings-goals-filter-empty-query-value="<%= t(".search.empty_with_query", query: "__QUERY__") %>"
data-savings-goals-filter-empty-filter-value="<%= t(".search.empty_with_filter") %>"
data-savings-goals-filter-empty-both-value="<%= t(".search.empty_with_both", query: "__QUERY__") %>"
data-savings-goals-filter-empty-default-value="<%= t(".search.empty") %>">
<div class="flex items-start justify-between mb-3 gap-3">
<div>
<h2 class="text-base font-semibold text-primary"><%= t(".goals_section.heading") %></h2>
@@ -117,7 +146,21 @@
<% end %>
</div>
<div class="hidden bg-container rounded-xl shadow-border-xs py-10 text-center" data-savings-goals-filter-target="empty">
<p class="text-sm text-secondary"><%= t(".search.empty") %></p>
<p class="text-sm text-secondary" data-savings-goals-filter-target="emptyCopy"><%= t(".search.empty") %></p>
<div class="mt-3 flex items-center justify-center gap-2">
<button type="button"
class="hidden text-xs font-medium text-secondary underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-alpha-black-100"
data-savings-goals-filter-target="emptyClearSearch"
data-action="click->savings-goals-filter#clearSearch">
<%= t(".search.clear_search") %>
</button>
<button type="button"
class="hidden text-xs font-medium text-secondary underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-alpha-black-100"
data-savings-goals-filter-target="emptyClearFilter"
data-action="click->savings-goals-filter#clearFilter">
<%= t(".search.show_all") %>
</button>
</div>
</div>
<% else %>
<div class="bg-container rounded-xl shadow-border-xs py-12 text-center">

View File

@@ -6,14 +6,30 @@ en:
subtitle: Your savings accounts and the goals you're working toward.
new_goal: New goal
empty_filtered: No goals match.
hero:
total_in_savings: Total in savings
delta_up: "+%{amount} (↑ %{percent}%) vs. last 30 days"
delta_down: "%{amount} (↓ %{percent}%) vs. last 30 days"
delta_flat: vs. last 30 days
accounts: Accounts
active_goals: Active goals
saved_toward_goals: Saved toward goals
kpi:
velocity_label: Contributed · last 30d
velocity_delta_up: "↑ %{percent}%% vs. prior 30d"
velocity_delta_down: "↓ %{percent}%% vs. prior 30d"
velocity_delta_flat: vs. prior 30d
velocity_delta_zero_base: First 30 days of activity
needs_this_month_label: Needs this month
needs_this_month_sub:
one: across 1 goal behind pace
other: "across %{count} goals behind pace"
needs_this_month_zero_sub: No goals behind pace
on_track_label: Goals on track
on_track_value: "%{on_track} of %{total}"
on_track_sub_parts:
behind:
one: 1 behind
other: "%{count} behind"
no_date:
one: 1 open-ended
other: "%{count} open-ended"
paused:
one: 1 paused
other: "%{count} paused"
on_track_sub_all_good: All active goals on pace
accounts_section:
heading: Accounts
subtitle: Your savings and emergency reserves
@@ -28,6 +44,11 @@ en:
placeholder: Search goals…
aria_label: Search savings goals
empty: No goals match.
empty_with_query: "No goals match \"%{query}\"."
empty_with_filter: No goals match this filter.
empty_with_both: "No goals match \"%{query}\" with this filter."
clear_search: Clear search
show_all: Show all
chips:
all: All
on_track: On track