From 314113e582a9fd44fd822f06e5bb0383ed1e0cba Mon Sep 17 00:00:00 2001 From: Guillem Arias Date: Fri, 15 May 2026 14:11:23 +0200 Subject: [PATCH] =?UTF-8?q?ux(goals):=20redesign=20show=20page=20=E2=80=94?= =?UTF-8?q?=20one=20CTA,=20calm=20banners?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Header collapses to title + kebab. The status pill and the `Record pledge` button leave the title row. Status moves into a one-line callout below the subtitle that doubles as the catch-up demand when behind, the reach-date when on track, or a prompt for a target date when missing. `Record pledge` is now the only pledge entry point on the page and lives under the ring. Behind goals pre-fill it with the catch-up delta. The standalone catch-up alert card is gone — its title is the callout, its pace breakdown moves into the projection chart's subtitle, and its CTA is the ring-adjacent button. The "Adjust target instead" link is absorbed into the kebab's existing Edit item. Pending-pledge banner switches from a warning Alert to a neutral container chip. It is informational state, not a warning. Title carries the relative pledged-at meta inline; verbose auto-confirms body stays but in subdued size. Projection chart drops the today-line pending stub (vertical line + dashed marker + "+ pending $X" text). That data already lives in the pending banner above the chart; the duplicate annotation clutters the today line, the small dashed circle reads as misaligned at small pending amounts, and the label overlaps the projection trajectory. Shortfall label gets a paint-order halo so it stays legible across the dashed projection line. --- .../goal_projection_chart_controller.js | 42 +--------- app/models/goal.rb | 31 ++++++- .../goals/_pending_pledge_banner.html.erb | 58 ++++++++------ app/views/goals/_status_callout.html.erb | 28 +++++++ app/views/goals/show.html.erb | 80 +++++++------------ config/locales/views/goals/en.yml | 5 ++ 6 files changed, 128 insertions(+), 116 deletions(-) create mode 100644 app/views/goals/_status_callout.html.erb diff --git a/app/javascript/controllers/goal_projection_chart_controller.js b/app/javascript/controllers/goal_projection_chart_controller.js index 621022fa4..791c0904c 100644 --- a/app/javascript/controllers/goal_projection_chart_controller.js +++ b/app/javascript/controllers/goal_projection_chart_controller.js @@ -146,8 +146,6 @@ export default class extends Controller { ] : []; - const pendingPledgeAmount = data.pending_pledge_amount || 0; - const yMax = Math.max(targetAmount * 1.05, projectionEnd, requiredEnd, currentAmount, 1); const x = d3.scaleTime().domain([start, endDate]).range([margin.left, margin.left + innerWidth]); @@ -332,47 +330,15 @@ export default class extends Controller { .attr("text-anchor", "end") .attr("font-size", 12) .attr("fill", textSecondary) + .attr("paint-order", "stroke") + .attr("stroke", containerBg) + .attr("stroke-width", 4) + .attr("stroke-linejoin", "round") .text(labelText); } } } - if (pendingPledgeAmount > 0 && target) { - const willHit = projectionEnd >= targetAmount; - const pendingColor = willHit ? "var(--color-green-600)" : "var(--color-yellow-600)"; - const pendingTop = Math.min(yMax, currentAmount + pendingPledgeAmount); - svg - .append("line") - .attr("x1", x(today)) - .attr("x2", x(today)) - .attr("y1", y(currentAmount)) - .attr("y2", y(pendingTop)) - .attr("stroke", pendingColor) - .attr("stroke-width", 3) - .attr("stroke-linecap", "round") - .attr("opacity", 0.4); - - svg - .append("circle") - .attr("cx", x(today)) - .attr("cy", y(pendingTop)) - .attr("r", 5) - .attr("fill", containerBg) - .attr("stroke", pendingColor) - .attr("stroke-width", 2) - .attr("stroke-dasharray", "2 2"); - - if (innerWidth >= 320) { - svg - .append("text") - .attr("x", x(today) + 10) - .attr("y", y(pendingTop) + 4) - .attr("font-size", 12) - .attr("fill", textSecondary) - .text(`+ pending ${data.pending_pledge_label_short}`); - } - } - svg .append("line") .attr("x1", x(today)) diff --git a/app/models/goal.rb b/app/models/goal.rb index 80d03d918..dbc96e4df 100644 --- a/app/models/goal.rb +++ b/app/models/goal.rb @@ -188,7 +188,6 @@ class Goal < ApplicationRecord saved_series = series_values.map { |v| { date: v.date.to_s, value: v.value.amount.to_f } } earliest = series_values.first&.date || created_at.to_date - pending = open_pledges.sum(:amount).to_d target_amt = target_amount.to_d proj_end = projection_end_amount @@ -206,8 +205,6 @@ class Goal < ApplicationRecord required_monthly: monthly_target_amount.to_f, currency: currency, status: status.to_s, - pending_pledge_amount: pending.to_f, - pending_pledge_label_short: short_money(pending, currency), projection_end_value: proj_end.to_f, projection_end_label: Money.new(proj_end, currency).format(precision: 0), projection_shortfall_label: (target_amt > proj_end ? Money.new(target_amt - proj_end, currency).format(precision: 0) : nil) @@ -306,6 +303,34 @@ class Goal < ApplicationRecord end end + # Single-line state summary rendered between the header and the ring on + # the show page. Replaces the stacked catch-up alert + inline status pill; + # carries the same actionable copy without owning a CTA. Returns nil when + # the projection-side cards already convey state (paused / archived / + # completed / reached) so the callout doesn't double up. + def status_callout_context + return nil if paused? || archived? || completed? || status == :reached + + case status + when :behind + delta = catch_up_delta_money.amount + if delta.positive? + I18n.t("goals.show.status_callout.behind", + amount: catch_up_delta_money.format(precision: 0)) + else + I18n.t("goals.show.status_callout.behind_covered") + end + when :on_track + if target_date && pace.to_d.positive? + months = (remaining_amount.to_d / pace.to_d).ceil + I18n.t("goals.show.status_callout.on_track", + date: I18n.l(Date.current >> months.to_i, format: "%b %Y")) + end + when :no_target_date + I18n.t("goals.show.status_callout.no_target_date") + end + end + # Header copy under the goal title on show. Used to live as a multi-line # if/elsif block in show.html.erb. Keeps the view template free of date # math + i18n key picking. diff --git a/app/views/goals/_pending_pledge_banner.html.erb b/app/views/goals/_pending_pledge_banner.html.erb index 1e93a246f..5b716a5c9 100644 --- a/app/views/goals/_pending_pledge_banner.html.erb +++ b/app/views/goals/_pending_pledge_banner.html.erb @@ -5,29 +5,37 @@ body_key = pledge.kind_transfer? ? "goals.show.pending_pledge.body_transfer" : "goals.show.pending_pledge.body_manual" title = t("goals.show.pending_pledge.title", amount: amount_money.format(precision: 0), account: account.name, count: days_left) %> -<%= render DS::Alert.new(variant: "warning", title: title, live: :polite) do %> -

<%= t(body_key) %>

-

<%= t("goals.show.pending_pledge.pledged_at", time_ago: time_ago_in_words(pledge.created_at)) %>

-
- <%= render DS::Button.new( - text: t("goals.show.pending_pledge.extend"), - href: renew_goal_pledge_path(pledge.goal, pledge), - method: :patch, - variant: "outline", - size: "sm" - ) %> - <%= render DS::Button.new( - text: t("goals.show.pending_pledge.cancel"), - href: goal_pledge_path(pledge.goal, pledge), - method: :delete, - variant: "ghost", - size: "sm", - confirm: CustomConfirm.new( - destructive: true, - title: t("goals.show.pending_pledge.confirm_cancel_title"), - body: t("goals.show.pending_pledge.confirm_cancel_body", amount: amount_money.format(precision: 0)), - btn_text: t("goals.show.pending_pledge.confirm_cancel_cta") - ) - ) %> +
+
+ <%= icon("info", size: "sm") %> +
+

+ <%= title %> + · <%= t("goals.show.pending_pledge.pledged_at", time_ago: time_ago_in_words(pledge.created_at)) %> +

+

<%= t(body_key) %>

+
+ <%= render DS::Button.new( + text: t("goals.show.pending_pledge.extend"), + href: renew_goal_pledge_path(pledge.goal, pledge), + method: :patch, + variant: "outline", + size: "sm" + ) %> + <%= render DS::Button.new( + text: t("goals.show.pending_pledge.cancel"), + href: goal_pledge_path(pledge.goal, pledge), + method: :delete, + variant: "ghost", + size: "sm", + confirm: CustomConfirm.new( + destructive: true, + title: t("goals.show.pending_pledge.confirm_cancel_title"), + body: t("goals.show.pending_pledge.confirm_cancel_body", amount: amount_money.format(precision: 0)), + btn_text: t("goals.show.pending_pledge.confirm_cancel_cta") + ) + ) %> +
+
-<% end %> +
diff --git a/app/views/goals/_status_callout.html.erb b/app/views/goals/_status_callout.html.erb new file mode 100644 index 000000000..204261aee --- /dev/null +++ b/app/views/goals/_status_callout.html.erb @@ -0,0 +1,28 @@ +<% + context = goal.status_callout_context + return if context.blank? + + variant_classes = case goal.status + when :behind + "bg-warning/10 border-warning/20 text-yellow-700 theme-dark:text-yellow-300" + when :on_track + "bg-success/10 border-success/20 text-green-700 theme-dark:text-green-300" + else + "bg-surface-inset border-secondary text-secondary" + end + + icon_glyph = case goal.status + when :behind then "triangle-alert" + when :on_track then "circle-check" + when :no_target_date then "infinity" + else "info" + end + + label = I18n.t("goals.status.#{goal.status}", default: goal.status.to_s.titleize) +%> +
+ <%= icon(icon_glyph, size: "sm") %> + <%= label %> + · + <%= context %> +
diff --git a/app/views/goals/show.html.erb b/app/views/goals/show.html.erb index ba697b0e2..5119bd738 100644 --- a/app/views/goals/show.html.erb +++ b/app/views/goals/show.html.erb @@ -1,35 +1,19 @@
-
-
- -
-
-

<%= @goal.name %>

- <%= render Goals::StatusPillComponent.new(goal: @goal) %> -
-

<%= @goal.header_summary %>

+
+ +
+

<%= @goal.name %>

+

<%= @goal.header_summary %>

<% last_days = @goal.last_matched_pledge_days_ago %> <% unless last_days.nil? %>

<%= last_days.zero? ? t("goals.goal_card.footer_last_today") : t("goals.goal_card.footer_last_days", count: last_days) %>

<% end %> -
-
- <% unless @goal.completed? || @goal.status == :reached %> - <%# Demote to outline when the catch-up alert below owns the primary - action. One primary per surface. %> - <%= render DS::Link.new( - text: t(".record_pledge_cta"), - variant: @goal.status == :behind ? "outline" : "primary", - href: new_goal_pledge_path(@goal), - icon: "plus", - frame: :modal - ) %> - <% end %> +
<%= render DS::Menu.new do |menu| %> <%# Edit lives in the kebab, matching the rest of Sure (accounts, categories, rules, family_merchants, chats, transactions all @@ -96,6 +80,8 @@
+ <%= render "status_callout", goal: @goal %> + <% @open_pledges.each do |pledge| %> <%= render "pending_pledge_banner", pledge: pledge %> <% end %> @@ -128,32 +114,6 @@
<% end %> <% end %> - <% elsif @goal.status == :behind && @goal.monthly_target_amount && @goal.catch_up_delta_money.amount.positive? %> - <%# Title uses the *delta*. the amount the user must add to current pace - to make the date, *after* subtracting open pledges. When pending - pledges already cover the gap, `catch_up_delta_money` returns zero - and this branch suppresses. no "Save $0/mo" demand. Pre-fill the - pledge CTA with the delta too, so submitting once funds the gap - instead of double-counting on top of pace and pending. %> - <%= render DS::Alert.new(variant: "warning", title: t("goals.show.catch_up.title", amount: @goal.catch_up_delta_money.format(precision: 0))) do %> -

- <%= t("goals.show.catch_up.body", avg: @goal.pace_money.format(precision: 0), required: Money.new(@goal.monthly_target_amount, @goal.currency).format(precision: 0)) %> -

-
- <%= render DS::Link.new( - text: t(".record_pledge_cta"), - variant: "primary", - size: "sm", - href: new_goal_pledge_path(@goal, amount: @goal.catch_up_delta_money.amount.to_f), - icon: "plus", - frame: :modal - ) %> - <%= link_to t("goals.show.catch_up.adjust_target_cta"), - edit_goal_path(@goal), - data: { turbo_frame: :modal }, - class: "text-xs text-secondary hover:text-primary underline-offset-2 hover:underline" %> -
- <% end %> <% end %> <%# Top row: ring panel (status, no elevation) + projection chart card %> @@ -164,6 +124,21 @@ <% unless @goal.completed? %>

<%= t(".ring.to_go", amount: @goal.remaining_amount_money.format(precision: 0)) %>

<% end %> + <% unless @goal.completed? || @goal.status == :reached || @goal.paused? || @goal.archived? %> + <%# Single Record pledge entry point on the page. Pre-filled with the + catch-up delta when behind so accepting once funds the gap. %> + <% prefill_amount = @goal.status == :behind && @goal.catch_up_delta_money.amount.positive? ? @goal.catch_up_delta_money.amount.to_f : nil %> +
+ <%= render DS::Link.new( + text: t(".record_pledge_cta"), + variant: "primary", + size: "sm", + href: new_goal_pledge_path(@goal, amount: prefill_amount), + icon: "plus", + frame: :modal + ) %> +
+ <% end %>
<% if @goal.archived? || @goal.paused? %> @@ -223,6 +198,11 @@

<%= t(".projection.heading") %>

<%= sanitize @goal.projection_summary %>

+ <% if @goal.status == :behind && @goal.monthly_target_amount %> +

+ <%= t("goals.show.catch_up.body", avg: @goal.pace_money.format(precision: 0), required: Money.new(@goal.monthly_target_amount, @goal.currency).format(precision: 0)) %> +

+ <% end %>
<% projection_color = @goal.status == :on_track ? "var(--color-green-600)" : "var(--color-yellow-600)" %>
diff --git a/config/locales/views/goals/en.yml b/config/locales/views/goals/en.yml index 0c65ea36e..fd1e4b8c1 100644 --- a/config/locales/views/goals/en.yml +++ b/config/locales/views/goals/en.yml @@ -105,6 +105,11 @@ en: notes: Notes funding_last_30d: last 30d funding_last_90d: last 90d + status_callout: + behind: "save %{amount}/mo more to catch up" + behind_covered: "pending pledges close the gap" + on_track: "reaches goal around %{date}" + no_target_date: "set a target date to project a finish line" pending_pledge: title: zero: "Pending: %{amount} into %{account} · expires today"