- <%= goal.current_balance_money.format %>
-
/ <%= goal.target_amount_money.format %>
+
+
<%= goal.name %>
+ <%= render Savings::StatusPillComponent.new(goal: goal) %>
-
<%= progress_percent %>%
+
<%= secondary_line %>
+
+
+ <%= goal.current_balance_money.format %>
+ / <%= goal.target_amount_money.format %>
+
+
+
+
<%= render Savings::AccountStackComponent.new(accounts: linked_accounts) %>
<%= linked_accounts_count_label %>
- <% if goal.completed? %>—<% else %><%= goal.remaining_amount_money.format %> to go<% end %>
+ <% if goal.completed? %>—<% else %><%= goal.remaining_amount_money.format %> <%= t("savings_goals.goal_card.left") %><% end %>
<% end %>
diff --git a/app/components/savings/goal_card_component.rb b/app/components/savings/goal_card_component.rb
index 4556cdbe9..fb3c52795 100644
--- a/app/components/savings/goal_card_component.rb
+++ b/app/components/savings/goal_card_component.rb
@@ -1,4 +1,7 @@
class Savings::GoalCardComponent < ApplicationComponent
+ RING_SIZE = 64
+ RING_STROKE = 6
+
def initialize(goal:)
@goal = goal
end
@@ -9,12 +12,12 @@ class Savings::GoalCardComponent < ApplicationComponent
goal.progress_percent
end
- def bar_color_style
+ def ring_color
case goal.status
when :reached then "var(--color-green-600)"
when :behind then "var(--color-yellow-500)"
when :on_track then "var(--text-primary)"
- else "var(--color-gray-400)"
+ else "var(--text-subdued)"
end
end
@@ -23,8 +26,7 @@ class Savings::GoalCardComponent < ApplicationComponent
end
def linked_accounts_count_label
- n = linked_accounts.size
- I18n.t("savings_goals.goal_card.accounts", count: n)
+ I18n.t("savings_goals.goal_card.accounts", count: linked_accounts.size)
end
def secondary_line
@@ -35,10 +37,23 @@ class Savings::GoalCardComponent < ApplicationComponent
else
days = (goal.target_date - Date.current).to_i
if days >= 0
- I18n.t("savings_goals.goal_card.days_left", count: days, date: I18n.l(goal.target_date, format: :long))
+ I18n.t("savings_goals.goal_card.days_left_by", count: days, date: I18n.l(goal.target_date, format: :long))
else
I18n.t("savings_goals.goal_card.past_due")
end
end
end
+
+ def ring_circumference
+ @ring_circumference ||= 2 * Math::PI * ring_radius
+ end
+
+ def ring_radius
+ @ring_radius ||= (RING_SIZE - RING_STROKE) / 2.0
+ end
+
+ def ring_offset
+ pct = [ [ progress_percent.to_i, 0 ].max, 100 ].min
+ ring_circumference * (1 - pct / 100.0)
+ end
end
diff --git a/app/controllers/savings_goals_controller.rb b/app/controllers/savings_goals_controller.rb
index d8590983a..12e516427 100644
--- a/app/controllers/savings_goals_controller.rb
+++ b/app/controllers/savings_goals_controller.rb
@@ -4,17 +4,19 @@ class SavingsGoalsController < ApplicationController
STATE_FILTERS = %w[all active paused completed archived].freeze
def index
- @state_filter = STATE_FILTERS.include?(params[:state]) ? params[:state] : "active"
- scope = Current.family.savings_goals.with_current_balance.alphabetically
- scope = scope.where(state: @state_filter) unless @state_filter == "all"
- @savings_goals = scope.to_a
-
@counts = STATE_FILTERS.each_with_object({}) do |state, h|
h[state] = state == "all" ? Current.family.savings_goals.count : Current.family.savings_goals.where(state: state).count
end
+ all_goals = Current.family.savings_goals.with_current_balance.alphabetically.to_a
+ @active_goals = all_goals.reject { |g| %w[completed archived].include?(g.state) }
+ @completed_goals = all_goals.select { |g| g.state == "completed" }
+
@linkable_account_count = Current.family.accounts.where(accountable_type: "Depository").visible.count
- @totals = totals_for_family
+ @savings_accounts = Current.family.savings_subtype_accounts
+ @account_goal_counts = goal_count_per_account(@savings_accounts)
+ @hero = hero_payload(all_goals)
+ @show_search = @active_goals.size > 6
end
def show
@@ -153,24 +155,35 @@ class SavingsGoalsController < ApplicationController
end
end
- def totals_for_family
- goals = Current.family.savings_goals.with_current_balance.to_a
- saved = goals.sum { |g| g.current_balance.to_d }
- target = goals.sum { |g| g.target_amount.to_d }
- currency = Current.family.primary_currency_code
- active_goals = goals.select { |g| g.state == "active" }
- on_track = active_goals.count { |g| g.status == :on_track || g.status == :reached }
- behind = active_goals.count { |g| g.status == :behind }
- overall_percent = target.zero? ? 0 : ((saved / target) * 100).round
+ def hero_payload(all_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
{
- saved: Money.new(saved, currency),
- target: Money.new(target, currency),
- overall_percent: [ overall_percent, 100 ].min,
- on_track_count: on_track,
- behind_count: behind
+ 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)
}
end
+ def goal_count_per_account(accounts)
+ return {} if accounts.empty?
+
+ SavingsGoalAccount
+ .where(account_id: accounts.map(&:id))
+ .joins(:savings_goal)
+ .where.not(savings_goals: { state: %w[archived] })
+ .group(:account_id)
+ .count
+ end
+
def stats_for(goal)
avg = goal.average_monthly_contribution.to_d
sub_avg = if goal.monthly_target_amount && goal.monthly_target_amount.to_d > avg
diff --git a/app/javascript/controllers/savings_goals_filter_controller.js b/app/javascript/controllers/savings_goals_filter_controller.js
new file mode 100644
index 000000000..23558d511
--- /dev/null
+++ b/app/javascript/controllers/savings_goals_filter_controller.js
@@ -0,0 +1,54 @@
+import { Controller } from "@hotwired/stimulus";
+
+// Free-text + status-chip filter for the savings-goals index grid.
+// Mirrors the providers-filter pattern. Each card has data-goal-name
+// 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"];
+ static values = { status: { type: String, default: "all" } };
+
+ connect() {
+ this.syncChipState();
+ }
+
+ filter() {
+ const query = this.hasInputTarget
+ ? this.inputTarget.value.toLocaleLowerCase().trim()
+ : "";
+ const active = this.statusValue;
+ let visible = 0;
+
+ this.cardTargets.forEach((card) => {
+ const name = (card.dataset.goalName || "").toLocaleLowerCase();
+ const status = card.dataset.goalStatus || "";
+ const matchesQuery = !query || name.includes(query);
+ const matchesStatus = active === "all" || status === active;
+ const show = matchesQuery && matchesStatus;
+ card.classList.toggle("hidden", !show);
+ if (show) visible++;
+ });
+
+ if (this.hasEmptyTarget) {
+ this.emptyTarget.classList.toggle("hidden", visible > 0);
+ }
+ }
+
+ selectChip(event) {
+ this.statusValue = event.currentTarget.dataset.status || "all";
+ this.syncChipState();
+ this.filter();
+ }
+
+ syncChipState() {
+ if (!this.hasChipTarget) return;
+ this.chipTargets.forEach((chip) => {
+ const active = chip.dataset.status === this.statusValue;
+ chip.setAttribute("aria-pressed", active);
+ chip.classList.toggle("bg-container", active);
+ chip.classList.toggle("shadow-border-xs", active);
+ chip.classList.toggle("text-primary", active);
+ chip.classList.toggle("text-secondary", !active);
+ });
+ }
+}
diff --git a/app/javascript/controllers/savings_sparkline_controller.js b/app/javascript/controllers/savings_sparkline_controller.js
new file mode 100644
index 000000000..e0ea79e14
--- /dev/null
+++ b/app/javascript/controllers/savings_sparkline_controller.js
@@ -0,0 +1,107 @@
+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));
+ const padding = (yMax - yMin) * 0.15 || yMax * 0.05 || 1;
+ const y = d3
+ .scaleLinear()
+ .domain([Math.max(0, 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);
+ }
+}
diff --git a/app/models/family.rb b/app/models/family.rb
index 3ccdc380e..db400598c 100644
--- a/app/models/family.rb
+++ b/app/models/family.rb
@@ -45,6 +45,62 @@ class Family < ApplicationRecord
has_many :savings_goals, dependent: :destroy
has_many :savings_contributions, through: :savings_goals
+ # Depository accounts with subtype = "savings". The /savings_goals
+ # index hero shows the total + sparkline across just these accounts;
+ # checking / HSA / CD / money-market are intentionally excluded.
+ def savings_subtype_accounts
+ accounts.where(accountable_type: "Depository").visible.alphabetically.select do |account|
+ account.subtype == "savings"
+ 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 }
+ end
+
has_many :llm_usages, dependent: :destroy
has_many :recurring_transactions, dependent: :destroy
diff --git a/app/views/savings_goals/index.html.erb b/app/views/savings_goals/index.html.erb
index dae256782..8ae292476 100644
--- a/app/views/savings_goals/index.html.erb
+++ b/app/views/savings_goals/index.html.erb
@@ -1,73 +1,140 @@
-