`Goals::CardComponent#ring_color` and `goals/_status_callout` reached
into the Tailwind palette directly (`text-yellow-700`,
`var(--color-green-600)`, etc.) for status-coded colors. The
sure-design-system already exposes the matching semantic tokens
(`text-warning`, `text-success`, `--color-success`, `--color-warning`),
which theme-swap correctly in dark mode and survive palette renames
without view edits.
- `ring_color`: collapse `:reached` / `:on_track` to `--color-success`
(the status pill already differentiates them via icon — completed star
vs check) and `:behind` to `--color-warning`. The `:no_target_date`
fallback keeps `--color-gray-400` for now since there's no semantic
neutral token; that gets cleaned up alongside the DS::ProgressRing
extraction.
- `_status_callout`: drop `text-yellow-700 theme-dark:text-yellow-300`
and `text-green-700 theme-dark:text-green-300` for the equivalent
semantic `text-warning` / `text-success` utilities.
No visual regression in light mode (success collapses two adjacent
greens into one); dark mode now properly inverts via the design
system's theme variants instead of hand-rolled overrides.
The `stroke="var(--budget-unused-fill)"` track on the inline card ring
stays for now — that's a token-rename refactor that touches budget
code outside this PR's scope and lands cleanest with the DS::ProgressRing
primitive that consolidates the three ring implementations.
Mirror the affordance used in the accounts/select_provider screen and
the bank-sync flows: a near-imperceptible gray-50 fill swap rather
than a shadow. Lighter than the previous shadow lift, doesn't introduce
elevation noise inside the grid, and the ring track (gray-200) + status
pill outline still keep enough separation against the gray-50 hover bg
that we don't reintroduce the original contrast issue.
- Pending pledges surface in the card footer as '· N pending' tacked
on after the existing footer line (text-subdued). Quiet, semantic,
doesn't compete with the status pill or the avatar.
- The top-of-page 'You have pending pledges' callout was using the
amber DS::Alert warning variant. Pending isn't a warning — it's a
passive 'we're waiting on a sync' state. Switch to the info
variant so the visual weight matches the meaning.
The amber DS::Pill dot on the avatar collided visually with the
amber 'Behind' status pill on the same card — two amber signals in
the same eye-line. Move the indicator to a text-subdued clock icon
between the goal name and the status pill: quieter, semantically
clearer (clock = pending sync / time-bound), no tone collision with
the status palette. Same accessibility hook (aria-label + title).
Goals with open + unexpired pledges now carry a small amber DS::Pill
dot at the top-right of the avatar on the index card. Same primitive
+ position pattern as the beta gate dot on the sidebar nav, so the
'small marker' affordance reads consistently across the app.
Pledges are preloaded via the existing .includes(:open_pledges, ...) on
the index query, so the indicator is free at request time.
The earlier 'filter archived too' attempts kept toggling the archived
section based on chip state, which produced more confusion than value
(filter shows partial counts, archived hides on some chips, etc.).
Step back: archived stays in its own collapsed-by-default section,
always visible, never reacts to the chip / search filter. Render
the cards with filterable: false so they don't add a filter target
in the first place — no JS handling needed, and the active grid +
chips behave exactly like they did before this whole thread.
The card was the only place in Sure setting hover:bg-surface-hover on
a bg-container card; every other static card (settings, recurring
transactions, ai_prompts, etc.) stays still. The hover bg (gray-100)
landed almost exactly on top of the ring track (gray-200) and washed
out the pill fill, fighting the very signals the card was trying to
show. Swap to hover:shadow-sm — the lift matches the transaction-tabs
precedent already in Sure, the bg stays white, and ring track + status
pill keep their contrast.
The status pill on the goal card used a 10%-alpha fill (bg-warning/10,
bg-green-500/10). On the card's hover state (bg-surface-hover) the fill
blended into the new background and the pill lost its tint outline.
Extend DS::Pill with a green tone and an optional icon: param (renders
a Lucide icon in place of the dot) so the same primitive can carry both
the beta marker and the goal status badges. Map Goals::StatusPillComponent
to DS::Pill outline style — transparent fill + colored border + colored
text + glyph — which is immune to any change in the surrounding card bg.
One badge primitive, light-mode contrast already fixed (the color-mix
30% darkening on text), and the card hover state no longer washes out
the status.
- Color picker had four hardcoded English strings ("Color", "Icon",
"Poor contrast, choose darker color or", "auto-adjust."). Move them
under `goals.color_picker.*` and call them through `t()`. CLAUDE.md
requires every user-facing string go through i18n.
- Status pill duplicated its visible label in `aria-label`, which makes
screen readers ignore the visible text. Drop the override so the
visible label is the accessible name.
- 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.