- Fix render-blocker: Money#symbol doesn't exist (use #currency.symbol).
- Sanitize projection_summary so the _html locale renders <strong> markup
instead of escaping it.
- Switch donut + card ring track to --budget-unused-fill;
--budget-unallocated-fill resolves to the same gray as bg-surface in
light mode so the unfilled arc was invisible on the detail page.
- Mobile detail: drop avatar, right-align action buttons, stack
projection header (subtitle + legend) so the subtitle reads on one
line; bump legend gap on mobile.
- Nowrap the projected reach-date so e.g. "Jul 2026" stays together.
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
User flagged two regressions: account colors didn't match between the
goal preview-card avatar stack on the index and the funding-widget
rows on the show page, and the color-picker pen toggle on the new-goal
modal still felt too big.
Color matching:
- `AccountStackComponent` (index card) used
`Goals::AvatarComponent.color_for(account.name)` — MD5-of-name into
the 10-color palette.
- `FundingAccountsBreakdownComponent` (show page) recently switched to
`color_for(account.id.to_s)` — MD5-of-id.
- Same account, two surfaces, two different palette picks. Plus
either hashing scheme can collide within a multi-account goal
(palette has 10 colors).
Move ownership to the Goal model: `Goal#account_color_map` returns
`{ account_id => palette_hex }` for the goal's linked accounts. Sort
by `id` for a stable order across reloads, then assign
`palette[i % palette.size]`. Stable + collision-free up to 10
accounts in a single goal (a realistic upper bound — most goals
link 1-3).
Both consumers now read off the same source:
- `AccountStackComponent.new(accounts:, color_map:)` accepts a hash
and falls back to the name-hash if no map provided (kept for
callers that don't have a goal in scope yet).
- `FundingAccountsBreakdownComponent#color_for` reads
`goal.account_color_map[account.id]`.
- Goal card on index passes `goal.account_color_map` to the stack.
Pen toggle:
The new-goal color-picker pen sat in a `w-5 h-5` circle with a
`border` ring + `text-secondary` icon. The border + secondary text
weight kept it loud against the avatar even at 20px. Drop the
border, drop the size another step (`w-4 h-4`), recolor the icon
`text-subdued` + `hover:text-secondary` so the affordance recedes
when not interacted with. Position shifts from `-bottom-1 -right-1`
(8px overhang) to `-bottom-0.5 -right-0.5` (2px overhang) since the
smaller circle doesn't need the larger float. Icon swaps "pen" for
"pencil" (the more conventional edit indicator across Sure).
Five small audit follow-ups bundled because they were each one-line
swaps and individually wouldn't earn their own commit.
Card text scale (vs Sure house style — budget_category h3 ≈ text-base,
budget _actuals_summary value text-xl, account row text-sm subtype):
- goal card title text-sm → text-base
- goal card balance text-lg → text-xl
- goal card pace/footer/subtitle text-[11px] → text-xs
- funding row subtype subtitle text-xs → text-sm
- funding row "last 30d / last 90d" labels text-[10px] → text-xs
Chart label scale (projection chart was an outlier at font-size: 10
while time_series_chart_controller uses 12):
- every `font-size: 10` in goal_projection_chart_controller.js → 12
- tooltip cssText font-size: 11 → 12
Color-picker pen toggle on the new-goal avatar was w-6 h-6 (24px
circle, ~55% of the lg 44px avatar). Shrink to w-5 h-5 + add a w-3 h-3
class on the inner icon so it scales down with it.
Graph continuity bug: the saved-line endpoint and the projection-line
start point could disagree by tens of $thousands. Saved came from
`Balance::ChartSeriesBuilder` (daily snapshot in `balances`),
projection started at `currentAmount = goal.current_balance.to_f`
(live `linked_accounts.sum(:balance)`). When the snapshot lagged
the live read, the chart showed a vertical gap at the "today" marker.
Filter any same-day-or-later points out of the raw saved series,
always extend the saved series to `(today, currentAmount)`. Saved
line now closes at exactly the projection's start. The recent
balance-drop story is still honestly shown (the line dips toward
the live value rather than ending at the stale snapshot).
Ring card focal-point (RUI audit): the left ring card on goals#show
sat at the same `shadow-border-xs` elevation as the projection chart
and funding card. "When every card is raised, nothing's primary."
Drop the shadow + container background — the ring now reads as a
status panel sitting on the page surface, not a content card
competing with its neighbours. Paused/archived/celebration/empty
right-slot variants keep elevation since they ARE content cards.
Deferred: light-mode pink distribution-bar contrast. The fix needs
a DS token decision (hairline outline vs darker step on the palette
entries); rolling it into a polish PR risks dragging in DS changes
unrelated to goals. Logged for a follow-up.
Behavioural + RUI audit follow-ups.
The yellow overload finding flagged three concurrent yellow surfaces
on the show page: the "Behind" status pill, the catch-up alert, and
the open-pledge banner(s). Demoting the alert to outline ownership
of the primary CTA addressed one layer, but the pill kept fighting
the alert for hue attention. "Behind" is a state, not a call to
action; the alert owns the action signal.
Switch the pill's classes from `bg-yellow-500/10 text-yellow-700`
to `bg-surface-inset text-yellow-700` (with the same dark-mode
override). Background goes neutral (matches paused/archived chips);
the text keeps the warning hue and the triangle-alert icon stays.
Signal preserved, weight reduced. The yellow alert below now reads
as the primary nudge instead of one of three matching tones.
Also: copy/em-dash sweep across goal surfaces. User-facing strings
that contained em-dashes ("Reaches 70% — $X of $Y", "into your
linked account — Sure will catch it", "You're at 80% — $X of $Y")
read as a stylistic tic; replace with comma/period/period
respectively. Form-stepper review placeholders "—" become "…"
(ellipsis reads as "not yet set" without the typographic weight).
Code comments + log messages also scrubbed for consistency; awkward
sed artifacts (//. its...) restored to readable English.
No locale-key shape changes; pure string-content edits + one
component-style tweak.
Two audit fixes that pair well.
PF audit B20: pace, family velocity, and the funding widget's
30/90-day totals all summed Entry amounts over the linked accounts
*including provider-pending transactions*. A pending Plaid/SimpleFIN
deposit inflated pace today; the next sync that reversed or dropped
it silently shrunk pace tomorrow, with no signal to the user.
Worse, the reconciler could match a pending transaction and flip
the pledge to "matched" before the underlying entry vanished.
`.merge(Transaction.excluding_pending)` on the three Entry queries
(Goal#pace, Family#savings_inflow_velocity, the funding widget's
`inflow_totals_map`) brings the existing
`Transaction::PENDING_PROVIDERS`-aware scope into play. Single-line
fix across the three call sites.
UX audit: funding-account rows used `grid-cols-[24px_1fr_48px_120px]`
at every breakpoint. On a 375pt iPhone viewport that left ~50px for
the name column after `p-5` padding + container chrome — name
truncated to "Ban…" and the per-row % column squeezed against the
weight/totals stack. The percent number is also already encoded in
the distribution bar above the rows; on mobile it can disappear
without losing signal.
Drop the % column at < sm:
- mobile grid: `grid-cols-[24px_minmax(0,1fr)_auto]` (avatar / name /
totals)
- sm+: original 4-column layout with the per-row %
- per-row balance subline + accountable label now also drops `.00`
cents (consistency with the rest of the page).
Tail of the redundancy + clarity pass:
- Goal card on the index drops ".00" cents from every Money.format
call (now `format(precision: 0)`). The card uses the same
visual rhythm as the show page; cents add zero info at this
scale.
- Card's "behind"-status footer now reads "Save $X/mo to catch
up" with X = `catch_up_delta_money` (the delta the user must
add), not the full `monthly_target_amount` (which read as a
total monthly burn). Same fix as the show-page banner.
- Pending-pledge banner title becomes pluralized: drops "0 days
left" / "1 days left" grammatical bugs. New locale tree:
title.zero → "Pending: $X into Y · expires today"
title.one → "Pending: $X into Y · 1 day left"
title.other → "Pending: $X into Y · N days left"
Also drops the "Watching for" phrasing (system-talk) for
"Pending:" (state-talk) and drops cents from the amount.
- `confirm_cancel_body` likewise renders amount without cents.
Cards and banner now read consistently with the show page; one
voice across the surfaces.
The show page repeated the same data multiple times across surfaces
that should each say one thing once. Per-screen counts before this
commit:
- Account % distribution: 4 places (distribution bar + dot-legend
strip + 5-bar weight pill + % column)
- Current balance: 3 places (ring, funding heading total, ring
"of $X" subline)
- Target amount: 3 places (header, ring subline, catch-up body)
- Target date: 3 places (header, catch-up body, chart axis)
- Pace: 2 places (catch-up body, projection subtitle)
- ".00" cents: every monetary string
This pass:
- Funding widget drops the dot-legend strip (color/name/% triplet
redundant with the distribution bar's color + the per-row avatar
color) and the 5-bar weight pill (rendered as "1-of-5 sliver" for
low-weight accounts — read as a glitch; the % number next to it
covered the same fact). Row grid shrinks from 5 to 4 columns.
- Funding section heading drops `· $187,031` — the ring card
already carries the total balance.
- Catch-up alert reframes:
Title was "Save $26,621/mo to stay on track" (the *full* required
rate, with the misleading "stay on track" while the pill says
"Behind"). Now "Save $20,002/mo more to catch up" using
`catch_up_delta_money` — the user's actual delta over current
pace.
Body collapsed from two with-date / no-date variants to a single
"Current pace $X/mo · required $Y/mo to hit your target." Drops
the target date duplication since the header already says it.
Pledge CTA pre-fills with the *delta*, not the full required —
so accepting it once funds the gap instead of stacking the full
required rate on top of existing pace.
Secondary link "Or adjust your target" → "Adjust target instead"
(less defeatist framing).
- Projection chart subtitle "At $X/mo you'll miss your target date."
drops the pace duplication (catch-up above already states pace).
New: "Falling short at current pace." Diagnostic only.
- All money on the show page uses `format(precision: 0)`. The ".00"
cents added no information at goal-tracking scale.
- Header `Record pledge` demotes to `outline` variant when status is
`:behind` — the catch-up alert below owns the primary action.
One primary action per surface.
Also adjacent fixes:
- Funding widget keys avatar / distribution color off `account.id`,
not `account.name`. Renaming an account no longer recolors it
retroactively; two accounts with name-hash collisions no longer
share a color (Ruby idiom audit finding).
- `Goals::StatusPillComponent`: add `:completed` variant with
`circle-check-big` icon. `Goal#display_status` now returns
`:completed` when `goal.completed?` so a manually-completed
goal (e.g. user stopped at 80%) reads "Completed" rather than
falling through to `:on_track`/`:behind` and lying on the index.
Locale: drop `body_with_date` (folded into `body`),
`projection.behind` no longer carries interpolation args (caller
doesn't pass them either), `projection.no_pace` plain-language
rewrite ("inflow" → "deposits"), add `status.completed: "Completed"`.
The cumulative-inflow chart kept producing readings that didn't
match user mental models — chart endpoint = 90-day cumulative,
right-hand column = 30-day total, and the visual line didn't carry
the "this is per-week deposit activity" intuition that a sparkline
implies. After iterating through bars, balance trajectory, and
filled-area cumulative, simplest is best.
Drop the chart. Each row now shows two right-aligned totals stacked
vertically:
- Primary line: "$X last 30d" — text-sm, text-primary, the "this
month so far" headline answer.
- Secondary line: "$Y last 90d" — text-xs, text-secondary, the
quarterly-trend reference. Tells the user whether this account
contributes regularly without forcing them to read a chart.
Both numbers compute in one query — pluck (account_id, date,
amount) over 90 days, sum per account into two buckets based on
whether the entry's date falls inside the 30-day cutoff.
Grid shrinks from 5 columns (avatar / name / weight / chart /
total) to 4 (avatar / name / weight / two-totals), with the
two-totals column getting 120px so both numbers fit
right-aligned with their labels.
Drop `funding_accounts_subtitle` and the chart-window plumbing
(`TRAJECTORY_SAMPLES`, `cumulative_inflow_map`, `column_total_from`,
SVG markup, `vector-effect`, etc.).
`preserveAspectRatio="none"` stretched the viewBox's 100×28 to the
container's natural width (≈600px) while leaving the Y axis at 1×.
With anisotropic scaling, SVG strokes inherit the path's local
scale — horizontal segments rendered ~6px wide, vertical segments
~1.5px wide, diagonals in between. The line read as visibly fatter
on the flat top run than on the climbing/dropping segments.
Add `vector-effect="non-scaling-stroke"` to the line path so the
stroke width stays a constant 1.5 CSS px regardless of how the
viewBox is scaled. Filled area is unaffected (no stroke).
Balance trajectory rendered every account as "near the top with a
gentle wobble" — pink ($1830/30d), orange ($0/30d), blue ($300/30d)
all looked nearly identical because each was a positive balance with
mild growth, and per-row 0-baseline scaling pushed all lines toward
the ceiling. The chart and the "$X last 30d" column on the right
were telling completely different stories on the same row.
Switch the metric the chart plots:
- Data: 31 daily samples (today − 30 … today) of *cumulative inflow*
per account, fetched in one `GROUP BY (account_id, date)` over
the 30-day window. Each per-account array is a monotonic
non-decreasing prefix sum starting at 0.
- Scale: per-row, anchored at 0, ceiling = `max(values) × 1.05`.
- The chart's rightmost point now equals the "$X last 30d" column
value by construction — chart and column tell the same story.
- `last_30_money` is read off the cumulative array's last element,
no separate aggregation query.
Visual shapes after the swap:
- Steady-inflow account → smooth diagonal climb
- Bumpy/episodic account → step pattern with flat plateaus
- No-inflow account → flat line at the bottom, no filled area
Removed `balances` query path entirely; trajectory_for is gone.
Bars communicated "events," not "where the account level sits." A
sparse-deposit account painted three thin bars at the bottom and
looked dead. An account with a single big deposit dominated every
other row's scale.
Swap to the same visual language as the projection chart on
goals#show — filled area below a stroked line — but one chart per
linked account, rendering that account's actual balance trajectory
over the last 90 days.
Mechanics:
- New `trajectory_map` on the component pulls every `balances` row
for every linked account in one query
(`Balance.where(account_id: account_ids, date: 90d..today)`).
Result is grouped per account and resampled to 24 points by a
single-pass forward walk that carry-forwards the most-recent
balance at-or-before each anchor date. O(rows + samples), not
O(rows × samples).
- Per-row Y-scale: baseline 0 (when the account has ever held a
positive balance), ceiling = max balance × 1.05. The chart reads
as "how full was this account over time" rather than "how dramatic
is the shape." Flat-at-$5k accounts paint near the top; growing
$200 → $500 accounts climb from 40% to top.
- Filled area at `opacity: 0.18` in the account color + stroked line
at full opacity on top — same treatment as the projection chart's
saved series.
- Grid track for the chart column widened from `minmax(60px, 1fr)`
to `minmax(80px, 1fr)` so the curve has enough horizontal room
to read.
Removed `shared_spark_max` + `sparkline_map` + the bucketed inflow
sparkline machinery. Per-row scale is correct here — magnitude
already lives in the weight pill on the left and the "$X last 30d"
column on the right; the chart's job is shape.
The shared-scale fix alone wasn't enough — a single outlier bucket
on one account compressed every other row to invisibility, and the
interpolated line between sparse non-zero buckets painted fake
"event triangles" between actual data points.
Switch from a stroked path to per-bucket bars:
- 12 rects per row, x = `i * 8 + 1`, width 6, 2px gap between.
- Bar height = `(value / shared_max) * 24`, floored at 1 unit so a
non-zero bucket is always visible even when an outlier elsewhere
dominates the scale.
- Empty buckets render nothing — no fake baseline, no interpolated
trough.
- Bars grounded at `y = 28` (bottom of viewBox), so "zero" is
implicit and the eye reads upward from a stable floor.
- Shared `spark_max` across every account's bars (the component
method introduced for the line version stays — that part of the
diagnosis was right, it just needed a chart type that handled the
scale honestly).
Net read: the column-chart-on-each-row layout matches "12 weeks of
deposits into this account" much more directly than a sparkline
ever did, and outlier-vs-modest-but-steady contributions are both
legible at a glance.
Loose ends from the V1 → V2 refactor that the architecture commit
didn't sweep.
- Demo generator (B14): the `goal_spec[:contributions]` arrays
+ the `wedding_contribs` / `house_contribs` builders still
shipped in the file, but the seeding loop that consumed them
was deleted alongside `GoalContribution`. Dead data. Strip both
the per-goal arrays and the two locals. Goal balance/pace in
the demo family now derives from the linked depository
accounts' own seeded entries elsewhere in the generator.
- Goal stepper controller (B16): the `static targets` declaration
still listed `initialContributionAmount` and
`initialContributionAccountSelect`, and `refreshAccountSelect`
+ its two callsites still ran every time a linked-account
checkbox flipped. The HTML targets disappeared with the V2
stepper rebuild, so `has*Target` guards short-circuited and the
method was a no-op — but it was still dispatched on every
change. Drop the targets, the method, and the two callsites.
- Chart series rescue (B25): `Goal#balance_series_values` and
`FundingAccountsBreakdownComponent#sparkline_map` both swallowed
`StandardError` with a `Rails.logger.warn(…)`. The chart then
degraded to "target line only" silently. Promote the log to
`error` level and forward to Sentry when present (matching the
pattern in `Account::Syncer`, `Sync`, `PlaidItem`). Fallback to
empty result still preserved so the surface degrades instead of
500-ing.
V2's funding widget ran (12 + 1) queries per linked account on the
goals#show render:
- one `last_30_inflow_for(account)` summed over a 30-day range,
- twelve separate `sparkline_for(account)` sums, one per 8-day
bucket inside a 90-day window.
For 3 linked accounts, that's 39 SQL queries from this component
alone before the projection chart's Balance::ChartSeriesBuilder
runs. Replace with two grouped queries that scan once across all
linked accounts:
- `last_30_inflow_map`: a `GROUP BY account_id` over the 30-day
window, returning a hash `{ account_id => clamped_inflow }`.
One query, no matter how many accounts are linked.
- `sparkline_map`: a `GROUP BY account_id,
LEAST(GREATEST((CURRENT_DATE - entries.date) / bucket_days, 0),
11)` over the 90-day window. One query covers every account ×
every bucket. Each per-account array is filled in oldest →
newest order so the SVG path reads left → right naturally.
Net query count for the funding widget drops from 13 × N to 2.
Both helpers fall through to safe defaults (`0`, all-zeros array)
on missing keys so the row loop stays branch-free.
V2 rebuilt the funding widget around per-account rows + a custom SVG
sparkline, but cut visible signal and DS adherence in the process.
This rebuild restores the V1 affordances and folds in the V2
sparkline as an enhancement.
- Heading regression: `text-lg font-medium` (with total in `text-lg`)
→ `text-sm font-medium` (total inheriting `text-sm`). The section
heading collapsed to body-copy size and no longer matched the
Projection heading beside it. Restore both to `text-lg`.
- Avatar regression: V2 hand-rolled
`w-10 h-10 rounded-full … style="color: white"`. That box (40px)
matches no `Goals::AvatarComponent` size (sm=24px, md=36px,
lg=44px), uses `rounded-full` where the DS uses
`rounded-md/lg/xl/2xl`, and hardcodes white text instead of the
`text-inverse` token. Render `Goals::AvatarComponent` directly
at `size: "sm"`.
- Privacy regression: `row[:balance_money]` subline ("Depository ·
$3,000") wasn't wrapped in `privacy-sensitive`. Blur mode no
longer hid the balance, while heading total and last-30d value
on the same row both had the class. Add `privacy-sensitive` to
the subline.
- Untranslated leak: `<%= account.accountable_type %>` printed the
raw "Depository" / "Investment" / "Crypto" class string with no
i18n. Add `accountable_label(account)` on the component that
prefers the depository subtype ("Savings", "HSA"…) via
`goals.form_stepper.step1.subtypes.*`, falling back through
`accounts.types.*` and finally a `titleize`.
- Lost weight signal: V1 had a stacked distribution bar across the
top, colored legend dots, and a 5-bar weight pill per row.
Users could see "Account A contributes 60% of balance" at a
glance. V2 deleted all three. Restore the distribution bar +
legend + the existing `pages/dashboard/group_weight` partial in
a `weight` column (skipped when only one account is linked).
- Lost container framing: V1 wrapped rows in
`bg-container-inset rounded-xl p-1` with `shared/ruler`
dividers between rows. V2 used `space-y-3` with no container
and no dividers, leaving rows floating. Restore both.
- Empty state regression: V2's fallback rendered the section
heading as a body paragraph (`<p>Funding accounts</p>`) inside
a `p-5 rounded-xl` card — looked like an unfinished widget.
Replace with a real empty state via `goals.show.funding_accounts.
empty.heading` + `body` ("Edit the goal to link the depository
accounts you save into.").
- Row order: V2 sorted by 30-day inflow (which can flatten to
ties at $0 across rows). Sort by balance instead — the column
the user is comparing against anyway.
- Pace alignment: drop the transfer-kind exclusion from the
component's `last_30_inflow_for` and `sparkline_for` so the
widget reads the same flow as `Goal#pace` (commit B). Internal
transfers between linked accounts net out per-account here too,
external transfers count as inflow on the receiving account.
The 12-bucket sparkline still runs 12 queries per account; that
N+1 lands in a follow-up commit alongside the component-level
query collapse.
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.
Goals::AvatarComponent had `attr_reader :icon` which shadowed the
global `icon` view helper. Template called `icon(icon, size:, color:)`
which Ruby resolved against the attr-reader (zero-arity), throwing
"wrong number of arguments (given 2, expected 0)" the moment a goal
had a saved icon and the show page tried to render its avatar.
- Drop `:icon` from attr_reader; expose as `icon_name` instead.
- Template uses `helpers.icon(icon_name, ...)` matching the
Goals::StatusPillComponent pattern (other Goals VCs already use
`helpers.icon`).
Reproduced + verified live via Playwright: edit modal → pick an icon
→ save → show page renders the new avatar with the SVG. Same for
create flow (new modal → pick icon → step 2 → submit → show renders).
Avatar letter/icon now uses `--avatar-color` CSS variable + the new
`.goal-avatar` class. Light mode darkens the text to 55% color + 45%
black so pale palette entries (cyan-300, green-300) stay readable on
the 10%-mix tint over white (~4.5:1). Dark mode reverts to the full
brand color via [data-theme="dark"] .goal-avatar override so the text
doesn't disappear against the near-black tinted surface. Verified
live: #805dee renders as a darker oklab in light mode and full
rgb(128,93,238) in dark mode.
Picker popup compacted:
- 80 (320px) wide, max-h-[60vh] overflow-y-auto so it never spills
off-screen.
- Anchored below the avatar + horizontally centered to it (top-full
left-1/2 -translate-x-1/2) so it doesn't drift off to the right
edge of the form on narrow modals.
- Icon grid max-h-40 (160px, ~5 rows) with the in-house `scrollbar`
utility for a thin gray thumb that works in both themes.
- Section headers (Color / Icon) styled `uppercase tracking-wide`
for visual hierarchy.
Verified popup at 320x310px in edit modal, no vertical overflow.
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.
Lower half of the goal detail used to be: (stat row: monthly pace +
total contributions) + (bottom row: contributions list + funding
breakdown card). Two of those four pieces were redundant:
- Total Contributions stat duplicated the count badge that already
sits beside the Contributions heading below.
- Monthly Pace stat repeated the same numbers the catch-up alert
surfaces above and the chart subtitle reads.
Adopt the dashboard Balance Sheet pattern (app/views/pages/dashboard/_
balance_sheet.html.erb) for the funding widget: inline header with
total ("Funding accounts · $13,250"), thin gap-separated segment bar,
color-dot legend with percent, and a bg-container-inset table with the
shared `pages/dashboard/group_weight` 5-stick weight indicator + value
column.
New show.html.erb bottom: just two full-width sections — funding
widget, then chronological contributions list. Both rendered only when
the goal has contributions (matches the empty-state branch added
earlier).
Locale: goals.show.funding_table.{name, weight, value}.
- catch_up alert: title now leads with the new info (delta) and body
states the required rate. Was "Save $1,000/mo to catch up" + "Currently
$750/mo behind" — confusingly double-stated. Now "Behind by $750/mo" +
"Save $1,000/mo to stay on track for {date}." Locale keys swap the
%{amount}/%{delta} placement.
- Goals::StatusPillComponent: each variant carries a theme-dark: text
override so the dark-700 text doesn't disappear against the dark-mode
tinted surface. Verified in dark mode: Paused pill text is now
rgb(231,231,231) (gray-200) instead of rgb(54,54,54) (gray-700).
Pre-existing token contrast fix tracked at we-promise/sure#1736 stays
the long-term path; this is the local workaround that doesn't drop
4.5:1 in either theme.
- New goals/_color_picker.html.erb partial: <details> disclosure with
current-color preview in the summary + swatch grid in the popover.
Mirrors the categories form's pen-icon-overlay pattern in spirit
(collapsed by default; user clicks to expand). Both _form_edit and
_form_stepper render the partial; the stepper's hidden color field is
replaced by the visible disclosure.
- Stepper footer: change `justify-between` to `flex items-center` plus
`ml-auto` on the Continue wrapper. Continue now sits right-aligned in
step 1 (where Back is hidden) and stays right in step 2 with Back
taking the left edge.
Whole card was wrapped in <%= link_to ... %>, so screen readers
concatenated every nested text node into one accessible name (~60 words
on a typical card: avatar initial + name + status pill + percent +
balance + target + pace + accounts + footer).
- Outer wrapper now <div> carrying the filter-target + goal-name +
goal-status data attrs.
- Inner <a> wraps only the goal name. aria-label = "<name>, <status>,
<percent>% of <target>" — concise SR sentence.
- `before:absolute before:inset-0` makes the inner link's hit area span
the whole card so sighted users keep the existing click affordance.
- Ring SVG + percent overlay marked aria-hidden (decorative — same info
already in the aria-label).
- New locale key goals.goal_card.aria_progress.