feat(savings): rebuild index to match Claude Design

- Page header: title "Savings" + "Your savings accounts and the goals
  you're working toward." Removed the top-right New goal button (moves
  into the Goals section).
- Hero card: "Total in savings" with sum-of-savings-subtype balance,
  30-day delta vs last 30 days (Family#savings_balance_30d_delta),
  3-stat sub-row (Accounts / Active goals / Saved toward goals), and a
  D3 sparkline area chart on the right (new
  `savings-sparkline` Stimulus controller, sourced from
  Family#savings_balance_series).
- Accounts section: lists Depository accounts with subtype = "savings"
  as cards (blue avatar, name, subtype, balance, "Funds N goals"). New
  Savings::AccountCardComponent.
- Goals section header: "Goals" + "Save toward what matters." + "New
  goal" button right-aligned to the section (not the page header).
- Removed state-filter pill nav. Active goals render in the main grid;
  Completed goals get a "Completed · N" divider w/ check-circle icon
  and their own grid below.
- Goal card layout reworked: horizontal bar replaced with a 64px donut
  ring on the right side of the card header (ring colour tracks
  goal.status — yellow=behind, primary=on-track, green=reached). Pill
  is inline with the goal name.
- Status pill copy: "Behind pace" → "Behind".
- Filter bar (copied from settings/providers): search input + status
  chips (All / On track / Behind / No date). Hidden when ≤ 6 active
  goals. Powered by `savings-goals-filter` Stimulus controller —
  toggles `.hidden` on cards by goal name + status.
- Family#savings_subtype_accounts, total_savings_balance,
  savings_balance_series, savings_balance_30d_delta helpers; controller
  computes hero payload + account-goal counts for the cards.
This commit is contained in:
Guillem Arias
2026-05-11 12:18:57 +02:00
parent 696fbc0b43
commit dad9cf70b6
10 changed files with 505 additions and 111 deletions

View File

@@ -0,0 +1,16 @@
<div class="bg-container rounded-xl shadow-border-xs p-5">
<div class="flex items-center gap-3 mb-3">
<span class="inline-flex items-center justify-center w-9 h-9 rounded-full text-inverse text-sm font-semibold"
style="background-color: var(--color-blue-500);">
<%= initial %>
</span>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-primary truncate"><%= account.name %></p>
<p class="text-xs text-secondary"><%= subtype_label %></p>
</div>
</div>
<p class="text-2xl font-medium text-primary tabular-nums privacy-sensitive">
<%= Money.new(account.balance, account.currency).format %>
</p>
<p class="text-xs text-subdued mt-1"><%= funds_label %></p>
</div>

View File

@@ -0,0 +1,20 @@
class Savings::AccountCardComponent < ApplicationComponent
def initialize(account:, goals_count: 0)
@account = account
@goals_count = goals_count
end
attr_reader :account, :goals_count
def initial
account.name.to_s.strip.first&.upcase || "?"
end
def subtype_label
(account.subtype || "savings").to_s.titleize
end
def funds_label
I18n.t("savings_goals.index.account_card.funds", count: goals_count)
end
end

View File

@@ -1,35 +1,59 @@
<%= link_to savings_goal_path(goal),
class: "group flex flex-col gap-3.5 p-[18px] bg-container rounded-xl shadow-border-xs hover:bg-surface-hover transition-colors" do %>
class: "group block bg-container rounded-xl shadow-border-xs hover:bg-surface-hover transition-colors p-[18px]",
data: {
savings_goals_filter_target: "card",
goal_name: goal.name,
goal_status: goal.status
} do %>
<div class="flex items-start gap-3">
<%= render Savings::GoalAvatarComponent.new(goal: goal, size: "lg") %>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-primary truncate"><%= goal.name %></p>
<p class="text-[11px] text-subdued mt-0.5 truncate"><%= secondary_line %></p>
</div>
<%= render Savings::StatusPillComponent.new(goal: goal) %>
</div>
<div>
<div class="flex items-baseline justify-between mb-1.5">
<div class="text-lg font-medium text-primary tabular-nums privacy-sensitive">
<%= goal.current_balance_money.format %>
<span class="text-xs text-subdued ml-1">/ <%= goal.target_amount_money.format %></span>
<div class="flex items-center gap-2 mb-0.5">
<p class="text-sm font-medium text-primary truncate"><%= goal.name %></p>
<%= render Savings::StatusPillComponent.new(goal: goal) %>
</div>
<span class="text-xs text-secondary tabular-nums"><%= progress_percent %>%</span>
<p class="text-[11px] text-subdued truncate"><%= secondary_line %></p>
</div>
<div class="h-1.5 w-full rounded-full bg-surface-inset overflow-hidden">
<div class="h-full rounded-full transition-all duration-500"
style="inline-size: <%= progress_percent %>%; background-color: <%= bar_color_style %>;"></div>
<div class="shrink-0 relative" style="width: <%= Savings::GoalCardComponent::RING_SIZE %>px; height: <%= Savings::GoalCardComponent::RING_SIZE %>px;">
<svg width="<%= Savings::GoalCardComponent::RING_SIZE %>" height="<%= Savings::GoalCardComponent::RING_SIZE %>" viewBox="0 0 <%= Savings::GoalCardComponent::RING_SIZE %> <%= Savings::GoalCardComponent::RING_SIZE %>">
<circle cx="<%= Savings::GoalCardComponent::RING_SIZE / 2.0 %>"
cy="<%= Savings::GoalCardComponent::RING_SIZE / 2.0 %>"
r="<%= ring_radius %>"
fill="none"
stroke="var(--color-gray-200)"
stroke-width="<%= Savings::GoalCardComponent::RING_STROKE %>" />
<circle cx="<%= Savings::GoalCardComponent::RING_SIZE / 2.0 %>"
cy="<%= Savings::GoalCardComponent::RING_SIZE / 2.0 %>"
r="<%= ring_radius %>"
fill="none"
stroke="<%= ring_color %>"
stroke-width="<%= Savings::GoalCardComponent::RING_STROKE %>"
stroke-linecap="round"
stroke-dasharray="<%= ring_circumference %>"
stroke-dashoffset="<%= ring_offset %>"
transform="rotate(-90 <%= Savings::GoalCardComponent::RING_SIZE / 2.0 %> <%= Savings::GoalCardComponent::RING_SIZE / 2.0 %>)" />
</svg>
<div class="absolute inset-0 flex items-center justify-center text-[11px] font-medium text-primary tabular-nums">
<%= progress_percent %>%
</div>
</div>
</div>
<div class="flex items-center justify-between pt-1">
<div class="mt-3.5">
<div class="flex items-baseline gap-1.5">
<span class="text-lg font-medium text-primary tabular-nums privacy-sensitive"><%= goal.current_balance_money.format %></span>
<span class="text-xs text-subdued tabular-nums">/ <%= goal.target_amount_money.format %></span>
</div>
</div>
<div class="mt-3 flex items-center justify-between">
<div class="flex items-center gap-2">
<%= render Savings::AccountStackComponent.new(accounts: linked_accounts) %>
<span class="text-[11px] text-subdued"><%= linked_accounts_count_label %></span>
</div>
<span class="text-[11px] text-subdued tabular-nums">
<% 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 %>
</span>
</div>
<% end %>

View File

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

View File

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

View File

@@ -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);
});
}
}

View File

@@ -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);
}
}

View File

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

View File

@@ -1,73 +1,140 @@
<div class="space-y-5">
<header class="flex items-start justify-between gap-3">
<div>
<h1 class="text-xl font-semibold text-primary"><%= t(".title") %></h1>
<p class="text-sm text-secondary mt-0.5"><%= t(".subtitle") %></p>
</div>
<% if @linkable_account_count > 0 %>
<%= render DS::Link.new(
text: t(".new_goal"),
variant: "primary",
href: new_savings_goal_path,
icon: "plus",
frame: :modal
) %>
<% end %>
<div class="space-y-6">
<header>
<h1 class="text-2xl font-semibold text-primary"><%= t(".title") %></h1>
<p class="text-sm text-secondary mt-1"><%= t(".subtitle") %></p>
</header>
<% if @savings_goals.empty? && @counts["all"].zero? %>
<% if @counts["all"].zero? && @savings_accounts.empty? %>
<%= render "empty_state", linkable_account_count: @linkable_account_count %>
<% else %>
<% if @counts["all"].positive? %>
<section class="bg-container rounded-xl shadow-border-xs p-5 grid grid-cols-1 md:grid-cols-4 gap-6 items-center">
<div class="md:col-span-1.5">
<p class="text-xs text-secondary mb-1"><%= t(".summary.total_saved") %></p>
<div class="flex items-baseline gap-2">
<span class="text-2xl font-medium text-primary tabular-nums privacy-sensitive"><%= @totals[:saved].format %></span>
<span class="text-xs text-subdued tabular-nums">/ <%= @totals[:target].format %></span>
<%# Hero card %>
<section class="bg-container rounded-xl shadow-border-xs p-6 grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)] gap-6 items-stretch">
<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) %>
</p>
<% end %>
<div class="grid grid-cols-3 gap-6 mt-6">
<div>
<p class="text-[11px] text-secondary"><%= t(".hero.accounts") %></p>
<p class="text-base font-medium text-primary mt-0.5 tabular-nums"><%= @hero[:accounts_count] %></p>
</div>
<div class="mt-2 h-1.5 w-full rounded-full bg-surface-inset overflow-hidden">
<div class="h-full bg-primary rounded-full" style="inline-size: <%= @totals[:overall_percent] %>%; background-color: var(--text-primary);"></div>
<div>
<p class="text-[11px] text-secondary"><%= t(".hero.active_goals") %></p>
<p class="text-base font-medium text-primary mt-0.5 tabular-nums"><%= @hero[:active_goals_count] %></p>
</div>
<div>
<p class="text-[11px] text-secondary"><%= t(".hero.saved_toward_goals") %></p>
<p class="text-base font-medium text-primary mt-0.5 tabular-nums privacy-sensitive"><%= @hero[:saved_toward_goals_money].format %></p>
</div>
</div>
<div>
<p class="text-xs text-secondary mb-1"><%= t(".summary.active_goals") %></p>
<p class="text-lg font-medium text-primary tabular-nums"><%= @counts["active"] %></p>
</div>
<% if @hero[:sparkline_series].size >= 2 %>
<div class="min-h-[160px]"
data-controller="savings-sparkline"
data-savings-sparkline-series-value="<%= @hero[:sparkline_series].to_json %>"></div>
<% end %>
</section>
<%# Accounts section %>
<% if @savings_accounts.any? %>
<section>
<div class="mb-3">
<h2 class="text-base font-semibold text-primary"><%= t(".accounts_section.heading") %></h2>
<p class="text-sm text-secondary"><%= t(".accounts_section.subtitle") %></p>
</div>
<div>
<p class="text-xs text-secondary mb-1"><%= t(".summary.on_track") %></p>
<p class="text-lg font-medium text-primary tabular-nums"><%= @totals[:on_track_count] %> / <%= @counts["active"] %></p>
</div>
<div>
<p class="text-xs text-secondary mb-1"><%= t(".summary.behind") %></p>
<p class="text-lg font-medium text-primary tabular-nums"><%= @totals[:behind_count] %></p>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
<% @savings_accounts.each do |account| %>
<%= render Savings::AccountCardComponent.new(account: account, goals_count: @account_goal_counts[account.id] || 0) %>
<% end %>
</div>
</section>
<% end %>
<nav class="flex bg-surface-inset p-1 rounded-lg" role="tablist">
<% SavingsGoalsController::STATE_FILTERS.each do |state| %>
<% active = state == @state_filter %>
<%= link_to savings_goals_path(state: state),
role: "tab",
"aria-selected": active,
class: "flex-1 inline-flex justify-center items-center gap-1.5 text-sm font-medium px-2 py-1 rounded-md transition-colors duration-200 #{active ? 'bg-white theme-dark:bg-gray-700 text-primary shadow-sm' : 'text-secondary hover:bg-surface-inset-hover'}" do %>
<span><%= t(".tabs.#{state}") %></span>
<span class="text-xs text-subdued tabular-nums"><%= @counts[state] %></span>
<%# Goals section %>
<section data-controller="savings-goals-filter">
<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>
<p class="text-sm text-secondary"><%= t(".goals_section.subtitle") %></p>
</div>
<% if @linkable_account_count > 0 %>
<%= render DS::Link.new(
text: t(".new_goal"),
variant: "primary",
href: new_savings_goal_path,
icon: "plus",
frame: :modal
) %>
<% end %>
<% end %>
</nav>
</div>
<% if @savings_goals.any? %>
<div class="grid gap-3.5 sm:grid-cols-2 xl:grid-cols-3">
<% @savings_goals.each do |goal| %>
<%= render Savings::GoalCardComponent.new(goal: goal) %>
<% end %>
</div>
<% else %>
<div class="bg-container rounded-xl shadow-border-xs py-12 text-center">
<p class="text-sm text-secondary"><%= t(".empty_filtered", state: t(".tabs.#{@state_filter}").downcase) %></p>
</div>
<% if @show_search %>
<div class="flex flex-wrap items-center gap-2.5 mb-4">
<div class="relative flex-1 min-w-[200px]">
<input type="search"
autocomplete="off"
data-savings-goals-filter-target="input"
data-action="input->savings-goals-filter#filter"
aria-label="<%= t(".search.aria_label") %>"
placeholder="<%= t(".search.placeholder") %>"
class="block w-full border border-secondary rounded-md py-2.5 pl-10 pr-3 bg-container focus:ring-gray-500 sm:text-sm">
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<%= icon "search", class: "text-secondary" %>
</div>
</div>
<div class="inline-flex items-center gap-1 p-1 bg-surface-inset rounded-xl">
<% %w[all on_track behind no_target_date].each do |status| %>
<% active = status == "all" %>
<button type="button"
data-savings-goals-filter-target="chip"
data-action="click->savings-goals-filter#selectChip"
data-status="<%= status %>"
aria-pressed="<%= active %>"
class="px-2.5 py-1 text-xs font-medium rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-alpha-black-100 <%= active ? "bg-container shadow-border-xs text-primary" : "text-secondary" %>">
<%= t(".chips.#{status}") %>
</button>
<% end %>
</div>
</div>
<% end %>
<% if @active_goals.any? %>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3.5">
<% @active_goals.each do |goal| %>
<%= render Savings::GoalCardComponent.new(goal: goal) %>
<% end %>
</div>
<div class="hidden bg-container rounded-xl shadow-border-xs py-10 text-center mt-3" data-savings-goals-filter-target="empty">
<p class="text-sm text-secondary"><%= t(".search.empty") %></p>
</div>
<% else %>
<div class="bg-container rounded-xl shadow-border-xs py-12 text-center">
<p class="text-sm text-secondary"><%= t(".empty_filtered") %></p>
</div>
<% end %>
</section>
<% if @completed_goals.any? %>
<section>
<div class="flex items-center gap-2 mb-3 text-sm text-secondary">
<%= icon("circle-check-big", size: "sm") %>
<span><%= t(".completed_section.heading") %></span>
<span class="text-subdued">·</span>
<span class="tabular-nums"><%= @completed_goals.size %></span>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3.5">
<% @completed_goals.each do |goal| %>
<%= render Savings::GoalCardComponent.new(goal: goal) %>
<% end %>
</div>
</section>
<% end %>
<% end %>
</div>

View File

@@ -2,21 +2,39 @@
en:
savings_goals:
index:
title: Savings goals
subtitle: Save toward what matters.
title: Savings
subtitle: Your savings accounts and the goals you're working toward.
new_goal: New goal
empty_filtered: No %{state} goals.
tabs:
all: All
active: Active
paused: Paused
completed: Completed
archived: Archived
summary:
total_saved: Total saved across goals
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
accounts_section:
heading: Accounts
subtitle: Your savings and emergency reserves
goals_section:
heading: Goals
subtitle: Save toward what matters.
completed_section:
heading: Completed
search:
placeholder: Search goals…
aria_label: Search savings goals
empty: No goals match.
chips:
all: All
on_track: On track
behind: Behind
no_target_date: No date
account_card:
funds:
one: Funds 1 goal
other: "Funds %{count} goals"
new:
heading: New savings goal
step1_subtitle: Step 1 of 2 · Goal details
@@ -107,7 +125,7 @@ en:
archived: Archived
status:
on_track: On track
behind: Behind pace
behind: Behind
reached: Reached
no_target_date: No date
empty_state:
@@ -120,6 +138,7 @@ en:
goal_card:
no_accounts: No linked accounts
n_accounts: "%{first} +%{count}"
left: left
accounts:
one: 1 account
other: "%{count} accounts"
@@ -127,6 +146,9 @@ en:
completed: Completed
past_due: Past due
days_left:
one: 1 day left
other: "%{count} days left"
days_left_by:
one: 1 day left · by %{date}
other: "%{count} days left · by %{date}"
form_stepper: