From 37dfd32628c9230b78bf977a931d444438ac78ad Mon Sep 17 00:00:00 2001
From: Guillem Arias
Date: Mon, 11 May 2026 19:40:21 +0200
Subject: [PATCH] fix(savings_goals): use display_status for inactive goals;
hide pace + projection
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- SavingsGoal#display_status returns :archived / :paused before falling
through to the visualization status. Memoized like #status. The plain
#status method keeps its meaning (visualization vs. target/pace) so
callers that genuinely want "is this on track" — KPI sort, goal-card
ring color, projection_payload — keep working unchanged.
- Savings::StatusPillComponent: status_key uses display_status; new
:archived variant (bg-surface-inset / text-gray-700 / archive icon).
Previously an archived goal showed "Behind" on the detail page while
the archived banner said the goal was archived — conflicting signal.
- show.html.erb: paused/archived goals render a static recap card
(current saved vs target) instead of the projection chart. Pace stat
(avg vs required monthly) is also hidden — extrapolating "Behind by
$X/mo" against a goal that isn't accepting contributions is misleading.
- New locale keys: savings_goals.status.archived,
savings_goals.show.inactive.{heading_paused, heading_archived, body}.
- Tests cover display_status for archived / paused / active goals.
---
.../savings/status_pill_component.rb | 6 ++---
app/models/savings_goal.rb | 15 +++++++++++
app/views/savings_goals/show.html.erb | 25 +++++++++++++++----
config/locales/views/savings_goals/en.yml | 5 ++++
test/models/savings_goal_test.rb | 17 +++++++++++++
5 files changed, 60 insertions(+), 8 deletions(-)
diff --git a/app/components/savings/status_pill_component.rb b/app/components/savings/status_pill_component.rb
index 0d43e3ef8..c1d42b824 100644
--- a/app/components/savings/status_pill_component.rb
+++ b/app/components/savings/status_pill_component.rb
@@ -9,7 +9,8 @@ class Savings::StatusPillComponent < ApplicationComponent
behind: { classes: "bg-yellow-500/10 text-yellow-700", icon: "triangle-alert" },
reached: { classes: "bg-green-500/10 text-green-700", icon: "star" },
no_target_date: { classes: "bg-surface-inset text-gray-700", icon: "infinity" },
- paused: { classes: "bg-surface-inset text-gray-700", icon: "pause" }
+ paused: { classes: "bg-surface-inset text-gray-700", icon: "pause" },
+ archived: { classes: "bg-surface-inset text-gray-700", icon: "archive" }
}.freeze
def initialize(goal:)
@@ -17,8 +18,7 @@ class Savings::StatusPillComponent < ApplicationComponent
end
def status_key
- return :paused if @goal.paused?
- @goal.status
+ @goal.display_status
end
def variant
diff --git a/app/models/savings_goal.rb b/app/models/savings_goal.rb
index ea5280548..3696fb443 100644
--- a/app/models/savings_goal.rb
+++ b/app/models/savings_goal.rb
@@ -162,6 +162,21 @@ class SavingsGoal < ApplicationRecord
}
end
+ # Display-layer status. Prefers AASM state for inactive goals so the UI
+ # doesn't compute a misleading "Behind / On track" verdict against a goal
+ # that isn't accepting contributions anymore.
+ def display_status
+ return @display_status if defined?(@display_status)
+
+ @display_status = if archived?
+ :archived
+ elsif paused?
+ :paused
+ else
+ status
+ end
+ end
+
# :reached → progress_percent >= 100
# :on_track → has target_date and current pace >= required monthly pace
# :behind → has target_date and current pace < required monthly pace
diff --git a/app/views/savings_goals/show.html.erb b/app/views/savings_goals/show.html.erb
index 7779e791c..c44a403c3 100644
--- a/app/views/savings_goals/show.html.erb
+++ b/app/views/savings_goals/show.html.erb
@@ -145,7 +145,20 @@
- <% if @savings_goal.completed? || @savings_goal.status == :reached %>
+ <% if @savings_goal.archived? || @savings_goal.paused? %>
+ <%# Paused / archived: pace + projection are misleading. Show a static recap card. %>
+
+
+ <%= icon(@savings_goal.archived? ? "archive" : "pause", size: "2xl") %>
+
+
+ <%= t(@savings_goal.archived? ? ".inactive.heading_archived" : ".inactive.heading_paused") %>
+
+
+ <%= t(".inactive.body", saved: @savings_goal.current_balance_money.format, target: @savings_goal.target_amount_money.format) %>
+
+
+ <% elsif @savings_goal.completed? || @savings_goal.status == :reached %>
<%# Reached celebration card %>
@@ -212,11 +225,13 @@
<% end %>
- <%# Stat row — combo pace card + contributions count. Reached goals
- hide the pace combo since the comparison is moot. %>
+ <%# Stat row — combo pace card + contributions count. Reached, paused,
+ or archived goals hide the pace combo since the comparison is moot
+ or misleading. %>
<% goal_reached = @savings_goal.completed? || @savings_goal.status == :reached %>
-
gap-3">
- <% unless goal_reached %>
+ <% hide_pace = goal_reached || @savings_goal.archived? || @savings_goal.paused? %>
+ gap-3">
+ <% unless hide_pace %>
<%# Combo: Avg vs Target pace %>
<%= t(".stats.monthly_pace") %>
diff --git a/config/locales/views/savings_goals/en.yml b/config/locales/views/savings_goals/en.yml
index 87a87113b..e6a49031f 100644
--- a/config/locales/views/savings_goals/en.yml
+++ b/config/locales/views/savings_goals/en.yml
@@ -137,6 +137,10 @@ en:
heading: Goal reached. Nice work.
body: "You hit your %{amount} target. Keep the goal as a record, or archive it now."
archive_cta: Archive goal
+ inactive:
+ heading_paused: This goal is paused
+ heading_archived: This goal is archived
+ body: "%{saved} of %{target} saved so far."
no_target_date:
heading: Add a target date
body: Set a deadline to project a finish line and track required pace.
@@ -168,6 +172,7 @@ en:
reached: Reached
no_target_date: No date
paused: Paused
+ archived: Archived
empty_state:
heading: No goals yet
body: Set a target, link the accounts you save into, and watch your progress add up. Goals can pull from multiple accounts.
diff --git a/test/models/savings_goal_test.rb b/test/models/savings_goal_test.rb
index e78eceace..d4ee5cd4d 100644
--- a/test/models/savings_goal_test.rb
+++ b/test/models/savings_goal_test.rb
@@ -126,6 +126,23 @@ class SavingsGoalTest < ActiveSupport::TestCase
assert_equal :no_target_date, @goal.status
end
+ test "display_status returns :archived for archived goal regardless of progress" do
+ @goal.save!
+ @goal.archive!
+ assert_equal :archived, @goal.display_status
+ end
+
+ test "display_status returns :paused for paused goal regardless of progress" do
+ @goal.save!
+ @goal.pause!
+ assert_equal :paused, @goal.display_status
+ end
+
+ test "display_status falls through to status for active goals" do
+ @goal.target_amount = 1
+ assert_equal :reached, @goal.display_status
+ end
+
test "advisory_lock_key_for is stable per family" do
k1 = SavingsGoal.advisory_lock_key_for(@family.id)
k2 = SavingsGoal.advisory_lock_key_for(@family.id)