feat(savings_goals): demo variety, breadcrumb naming, ring token, list pattern, header split, tone down behind noise

Demo — extend generate_savings_goals! with three more goals to exercise
status-specific UX: Wedding fund (on_track w/ 6 months of contributions
matching required pace), Sabbatical (paused), Old laptop fund (archived).
House downpayment gains 12 contributions so the scrollable list has real
density. Total now 7 demo goals covering behind / on_track / no_date /
paused / archived / reached.

Breadcrumbs — set @breadcrumbs on index too (it was relying on the
Rails-derived "Savings goals" label). Both views now read "Home →
Savings → ..." consistently, matching the sidebar nav text and H1.

Ring token — goal-card ring stroke switched from var(--color-gray-200)
(a hard light color identical in both themes) to
var(--budget-unallocated-fill) which is gray-50 light / gray-700 dark,
matching the detail page's progress ring.

Contributions list — replace the inline hover-revealed delete-X with
DS::Menu kebab, matching tags/_tag.html.erb and categories/_category.
Each row also gets hover:bg-surface-hover with a px-3 -mx-3 negative
margin to extend the hover area across the card padding. Non-manual
contributions render a 9x9 spacer so the right column stays aligned.

Header sub split — drop the long "·" chain into two lines: primary fact
(target / days left) in text-secondary, recency note in text-subdued
underneath. Less wall-of-text.

Behind noise — pill, ring, catch-up alert and projection chart already
signal "behind". The Monthly-pace combo card's "Behind by $X/mo" delta
no longer renders in text-warning — it switches to text-subdued so the
warning palette doesn't repeat across the page. The catch-up alert stays
loud because it's the primary action; the rest stays informational.

CustomConfirm wired with destructive: true on the contribution delete so
the confirm button gets the outline-destructive treatment.
This commit is contained in:
Guillem Arias
2026-05-11 16:37:11 +02:00
parent 64f3854e02
commit ed9759b87b
7 changed files with 95 additions and 27 deletions

View File

@@ -21,7 +21,7 @@
cy="<%= Savings::GoalCardComponent::RING_SIZE / 2.0 %>"
r="<%= ring_radius %>"
fill="none"
stroke="var(--color-gray-200)"
stroke="var(--budget-unallocated-fill)"
stroke-width="<%= Savings::GoalCardComponent::RING_STROKE %>" />
<circle cx="<%= Savings::GoalCardComponent::RING_SIZE / 2.0 %>"
cy="<%= Savings::GoalCardComponent::RING_SIZE / 2.0 %>"

View File

@@ -17,6 +17,10 @@ class SavingsGoalsController < ApplicationController
@linkable_account_count = Current.family.accounts.where(accountable_type: "Depository").visible.count
@kpi = kpi_payload(@active_goals)
@show_search = @active_goals.size > 6
@breadcrumbs = [
[ t("breadcrumbs.home"), root_path ],
[ t("savings_goals.index.title"), nil ]
]
end
def show

View File

@@ -1287,6 +1287,30 @@ class Demo::Generator
primary = eligible.first
secondary = eligible[1] || primary
# Build "Wedding fund" on_track contributions: target 12 months out,
# $200/mo required, demo contributes $220/mo for last 6 months → on
# pace.
wedding_contribs = (0..5).map do |i|
{ amount: 220, source: i.zero? ? "initial" : "manual", days_ago: 30 * (6 - i), account: primary }
end
# "House downpayment" gets a fuller contribution history so the
# scrollable list has real density.
house_contribs = [
{ amount: 5_000, source: "initial", days_ago: 365, account: primary },
{ amount: 750, source: "manual", days_ago: 330, account: primary },
{ amount: 750, source: "manual", days_ago: 300, account: secondary },
{ amount: 750, source: "manual", days_ago: 270, account: primary },
{ amount: 750, source: "manual", days_ago: 240, account: primary },
{ amount: 750, source: "manual", days_ago: 210, account: secondary },
{ amount: 750, source: "manual", days_ago: 180, account: primary },
{ amount: 750, source: "manual", days_ago: 150, account: primary },
{ amount: 750, source: "manual", days_ago: 120, account: secondary },
{ amount: 750, source: "manual", days_ago: 90, account: primary },
{ amount: 750, source: "manual", days_ago: 60, account: primary },
{ amount: 750, source: "manual", days_ago: 30, account: secondary }
]
goals = [
{
name: "Vacation in Italy",
@@ -1299,13 +1323,22 @@ class Demo::Generator
{ amount: 250, source: "manual", days_ago: 30, account: secondary }
]
},
{
name: "Wedding fund",
target: 2_400,
target_date: 6.months.from_now.to_date,
accounts: eligible.first(2),
contributions: wedding_contribs
},
{
name: "Emergency fund",
target: 10_000,
target_date: nil,
accounts: [ primary ],
contributions: [
{ amount: 1_000, source: "initial", days_ago: 180, account: primary }
{ amount: 1_000, source: "initial", days_ago: 180, account: primary },
{ amount: 250, source: "manual", days_ago: 60, account: primary },
{ amount: 250, source: "manual", days_ago: 30, account: primary }
]
},
{
@@ -1313,8 +1346,27 @@ class Demo::Generator
target: 50_000,
target_date: 24.months.from_now.to_date,
accounts: eligible.first(2),
contributions: house_contribs
},
{
name: "Sabbatical",
target: 15_000,
target_date: 18.months.from_now.to_date,
state: "paused",
accounts: [ primary ],
contributions: [
{ amount: 5_000, source: "initial", days_ago: 365, account: primary }
{ amount: 1_500, source: "initial", days_ago: 200, account: primary },
{ amount: 500, source: "manual", days_ago: 150, account: primary }
]
},
{
name: "Old laptop fund",
target: 1_500,
target_date: 12.months.ago.to_date,
state: "archived",
accounts: [ primary ],
contributions: [
{ amount: 400, source: "initial", days_ago: 540, account: primary }
]
},
{

View File

@@ -5,9 +5,9 @@
<p class="text-sm text-secondary"><%= t("savings_goals.show.no_contributions_yet") %></p>
</div>
<% else %>
<ul class="space-y-3">
<ul>
<% contributions.each do |contribution| %>
<li class="flex items-center gap-3 group">
<li class="flex items-center gap-3 px-3 -mx-3 py-2 rounded-lg hover:bg-surface-hover">
<%= render Savings::GoalAvatarComponent.new(
name: contribution.account.name,
color: @savings_goal.color,
@@ -22,14 +22,24 @@
</div>
<span class="text-sm font-medium text-success tabular-nums">+<%= contribution.amount_money.format %></span>
<% if contribution.manual? %>
<%= button_to savings_goal_contribution_path(@savings_goal, contribution),
method: :delete,
class: "text-subdued hover:text-destructive p-1 rounded opacity-0 group-hover:opacity-100 transition-opacity",
form: { data: { turbo_confirm: t("savings_goals.show.confirm_delete_contribution") } } do %>
<%= icon("x", size: "sm", color: "current") %>
<%= render DS::Menu.new do |menu| %>
<% menu.with_item(
variant: "button",
text: t("savings_goals.show.delete_contribution"),
icon: "trash-2",
destructive: true,
href: savings_goal_contribution_path(@savings_goal, contribution),
method: :delete,
confirm: CustomConfirm.new(
destructive: true,
title: t("savings_goals.show.confirm_delete_contribution_title"),
body: t("savings_goals.show.confirm_delete_contribution_body", amount: contribution.amount_money.format),
btn_text: t("savings_goals.show.confirm_delete_contribution_cta")
)
) %>
<% end %>
<% else %>
<span class="w-7 h-7"></span>
<span class="w-9 h-9"></span>
<% end %>
</li>
<% end %>

View File

@@ -8,29 +8,27 @@
</div>
<p class="text-sm text-secondary">
<%
parts = []
primary_parts = []
if @savings_goal.target_date
parts << t(".header.target_by", amount: @savings_goal.target_amount_money.format, date: I18n.l(@savings_goal.target_date, format: :long))
primary_parts << t(".header.target_by", amount: @savings_goal.target_amount_money.format, date: I18n.l(@savings_goal.target_date, format: :long))
unless @savings_goal.completed? || @savings_goal.status == :reached
days = (@savings_goal.target_date - Date.current).to_i
if days > 0
parts << t("savings_goals.goal_card.days_left", count: days, date: I18n.l(@savings_goal.target_date, format: :long)).split(" · ").first
primary_parts << t("savings_goals.goal_card.days_left", count: days, date: I18n.l(@savings_goal.target_date, format: :long)).split(" · ").first
end
end
else
parts << t(".header.target", amount: @savings_goal.target_amount_money.format)
end
last_days = @savings_goal.last_contribution_days_ago
unless last_days.nil?
parts << if last_days.zero?
t("savings_goals.goal_card.footer_last_today")
else
t("savings_goals.goal_card.footer_last_days", count: last_days)
end
primary_parts << t(".header.target", amount: @savings_goal.target_amount_money.format)
end
%>
<%= parts.join(" · ") %>
<%= primary_parts.join(" · ") %>
</p>
<% last_days = @savings_goal.last_contribution_days_ago %>
<% unless last_days.nil? %>
<p class="text-xs text-subdued mt-0.5">
<%= last_days.zero? ? t("savings_goals.goal_card.footer_last_today") : t("savings_goals.goal_card.footer_last_days", count: last_days) %>
</p>
<% end %>
</div>
<div class="flex items-center gap-2">
<%= render DS::Link.new(
@@ -220,9 +218,9 @@
<% if @savings_goal.monthly_target_amount && @savings_goal.monthly_target_amount.to_d.positive? %>
<% delta = @savings_goal.monthly_target_amount.to_d - @stats[:avg_monthly].to_d %>
<% if delta.positive? %>
<p class="text-xs text-warning mt-1 tabular-nums"><%= t(".stats.behind_by", amount: Money.new(delta, @savings_goal.currency).format) %></p>
<p class="text-xs text-subdued mt-1 tabular-nums"><%= t(".stats.behind_by", amount: Money.new(delta, @savings_goal.currency).format) %></p>
<% else %>
<p class="text-xs text-success mt-1 tabular-nums"><%= t(".stats.above_target_pace") %></p>
<p class="text-xs text-subdued mt-1 tabular-nums"><%= t(".stats.above_target_pace") %></p>
<% end %>
<% else %>
<p class="text-xs text-subdued mt-1"><%= t(".stats.no_required_pace") %></p>

View File

@@ -93,7 +93,11 @@ en:
add_contribution: Add contribution
funding_accounts_heading: Funding accounts
no_contributions_yet: No contributions yet.
delete_contribution: Delete contribution
confirm_delete_contribution: Delete this contribution?
confirm_delete_contribution_title: Delete contribution?
confirm_delete_contribution_body: "Remove the %{amount} contribution from this goal's history. This can't be undone."
confirm_delete_contribution_cta: Delete contribution
funding_balance: "balance %{amount}"
of_saved: of saved
notes: Notes

View File

@@ -12,7 +12,7 @@ class SavingsGoalsControllerTest < ActionDispatch::IntegrationTest
test "index renders with active filter by default" do
get savings_goals_url
assert_response :success
assert_match(/Savings goals/i, response.body)
assert_match(/Savings/i, response.body)
end
test "index honors state filter" do