mirror of
https://github.com/we-promise/sure.git
synced 2026-05-31 08:19:03 +00:00
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:
16
app/components/savings/account_card_component.html.erb
Normal file
16
app/components/savings/account_card_component.html.erb
Normal 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>
|
||||
20
app/components/savings/account_card_component.rb
Normal file
20
app/components/savings/account_card_component.rb
Normal 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
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
107
app/javascript/controllers/savings_sparkline_controller.js
Normal file
107
app/javascript/controllers/savings_sparkline_controller.js
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user