fix(savings): DS conformance pass on stepper, ring, card, status pill

- StatusPill: use functional `text-success` / `text-warning` tokens with
  matching icon colors and `px-2 py-1`, mirroring
  `app/views/budget_categories/_budget_category.html.erb:29-43`.
- ProgressRing: rework center text to match `_budget_donut.html.erb`
  (small "Saved" label, `text-3xl font-medium` headline, "of $X"
  underline). Stroke color now derives from goal.status (yellow when
  behind, blue on track, green reached, gray for no-date).
- GoalCard bar: track height + transition match budget category bar
  (`h-1.5`, `transition-all duration-500`, `inline-size`).
- Index/show layouts: render page header inline (`<h1>` + actions). The
  default application layout doesn't yield `:page_actions`, so the
  CTA + kebab menu wouldn't appear when emitted via `content_for`.
- Stepper review summary: target the actual form inputs by `name`
  rather than relying on the `data-target` Stimulus attribute, since
  `money_field` puts the attribute on the wrapper. Step 1 validation
  scoped to the step 1 panel.
- Demo generator: filter Depository accounts via
  `where(accountable_type: "Depository")` — Rails delegated_type
  generates the `depository?` predicate, not a `.depository` scope.
This commit is contained in:
Guillem Arias
2026-05-11 11:39:13 +02:00
parent 77660d2ee4
commit 8a9f4b1a67
12 changed files with 139 additions and 123 deletions

4
.gitignore vendored
View File

@@ -127,3 +127,7 @@ logs/security/
# Added by codex
.codex
# Ignore Playwright artifacts
playwright-report/
.playwright-mcp

View File

@@ -15,8 +15,8 @@
<span class="text-secondary"><%= goal.current_balance_money.format %></span>
<span class="text-secondary">/ <%= goal.target_amount_money.format %></span>
</div>
<div class="mt-2 h-2 w-full rounded-full bg-container-inset overflow-hidden">
<div class="h-full <%= bar_color_class %>" style="width: <%= progress_percent %>%"></div>
<div class="mt-2 h-1.5 w-full rounded-full bg-container-inset overflow-hidden">
<div class="h-full <%= bar_color_class %> rounded-full transition-all duration-500" style="inline-size: <%= progress_percent %>%"></div>
</div>
<div class="mt-1 flex items-center justify-between text-xs text-secondary tabular-nums">
<span><%= progress_percent %>%</span>

View File

@@ -21,10 +21,11 @@ class Savings::GoalCardComponent < ApplicationComponent
end
def bar_color_class
case progress_percent
when 0...25 then "bg-gray-400"
when 25...75 then "bg-blue-500"
else "bg-green-600"
case goal.status
when :reached then "bg-green-500"
when :behind then "bg-yellow-500"
when :on_track then "bg-blue-500"
else "bg-gray-400"
end
end
end

View File

@@ -20,8 +20,8 @@
transform="rotate(-90 <%= Savings::ProgressRingComponent::SIZE / 2.0 %> <%= Savings::ProgressRingComponent::SIZE / 2.0 %>)" />
</svg>
<div class="absolute inset-0 flex flex-col items-center justify-center text-center">
<span class="text-2xl font-semibold text-primary tabular-nums"><%= percent %>%</span>
<span class="text-xs text-secondary tabular-nums mt-1"><%= current_label %></span>
<span class="text-xs text-secondary tabular-nums">of <%= target_label %></span>
<span class="text-secondary text-sm mb-1"><%= t("savings_goals.show.ring.saved") %></span>
<span class="text-3xl font-medium text-primary tabular-nums privacy-sensitive"><%= current_label %></span>
<span class="text-secondary text-sm mt-1 tabular-nums">of <%= target_label %></span>
</div>
</div>

View File

@@ -1,5 +1,5 @@
class Savings::ProgressRingComponent < ApplicationComponent
SIZE = 180
SIZE = 220
STROKE = 14
RADIUS = (SIZE - STROKE) / 2.0
CIRCUMFERENCE = 2 * Math::PI * RADIUS
@@ -19,10 +19,11 @@ class Savings::ProgressRingComponent < ApplicationComponent
end
def stroke_color
case percent
when 0...25 then "var(--color-gray-400)"
when 25...75 then "var(--color-blue-500)"
else "var(--color-green-600)"
case goal.status
when :reached then "var(--color-green-500)"
when :behind then "var(--color-yellow-500)"
when :on_track then "var(--color-blue-500)"
else "var(--color-gray-400)"
end
end

View File

@@ -1,4 +1,4 @@
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium <%= classes %>">
<%= helpers.icon(icon_name, size: "xs", color: "current") %>
<span class="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap <%= classes %>">
<%= helpers.icon(icon_name, size: "sm", color: icon_color) %>
<%= label %>
</span>

View File

@@ -1,9 +1,9 @@
class Savings::StatusPillComponent < ApplicationComponent
VARIANTS = {
on_track: { classes: "bg-green-600/10 text-green-700", icon: "check" },
behind: { classes: "bg-yellow-500/10 text-yellow-700", icon: "alert-triangle" },
reached: { classes: "bg-green-600/10 text-green-700", icon: "circle-check-big" },
no_target_date: { classes: "bg-container-inset text-secondary", icon: "calendar-off" }
on_track: { classes: "bg-green-500/10 text-success", icon: "check-circle", icon_color: "green" },
behind: { classes: "bg-yellow-500/10 text-warning", icon: "alert-triangle", icon_color: "yellow" },
reached: { classes: "bg-green-500/10 text-success", icon: "circle-check-big", icon_color: "green" },
no_target_date: { classes: "bg-surface-inset text-secondary", icon: "calendar-off", icon_color: "default" }
}.freeze
def initialize(goal:)
@@ -26,6 +26,10 @@ class Savings::StatusPillComponent < ApplicationComponent
variant[:icon]
end
def icon_color
variant[:icon_color]
end
def classes
variant[:classes]
end

View File

@@ -52,10 +52,13 @@ export default class extends Controller {
}
validateStep1() {
const requiredInputs = this.step1PanelTarget.querySelectorAll(
'input[name="savings_goal[name]"], input[name="savings_goal[target_amount]"]'
);
let ok = true;
this.step1FieldTargets.forEach((field) => {
if (!field.checkValidity()) {
field.reportValidity();
requiredInputs.forEach((input) => {
if (typeof input.checkValidity === "function" && !input.checkValidity()) {
input.reportValidity();
ok = false;
}
});
@@ -95,11 +98,13 @@ export default class extends Controller {
updateReview() {
if (!this.hasReviewPanelTarget) return;
if (this.hasReviewNameTarget && this.hasNameFieldTarget) {
this.reviewNameTarget.textContent = this.nameFieldTarget.value || "—";
if (this.hasReviewNameTarget) {
const nameInput = this.element.querySelector('input[name="savings_goal[name]"]');
this.reviewNameTarget.textContent = nameInput?.value || "—";
}
if (this.hasReviewAmountTarget && this.hasTargetAmountFieldTarget) {
this.reviewAmountTarget.textContent = this.targetAmountFieldTarget.value || "—";
if (this.hasReviewAmountTarget) {
const amountInput = this.element.querySelector('input[name="savings_goal[target_amount]"]');
this.reviewAmountTarget.textContent = amountInput?.value || "—";
}
if (this.hasReviewDateTarget) {
const dateInput = this.element.querySelector('input[type="date"][name="savings_goal[target_date]"]');

View File

@@ -1279,7 +1279,7 @@ class Demo::Generator
end
def generate_savings_goals!(family)
depository_accounts = family.accounts.depository.visible.to_a
depository_accounts = family.accounts.where(accountable_type: "Depository").visible.to_a
return if depository_accounts.empty?
currency = depository_accounts.first.currency

View File

@@ -1,20 +1,20 @@
<%= content_for :page_title, t(".title") %>
<%= content_for :page_actions do %>
<% 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 %>
<div class="space-y-4">
<header class="flex items-center justify-between gap-3">
<h1 class="text-lg font-semibold text-primary"><%= t(".title") %></h1>
<% 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 %>
</header>
<% if @savings_goals.empty? && @counts["all"].zero? %>
<%= render "empty_state", linkable_account_count: @linkable_account_count %>
<% else %>
<div class="space-y-4">
<% if @savings_goals.empty? && @counts["all"].zero? %>
<%= render "empty_state", linkable_account_count: @linkable_account_count %>
<% else %>
<nav class="flex items-center gap-1 overflow-x-auto" role="tablist">
<% SavingsGoalsController::STATE_FILTERS.each do |state| %>
<% active = state == @state_filter %>
@@ -39,5 +39,5 @@
<p class="text-sm text-secondary"><%= t(".empty_filtered", state: t(".tabs.#{@state_filter}").downcase) %></p>
</div>
<% end %>
</div>
<% end %>
<% end %>
</div>

View File

@@ -1,73 +1,80 @@
<%= content_for :page_title, @savings_goal.name %>
<%= content_for :page_actions do %>
<%= render DS::Menu.new do |menu| %>
<% menu.with_item(
variant: "link",
text: t(".edit"),
icon: "pencil",
href: edit_savings_goal_path(@savings_goal),
data: { turbo_frame: :modal }
) %>
<% if @savings_goal.may_pause? %>
<% menu.with_item(
variant: "button",
text: t(".pause"),
icon: "pause",
href: pause_savings_goal_path(@savings_goal),
method: :patch
) %>
<% end %>
<% if @savings_goal.may_resume? %>
<% menu.with_item(
variant: "button",
text: t(".resume"),
icon: "play",
href: resume_savings_goal_path(@savings_goal),
method: :patch
) %>
<% end %>
<% if @savings_goal.may_complete? %>
<% menu.with_item(
variant: "button",
text: t(".complete"),
icon: "circle-check-big",
href: complete_savings_goal_path(@savings_goal),
method: :patch
) %>
<% end %>
<% if @savings_goal.may_archive? %>
<% menu.with_item(
variant: "button",
text: t(".archive"),
icon: "archive",
href: archive_savings_goal_path(@savings_goal),
method: :patch
) %>
<% end %>
<% if @savings_goal.may_unarchive? %>
<% menu.with_item(
variant: "button",
text: t(".unarchive"),
icon: "archive-restore",
href: unarchive_savings_goal_path(@savings_goal),
method: :patch
) %>
<% end %>
<% if @savings_goal.archived? %>
<% menu.with_item(
variant: "button",
text: t(".delete"),
icon: "trash-2",
href: savings_goal_path(@savings_goal),
method: :delete,
destructive: true,
confirm: CustomConfirm.for_resource_deletion(@savings_goal.name, high_severity: true)
) %>
<% end %>
<% end %>
<% end %>
<div class="space-y-4">
<header class="flex items-center justify-between gap-3">
<div class="flex items-center gap-3 min-w-0">
<%= render Savings::GoalAvatarComponent.new(goal: @savings_goal, size: "md") %>
<div class="min-w-0">
<h1 class="text-lg font-semibold text-primary truncate"><%= @savings_goal.name %></h1>
<p class="text-xs text-secondary"><%= t("savings_goals.states.#{@savings_goal.state}") %></p>
</div>
</div>
<%= render DS::Menu.new do |menu| %>
<% menu.with_item(
variant: "link",
text: t(".edit"),
icon: "pencil",
href: edit_savings_goal_path(@savings_goal),
data: { turbo_frame: :modal }
) %>
<% if @savings_goal.may_pause? %>
<% menu.with_item(
variant: "button",
text: t(".pause"),
icon: "pause",
href: pause_savings_goal_path(@savings_goal),
method: :patch
) %>
<% end %>
<% if @savings_goal.may_resume? %>
<% menu.with_item(
variant: "button",
text: t(".resume"),
icon: "play",
href: resume_savings_goal_path(@savings_goal),
method: :patch
) %>
<% end %>
<% if @savings_goal.may_complete? %>
<% menu.with_item(
variant: "button",
text: t(".complete"),
icon: "circle-check-big",
href: complete_savings_goal_path(@savings_goal),
method: :patch
) %>
<% end %>
<% if @savings_goal.may_archive? %>
<% menu.with_item(
variant: "button",
text: t(".archive"),
icon: "archive",
href: archive_savings_goal_path(@savings_goal),
method: :patch
) %>
<% end %>
<% if @savings_goal.may_unarchive? %>
<% menu.with_item(
variant: "button",
text: t(".unarchive"),
icon: "archive-restore",
href: unarchive_savings_goal_path(@savings_goal),
method: :patch
) %>
<% end %>
<% if @savings_goal.archived? %>
<% menu.with_item(
variant: "button",
text: t(".delete"),
icon: "trash-2",
href: savings_goal_path(@savings_goal),
method: :delete,
destructive: true,
confirm: CustomConfirm.for_resource_deletion(@savings_goal.name, high_severity: true)
) %>
<% end %>
<% end %>
</header>
<section class="bg-container rounded-xl shadow-border-xs p-6">
<div class="flex flex-col md:flex-row items-start md:items-center gap-6">
<div>
@@ -75,14 +82,6 @@
</div>
<div class="flex-1 space-y-3 min-w-0">
<div class="flex items-center gap-2">
<%= render Savings::GoalAvatarComponent.new(goal: @savings_goal, size: "lg") %>
<div class="min-w-0">
<h1 class="text-lg font-semibold text-primary truncate"><%= @savings_goal.name %></h1>
<p class="text-xs text-secondary"><%= t("savings_goals.states.#{@savings_goal.state}") %></p>
</div>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3 text-sm">
<div>
<p class="text-xs text-secondary"><%= t(".stats.current") %></p>

View File

@@ -51,6 +51,8 @@ en:
funding_accounts_heading: Funding accounts
no_contributions_yet: No contributions yet.
confirm_delete_contribution: Delete this contribution?
ring:
saved: Saved
source:
initial: Initial
manual: Manual