ux(goals): redesign show page — one CTA, calm banners

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.
This commit is contained in:
Guillem Arias
2026-05-15 14:11:23 +02:00
parent 33189c2673
commit 314113e582
6 changed files with 128 additions and 116 deletions

View File

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

View File

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

View File

@@ -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 %>
<p class="text-secondary"><%= t(body_key) %></p>
<p class="text-xs text-subdued mt-1"><%= t("goals.show.pending_pledge.pledged_at", time_ago: time_ago_in_words(pledge.created_at)) %></p>
<div class="mt-2 flex items-center gap-2 flex-wrap">
<%= 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")
)
) %>
<div class="rounded-lg bg-surface-inset px-3 py-2.5 text-sm" role="status" aria-live="polite">
<div class="flex items-start gap-2.5">
<span class="text-secondary shrink-0 mt-0.5"><%= icon("info", size: "sm") %></span>
<div class="flex-1 min-w-0">
<p class="text-primary">
<span class="font-medium"><%= title %></span>
<span class="text-secondary">· <%= t("goals.show.pending_pledge.pledged_at", time_ago: time_ago_in_words(pledge.created_at)) %></span>
</p>
<p class="text-xs text-subdued mt-0.5"><%= t(body_key) %></p>
<div class="mt-2 flex items-center gap-2 flex-wrap">
<%= 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")
)
) %>
</div>
</div>
</div>
<% end %>
</div>

View File

@@ -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)
%>
<div class="rounded-lg border px-3 py-2 text-sm flex items-center gap-2 <%= variant_classes %>">
<span class="shrink-0"><%= icon(icon_glyph, size: "sm") %></span>
<span class="font-medium"><%= label %></span>
<span class="opacity-60">·</span>
<span class="opacity-90"><%= context %></span>
</div>

View File

@@ -1,35 +1,19 @@
<div class="space-y-4 pb-6 lg:pb-12">
<header class="flex flex-col gap-3 sm:flex-row sm:items-start sm:gap-4">
<div class="flex items-start gap-3 min-w-0 flex-1">
<div class="hidden sm:block">
<%= render Goals::AvatarComponent.new(goal: @goal, size: "xl") %>
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 mb-1 flex-wrap">
<h1 class="text-2xl font-semibold text-primary min-w-0 break-words"><%= @goal.name %></h1>
<%= render Goals::StatusPillComponent.new(goal: @goal) %>
</div>
<p class="text-sm text-secondary"><%= @goal.header_summary %></p>
<header class="flex items-start gap-3 sm:gap-4">
<div class="hidden sm:block">
<%= render Goals::AvatarComponent.new(goal: @goal, size: "xl") %>
</div>
<div class="min-w-0 flex-1">
<h1 class="text-2xl font-semibold text-primary break-words"><%= @goal.name %></h1>
<p class="text-sm text-secondary mt-1"><%= @goal.header_summary %></p>
<% last_days = @goal.last_matched_pledge_days_ago %>
<% unless last_days.nil? %>
<p class="text-xs text-subdued mt-0.5">
<%= last_days.zero? ? t("goals.goal_card.footer_last_today") : t("goals.goal_card.footer_last_days", count: last_days) %>
</p>
<% end %>
</div>
</div>
<div class="flex items-center gap-2 flex-wrap justify-end sm:shrink-0">
<% 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 %>
<div class="shrink-0">
<%= 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 @@
</div>
</header>
<%= render "status_callout", goal: @goal %>
<% @open_pledges.each do |pledge| %>
<%= render "pending_pledge_banner", pledge: pledge %>
<% end %>
@@ -128,32 +114,6 @@
</div>
<% 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 %>
<p class="text-secondary">
<%= 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)) %>
</p>
<div class="mt-2 flex items-center gap-3 flex-wrap">
<%= 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" %>
</div>
<% end %>
<% end %>
<%# Top row: ring panel (status, no elevation) + projection chart card %>
@@ -164,6 +124,21 @@
<% unless @goal.completed? %>
<p class="text-xs text-subdued tabular-nums mt-0.5"><%= t(".ring.to_go", amount: @goal.remaining_amount_money.format(precision: 0)) %></p>
<% 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 %>
<div class="mt-4">
<%= 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
) %>
</div>
<% end %>
</div>
<% if @goal.archived? || @goal.paused? %>
@@ -223,6 +198,11 @@
<div class="min-w-0">
<h2 class="text-sm font-medium text-primary"><%= t(".projection.heading") %></h2>
<p class="text-xs text-secondary mt-0.5"><%= sanitize @goal.projection_summary %></p>
<% if @goal.status == :behind && @goal.monthly_target_amount %>
<p class="text-xs text-secondary mt-0.5 tabular-nums">
<%= 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)) %>
</p>
<% end %>
</div>
<% projection_color = @goal.status == :on_track ? "var(--color-green-600)" : "var(--color-yellow-600)" %>
<div class="flex items-center flex-wrap gap-x-5 gap-y-1 text-[11px] text-secondary sm:gap-x-3 sm:shrink-0">

View File

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