diff --git a/app/javascript/controllers/goal_projection_chart_controller.js b/app/javascript/controllers/goal_projection_chart_controller.js index 7d9bf14a9..79c456e8a 100644 --- a/app/javascript/controllers/goal_projection_chart_controller.js +++ b/app/javascript/controllers/goal_projection_chart_controller.js @@ -11,7 +11,14 @@ import * as d3 from "d3"; // Data shape passed via `data-goal-projection-chart-data-value` // matches Goal#projection_payload. export default class extends Controller { - static values = { data: Object, ariaLabel: String, ariaDescription: String }; + static values = { + data: Object, + ariaLabel: String, + ariaDescription: String, + todayLabel: { type: String, default: "Today" }, + projectedTemplate: { type: String, default: "Projected: {amount}" }, + savedTemplate: { type: String, default: "Saved: {amount}" }, + }; connect() { this._resize = this._draw.bind(this); @@ -400,7 +407,7 @@ export default class extends Controller { .attr("text-anchor", "middle") .attr("font-size", 12) .attr("fill", textSecondary) - .text("Today"); + .text(this.todayLabelValue); } // Full 4-digit year so the terminal "Jan 2027" reads as the year, not @@ -515,7 +522,7 @@ export default class extends Controller { const projValue = currentAmount + tFrac * (projectionEnd - currentAmount); hoverProjDot.attr("cx", hoverX).attr("cy", y(projValue)).style("display", null); hoverSavedDot.style("display", "none"); - lines.push(`Projected: ${this._fmtMoney(projValue, data.currency)}`); + lines.push(this.projectedTemplateValue.replace("{amount}", this._fmtMoney(projValue, data.currency))); } else { // Saved segment: hoverDate is already snapped to nearest savedSeries // entry above, so reuse that entry directly instead of running @@ -523,7 +530,7 @@ export default class extends Controller { const savedPoint = savedSeries.find((p) => p.date.getTime() === hoverDate.getTime()) || savedSeries[savedSeries.length - 1]; hoverSavedDot.attr("cx", x(savedPoint.date)).attr("cy", y(savedPoint.value)).style("display", null); hoverProjDot.style("display", "none"); - lines.push(`Saved: ${this._fmtMoney(savedPoint.value, data.currency)}`); + lines.push(this.savedTemplateValue.replace("{amount}", this._fmtMoney(savedPoint.value, data.currency))); } tooltip.textContent = lines.join("\n"); diff --git a/app/models/account/provider_import_adapter.rb b/app/models/account/provider_import_adapter.rb index 536046a8c..d307e81bf 100644 --- a/app/models/account/provider_import_adapter.rb +++ b/app/models/account/provider_import_adapter.rb @@ -223,7 +223,15 @@ class Account::ProviderImportAdapter # Auto-resolve any open Goal pledges on this account whose tolerance # window matches the posted transaction. Idempotent via the partial-unique # index on transactions.extra->'goal'->>'pledge_id'. - GoalPledge::Reconciler.new(entry).run unless incoming_pending + # + # Short-circuit when the account isn't linked to any goal: a 2k-row + # historical Plaid import on an unlinked account otherwise pays one + # SELECT per row. goal_accounts membership is stable across a sync + # batch, so memoize once per adapter instance (one query per account + # synced, not per transaction). + if !incoming_pending && account_linked_to_any_goal? + GoalPledge::Reconciler.new(entry).run + end # AFTER save: For NEW posted transactions, check for fuzzy matches to SUGGEST (not auto-claim) # This handles tip adjustments where auto-matching is too risky @@ -968,4 +976,12 @@ class Account::ProviderImportAdapter account_name: entry.account.name } end + + # Memoized per adapter instance (which is per-account). Membership in + # goal_accounts is stable across a sync batch. + def account_linked_to_any_goal? + return @account_linked_to_any_goal if defined?(@account_linked_to_any_goal) + + @account_linked_to_any_goal = account.goal_accounts.exists? + end end diff --git a/app/views/goals/show.html.erb b/app/views/goals/show.html.erb index 8411afc33..374bcd6c5 100644 --- a/app/views/goals/show.html.erb +++ b/app/views/goals/show.html.erb @@ -246,7 +246,10 @@ 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-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}") %>"> <% if @goal.target_date.nil? %>