mirror of
https://github.com/we-promise/sure.git
synced 2026-09-09 08:34:26 +00:00
* feat(goals): offer recording a spend where the user is standing Adding money had a button on the goal page. Using it had none — the entry lived in the overflow menu, behind three conditions, and nowhere else. The asymmetry bit hardest at the one moment it mattered. "Record pledge" disappears once a goal is reached, so a user who hit the target and then spent some of it arrived at a page offering a single action: "Close this goal". That releases the earmark, and its own hint tells them to do it "once you have actually spent it" — asking for something the page gave them no way to say. The celebration panel now offers it beside closing, in that order, because that is the order the two happen in and closing is the one another click cannot undo. Same condition as the menu entry, which stays where it is: this is a second door, not a move. Beside, never instead. Plenty of goals are closed with nothing recorded, and the offer must not read as a step to clear first — a test pins that closing is still offered whenever it was before. The row is conditional rather than always rendered: a reserve gets neither action, and an empty flex div still carries its top margin, which would open a gap under copy that says there is nothing to do. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * fix(goals): hold the spend offer to accounts the reader can reach Review on #3215. `offer_recording_a_spend?` rode on `current_balance`, which counts every linked account — private ones included. A reader backed only by somebody else's private account was shown the link, then sent to a dialog with nothing to pick and a refusal on submit. The dialog has applied this scoping since #3176; the panel that points at it had not. It goes through `backing_within` now, on the reader's own accessible accounts. The component tests gained a session for the same reason: without a reader the offer is correctly withheld, so every assertion about it was measuring the wrong thing. Also from review: the reserve's empty-row test renders the component rather than only asking its predicates. Both could stay false while the template emitted the row anyway, which is precisely the gap that test exists for — it needed `ViewComponent::TestCase` to do it. The two page tests move above the first `private`. They did run where they were — Rails' `test` macro defines methods through `define_method` from a class method, which is public regardless of the surrounding visibility, and `-n` confirms Minitest picks them up — but tests wedged between private helpers read as a mistake whether or not they behave like one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * fix(goals): close the second door to the spend dialog Review on #3215. Scoping the offer to the reader's own accounts fixed the lifecycle panel and left the overflow menu on the old condition, so the two doors to the same dialog disagreed — and the older one still had the bug the newer one was written to avoid. A reader backed only by another member's private account was shown the menu entry, opened a dialog with nothing to pick, and was refused on submit. The question moves to the goal, where both doors ask it, and the reader's accounts come from the list the controller already builds for the dialog. That also removes the component's own broader lookup: it was plucking every accessible account on each goal-show render, duplicating work done upstream in the same request. It now takes the ids in, defaulting to none — a caller that forgets them withholds the offer, which is the safe way to be wrong about a permission. A controller test pins it page-wide, since the point is that neither door may offer it; putting the old condition back makes it fail. Also from review: the panel test claimed to be scoped to the panel while selecting `section`, which DS::Card emits for every card on the page. The action row has an id now and both tests use it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
188 lines
8.3 KiB
ERB
188 lines
8.3 KiB
ERB
<div class="space-y-4 pb-6 lg:pb-12">
|
|
<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>
|
|
<%# Each segment is its own nowrap span so a short trailing one — "184 days
|
|
left" — moves to the next line whole instead of breaking after "days".
|
|
The first segment carries the target and a long-format date, so it is
|
|
left free to wrap; pinning it would overflow a narrow screen. %>
|
|
<p class="text-sm text-secondary mt-1 privacy-sensitive">
|
|
<%= safe_join(
|
|
@goal.header_summary_parts.map.with_index { |part, i|
|
|
tag.span(part, class: ("whitespace-nowrap" unless i.zero?))
|
|
},
|
|
" · "
|
|
) %>
|
|
</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 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
|
|
put their Edit action inside the kebab dropdown). Keeps the
|
|
header to one primary action. %>
|
|
<% menu.with_item(variant: "link", text: t(".edit"), icon: "pencil", href: edit_goal_path(@goal), data: { turbo_frame: :modal }) %>
|
|
<% 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 %>
|
|
<%# Partial spend: only offered on a one-off goal that is still running
|
|
and has something THIS READER can spend. A reserve is drawn down and
|
|
refilled, not consumed — the model refuses it outright. Same
|
|
question the lifecycle panel asks, off the same scoped list, so the
|
|
two doors to this dialog cannot disagree. %>
|
|
<% if @goal.spendable_within?(eligible_consumption_accounts.map(&:id)) %>
|
|
<% menu.with_item(variant: "link", text: t(".consume"), icon: "hand-coins",
|
|
href: consume_goal_path(@goal), data: { turbo_frame: :modal }) %>
|
|
<% end %>
|
|
<% if @goal.may_complete? %>
|
|
<% menu.with_item(
|
|
variant: "button",
|
|
text: t(".complete"),
|
|
icon: "circle-check-big",
|
|
href: complete_goal_path(@goal),
|
|
method: :patch,
|
|
confirm: goal_complete_confirm(@goal)
|
|
) %>
|
|
<% 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.may_reopen? %>
|
|
<% menu.with_item(variant: "button", text: t(".reopen"), icon: "rotate-ccw", href: reopen_goal_path(@goal), method: :patch) %>
|
|
<% end %>
|
|
<%# Available in any state — see GoalsController#destroy for why there's
|
|
no archive-first gate. %>
|
|
<% menu.with_item(
|
|
variant: "button",
|
|
text: t(".delete"),
|
|
icon: "trash-2",
|
|
href: goal_path(@goal),
|
|
method: :delete,
|
|
destructive: true,
|
|
confirm: @goal.deletion_confirm
|
|
) %>
|
|
<% end %>
|
|
</div>
|
|
</header>
|
|
|
|
<%= render "status_callout", goal: @goal %>
|
|
|
|
<% @open_pledges.each do |pledge| %>
|
|
<%= render "pending_pledge_banner", pledge: pledge %>
|
|
<% end %>
|
|
|
|
<% if @unattributed_outflows.any? %>
|
|
<%= render "unattributed_outflows", goal: @goal, outflows: @unattributed_outflows %>
|
|
<% 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 %>
|
|
<% 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) %>
|
|
<%# The figure the ring is drawn from — held plus already spent on the
|
|
goal — so the two agree. What is still sitting in each account is in
|
|
the funding breakdown below, which is where that question belongs. %>
|
|
<p class="text-xl font-medium text-primary tabular-nums privacy-sensitive mt-4"><%= @goal.progress_amount_money.format(precision: 0) %></p>
|
|
<% if @goal.any_consumption? %>
|
|
<p class="text-xs text-secondary tabular-nums mt-0.5 privacy-sensitive">
|
|
<%= t(".ring.including_used", amount: @goal.consumed_amount_money.format(precision: 0)) %>
|
|
</p>
|
|
<% end %>
|
|
<% if @goal.contributions_basis? %>
|
|
<p class="text-xs text-secondary tabular-nums mt-0.5 privacy-sensitive"><%= t(".ring.market_value", amount: @goal.market_value_money.format(precision: 0)) %></p>
|
|
<% end %>
|
|
<% unless @goal.completed? %>
|
|
<p class="text-xs text-subdued tabular-nums mt-0.5 privacy-sensitive"><%= t(".ring.to_go", amount: @goal.remaining_amount_money.format(precision: 0)) %></p>
|
|
<% end %>
|
|
<% unless @goal.completed? || @goal.status == :reached || @goal.status == :funded || @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>
|
|
|
|
<%= render Goals::LifecyclePanelComponent.new(
|
|
goal: @goal, viewer_account_ids: eligible_consumption_accounts.map(&:id)
|
|
) %>
|
|
</section>
|
|
|
|
<% if @goal.linked_accounts.any? %>
|
|
<%= render DS::Card.new do %>
|
|
<%= render Goals::FundingAccountsBreakdownComponent.new(goal: @goal) %>
|
|
<% end %>
|
|
<% end %>
|
|
|
|
<% if @goal.notes.present? %>
|
|
<%= render DS::Card.new do %>
|
|
<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>
|
|
<% end %>
|
|
<% end %>
|
|
</div>
|