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

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