mirror of
https://github.com/we-promise/sure.git
synced 2026-05-30 07:49:01 +00:00
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:
@@ -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 %>"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user