fix(savings_goals): use display_status for inactive goals; hide pace + projection

- 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.
This commit is contained in:
Guillem Arias
2026-05-11 19:40:21 +02:00
parent 029c859fcb
commit 37dfd32628
5 changed files with 60 additions and 8 deletions

View File

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

View File

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

View File

@@ -145,7 +145,20 @@
</p>
</div>
<% 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. %>
<div class="bg-container rounded-xl shadow-border-xs p-5 flex flex-col items-center justify-center text-center">
<div class="w-16 h-16 rounded-full bg-surface-inset inline-flex items-center justify-center text-secondary mb-3">
<%= icon(@savings_goal.archived? ? "archive" : "pause", size: "2xl") %>
</div>
<h2 class="text-lg font-semibold text-primary">
<%= t(@savings_goal.archived? ? ".inactive.heading_archived" : ".inactive.heading_paused") %>
</h2>
<p class="text-sm text-secondary mt-1 max-w-md tabular-nums">
<%= t(".inactive.body", saved: @savings_goal.current_balance_money.format, target: @savings_goal.target_amount_money.format) %>
</p>
</div>
<% elsif @savings_goal.completed? || @savings_goal.status == :reached %>
<%# Reached celebration card %>
<div class="bg-container rounded-xl shadow-border-xs p-5 flex flex-col items-center justify-center text-center">
<div class="w-16 h-16 rounded-full bg-green-500/10 inline-flex items-center justify-center text-success mb-3">
@@ -212,11 +225,13 @@
<% end %>
</section>
<%# 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 %>
<section class="grid grid-cols-1 <%= goal_reached ? "" : "md:grid-cols-3" %> gap-3">
<% unless goal_reached %>
<% hide_pace = goal_reached || @savings_goal.archived? || @savings_goal.paused? %>
<section class="grid grid-cols-1 <%= hide_pace ? "" : "md:grid-cols-3" %> gap-3">
<% unless hide_pace %>
<%# Combo: Avg vs Target pace %>
<div class="md:col-span-2 bg-container rounded-xl shadow-border-xs px-5 py-4">
<p class="text-[11px] text-secondary mb-2"><%= t(".stats.monthly_pace") %></p>

View File

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

View File

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