mirror of
https://github.com/we-promise/sure.git
synced 2026-06-01 00:39:01 +00:00
Five small audit follow-ups bundled because they were each one-line swaps and individually wouldn't earn their own commit. Card text scale (vs Sure house style — budget_category h3 ≈ text-base, budget _actuals_summary value text-xl, account row text-sm subtype): - goal card title text-sm → text-base - goal card balance text-lg → text-xl - goal card pace/footer/subtitle text-[11px] → text-xs - funding row subtype subtitle text-xs → text-sm - funding row "last 30d / last 90d" labels text-[10px] → text-xs Chart label scale (projection chart was an outlier at font-size: 10 while time_series_chart_controller uses 12): - every `font-size: 10` in goal_projection_chart_controller.js → 12 - tooltip cssText font-size: 11 → 12 Color-picker pen toggle on the new-goal avatar was w-6 h-6 (24px circle, ~55% of the lg 44px avatar). Shrink to w-5 h-5 + add a w-3 h-3 class on the inner icon so it scales down with it. Graph continuity bug: the saved-line endpoint and the projection-line start point could disagree by tens of $thousands. Saved came from `Balance::ChartSeriesBuilder` (daily snapshot in `balances`), projection started at `currentAmount = goal.current_balance.to_f` (live `linked_accounts.sum(:balance)`). When the snapshot lagged the live read, the chart showed a vertical gap at the "today" marker. Filter any same-day-or-later points out of the raw saved series, always extend the saved series to `(today, currentAmount)`. Saved line now closes at exactly the projection's start. The recent balance-drop story is still honestly shown (the line dips toward the live value rather than ending at the stale snapshot). Ring card focal-point (RUI audit): the left ring card on goals#show sat at the same `shadow-border-xs` elevation as the projection chart and funding card. "When every card is raised, nothing's primary." Drop the shadow + container background — the ring now reads as a status panel sitting on the page surface, not a content card competing with its neighbours. Paused/archived/celebration/empty right-slot variants keep elevation since they ARE content cards. Deferred: light-mode pink distribution-bar contrast. The fix needs a DS token decision (hairline outline vs darker step on the palette entries); rolling it into a polish PR risks dragging in DS changes unrelated to goals. Logged for a follow-up.
298 lines
14 KiB
Plaintext
298 lines
14 KiB
Plaintext
<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">
|
|
<%= 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">
|
|
<%
|
|
primary_parts = []
|
|
if @goal.target_date
|
|
days = (@goal.target_date - Date.current).to_i
|
|
past_due = days < 0 && !(@goal.completed? || @goal.status == :reached)
|
|
if past_due
|
|
primary_parts << t(".header.target_by_past", amount: @goal.target_amount_money.format(precision: 0), date: I18n.l(@goal.target_date, format: :long))
|
|
else
|
|
primary_parts << t(".header.target_by", amount: @goal.target_amount_money.format(precision: 0), date: I18n.l(@goal.target_date, format: :long))
|
|
if days > 0 && !(@goal.completed? || @goal.status == :reached)
|
|
primary_parts << t("goals.goal_card.days_left", count: days, date: I18n.l(@goal.target_date, format: :long)).split(" · ").first
|
|
end
|
|
end
|
|
else
|
|
primary_parts << t(".header.target", amount: @goal.target_amount_money.format(precision: 0))
|
|
end
|
|
%>
|
|
<%= primary_parts.join(" · ") %>
|
|
</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">
|
|
<%= render DS::Link.new(
|
|
text: t(".edit"),
|
|
variant: "outline",
|
|
href: edit_goal_path(@goal),
|
|
icon: "pencil",
|
|
frame: :modal
|
|
) %>
|
|
<% 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| %>
|
|
<% if @goal.may_pause? %>
|
|
<% menu.with_item(variant: "button", text: t(".pause"), icon: "pause", href: pause_goal_path(@goal), method: :patch) %>
|
|
<% end %>
|
|
<% if @goal.may_resume? %>
|
|
<% menu.with_item(variant: "button", text: t(".resume"), icon: "play", href: resume_goal_path(@goal), method: :patch) %>
|
|
<% end %>
|
|
<% if @goal.may_complete? %>
|
|
<% complete_body = if @goal.progress_percent < 100
|
|
t(".confirm_complete_body_short",
|
|
progress: @goal.progress_percent,
|
|
saved: @goal.current_balance_money.format(precision: 0),
|
|
target: @goal.target_amount_money.format(precision: 0))
|
|
else
|
|
t(".confirm_complete_body")
|
|
end %>
|
|
<% menu.with_item(
|
|
variant: "button",
|
|
text: t(".complete"),
|
|
icon: "circle-check-big",
|
|
href: complete_goal_path(@goal),
|
|
method: :patch,
|
|
confirm: CustomConfirm.new(
|
|
title: t(".confirm_complete_title"),
|
|
body: complete_body,
|
|
btn_text: t(".confirm_complete_cta")
|
|
)
|
|
) %>
|
|
<% end %>
|
|
<% if @goal.may_archive? %>
|
|
<% menu.with_item(
|
|
variant: "button",
|
|
text: t(".archive"),
|
|
icon: "archive",
|
|
href: archive_goal_path(@goal),
|
|
method: :patch,
|
|
confirm: CustomConfirm.new(
|
|
title: t(".confirm_archive_title"),
|
|
body: t(".confirm_archive_body"),
|
|
btn_text: t(".confirm_archive_cta")
|
|
)
|
|
) %>
|
|
<% end %>
|
|
<% if @goal.may_unarchive? %>
|
|
<% menu.with_item(variant: "button", text: t(".unarchive"), icon: "archive-restore", href: unarchive_goal_path(@goal), method: :patch) %>
|
|
<% end %>
|
|
<% if @goal.archived? %>
|
|
<% menu.with_item(
|
|
variant: "button",
|
|
text: t(".delete"),
|
|
icon: "trash-2",
|
|
href: goal_path(@goal),
|
|
method: :delete,
|
|
destructive: true,
|
|
confirm: CustomConfirm.for_resource_deletion(@goal.name, high_severity: true)
|
|
) %>
|
|
<% end %>
|
|
<% end %>
|
|
</div>
|
|
</header>
|
|
|
|
<% @open_pledges.each do |pledge| %>
|
|
<%= render "pending_pledge_banner", pledge: pledge %>
|
|
<% end %>
|
|
|
|
<% if @goal.paused? %>
|
|
<%= render DS::Alert.new(variant: "info", title: t("goals.show.paused_banner.title")) do %>
|
|
<p class="text-secondary"><%= t("goals.show.paused_banner.body") %></p>
|
|
<div class="mt-2">
|
|
<%= render DS::Button.new(
|
|
text: t("goals.show.paused_banner.resume_cta"),
|
|
href: resume_goal_path(@goal),
|
|
variant: "primary",
|
|
size: "sm",
|
|
method: :patch
|
|
) %>
|
|
</div>
|
|
<% end %>
|
|
<% elsif @goal.archived? %>
|
|
<%= render DS::Alert.new(variant: "info", title: t("goals.show.archived_banner.title")) do %>
|
|
<p class="text-secondary"><%= t("goals.show.archived_banner.body") %></p>
|
|
<% if @goal.may_unarchive? %>
|
|
<div class="mt-2">
|
|
<%= render DS::Button.new(
|
|
text: t("goals.show.archived_banner.restore_cta"),
|
|
href: unarchive_goal_path(@goal),
|
|
variant: "primary",
|
|
size: "sm",
|
|
method: :patch
|
|
) %>
|
|
</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 %>
|
|
<section class="grid grid-cols-1 lg:grid-cols-[320px_minmax(0,1fr)] gap-3">
|
|
<div class="rounded-xl p-5 flex flex-col items-center justify-center text-center">
|
|
<%= render Goals::ProgressRingComponent.new(goal: @goal, size: 180) %>
|
|
<p class="text-xl font-medium text-primary tabular-nums privacy-sensitive mt-4"><%= @goal.current_balance_money.format(precision: 0) %></p>
|
|
<% 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 %>
|
|
</div>
|
|
|
|
<% if @goal.archived? || @goal.paused? %>
|
|
<div class="bg-container rounded-xl shadow-border-xs p-5 flex flex-col items-center justify-center text-center">
|
|
<div class="w-16 h-16 rounded-full bg-surface-inset inline-flex items-center justify-center text-secondary mb-3">
|
|
<%= icon(@goal.archived? ? "archive" : "pause", size: "2xl") %>
|
|
</div>
|
|
<h2 class="text-lg font-semibold text-primary">
|
|
<%= t(@goal.archived? ? ".inactive.heading_archived" : ".inactive.heading_paused") %>
|
|
</h2>
|
|
<p class="text-sm text-secondary mt-1 max-w-md tabular-nums">
|
|
<%= t(".inactive.body", saved: @goal.current_balance_money.format(precision: 0), target: @goal.target_amount_money.format(precision: 0)) %>
|
|
</p>
|
|
</div>
|
|
<% elsif @goal.completed? || @goal.status == :reached %>
|
|
<div class="bg-container rounded-xl shadow-border-xs p-5 flex flex-col items-center justify-center text-center">
|
|
<div class="w-16 h-16 rounded-full bg-success/10 inline-flex items-center justify-center text-success mb-3">
|
|
<%= icon("party-popper", size: "2xl", color: "success") %>
|
|
</div>
|
|
<h2 class="text-lg font-semibold text-primary"><%= t(".celebration.heading") %></h2>
|
|
<p class="text-sm text-secondary mt-1 max-w-md"><%= t(".celebration.body", saved: @goal.current_balance_money.format(precision: 0), target: @goal.target_amount_money.format(precision: 0)) %></p>
|
|
<% if @goal.may_archive? %>
|
|
<div class="mt-4">
|
|
<%= render DS::Button.new(
|
|
text: t(".celebration.archive_cta"),
|
|
href: archive_goal_path(@goal),
|
|
variant: "outline",
|
|
size: "sm",
|
|
method: :patch
|
|
) %>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
<% elsif @goal.current_balance.to_d.zero? && @goal.pace.to_d.zero? %>
|
|
<%# No movement yet on the linked account: render an inline "make your first transfer"
|
|
CTA card instead of a flat-at-$0 chart that looks broken. %>
|
|
<div class="bg-container rounded-xl shadow-border-xs p-5 flex flex-col items-center justify-center text-center">
|
|
<div class="w-16 h-16 rounded-full bg-surface-inset inline-flex items-center justify-center text-secondary mb-3">
|
|
<%= icon("piggy-bank", size: "2xl") %>
|
|
</div>
|
|
<h2 class="text-lg font-semibold text-primary"><%= t(".empty.heading") %></h2>
|
|
<p class="text-sm text-secondary mt-1 max-w-md"><%= t(".empty.body") %></p>
|
|
<div class="mt-4">
|
|
<%= render DS::Link.new(
|
|
text: t(".record_pledge_cta"),
|
|
variant: "primary",
|
|
size: "sm",
|
|
href: new_goal_pledge_path(@goal),
|
|
icon: "plus",
|
|
frame: :modal
|
|
) %>
|
|
</div>
|
|
</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="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>
|
|
</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">
|
|
<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") %>
|
|
</span>
|
|
<% unless @goal.target_date.nil? %>
|
|
<span class="inline-flex items-center gap-1.5">
|
|
<svg width="18" height="6"><line x1="0" y1="3" x2="18" y2="3" stroke="<%= projection_color %>" stroke-width="2" stroke-dasharray="3 3" /></svg>
|
|
<%= t(".projection.legend_projection") %>
|
|
</span>
|
|
<% if @goal.monthly_target_amount.to_d.positive? && @goal.remaining_amount.to_d.positive? %>
|
|
<span class="inline-flex items-center gap-1.5">
|
|
<svg width="18" height="6" class="text-secondary"><line x1="0" y1="3" x2="18" y2="3" stroke="currentColor" stroke-width="2" stroke-dasharray="2 4" opacity="0.5" /></svg>
|
|
<%= t(".projection.legend_required") %>
|
|
</span>
|
|
<% end %>
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
<div class="flex-1 min-h-[200px]"
|
|
data-controller="goal-projection-chart"
|
|
data-goal-projection-chart-data-value="<%= @goal.projection_payload.to_json %>"
|
|
data-goal-projection-chart-aria-label-value="<%= t("goals.show.projection.aria_label", name: @goal.name) %>"
|
|
data-goal-projection-chart-aria-description-value="<%= strip_tags(@goal.projection_summary) %>"></div>
|
|
<% if @goal.target_date.nil? %>
|
|
<div class="mt-3 flex items-center gap-2 text-xs text-secondary">
|
|
<span class="text-subdued"><%= icon("calendar-plus", size: "sm") %></span>
|
|
<span class="flex-1"><%= t(".no_target_date.body") %></span>
|
|
<%= link_to t(".no_target_date.cta"),
|
|
edit_goal_path(@goal),
|
|
data: { turbo_frame: :modal },
|
|
class: "font-medium text-primary underline-offset-2 hover:underline" %>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
<% end %>
|
|
</section>
|
|
|
|
<% if @goal.linked_accounts.any? %>
|
|
<section class="bg-container rounded-xl shadow-border-xs p-5">
|
|
<%= render Goals::FundingAccountsBreakdownComponent.new(goal: @goal) %>
|
|
</section>
|
|
<% end %>
|
|
|
|
<% if @goal.notes.present? %>
|
|
<section class="bg-container rounded-xl shadow-border-xs p-5">
|
|
<h2 class="text-sm font-medium text-primary mb-2"><%= t(".notes") %></h2>
|
|
<p class="text-sm text-secondary whitespace-pre-line"><%= @goal.notes %></p>
|
|
</section>
|
|
<% end %>
|
|
</div>
|