feat(savings): rebuild UI to match Claude Design + adopt shared donut-chart

Previous savings goals UI looked nothing like the Claude Design output
(see sure-design-context/design/savings-goals/project/goals/*.jsx) and
the hand-rolled ring did not match the segmented D3 donut used at
app/views/budgets/_budget_donut.html.erb. This rewires the surface end
to end.

Donut chart:
- SavingsGoal#to_donut_segments_json returns the same segment shape as
  Budget#to_donut_segments_json: filled portion in goal color, unused
  remainder as `var(--budget-unallocated-fill)`. Visual identity is now
  the same: segmented arc with cornerRadius and gap, courtesy of the
  shared `donut-chart` Stimulus controller and D3.
- ProgressRingComponent renders a `data-controller="donut-chart"` div
  with the same default-content/inner-text pattern as `_budget_donut`.

Index page (matches GoalsIndex.jsx):
- Page header: title + "Save toward what matters." subtitle + "New goal"
  primary CTA right-aligned.
- Summary strip card: total saved / target, overall bar, active goals,
  on-track ratio, behind count.
- State filter rendered as DS::Tabs-style pill nav (`bg-surface-inset
  p-1 rounded-lg`, white-pill active state).
- Cards rebuilt: avatar (44px, rounded-xl, white initial on goal color)
  + name + secondary line ("N days left · by date" / "No target date" /
  "Completed" / "Past due"), status pill with leading dot, big
  $current/$target line + percent, bar in status colour, AccountStack
  (overlapping initials) + "N accounts" + "to go".

Goal detail (matches GoalDetail.jsx):
- Header: 64px avatar + h1 name + status pill + "Target $X by date ·
  N days left" subline + Edit (outline) + Add contribution (primary) +
  kebab (DS::Menu for AASM transitions).
- Donut-chart ring card with stats overlay.
- 4-col stat row (Avg monthly, Total contributions, Target date,
  Started) with mono numerals and "Needs $X/mo" / "Above target pace"
  sub-captions where relevant.
- Two-col bottom: contributions list (avatar + account · date · source
  · green +$amount) and funding accounts breakdown (stacked bar +
  per-account row with $ and % of saved).

New components: Savings::AccountStackComponent (overlapping account
initials with ring-2 ring-container). StatusPillComponent now uses a
leading colored dot instead of an icon. GoalAvatarComponent radii
match Claude Design (rounded-md/lg/xl/2xl) and white initial.

Locale: new keys under savings_goals.{index.subtitle, index.summary.*,
goal_card.{accounts,days_left,completed,past_due,no_target_date},
show.header.*, show.ring.{of,to_go}, show.stats.*, show.funding_balance,
show.of_saved, show.notes}.
This commit is contained in:
Guillem Arias
2026-05-11 11:52:35 +02:00
parent 8a9f4b1a67
commit 39743a9ec4
17 changed files with 412 additions and 263 deletions

View File

@@ -0,0 +1,13 @@
<span class="inline-flex items-center">
<% shown.each_with_index do |account, i| %>
<span class="inline-flex items-center justify-center w-5 h-5 rounded-full text-inverse text-[9px] font-semibold ring-2 ring-container"
style="background-color: var(--color-blue-500); <%= "margin-left: -6px;" if i > 0 %>"
title="<%= account.name %>">
<%= initial_for(account) %>
</span>
<% end %>
<% if extra_count > 0 %>
<span class="inline-flex items-center justify-center w-5 h-5 rounded-full bg-surface-inset text-secondary text-[9px] font-semibold ring-2 ring-container"
style="margin-left: -6px;">+<%= extra_count %></span>
<% end %>
</span>

View File

@@ -0,0 +1,18 @@
class Savings::AccountStackComponent < ApplicationComponent
def initialize(accounts:, max: 3)
@accounts = accounts
@max = max
end
def shown
@accounts.first(@max)
end
def extra_count
[ @accounts.size - @max, 0 ].max
end
def initial_for(account)
account.name.to_s.strip.first&.upcase || "?"
end
end

View File

@@ -1,25 +1,27 @@
<section class="bg-container rounded-xl shadow-border-xs p-4">
<h3 class="text-sm font-medium text-primary mb-3"><%= t("savings_goals.show.funding_accounts_heading") %></h3>
<% if total.zero? %>
<p class="text-sm text-secondary"><%= t("savings_goals.show.no_contributions_yet") %></p>
<% else %>
<div class="flex h-2 rounded-full overflow-hidden mb-4">
<% rows.each do |row| %>
<% next if row[:amount].to_d.zero? %>
<div style="width: <%= percent_for(row[:amount]) %>%; background-color: <%= goal.color %>;"
title="<%= row[:account].name %>"></div>
<% end %>
</div>
<% if total.zero? %>
<p class="text-sm text-secondary"><%= t("savings_goals.show.no_contributions_yet") %></p>
<% else %>
<div class="space-y-3">
<% rows.each do |row| %>
<div class="flex items-center gap-3">
<%= render Savings::GoalAvatarComponent.new(name: row[:account].name, color: goal.color, size: "sm") %>
<div class="min-w-0 flex-1">
<div class="flex items-center justify-between gap-2 text-sm">
<span class="text-primary truncate"><%= row[:account].name %></span>
<span class="text-secondary tabular-nums"><%= row[:money].format %></span>
</div>
<div class="mt-1 h-1.5 w-full rounded-full bg-container-inset overflow-hidden">
<div class="h-full bg-blue-500" style="width: <%= percent_for(row[:amount]) %>%"></div>
</div>
</div>
<span class="text-xs text-secondary tabular-nums w-10 text-right"><%= percent_for(row[:amount]) %>%</span>
<ul class="space-y-3">
<% rows.each do |row| %>
<li class="flex items-center gap-3">
<%= render Savings::GoalAvatarComponent.new(name: row[:account].name, color: goal.color, size: "sm") %>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-primary truncate"><%= row[:account].name %></p>
<p class="text-[11px] text-subdued"><%= row[:account].subtype&.titleize || row[:account].accountable_type %> · <%= t("savings_goals.show.funding_balance", amount: Money.new(row[:account].balance, row[:account].currency).format) %></p>
</div>
<% end %>
</div>
<% end %>
</section>
<div class="text-right">
<p class="text-sm font-medium text-primary tabular-nums"><%= row[:money].format %></p>
<p class="text-[10px] text-subdued tabular-nums"><%= percent_for(row[:amount]) %>% <%= t("savings_goals.show.of_saved") %></p>
</div>
</li>
<% end %>
</ul>
<% end %>

View File

@@ -1,4 +1,4 @@
<span class="inline-flex items-center justify-center rounded-full text-inverse font-medium <%= box_classes %> <%= text_classes %>"
<span class="inline-flex items-center justify-center text-inverse font-semibold <%= box_classes %> <%= text_classes %> <%= radius_classes %>"
style="background-color: <%= color %>;"
data-testid="savings-goal-avatar">
<%= initial %>

View File

@@ -1,8 +1,9 @@
class Savings::GoalAvatarComponent < ApplicationComponent
SIZES = {
"sm" => { box: "w-6 h-6", text: "text-xs" },
"md" => { box: "w-8 h-8", text: "text-sm" },
"lg" => { box: "w-12 h-12", text: "text-lg" }
"sm" => { box: "w-6 h-6", text: "text-[10px]", radius: "rounded-md" },
"md" => { box: "w-9 h-9", text: "text-sm", radius: "rounded-lg" },
"lg" => { box: "w-11 h-11", text: "text-base", radius: "rounded-xl" },
"xl" => { box: "w-16 h-16", text: "text-2xl", radius: "rounded-2xl" }
}.freeze
def initialize(goal: nil, name: nil, color: nil, size: "md")
@@ -26,4 +27,8 @@ class Savings::GoalAvatarComponent < ApplicationComponent
def text_classes
SIZES[@size][:text]
end
def radius_classes
SIZES[@size][:radius]
end
end

View File

@@ -1,28 +1,35 @@
<%= link_to savings_goal_path(goal), class: "block bg-container rounded-xl shadow-border-xs hover:shadow-border-sm p-4 transition-shadow" do %>
<%= link_to savings_goal_path(goal),
class: "group flex flex-col gap-3.5 p-[18px] bg-container rounded-xl shadow-border-xs hover:bg-surface-hover transition-colors" do %>
<div class="flex items-start gap-3">
<%= render Savings::GoalAvatarComponent.new(goal: goal, size: "md") %>
<%= render Savings::GoalAvatarComponent.new(goal: goal, size: "lg") %>
<div class="min-w-0 flex-1">
<div class="flex items-center justify-between gap-2">
<p class="text-sm font-medium text-primary truncate"><%= goal.name %></p>
<%= render Savings::StatusPillComponent.new(goal: goal) %>
<p class="text-sm font-medium text-primary truncate"><%= goal.name %></p>
<p class="text-[11px] text-subdued mt-0.5 truncate"><%= secondary_line %></p>
</div>
<%= render Savings::StatusPillComponent.new(goal: goal) %>
</div>
<div>
<div class="flex items-baseline justify-between mb-1.5">
<div class="text-lg font-medium text-primary tabular-nums privacy-sensitive">
<%= goal.current_balance_money.format %>
<span class="text-xs text-subdued ml-1">/ <%= goal.target_amount_money.format %></span>
</div>
<p class="text-xs text-secondary truncate mt-0.5"><%= linked_accounts_label %></p>
<span class="text-xs text-secondary tabular-nums"><%= progress_percent %>%</span>
</div>
<div class="h-1.5 w-full rounded-full bg-surface-inset overflow-hidden">
<div class="h-full rounded-full transition-all duration-500"
style="inline-size: <%= progress_percent %>%; background-color: <%= bar_color_style %>;"></div>
</div>
</div>
<div class="mt-4">
<div class="flex items-center justify-between gap-2 text-xs tabular-nums">
<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-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>
<% if goal.target_date %>
<span><%= I18n.l(goal.target_date, format: :long) %></span>
<% end %>
<div class="flex items-center justify-between pt-1">
<div class="flex items-center gap-2">
<%= render Savings::AccountStackComponent.new(accounts: linked_accounts) %>
<span class="text-[11px] text-subdued"><%= linked_accounts_count_label %></span>
</div>
<span class="text-[11px] text-subdued tabular-nums">
<% if goal.completed? %>—<% else %><%= goal.remaining_amount_money.format %> to go<% end %>
</span>
</div>
<% end %>

View File

@@ -5,27 +5,40 @@ class Savings::GoalCardComponent < ApplicationComponent
attr_reader :goal
def linked_accounts_label
names = goal.linked_accounts.pluck(:name)
case names.size
when 0 then I18n.t("savings_goals.goal_card.no_accounts")
when 1 then names.first
when 2 then names.join(", ")
else
I18n.t("savings_goals.goal_card.n_accounts", first: names.first, count: names.size - 1)
end
end
def progress_percent
goal.progress_percent
end
def bar_color_class
def bar_color_style
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"
when :reached then "var(--color-green-600)"
when :behind then "var(--color-yellow-500)"
when :on_track then "var(--text-primary)"
else "var(--color-gray-400)"
end
end
def linked_accounts
@linked_accounts ||= goal.linked_accounts.to_a
end
def linked_accounts_count_label
n = linked_accounts.size
I18n.t("savings_goals.goal_card.accounts", count: n)
end
def secondary_line
if goal.completed?
I18n.t("savings_goals.goal_card.completed")
elsif goal.target_date.nil?
I18n.t("savings_goals.goal_card.no_target_date")
else
days = (goal.target_date - Date.current).to_i
if days >= 0
I18n.t("savings_goals.goal_card.days_left", count: days, date: I18n.l(goal.target_date, format: :long))
else
I18n.t("savings_goals.goal_card.past_due")
end
end
end
end

View File

@@ -1,27 +1,15 @@
<div class="relative inline-flex items-center justify-center" style="width: <%= Savings::ProgressRingComponent::SIZE %>px; height: <%= Savings::ProgressRingComponent::SIZE %>px;">
<svg width="<%= Savings::ProgressRingComponent::SIZE %>"
height="<%= Savings::ProgressRingComponent::SIZE %>"
viewBox="0 0 <%= Savings::ProgressRingComponent::SIZE %> <%= Savings::ProgressRingComponent::SIZE %>">
<circle cx="<%= Savings::ProgressRingComponent::SIZE / 2.0 %>"
cy="<%= Savings::ProgressRingComponent::SIZE / 2.0 %>"
r="<%= Savings::ProgressRingComponent::RADIUS %>"
fill="none"
stroke="var(--color-gray-200)"
stroke-width="<%= Savings::ProgressRingComponent::STROKE %>" />
<circle cx="<%= Savings::ProgressRingComponent::SIZE / 2.0 %>"
cy="<%= Savings::ProgressRingComponent::SIZE / 2.0 %>"
r="<%= Savings::ProgressRingComponent::RADIUS %>"
fill="none"
stroke="<%= stroke_color %>"
stroke-width="<%= Savings::ProgressRingComponent::STROKE %>"
stroke-linecap="round"
stroke-dasharray="<%= Savings::ProgressRingComponent::CIRCUMFERENCE %>"
stroke-dashoffset="<%= offset %>"
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-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 data-controller="donut-chart"
data-donut-chart-segments-value="<%= goal.to_donut_segments_json.to_json %>"
data-donut-chart-segment-height-value="6"
class="relative mx-auto"
style="width: <%= size %>px; height: <%= size %>px;">
<div data-donut-chart-target="chartContainer" class="absolute inset-0 pointer-events-none"></div>
<div data-donut-chart-target="contentContainer" class="flex items-center justify-center h-full">
<div data-donut-chart-target="defaultContent" class="flex flex-col items-center text-center">
<span class="text-secondary text-xs mb-1"><%= t("savings_goals.show.ring.saved") %></span>
<span class="text-3xl font-medium tabular-nums privacy-sensitive" style="color: <%= percent_text_color %>;"><%= percent %>%</span>
<span class="text-xs text-subdued tabular-nums mt-1"><%= amount_label %></span>
<span class="text-xs text-subdued tabular-nums">of <%= target_label %></span>
</div>
</div>
</div>

View File

@@ -1,37 +1,32 @@
class Savings::ProgressRingComponent < ApplicationComponent
SIZE = 220
STROKE = 14
RADIUS = (SIZE - STROKE) / 2.0
CIRCUMFERENCE = 2 * Math::PI * RADIUS
def initialize(goal:)
def initialize(goal:, size: 180)
@goal = goal
@size = size
end
attr_reader :goal
attr_reader :goal, :size
def percent
[ [ goal.progress_percent.to_i, 0 ].max, 100 ].min
goal.progress_percent
end
def offset
CIRCUMFERENCE * (1 - percent / 100.0)
end
def stroke_color
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
def current_label
def amount_label
goal.current_balance_money.format
end
def target_label
goal.target_amount_money.format
end
def remaining_label
goal.remaining_amount_money.format
end
def percent_text_color
case goal.status
when :reached then "var(--color-green-600)"
when :behind then "var(--color-yellow-600)"
else "var(--text-primary)"
end
end
end

View File

@@ -1,4 +1,4 @@
<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) %>
<span class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium whitespace-nowrap <%= classes %>">
<span class="w-1.5 h-1.5 rounded-full <%= dot_classes %>"></span>
<%= label %>
</span>

View File

@@ -1,9 +1,9 @@
class Savings::StatusPillComponent < ApplicationComponent
VARIANTS = {
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" }
on_track: { classes: "bg-green-500/10 text-success", dot: "bg-green-600" },
behind: { classes: "bg-yellow-500/10 text-warning", dot: "bg-yellow-500" },
reached: { classes: "bg-green-500/10 text-success", dot: "bg-green-600" },
no_target_date: { classes: "bg-surface-inset text-secondary", dot: "bg-gray-400" }
}.freeze
def initialize(goal:)
@@ -22,15 +22,11 @@ class Savings::StatusPillComponent < ApplicationComponent
I18n.t("savings_goals.status.#{status}")
end
def icon_name
variant[:icon]
end
def icon_color
variant[:icon_color]
end
def classes
variant[:classes]
end
def dot_classes
variant[:dot]
end
end

View File

@@ -14,11 +14,13 @@ class SavingsGoalsController < ApplicationController
end
@linkable_account_count = Current.family.accounts.where(accountable_type: "Depository").visible.count
@totals = totals_for_family
end
def show
@contributions = @savings_goal.savings_contributions.includes(:account).chronological.limit(50)
@funding_breakdown = funding_breakdown_for(@savings_goal)
@stats = stats_for(@savings_goal)
end
def new
@@ -151,6 +153,47 @@ class SavingsGoalsController < ApplicationController
end
end
def totals_for_family
goals = Current.family.savings_goals.with_current_balance.to_a
saved = goals.sum { |g| g.current_balance.to_d }
target = goals.sum { |g| g.target_amount.to_d }
currency = Current.family.primary_currency_code
active_goals = goals.select { |g| g.state == "active" }
on_track = active_goals.count { |g| g.status == :on_track || g.status == :reached }
behind = active_goals.count { |g| g.status == :behind }
overall_percent = target.zero? ? 0 : ((saved / target) * 100).round
{
saved: Money.new(saved, currency),
target: Money.new(target, currency),
overall_percent: [ overall_percent, 100 ].min,
on_track_count: on_track,
behind_count: behind
}
end
def stats_for(goal)
avg = goal.average_monthly_contribution.to_d
sub_avg = if goal.monthly_target_amount && goal.monthly_target_amount.to_d > avg
t("savings_goals.show.stats.needs_per_month", amount: Money.new(goal.monthly_target_amount, goal.currency).format)
else
t("savings_goals.show.stats.above_target_pace")
end
sub_target = if goal.monthly_target_amount
t("savings_goals.show.stats.needs_per_month", amount: Money.new(goal.monthly_target_amount, goal.currency).format)
else
t("savings_goals.show.stats.no_required_pace")
end
months_since_start = ((Date.current.year - goal.created_at.year) * 12 + (Date.current.month - goal.created_at.month)).clamp(0, 1200)
sub_started = t("savings_goals.show.stats.months_ago", count: months_since_start)
{
avg_monthly: avg,
avg_monthly_sub: sub_avg,
contributions_count: goal.savings_contributions.count,
monthly_target_sub: sub_target,
started_sub: sub_started
}
end
def perform_transition!(event)
if @savings_goal.aasm.may_fire_event?(event)
@savings_goal.public_send("#{event}!")

View File

@@ -113,6 +113,24 @@ class SavingsGoal < ApplicationRecord
end
end
# Segment array consumed by the shared `donut-chart` Stimulus controller
# (see app/javascript/controllers/donut_chart_controller.js). Same shape
# as Budget#to_donut_segments_json: filled portion in goal color, unused
# remainder as the system "unallocated" fill.
def to_donut_segments_json
filled = current_balance.to_d
rem = remaining_amount.to_d
if filled.zero? && rem.zero?
return [ { color: "var(--budget-unallocated-fill)", amount: 1, id: "unused" } ]
end
segments = []
segments << { color: color.presence || "var(--color-blue-500)", amount: filled, id: "saved" } if filled.positive?
segments << { color: "var(--budget-unallocated-fill)", amount: rem, id: "unused" } if rem.positive?
segments
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

@@ -1,25 +1,30 @@
<%# locals: (contributions:) %>
<% if contributions.empty? %>
<p class="text-sm text-secondary"><%= t("savings_goals.show.no_contributions_yet") %></p>
<div class="px-5 py-8 text-center">
<p class="text-sm text-secondary"><%= t("savings_goals.show.no_contributions_yet") %></p>
</div>
<% else %>
<ul class="divide-y divide-divider">
<ul class="divide-y divide-subdued">
<% contributions.each do |contribution| %>
<li class="flex items-center gap-3 py-2">
<li class="flex items-center gap-3 px-5 py-3">
<%= render Savings::GoalAvatarComponent.new(
name: contribution.account.name,
color: @savings_goal.color,
size: "sm"
) %>
<div class="flex-1 min-w-0">
<p class="text-sm text-primary truncate"><%= contribution.account.name %></p>
<p class="text-xs text-secondary"><%= I18n.l(contribution.contributed_at, format: :long) %> · <%= t("savings_goals.show.source.#{contribution.source}") %></p>
<p class="text-sm font-medium text-primary truncate"><%= contribution.account.name %></p>
<p class="text-[11px] text-subdued">
<%= I18n.l(contribution.contributed_at, format: :long) %> ·
<%= t("savings_goals.show.source.#{contribution.source}") %>
</p>
</div>
<span class="text-sm text-primary tabular-nums"><%= contribution.amount_money.format %></span>
<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-secondary hover:text-destructive",
class: "text-secondary hover:text-destructive p-1 rounded",
form: { data: { turbo_confirm: t("savings_goals.show.confirm_delete_contribution") } } do %>
<%= icon("x", size: "sm") %>
<% end %>

View File

@@ -1,6 +1,9 @@
<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>
<div class="space-y-5">
<header class="flex items-start justify-between gap-3">
<div>
<h1 class="text-xl font-semibold text-primary"><%= t(".title") %></h1>
<p class="text-sm text-secondary mt-0.5"><%= t(".subtitle") %></p>
</div>
<% if @linkable_account_count > 0 %>
<%= render DS::Link.new(
text: t(".new_goal"),
@@ -15,13 +18,40 @@
<% 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">
<% if @counts["all"].positive? %>
<section class="bg-container rounded-xl shadow-border-xs p-5 grid grid-cols-1 md:grid-cols-4 gap-6 items-center">
<div class="md:col-span-1.5">
<p class="text-xs text-secondary mb-1"><%= t(".summary.total_saved") %></p>
<div class="flex items-baseline gap-2">
<span class="text-2xl font-medium text-primary tabular-nums privacy-sensitive"><%= @totals[:saved].format %></span>
<span class="text-xs text-subdued tabular-nums">/ <%= @totals[:target].format %></span>
</div>
<div class="mt-2 h-1.5 w-full rounded-full bg-surface-inset overflow-hidden">
<div class="h-full bg-primary rounded-full" style="inline-size: <%= @totals[:overall_percent] %>%; background-color: var(--text-primary);"></div>
</div>
</div>
<div>
<p class="text-xs text-secondary mb-1"><%= t(".summary.active_goals") %></p>
<p class="text-lg font-medium text-primary tabular-nums"><%= @counts["active"] %></p>
</div>
<div>
<p class="text-xs text-secondary mb-1"><%= t(".summary.on_track") %></p>
<p class="text-lg font-medium text-primary tabular-nums"><%= @totals[:on_track_count] %> / <%= @counts["active"] %></p>
</div>
<div>
<p class="text-xs text-secondary mb-1"><%= t(".summary.behind") %></p>
<p class="text-lg font-medium text-primary tabular-nums"><%= @totals[:behind_count] %></p>
</div>
</section>
<% end %>
<nav class="flex bg-surface-inset p-1 rounded-lg" role="tablist">
<% SavingsGoalsController::STATE_FILTERS.each do |state| %>
<% active = state == @state_filter %>
<%= link_to savings_goals_path(state: state),
role: "tab",
"aria-selected": active,
class: "inline-flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium #{active ? 'bg-container shadow-border-xs text-primary' : 'text-secondary hover:text-primary'}" do %>
class: "flex-1 inline-flex justify-center items-center gap-1.5 text-sm font-medium px-2 py-1 rounded-md transition-colors duration-200 #{active ? 'bg-white theme-dark:bg-gray-700 text-primary shadow-sm' : 'text-secondary hover:bg-surface-inset-hover'}" do %>
<span><%= t(".tabs.#{state}") %></span>
<span class="text-xs text-subdued tabular-nums"><%= @counts[state] %></span>
<% end %>
@@ -29,7 +59,7 @@
</nav>
<% if @savings_goals.any? %>
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<div class="grid gap-3.5 sm:grid-cols-2 xl:grid-cols-3">
<% @savings_goals.each do |goal| %>
<%= render Savings::GoalCardComponent.new(goal: goal) %>
<% end %>

View File

@@ -1,138 +1,126 @@
<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>
<header class="flex items-start gap-4">
<%= render Savings::GoalAvatarComponent.new(goal: @savings_goal, size: "xl") %>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 mb-1">
<h1 class="text-2xl font-semibold text-primary truncate"><%= @savings_goal.name %></h1>
<%= render Savings::StatusPillComponent.new(goal: @savings_goal) %>
</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>
<%= render Savings::ProgressRingComponent.new(goal: @savings_goal) %>
</div>
<div class="flex-1 space-y-3 min-w-0">
<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>
<p class="text-primary tabular-nums"><%= @savings_goal.current_balance_money.format %></p>
</div>
<div>
<p class="text-xs text-secondary"><%= t(".stats.target") %></p>
<p class="text-primary tabular-nums"><%= @savings_goal.target_amount_money.format %></p>
</div>
<div>
<p class="text-xs text-secondary"><%= t(".stats.remaining") %></p>
<p class="text-primary tabular-nums"><%= @savings_goal.remaining_amount_money.format %></p>
</div>
<div>
<p class="text-xs text-secondary"><%= t(".stats.target_date") %></p>
<p class="text-primary"><%= @savings_goal.target_date ? I18n.l(@savings_goal.target_date, format: :long) : t(".stats.no_target_date") %></p>
</div>
<% if @savings_goal.monthly_target_amount %>
<div>
<p class="text-xs text-secondary"><%= t(".stats.monthly_target") %></p>
<p class="text-primary tabular-nums"><%= Money.new(@savings_goal.monthly_target_amount, @savings_goal.currency).format %></p>
</div>
<p class="text-sm text-secondary">
<% if @savings_goal.target_date %>
<%= t(".header.target_by", amount: @savings_goal.target_amount_money.format, date: I18n.l(@savings_goal.target_date, format: :long)) %>
<% days = (@savings_goal.target_date - Date.current).to_i %>
<% if days > 0 %>
· <%= t("savings_goals.goal_card.days_left", count: days, date: I18n.l(@savings_goal.target_date, format: :long)).split(" · ").first %>
<% end %>
<div>
<p class="text-xs text-secondary"><%= t(".stats.status") %></p>
<%= render Savings::StatusPillComponent.new(goal: @savings_goal) %>
</div>
</div>
<% if @savings_goal.notes.present? %>
<p class="text-sm text-secondary"><%= simple_format(@savings_goal.notes) %></p>
<% else %>
<%= t(".header.target", amount: @savings_goal.target_amount_money.format) %>
<% end %>
</div>
</p>
</div>
</section>
<%= render Savings::FundingAccountsBreakdownComponent.new(goal: @savings_goal, rows: @funding_breakdown) %>
<section class="bg-container rounded-xl shadow-border-xs p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-medium text-primary"><%= t(".contributions_heading") %></h3>
<div class="flex items-center gap-2">
<%= render DS::Link.new(
text: t(".edit"),
variant: "outline",
href: edit_savings_goal_path(@savings_goal),
icon: "pencil",
frame: :modal
) %>
<%= render DS::Link.new(
text: t(".add_contribution"),
variant: "primary",
size: "sm",
href: new_savings_goal_contribution_path(@savings_goal),
icon: "plus",
frame: :modal
) %>
<%= render DS::Menu.new do |menu| %>
<% 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 %>
</div>
</header>
<section class="bg-container rounded-xl shadow-border-xs p-6">
<div class="flex flex-col items-center text-center">
<%= render Savings::ProgressRingComponent.new(goal: @savings_goal, size: 200) %>
<div class="mt-4 text-center">
<p class="text-xl font-medium text-primary tabular-nums privacy-sensitive"><%= @savings_goal.current_balance_money.format %></p>
<p class="text-xs text-subdued tabular-nums mt-0.5">
<%= t(".ring.of", target: @savings_goal.target_amount_money.format) %>
<% unless @savings_goal.completed? %>
· <%= t(".ring.to_go", amount: @savings_goal.remaining_amount_money.format) %>
<% end %>
</p>
</div>
</div>
</section>
<section class="grid grid-cols-2 md:grid-cols-4 gap-3">
<div class="bg-container rounded-xl shadow-border-xs px-5 py-4">
<p class="text-[11px] text-secondary mb-1"><%= t(".stats.avg_monthly") %></p>
<p class="text-lg font-medium text-primary tabular-nums"><%= Money.new(@stats[:avg_monthly], @savings_goal.currency).format %></p>
<p class="text-[11px] text-subdued mt-1"><%= @stats[:avg_monthly_sub] %></p>
</div>
<div class="bg-container rounded-xl shadow-border-xs px-5 py-4">
<p class="text-[11px] text-secondary mb-1"><%= t(".stats.total_contributions") %></p>
<p class="text-lg font-medium text-primary tabular-nums"><%= @stats[:contributions_count] %></p>
<p class="text-[11px] text-subdued mt-1"><%= t(".stats.across_all_accounts") %></p>
</div>
<div class="bg-container rounded-xl shadow-border-xs px-5 py-4">
<p class="text-[11px] text-secondary mb-1"><%= t(".stats.target_date") %></p>
<p class="text-lg font-medium text-primary"><%= @savings_goal.target_date ? I18n.l(@savings_goal.target_date, format: :long) : t(".stats.no_target_date") %></p>
<p class="text-[11px] text-subdued mt-1"><%= @stats[:monthly_target_sub] %></p>
</div>
<div class="bg-container rounded-xl shadow-border-xs px-5 py-4">
<p class="text-[11px] text-secondary mb-1"><%= t(".stats.started") %></p>
<p class="text-lg font-medium text-primary"><%= I18n.l(@savings_goal.created_at.to_date, format: :long) %></p>
<p class="text-[11px] text-subdued mt-1"><%= @stats[:started_sub] %></p>
</div>
</section>
<section class="grid grid-cols-1 lg:grid-cols-5 gap-3">
<div class="bg-container rounded-xl shadow-border-xs overflow-hidden lg:col-span-3">
<div class="flex items-center px-5 py-3.5 border-b border-subdued">
<h3 class="text-sm font-medium text-primary"><%= t(".contributions_heading") %></h3>
<span class="ml-2 text-xs text-subdued tabular-nums"><%= @contributions.size %></span>
</div>
<%= render "contributions_list", contributions: @contributions %>
</div>
<%= render "contributions_list", contributions: @contributions %>
<div class="bg-container rounded-xl shadow-border-xs p-5 lg:col-span-2">
<h3 class="text-sm font-medium text-primary mb-3"><%= t(".funding_accounts_heading") %></h3>
<%= render Savings::FundingAccountsBreakdownComponent.new(goal: @savings_goal, rows: @funding_breakdown) %>
</div>
</section>
<% if @savings_goal.notes.present? %>
<section class="bg-container rounded-xl shadow-border-xs p-5">
<h3 class="text-sm font-medium text-primary mb-2"><%= t(".notes") %></h3>
<p class="text-sm text-secondary whitespace-pre-line"><%= @savings_goal.notes %></p>
</section>
<% end %>
</div>

View File

@@ -3,6 +3,7 @@ en:
savings_goals:
index:
title: Savings goals
subtitle: Save toward what matters.
new_goal: New goal
empty_filtered: No %{state} goals.
tabs:
@@ -11,6 +12,11 @@ en:
paused: Paused
completed: Completed
archived: Archived
summary:
total_saved: Total saved across goals
active_goals: Active goals
on_track: On track
behind: Behind
new:
heading: New savings goal
edit:
@@ -51,19 +57,32 @@ en:
funding_accounts_heading: Funding accounts
no_contributions_yet: No contributions yet.
confirm_delete_contribution: Delete this contribution?
funding_balance: "balance %{amount}"
of_saved: of saved
notes: Notes
header:
target: "Target %{amount}"
target_by: "Target %{amount} by %{date}"
ring:
saved: Saved
of: "of %{target}"
to_go: "%{amount} to go"
source:
initial: Initial
manual: Manual
stats:
current: Current
target: Target
remaining: Remaining
avg_monthly: Avg monthly
total_contributions: Total contributions
across_all_accounts: Across all accounts
target_date: Target date
no_target_date: No target date
monthly_target: Per month
status: Status
no_required_pace: No required pace
started: Started
needs_per_month: "Needs %{amount}/mo"
above_target_pace: Above target pace
months_ago:
one: 1 month ago
other: "%{count} months ago"
states:
active: Active
paused: Paused
@@ -83,6 +102,15 @@ en:
goal_card:
no_accounts: No linked accounts
n_accounts: "%{first} +%{count}"
accounts:
one: 1 account
other: "%{count} accounts"
no_target_date: No target date
completed: Completed
past_due: Past due
days_left:
one: 1 day left · by %{date}
other: "%{count} days left · by %{date}"
form_stepper:
continue: Continue
back: Back