Merge remote-tracking branch 'origin/feat/goals-v2-architecture' into feat/goals-v2-architecture

This commit is contained in:
Guillem Arias
2026-05-17 16:57:31 +02:00
7 changed files with 138 additions and 124 deletions

View File

@@ -24,7 +24,7 @@
cy="<%= Goals::CardComponent::RING_SIZE / 2.0 %>"
r="<%= ring_radius %>"
fill="none"
stroke="var(--budget-unallocated-fill)"
stroke="var(--budget-unused-fill)"
stroke-width="<%= Goals::CardComponent::RING_STROKE %>" />
<circle cx="<%= Goals::CardComponent::RING_SIZE / 2.0 %>"
cy="<%= Goals::CardComponent::RING_SIZE / 2.0 %>"

View File

@@ -153,8 +153,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]);
@@ -339,47 +337,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))
@@ -577,8 +543,8 @@ export default class extends Controller {
_fmtMoneyShort(amount, _currency) {
// The server ships `currency_symbol` via projection_payload (resolved
// through Money.new(0, code).symbol so EUR/GBP/JPY/etc. render with
// the family-locale-correct glyph). Fall back to "$" if a stale
// through Money.new(0, code).currency.symbol so EUR/GBP/JPY/etc. render
// with the family-locale-correct glyph). Fall back to "$" if a stale
// payload reaches us mid-deploy.
const symbol = this.dataValue?.currency_symbol || "$";
const abs = Math.abs(amount);

View File

@@ -168,12 +168,12 @@ class Goal < ApplicationRecord
rem = remaining_amount.to_d
if filled.zero? && rem.zero?
return [ { color: "var(--budget-unallocated-fill)", amount: 1, id: "unused" } ]
return [ { color: "var(--budget-unused-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 << { color: "var(--budget-unused-fill)", amount: rem, id: "unused" } if rem.positive?
segments
end
@@ -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
@@ -200,14 +199,12 @@ class Goal < ApplicationRecord
target_amount: target_amt.to_f,
target_amount_label: Money.new(target_amt, currency).format(precision: 0),
target_amount_short_label: short_money(target_amt, currency),
currency_symbol: Money.new(0, currency).symbol,
currency_symbol: Money.new(0, currency).currency.symbol,
current_amount: current_balance.to_f,
avg_monthly: pace.to_f,
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.
@@ -377,7 +402,7 @@ class Goal < ApplicationRecord
# Money so the chart matches the rest of the app for EUR/GBP families.
def short_money(amount, code)
amount_f = amount.to_f
symbol = Money.new(0, code).symbol
symbol = Money.new(0, code).currency.symbol
abs = amount_f.abs
if abs >= 1_000_000
short = (amount_f / 1_000_000.0).round(1)

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,33 +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">
<header class="flex items-start gap-3 sm:gap-4">
<div class="hidden sm:block">
<%= render Goals::AvatarComponent.new(goal: @goal, size: "xl") %>
<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>
</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 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
@@ -94,6 +80,8 @@
</div>
</header>
<%= render "status_callout", goal: @goal %>
<% @open_pledges.each do |pledge| %>
<%= render "pending_pledge_banner", pledge: pledge %>
<% end %>
@@ -126,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 %>
@@ -162,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? %>
@@ -217,13 +194,18 @@
</div>
<% else %>
<div class="bg-container rounded-xl shadow-border-xs p-5 flex flex-col">
<div class="flex items-start justify-between mb-2 gap-3">
<div class="flex flex-col gap-2 mb-2 sm:flex-row sm:items-start sm:justify-between sm:gap-3">
<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"><%= @goal.projection_summary %></p>
<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 gap-3 text-[11px] text-secondary shrink-0">
<div class="flex items-center flex-wrap gap-x-5 gap-y-1 text-[11px] text-secondary sm:gap-x-3 sm:shrink-0">
<span class="inline-flex items-center gap-1.5">
<svg width="18" height="6" class="text-primary"><line x1="0" y1="3" x2="18" y2="3" stroke="currentColor" stroke-width="2" /></svg>
<%= t(".projection.legend_saved") %>

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"
@@ -137,7 +142,7 @@ en:
no_target_date: No target date set. Set one to project a finish line.
no_pace: No deposits yet. Add money to a linked account to start a projection.
behind: Falling short at current pace.
on_track_html: At your current pace, you'll reach this goal around <strong class="text-primary">%{date}</strong>.
on_track_html: At your current pace, you'll reach this goal around <strong class="text-primary whitespace-nowrap">%{date}</strong>.
aria_label: "Projection chart for %{name}"
today_marker: Today
tooltip_projected: "Projected: %{amount}"