diff --git a/app/components/goals/card_component.html.erb b/app/components/goals/card_component.html.erb index cc5b6f43f..df05906f4 100644 --- a/app/components/goals/card_component.html.erb +++ b/app/components/goals/card_component.html.erb @@ -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 %>" /> 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); diff --git a/app/models/goal.rb b/app/models/goal.rb index c34fff00b..dbc96e4df 100644 --- a/app/models/goal.rb +++ b/app/models/goal.rb @@ -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) 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 374bcd6c5..7036e1638 100644 --- a/app/views/goals/show.html.erb +++ b/app/views/goals/show.html.erb @@ -1,33 +1,19 @@
-
-
+
+ -
- <% 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 @@ -94,6 +80,8 @@
+ <%= render "status_callout", goal: @goal %> + <% @open_pledges.each do |pledge| %> <%= render "pending_pledge_banner", pledge: pledge %> <% end %> @@ -126,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 %> @@ -162,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? %> @@ -217,13 +194,18 @@
<% else %>
-
+

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

-

<%= @goal.projection_summary %>

+

<%= 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)" %> -
+
<%= t(".projection.legend_saved") %> diff --git a/config/locales/views/goals/en.yml b/config/locales/views/goals/en.yml index 3104db9ac..876727f33 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" @@ -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 %{date}. + on_track_html: At your current pace, you'll reach this goal around %{date}. aria_label: "Projection chart for %{name}" today_marker: Today tooltip_projected: "Projected: %{amount}"