The v2 rewrite dropped the velocity_delta_percent / velocity_direction
keys that powered the 'Contributed last 30d' card's '↑ 27.2% vs. prior 30d'
line and the 'Goals on track' multi-part subtitle ('1 behind · 1 paused').
Restore both, sourcing velocity from Family#savings_inflow_velocity with
explicit current-window and prior-window ranges.
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.
User requested replacing the in-house color disclosure with the
categories color+icon popover. Done as a controller extraction so
categories and goals share one Stimulus controller (user's option:
"Extract a shared color_icon_picker_controller.js").
- `git mv` app/javascript/controllers/category_controller.js to
color_icon_picker_controller.js. Categories form + color_avatar
partial updated to use the new identifier (data-controller=
"color-icon-picker", target/action selectors renamed).
- Goal model gains an icon column (migration
20260511190000_add_icon_to_goals.rb) + ICONS = Category.icon_codes
+ inclusion validation. GoalsController permits :icon in
goal_params + goal_update_params.
- Goals::AvatarComponent now renders icon when present (falls back to
first-letter initial), and adopts the Categories tinted-bg + colored
-content style (bg = `color-mix(in oklab, COLOR 10%, transparent)`,
text/icon = COLOR). Matches the picker's live preview so what the
user sees during selection equals the saved state.
- New goals/_color_picker.html.erb mirrors categories/_form's popover:
avatar + pen overlay summary + popup with color row (+ rainbow
custom-hex trigger) + icon grid. Pickr / contrast validation / auto-
adjust all inherited from the shared controller.
- Stepper step 1 layout: drop the inline letter-avatar (data-goal-
stepper-target="avatarPreview") in favour of the picker avatar next
to the name input. Step 1's tail no longer renders a separate color
partial. Edit form passes icons local through.
Verified live: new goal modal renders 11 color radios (10 presets +
custom) + 141 icon radios + pen-summary; categories form still
operational (no console errors) under the renamed controller.
Direct nav to /goals/new used to render the index page with an empty
modal frame because the entire template was wrapped in DS::Dialog.
The URL was effectively un-shareable.
Branch on turbo_frame_request? — Turbo Frame requests still render
the DS::Dialog wrapper (the existing in-modal flow on the index page
keeps working). Non-frame requests render a standalone page-level
header (h1 + subtitle + icon) followed by the form_stepper partial.
Same Stimulus controller, same data-goal-stepper-modal-subtitle
selector, so the stepper's subtitle update path works identically.
Controller sets @breadcrumbs so the standalone variant gets the
Home > Goals > New goal trail.
Verified both paths via Playwright: direct GET renders standalone
form with h1 "New goal" + no dialog; click-from-index opens the
DS::Dialog with the stepper inside.
Previously a user who linked the wrong account at creation had to
delete + recreate the goal. Now the edit modal carries the same
funding-accounts checkbox group as Step 1 of the stepper, pre-checked
with the goal's current links.
- GoalsController#edit loads @linkable_accounts + @currently_linked_account_ids.
- #update accepts account_ids; when supplied, runs the create / update
inside a Goal.transaction and syncs linked accounts via
sync_linked_accounts! (set-diff: destroy_all unselected goal_accounts,
create the new ones). Validates at least one account before touching
goal_accounts so the user gets a clean re-render.
- Removing an account preserves the goal's existing contributions —
GoalContribution#account_must_be_linked_to_goal only fires on save,
so historical rows stay valid.
- _form_edit partial accepts new locals; edit.html.erb threads them
through.
- 3 new controller tests: identity-only patch leaves links intact;
account_ids patch replaces the link set; empty account_ids
re-renders with error.
- index: STATE_FILTERS count loop replaced with single Current.family.goals.group(:state).count + per-state lookup. 5 SQL queries -> 1.
- GoalsController + GoalContributionsController: rescue_from ActiveRecord::RecordNotFound -> redirect_to goals_path with a flash. Affects stale deep links AND cross-family access (previously bare 404 -> Chrome error page). Test for cross-family access updated to assert the redirect + flash key.
- New locale key goals.errors.not_found.