From 39743a9ec4b2c2eaf683d0c25c65c3395bd04af5 Mon Sep 17 00:00:00 2001 From: Guillem Arias Date: Mon, 11 May 2026 11:52:35 +0200 Subject: [PATCH] feat(savings): rebuild UI to match Claude Design + adopt shared donut-chart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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}. --- .../savings/account_stack_component.html.erb | 13 + .../savings/account_stack_component.rb | 18 ++ ...ding_accounts_breakdown_component.html.erb | 48 ++-- .../savings/goal_avatar_component.html.erb | 2 +- .../savings/goal_avatar_component.rb | 11 +- .../savings/goal_card_component.html.erb | 45 ++-- app/components/savings/goal_card_component.rb | 45 ++-- .../savings/progress_ring_component.html.erb | 38 +-- .../savings/progress_ring_component.rb | 39 ++- .../savings/status_pill_component.html.erb | 4 +- .../savings/status_pill_component.rb | 20 +- app/controllers/savings_goals_controller.rb | 43 ++++ app/models/savings_goal.rb | 18 ++ .../_contributions_list.html.erb | 19 +- app/views/savings_goals/index.html.erb | 42 +++- app/views/savings_goals/show.html.erb | 232 +++++++++--------- config/locales/views/savings_goals/en.yml | 38 ++- 17 files changed, 412 insertions(+), 263 deletions(-) create mode 100644 app/components/savings/account_stack_component.html.erb create mode 100644 app/components/savings/account_stack_component.rb diff --git a/app/components/savings/account_stack_component.html.erb b/app/components/savings/account_stack_component.html.erb new file mode 100644 index 000000000..3a3ee6f87 --- /dev/null +++ b/app/components/savings/account_stack_component.html.erb @@ -0,0 +1,13 @@ + + <% shown.each_with_index do |account, i| %> + 0 %>" + title="<%= account.name %>"> + <%= initial_for(account) %> + + <% end %> + <% if extra_count > 0 %> + +<%= extra_count %> + <% end %> + diff --git a/app/components/savings/account_stack_component.rb b/app/components/savings/account_stack_component.rb new file mode 100644 index 000000000..44474a406 --- /dev/null +++ b/app/components/savings/account_stack_component.rb @@ -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 diff --git a/app/components/savings/funding_accounts_breakdown_component.html.erb b/app/components/savings/funding_accounts_breakdown_component.html.erb index d44c3e2f7..ca3f674fb 100644 --- a/app/components/savings/funding_accounts_breakdown_component.html.erb +++ b/app/components/savings/funding_accounts_breakdown_component.html.erb @@ -1,25 +1,27 @@ -
-

<%= t("savings_goals.show.funding_accounts_heading") %>

+<% if total.zero? %> +

<%= t("savings_goals.show.no_contributions_yet") %>

+<% else %> +
+ <% rows.each do |row| %> + <% next if row[:amount].to_d.zero? %> +
+ <% end %> +
- <% if total.zero? %> -

<%= t("savings_goals.show.no_contributions_yet") %>

- <% else %> -
- <% rows.each do |row| %> -
- <%= render Savings::GoalAvatarComponent.new(name: row[:account].name, color: goal.color, size: "sm") %> -
-
- <%= row[:account].name %> - <%= row[:money].format %> -
-
-
-
-
- <%= percent_for(row[:amount]) %>% +
    + <% rows.each do |row| %> +
  • + <%= render Savings::GoalAvatarComponent.new(name: row[:account].name, color: goal.color, size: "sm") %> +
    +

    <%= row[:account].name %>

    +

    <%= row[:account].subtype&.titleize || row[:account].accountable_type %> · <%= t("savings_goals.show.funding_balance", amount: Money.new(row[:account].balance, row[:account].currency).format) %>

    - <% end %> -
- <% end %> -
+
+

<%= row[:money].format %>

+

<%= percent_for(row[:amount]) %>% <%= t("savings_goals.show.of_saved") %>

+
+ + <% end %> + +<% end %> diff --git a/app/components/savings/goal_avatar_component.html.erb b/app/components/savings/goal_avatar_component.html.erb index da1ec91f9..b415cce5b 100644 --- a/app/components/savings/goal_avatar_component.html.erb +++ b/app/components/savings/goal_avatar_component.html.erb @@ -1,4 +1,4 @@ - <%= initial %> diff --git a/app/components/savings/goal_avatar_component.rb b/app/components/savings/goal_avatar_component.rb index afdee63fd..01d2af314 100644 --- a/app/components/savings/goal_avatar_component.rb +++ b/app/components/savings/goal_avatar_component.rb @@ -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 diff --git a/app/components/savings/goal_card_component.html.erb b/app/components/savings/goal_card_component.html.erb index 550c7844c..44a24137a 100644 --- a/app/components/savings/goal_card_component.html.erb +++ b/app/components/savings/goal_card_component.html.erb @@ -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 %>
- <%= render Savings::GoalAvatarComponent.new(goal: goal, size: "md") %> + <%= render Savings::GoalAvatarComponent.new(goal: goal, size: "lg") %>
-
-

<%= goal.name %>

- <%= render Savings::StatusPillComponent.new(goal: goal) %> +

<%= goal.name %>

+

<%= secondary_line %>

+
+ <%= render Savings::StatusPillComponent.new(goal: goal) %> +
+ +
+
+
+ <%= goal.current_balance_money.format %> + / <%= goal.target_amount_money.format %>
-

<%= linked_accounts_label %>

+ <%= progress_percent %>% +
+
+
-
-
- <%= goal.current_balance_money.format %> - / <%= goal.target_amount_money.format %> -
-
-
-
-
- <%= progress_percent %>% - <% if goal.target_date %> - <%= I18n.l(goal.target_date, format: :long) %> - <% end %> +
+
+ <%= render Savings::AccountStackComponent.new(accounts: linked_accounts) %> + <%= linked_accounts_count_label %>
+ + <% if goal.completed? %>—<% else %><%= goal.remaining_amount_money.format %> to go<% end %> +
<% end %> diff --git a/app/components/savings/goal_card_component.rb b/app/components/savings/goal_card_component.rb index 554bf862c..4556cdbe9 100644 --- a/app/components/savings/goal_card_component.rb +++ b/app/components/savings/goal_card_component.rb @@ -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 diff --git a/app/components/savings/progress_ring_component.html.erb b/app/components/savings/progress_ring_component.html.erb index 55b313104..aa494b34a 100644 --- a/app/components/savings/progress_ring_component.html.erb +++ b/app/components/savings/progress_ring_component.html.erb @@ -1,27 +1,15 @@ -
- - - - -
- <%= t("savings_goals.show.ring.saved") %> - <%= current_label %> - of <%= target_label %> +
+
+
+
+ <%= t("savings_goals.show.ring.saved") %> + <%= percent %>% + <%= amount_label %> + of <%= target_label %> +
diff --git a/app/components/savings/progress_ring_component.rb b/app/components/savings/progress_ring_component.rb index 4989f2b1b..7f6d288c8 100644 --- a/app/components/savings/progress_ring_component.rb +++ b/app/components/savings/progress_ring_component.rb @@ -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 diff --git a/app/components/savings/status_pill_component.html.erb b/app/components/savings/status_pill_component.html.erb index b1037bb75..3246b6bbf 100644 --- a/app/components/savings/status_pill_component.html.erb +++ b/app/components/savings/status_pill_component.html.erb @@ -1,4 +1,4 @@ - - <%= helpers.icon(icon_name, size: "sm", color: icon_color) %> + + <%= label %> diff --git a/app/components/savings/status_pill_component.rb b/app/components/savings/status_pill_component.rb index bac6d9fdb..8adbb6378 100644 --- a/app/components/savings/status_pill_component.rb +++ b/app/components/savings/status_pill_component.rb @@ -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 diff --git a/app/controllers/savings_goals_controller.rb b/app/controllers/savings_goals_controller.rb index 6e432fa6b..466d83d62 100644 --- a/app/controllers/savings_goals_controller.rb +++ b/app/controllers/savings_goals_controller.rb @@ -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}!") diff --git a/app/models/savings_goal.rb b/app/models/savings_goal.rb index 85626a745..2f11c81fb 100644 --- a/app/models/savings_goal.rb +++ b/app/models/savings_goal.rb @@ -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 diff --git a/app/views/savings_goals/_contributions_list.html.erb b/app/views/savings_goals/_contributions_list.html.erb index 4bf162596..686aabc54 100644 --- a/app/views/savings_goals/_contributions_list.html.erb +++ b/app/views/savings_goals/_contributions_list.html.erb @@ -1,25 +1,30 @@ <%# locals: (contributions:) %> <% if contributions.empty? %> -

<%= t("savings_goals.show.no_contributions_yet") %>

+
+

<%= t("savings_goals.show.no_contributions_yet") %>

+
<% else %> -
    +
      <% contributions.each do |contribution| %> -
    • +
    • <%= render Savings::GoalAvatarComponent.new( name: contribution.account.name, color: @savings_goal.color, size: "sm" ) %>
      -

      <%= contribution.account.name %>

      -

      <%= I18n.l(contribution.contributed_at, format: :long) %> · <%= t("savings_goals.show.source.#{contribution.source}") %>

      +

      <%= contribution.account.name %>

      +

      + <%= I18n.l(contribution.contributed_at, format: :long) %> · + <%= t("savings_goals.show.source.#{contribution.source}") %> +

      - <%= contribution.amount_money.format %> + +<%= contribution.amount_money.format %> <% 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 %> diff --git a/app/views/savings_goals/index.html.erb b/app/views/savings_goals/index.html.erb index 5d2da4dd4..dae256782 100644 --- a/app/views/savings_goals/index.html.erb +++ b/app/views/savings_goals/index.html.erb @@ -1,6 +1,9 @@ -
      -
      -

      <%= t(".title") %>

      +
      +
      +
      +

      <%= t(".title") %>

      +

      <%= t(".subtitle") %>

      +
      <% 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 %> -