- Family#savings_inflow_windows wraps the current/prior 30d sums in a
single helper that memoizes the linked-account-id lookup. The KPI tile
on the goals index used to run the join+pluck twice per request.
- Replace two instance_variable_set pokes and one any_instance.stubs in
the goal/controller tests. Refetching the goal exercises the real
request lifecycle and stops the tests from leaning on implementation
details. The 'All caught up' assertion now relies on a real reached
state (target 1 vs the depository fixture's 5000 balance) rather than
stubbing :status.
- Add tests covering: hex format validation on Goal#color, AASM cache
reset (display_status reads the new state on the same instance after
pause!), negative pledge amount rejection, expire! no-op on already-
expired pledge, cancel! NotOpenError on non-open pledge, sweep job
idempotency on a second pass, and strong-params rejection of state /
family_id on goal create.
Three issues raised on PR #1798 review:
- ProviderImportAdapter now memoizes account.goal_accounts.exists?
per-account so a bulk historical import on an unlinked account
short-circuits the reconciler instead of paying one SELECT per row.
Linked accounts still hit the per-row reconciler with no change.
- goal_projection_chart_controller.js reads Today / Projected /
Saved labels via Stimulus values fed from
goals.show.projection.* locale keys instead of inlining English.
- goal_test.rb now covers Goal#pace with real inflows, asserting
the 90-day window cutoff plus the Transaction.excluding_pending
and entries.excluded = false filters.
Correctness:
- GoalPledge#matches? rejects outflows on transfer pledges so a +$200
purchase no longer satisfies a $200 deposit pledge after .abs
- GoalsController#sync_linked_accounts! saves through the goal so
currency/depository/family validations actually run on update
- AlreadyClaimedError replaces empty RecordInvalid in resolve_with! and
reconciler rescues the dedicated class
- SweepExpiredGoalPledgesJob wraps each expire! in a per-record rescue
- Assistant::Function::CreateGoal disambiguates duplicate account names
and returns an absolute URL via mailer host config
- Family#savings_inflow_velocity defensively scopes from the family's
accounts (was Account.joins(:goal_accounts).where(goal_id: ...))
- GoalPledgesController#set_goal preloads linked_accounts + providers
to drop the N+1 on any_connected_account?
- Stepper subtitle update walks to the enclosing dialog before
querySelector so two stepper instances don't fight over one header
- categories/_form.html.erb data-action targets color-icon-picker, not
the non-existent "category" controller
UX / visual:
- Projection chart drops preserveAspectRatio="none" and pins endDate at
today for past-due goals so the today marker stays in-domain
- _color_picker / categories form swap non-standard border-1 for border
- Goals index search input uses ring-alpha-black-100 (was raw gray-500)
Refactors:
- Goal#header_summary extracts the multi-line ERB header block
- Goal#catch_up_delta_money sums open_pledges in SQL
- Goal#projection_summary uses I18n.l for the on-track month label
- Account#default_pledge_kind moves the manual/transfer decision out of
GoalPledgesController
- GoalPledge::Reconciler iterates ordered (created_at, id) so first-claim
wins is deterministic under non-sequential PKs
- Goals::FundingAccountsBreakdownComponent + Goals::AccountStackComponent
use clamp(0..) instead of Float::INFINITY / [x, 0].max
- Goals::StatusPillComponent#label provides a titleize fallback
- Goal projection chart skips the redundant initial _draw and reuses
the snapped point in the past branch (no double-bisect)
- Goal pledge preview drops maximumFractionDigits: 0 so USD/EUR show
cents while JPY/KRW stay whole-unit
- Demo generator captures the Wedding fund goal in the seed loop
instead of looking it up by hardcoded name
Tests:
- GoalPledgeTest: outflow rejection
- GoalsControllerTest: cross-currency attachment rejected on update
- SweepExpiredGoalPledgesJobTest: cancelled coverage + per-record rescue
- GoalTest: pledge_action_label_key flips to manual_save without an
unconditional guard
Second pass on user-facing strings after the em-dash sweep and
yellow-pill demotion. Voice/abbreviation/edge-value parity.
Voice consistency:
- `index.pending_pledges_callout` reframed from "Sure is watching
your linked accounts" (system-as-watcher voice) to "You have
pending pledges. Sure will confirm them on the next sync."
(user-actor, system-action). Matches the surrounding
user-centric voice on the KPI strip and the helper-text pattern
("Sure will look for…", "Sure will catch it") used elsewhere.
- `goal_pledges.new.helper_manual` flipped pronoun "We'll record"
to "Sure will record" so the modal's two helper lines share a
single narrator. The transfer-helper already says "Sure will
look for"; this matches.
- `form_stepper.errors.*` dropped the apologetic "Please …" voice
("Please give your goal a name.") for the terse imperative
the rest of the feature uses ("Give your goal a name." / "Set
a target above zero." / "Pick at least one funding account.").
Parallelism:
- `kpi.velocity_delta_zero_base` was the only `velocity_delta_*`
string spelling out "30 days" while siblings used `30d`. Switch
to "First 30d of activity" so the sub-tile reads in one unit.
- `Depository` titlecase in `at_least_one_linked_account_required`,
`must_be_depository`, and `no_depository_accounts` collapsed to
lowercase. Common noun, not a UI label. Matches the empty-state
body in `funding_accounts.empty.body` which was already lowercase.
Test fixture for `must_be_depository` updated.
- `projection.reached` was the same string as `celebration.heading`
("Goal reached. Nice work."), making the celebration moment feel
templated. The projection slot is the chart's empty state when
there's nothing to project; rephrase to "You've hit the target.
No projection needed." Celebration keeps the warm tone.
Edge value:
- `celebration.body` was "You hit your $X target." When the user
marks a goal complete at sub-100% (a flow the new
`confirm_complete_body_short` already warns about), this lied
about the achievement. Rewrite to "Goal closed at %{saved} of
%{target}. Keep it as a record, or archive it now." Interpolation
now passes both `saved` and `target` from the show template, so
the celebration card honors the actual saved amount whether the
user hit, overshot, or stopped short.
Notes deferred (verify-only, not string changes):
- `goal_card.footer_catch_up` is interpolated with
`catch_up_delta_money` in `CardComponent#footer_line`; the show-
page guard `.amount.positive?` already lives there. No copy
change needed.
- `pending_pledge.title.zero` bucket fires only when `count: 0`
reaches the I18n call; `GoalPledge#days_left` clamps at 0, so
the friendlier "expires today" copy is reachable.
- `paused_banner.title` / `inactive.heading_paused` duplicate
strings noted but left in place; consolidation is a separate
refactor.
Two semantic shifts in V2 that drove the worst on-screen confusion.
B3/B4 — `Goal#pace` excluded `Transaction::TRANSFER_KINDS`. When a
user tapped "I just transferred…" and the deposit landed, the linked
account's balance went up but pace did not: pace ignored transfer-
kind entries, so the goal stayed `:behind` against `monthly_target`
and the catch-up callout kept demanding $X/mo even though the user
had just moved the money in. Same root cause hit any long-time saver
whose 90-day net was zero — pace=0, status=:behind, projection says
"At $0.00/mo you'll miss your target date" while the ring sits at
80%.
Drop the transfer-kind exclusion. Pace is now net inflow into linked
accounts over 90 days. Transfers between linked accounts already net
out (both legs land inside the same account set); transfers from
outside (checking → linked savings) net positive, which is exactly
the case the pledge flow records.
B19 — `Family#savings_inflow_velocity` summed entry amounts across
every depository account linked to any goal regardless of currency,
then rendered the result in the family's primary currency. A family
with one USD goal and one EUR goal saw `usd_inflow + eur_inflow`
reported as USD with no FX conversion. Scope the account set to the
family's primary currency until proper FX-conversion lands. Also
let the result go negative (net outflow) — clamping to ≥0 lost
signal; the controller decides how to render the sign.
V20 (controller) — `velocity_30d_sign` was wired off the *delta*
direction, so a $1,234 down-month rendered as "−$1,234 ↓ 27% vs
prior 30d". The minus read as a loss but $1,234 was the (positive)
contribution. Re-wire the headline sign off the headline value
itself; the delta-direction stays on the subline as ↑/↓ N%. With
the family-rollup change above, the headline can now legitimately
be negative — UI now says "−$200 ↓ 50% vs prior 30d" when the
family had net outflow.
B21 — KPI tile `on_track_count` lumped `:reached` goals into "on
track", inflating the numerator while the sort order placed reached
goals at the bottom of the list. Split `reached_count` out and
render it as its own segment in the on-track subline ("1 reached ·
1 behind · 1 paused").
Test: rename the pace=zero test to match its new premise (no
transactions vs. no non-transfer entries). The fixture still has no
entries, so the assertion holds.
Regenerate schema.rb after the three v2 migrations so CI's db:schema:load
picks up goal_pledges, the dropped goal_contributions, and the partial
unique pledge_id index.
Brakeman:
- Drop :account_id and :kind from goal_pledge permit; look the account
up via @goal.linked_accounts.find_by(id:) instead and set kind
server-side from goal.any_connected_account?.
- Rename goals.show.projection.on_track to .on_track_html so I18n
marks the result html_safe automatically; drop the unconditional
.html_safe call in show.html.erb.
Pledge modal: rewrite app/views/goal_pledges/new.html.erb to use
DS::Dialog (the Sure convention for create modals — matches
categories/transfers).
Error handling: replace `raise ActiveRecord::RecordInvalid, "string"`
in GoalPledge#extend!/cancel! with a dedicated GoalPledge::NotOpenError;
the controller rescues that specifically.
Tests: rewrite the "pace is zero" test to create a fresh account with
no entries (the fixture's depository accounts carry transaction history
that produces a non-zero pace). All goal tests now green (73 runs,
157 assertions, 0 failures).
Reshape the goals feature to live on top of linked-account balances.
A goal's balance is now the live balance of every depository account
linked to it — no parallel ledger, no "log a contribution" step.
The "Add contribution" affordance is replaced by a 7-day GoalPledge
(kind: transfer | manual_save). GoalPledge::Reconciler matches incoming
Transactions (via Account::ProviderImportAdapter) and Valuations (via
Account::ReconciliationManager) against open pledges within ±5 days,
±$0.50, or ±1% — single hook covers every provider (Plaid, SimpleFIN,
Lunchflow, Enable Banking, Brex, IBKR, Kraken, SnapTrade) plus manual
balance edits. A 15-minute Sidekiq cron sweeps expired pledges.
Goal model: balance derived from linked_accounts.sum(&:balance), new
pace (90-day net non-transfer inflow), months_of_runway,
last_matched_pledge_*, pledge_action_label_key (the "I just
transferred…" vs "I just saved…" verb switch).
UI:
- Index gets a 3-card KPI strip (Contributed last 30d / Needs this
month / On track) plus a pending-pledges callout.
- Show page swaps the "Add contribution" CTA for the pledge modal,
replaces the contribution list with a pending-pledge banner, and
rebuilds the funding widget into per-account rows with a 12-bucket
weekly sparkline and last-30 inflow.
- Projection chart adds a required-line (dashed light from
today → target) and a translucent pending-pledge bump at today's X.
Schema (3 migrations):
1. goal_pledges table with PG enums (goal_pledge_kind, goal_pledge_status),
open-by-expiry index, and unique-when-not-null matched_transaction_id.
2. Drop goal_contributions.
3. Partial unique index on
transactions ((extra -> 'goal' ->> 'pledge_id')) built CONCURRENTLY
so it doesn't block prod.
After pulling: run bin/rails db:migrate, then commit the schema.rb sync
separately (or let CI regenerate).
Deferred to v1.1: allocation columns, contention/archived banners,
"why is this behind?" diagnostic, reallocate flow, refresh-sync +
Plaid throttle, unallocated-cash chip, joint-account approval,
goal_activities log, polymorphic matched_entry_id/type for manual
pledge audit.