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

@@ -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