mirror of
https://github.com/we-promise/sure.git
synced 2026-08-05 08:32:15 +00:00
* feat(goals): earmark a portion of an account toward a goal Goals currently count each linked account's whole balance, so an account shared across goals double-counts and one account can't fund several goals in distinct slices. Add a per-account earmark — the "GoalBacking" the v1 model already foreshadowed (goal.rb). - goal_accounts.allocated_amount (nullable). NULL = "dedicate the whole balance" (the v1 default: no backfill, existing goals unchanged); a set amount reserves a fixed slice. - Goal#current_balance is now the single chokepoint computing each account's backing under a family-wide shared pool: fixed earmarks take their slice, an unallocated link takes the remainder, and when fixed earmarks exceed the balance every slice is scaled down pro-rata so the goals' shares can never sum past the account balance (no double-counting). - Account#free_to_earmark / #goal_earmarked_total (mirror Budget's available_to_allocate) back a soft, non-blocking over-allocation hint. - GoalsController threads a goal[allocations] hash through create/update. Phase 1 of the goals earmarking work; investment-backed goals follow. * feat(goals): earmark UI on the goal form + backing-aware funding breakdown - Goal form: a per-account "earmark amount" input (blank = whole balance) next to each funding-account checkbox, prefilled from the saved allocation on edit. - Goal#account_backing exposes a single linked account's share so the funding-accounts breakdown shows each account's earmarked contribution and percent instead of its whole balance — keeping the show page consistent with the (now allocation-aware) progress ring. - English strings for the earmark controls and the "earmarked of balance" breakdown line. * fix(goals): address review on the earmark shared-pool math - Overdrawn (<= 0 balance) accounts now back nothing on both the fixed and whole-balance paths. The fixed path previously produced negative backing and let a goal claim money the account doesn't hold. - An archived goal reads its OWN earmark from its own goal_accounts instead of the shared pool (which excludes archived goals), so it no longer mis-reports the whole account balance for itself. - goals#index injects one family-wide earmark pool into every card (Goal.pooled_allocations_for) instead of querying once per goal (N+1), and preloads goal_accounts. - The projection chart scales its whole-account historical series by the backing ratio so the saved line meets current_balance at "today" rather than dropping off a cliff for earmarked goals. - Honest comments: free_to_earmark no longer claims a form warning that doesn't exist yet; pace documents its deliberate whole-account basis. * fix(goals): widen the earmark input so the 'Whole balance' placeholder isn't clipped * fix(goals): address review on #2490 - autosave: true on goal_accounts so earmark edits to already-linked accounts persist through goal.save! (Rails only auto-saves newly built children, so changing/clearing an existing earmark was silently dropped). + test. - Reset the balance/progress memos on AASM transitions, not just the status memos, so a same-instance render after complete!/archive! isn't stale. + test. - backing_ratio is 0 (not 1) when the linked-account total is non-positive, so the projection saved series ends at 0 to match the forced-zero current_balance. - Localize the funding-row subtype label via goals.form.subtypes.*. - Add the earmark strings to zh-CN (the maintained second locale; goals has no ca locale, so Catalan keeps falling back to en like the rest of goals). * feat(goals): investment-backed goals (Phase 2) Goals can now be funded by investment accounts, not just depository. - Relax linked_accounts_must_be_depository -> _must_be_fundable (depository || investment); the funding picker + counts include investment accounts. - Add goals.progress_basis ('balance' | 'contributions', default 'balance'). Investment-backed goals default to 'contributions' so a market swing doesn't move the goal: current_balance = value - cumulative market gain (Sum of balances.net_market_flows); depository accounts have zero net_market_flows, so they're unchanged. Goal#market_value_money shows what it's worth today next to the contributed figure on the show page. - Pledge false-match guard: investment accounts never use manual_save / valuation-delta matching (a market move isn't a deposit) - they resolve on transfer (cash-inflow) entries only. Guarded in both Account#default_pledge_kind and GoalPledge#matches?. - Add a `reopen` AASM event (completed -> active) + route/action/menu item so a manually-completed goal whose value later dips can be reopened. Stacked on the earmarking branch (#2490). Full suite green; +6 goal tests. * fix(goals): address Phase 2 review — allocation-aware contributions, N+1, basis-on-update - Contributions basis now goes through the same earmark/shared-pool logic as the balance basis: backing_balance_for -> backing_share_for(account, base), where base is the live balance (balance basis) or net contributions (contributions basis). Earmarks are respected and shared accounts no longer double-count on contributions goals; market_value_money stays consistent. - Batch the per-account net_market_flows sum (Goal.market_flows_for) and inject it on index like pooled_allocations, killing the N+1 for contributions goals. - Default the basis on update too (not just create), so adding an investment account to an existing depository goal flips it to contributions instead of silently tracking market value. - Fix the stale reconciliation_manager comment (renamed validation) and the orphaned zh-CN must_be_depository key. * fix(goals): address review on #2491 - before_save (not before_validation) for the progress_basis default, so a goal can be inspected via valid? without its basis flipping as a side effect (jjmata). - Pledge copy keys off default_pledge_kind, not manual?, so a manual investment account — which pledges via transfer — shows the transfer prompt instead of the "update your manual balance" flow (codex). pledge_action_label_key and the pledge modal's per-account helper flag both use it. - Add the Phase 2 strings (reopen success/invalid_transition, show.reopen, ring.market_value) to zh-CN. --------- Signed-off-by: Juan José Mata <juanjo.mata@gmail.com> Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
269 lines
14 KiB
Plaintext
269 lines
14 KiB
Plaintext
<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>
|
|
<p class="text-sm text-secondary mt-1 privacy-sensitive"><%= @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 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 %>
|
|
<% 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.may_reopen? %>
|
|
<% menu.with_item(variant: "button", text: t(".reopen"), icon: "rotate-ccw", href: reopen_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>
|
|
|
|
<%= render "status_callout", goal: @goal %>
|
|
|
|
<% @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 %>
|
|
<% 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>
|
|
<% 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.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? %>
|
|
<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 privacy-sensitive">
|
|
<%= 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 privacy-sensitive"><%= 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 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"><%= sanitize @goal.projection_summary %></p>
|
|
<% if @goal.status == :behind && @goal.monthly_target_amount %>
|
|
<p class="text-xs text-secondary mt-0.5 tabular-nums privacy-sensitive">
|
|
<%= 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">
|
|
<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] privacy-sensitive"
|
|
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) %>"
|
|
data-goal-projection-chart-today-label-value="<%= t("goals.show.projection.today_marker") %>"
|
|
data-goal-projection-chart-projected-template-value="<%= t("goals.show.projection.tooltip_projected", amount: "{amount}") %>"
|
|
data-goal-projection-chart-saved-template-value="<%= t("goals.show.projection.tooltip_saved", amount: "{amount}") %>"
|
|
data-goal-projection-chart-target-relation-template-value="<%= t("goals.show.projection.tooltip_target_relation", percent: "{percent}", target: "{target}") %>"></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>
|