Commit Graph

8 Commits

Author SHA1 Message Date
Guillem Arias
f672aae3cf perf + tests(goals): share account-ids across velocity windows + cover gaps
- 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.
2026-05-18 21:11:30 +02:00
Guillem Arias
89bae8a59b fix(goals): jjmata review — reconciler guard, chart i18n, pace test
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.
2026-05-17 16:54:13 +02:00
Guillem Arias
9f29185160 fix(goals): address AI review on PR #1798 (CodeRabbit + Codex)
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
2026-05-15 00:01:13 +02:00
Guillem Arias
1b323677d0 fix(goals/copy): apply stylistic pass from copy expert
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.
2026-05-14 22:16:55 +02:00
Guillem Arias
150dc4bdc9 fix(goals): pace counts transfers, family rollup currency-scoped
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.
2026-05-14 19:17:12 +02:00
Guillem Arias
eb7ef50eed fix(goals): CI green — schema, brakeman, pledge modal, error class
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).
2026-05-14 17:54:08 +02:00
Guillem Arias
88032ce020 feat(goals): v2 architecture — drop ledger, derive balance, add pledge
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.
2026-05-14 16:07:14 +02:00
Guillem Arias
9b61e4a41b refactor: rename Savings Goals feature to Goals
User-facing rename + structural rename. Feature is now called just
"Goals" everywhere — page title, sidebar nav, modal headings, flash
messages, AI assistant tool. Code identifiers follow:

- Models: SavingsGoal → Goal, SavingsContribution → GoalContribution,
  SavingsGoalAccount → GoalAccount.
- Tables: savings_goals → goals, savings_contributions → goal_contributions,
  savings_goal_accounts → goal_accounts. FK columns savings_goal_id →
  goal_id. New migration db/migrate/20260511100003_rename_savings_to_goals.rb
  uses rename_table + rename_column; PG handles index renaming and FK
  redirection automatically.
- Controllers: SavingsGoalsController → GoalsController,
  SavingsContributionsController → GoalContributionsController.
- Routes: /savings_goals → /goals, nested /goals/:id/contributions
  (resource name shifts; old route name aliases dropped).
- ViewComponent namespace: Savings::* → Goals::*. Component class
  names drop their redundant "Goal" prefix where the namespace already
  carries it: Savings::GoalCardComponent → Goals::CardComponent,
  Savings::GoalAvatarComponent → Goals::AvatarComponent. Others keep
  their names (Goals::ProgressRingComponent, Goals::StatusPillComponent,
  Goals::AccountStackComponent, Goals::FundingAccountsBreakdownComponent).
- Stimulus controllers: savings_goal_* → goal_*, savings_goals_filter
  → goals_filter. Stimulus identifiers in data-controller / data-*
  attributes follow.
- Locale keys: savings_goals: → goals: (top level), savings_contributions:
  → goal_contributions: (top level). All t() callers updated.
- AI assistant tool: Assistant::Function::CreateSavingsGoal →
  Assistant::Function::CreateGoal, tool name "create_savings_goal" →
  "create_goal", description / response text updated.
- Sidebar nav label "Savings" → "Goals". Goals/show + index page title
  "Savings" → "Goals". Empty goals_section heading/subtitle dropped
  (duplicated the page title post-rename).

Original migrations create_savings_goals / create_savings_goal_accounts /
create_savings_contributions remain untouched so historical replay
still works; the rename migration runs on top.
2026-05-11 20:08:32 +02:00