- Demo seed_matched_pledge tie-breaks `entries.date DESC` with
`entries.id DESC` so dense-same-day inflows pick the same row on
every reseed
- projection_payload exposes the family-currency symbol via
Money.new(0, currency).symbol; the chart's `_fmtMoneyShort` / fallback
now reads it instead of the hardcoded $/€/£ map, so JPY/KRW/CHF
goals get the correct glyph
- Parse "YYYY-MM-DD" date-only strings as local midnight in the
projection chart so users west of UTC stop seeing the today marker
and hover dates land one calendar day back
- Order the demo-generator depository pickup by (created_at, id) so
primary/secondary roles stay stable across reseeds and the state
matrix (behind / on_track / reached / no_target_date / past-due)
surfaces the same goals every time
- Drop the brittle " · "-split on goals.goal_card.days_left in
Goal#header_summary (the translation has no separator suffix)
- Goal#projection_payload ships pre-formatted strings for the static
chart annotations (target_amount_label / short, projection_end_label,
projection_shortfall_label, pending_pledge_label_short) and the
controller now renders those instead of running Intl.NumberFormat on
each draw. Y-axis tick labels stay JS-side because they depend on
D3's dynamically-chosen tick values.
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
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 screenshot-driven audit fixes.
The chart's "Short $160.6K" annotation used `_fmtMoneyShort`'s
K/M shorthand while the page's other monetary readouts ("$26,621
to catch up", "$187,031 saved", "$1,830 last 30d") were full
`Intl.NumberFormat` output. Inconsistent units in the same
viewport. Switch the annotation to `_fmtMoney` ("$160,634 short")
+ reword to put the money first ("$X short" reads more naturally
than "Short $X"). Y-axis tick labels keep the K/M shorthand —
that column is space-constrained and the same convention is
already understood as "axis abbreviation."
The x-axis terminal tick rendered `"Jan '27"` from the
`"%b '%y"` time-format string. A glance read it as January 27th
of the year, not January 2027 — and the target-date tick is the
single one users navigate the chart for. Switch to `"%b %Y"` →
"Jan 2027." Slightly wider per tick; the existing adjacent-
duplicate-removal logic keeps the count sane.
V6 — three "Record pledge" affordances on goals#show (header, empty
state, catch-up callout) all rendered as `t(@goal.pledge_action_
label_key)` + `icon: "arrow-up-right"`. That dropped the user into a
button labelled "I just transferred…" or "I just saved…" with a
trailing ellipsis — past-tense as a CTA reads as a status
declaration, the ellipsis suggests an unresolved action, and
`arrow-up-right` is the icon Sure reserves elsewhere for
external-link affordances.
Switch the on-page CTA to a single new locale key,
`goals.show.record_pledge_cta: "Record pledge"`, with `icon: "plus"`
matching the other "new resource" buttons across the app (new goal,
new account, new rule). The modal title keeps using
`pledge_action_label_key`, so the past-tense "I just transferred…"
framing still anchors the dialog's confirm step.
V8 — the projection chart's "required" dashed line was hardcoded to
`var(--color-green-600)`. When the goal status is `:on_track` the
projection line is also green-dashed, and the two stroked the same
hue and dash pattern — they overlapped visually. Re-stroke the
required line in `textSecondary` (the chart's neutral foreground)
so it reads as a reference path independent of status.
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.
Repro: index -> goal show (chart drawn) -> open edit modal in turbo
frame -> pick a new icon -> submit. Server responds with
turbo_stream.action(:redirect, goal_path). Turbo morphs the show
page, wiping the chart container's children, but Stimulus' connect()
isn't re-run on the morphed element so _draw never fires again. The
ResizeObserver doesn't help — the container's box dimensions are
unchanged.
Listen for turbo:render and turbo:frame-load on document and re-draw
when the container's SVG is missing. Cheap idempotent check
(querySelector('svg')) — no-op if the chart is already there.
Listeners cleaned up in disconnect().
Verified: same flow now lands on the show page with the chart fully
rendered (23 SVG children).
Issue: an on-track goal whose projected end is just above the target
showed two right-anchored labels stacked on top of each other —
"Target · $2,400" and the projection-end short value "$2.4K". The
projection dot already conveys "you'll hit the target on time"; the
extra label adds noise.
Now: when willHit AND |projDotY - y(targetAmount)| < 18px, skip the
projection-end label entirely. The colored dot at the target_date
keeps the visual cue.
Also refactor the y-axis label collision check from value-based
(within 5% of yMax) to pixel-based (within 18px of target's y),
matching the projection-end logic. When a y-tick is close to target,
the Target label drops into the y-axis column at that row (short
format) instead of right-edge full format. Either way, no two labels
ever stack within 18 vertical px.
Verified live: Wedding fund (on_track, projection ≈ target) → just
"Target · $2,400" + y-ticks, no "$2.4K". House downpayment (behind) →
"Target · $50,000" + "Short $12.3K" both retained (well separated).
Four chart fixes in one pass.
1) Browser was rendering the <title> child as a native hover tooltip
that fought with the custom crosshair tooltip. Drop <title>; use
aria-label on the <svg role="img"> instead — same SR accessible name,
no native tooltip side-effect.
2/3) The hover crosshair clamped at today: bisector ran the saved
series, which ends at today, so future hovers stuck the dot at the
last saved point. Now the controller forks:
- Past hover: snap to nearest contribution via bisector.
- Future hover: snap to whole-week intervals along the projection
segment ([today, target_date]) and place the dot at the
interpolated y on the dashed line. Movement steps cleanly week
by week instead of pixel-by-pixel jitter.
4) Tooltip drops the redundant line:
- Past: "<date> · Saved: $X" (no Projected — there isn't one).
- Future: "<date> · Projected: $X" (no Saved — it's the future).
5) Y-axis tick label suppressed when its value falls within 5% of the
target line so "$2.5K" and "Target · $2,400" stop overlapping near
the right edge. Gridline stays; only the y-axis label drops.
Verified live via Playwright on House downpayment goal: <title>
absent, aria-label populated, past tooltip "Feb 10, 2026 · Saved:
$11,750", future tooltip "Nov 29, 2027 · Projected: $32,235",
neighbouring future x snaps to "Dec 13, 2027 · $32,704" (2-week jump
across the snapping boundary).
B — Step 2 of the create stepper used to echo Step 1 fields back at
the user in three labelled rows (Funding accounts: 2 · $123,456 balance;
Suggested monthly: $1,003/mo over 12 months). Replaces those rows with
a single derived sentence:
"Save $1,003/mo across 2 accounts to hit it on time."
If no target date is set: "Set a target date to project a finish line."
The previous "Suggested monthly" + "Funding accounts" rows are dropped;
review block shows only Name, "$12,000 by May 11 2027", and the
derived insight sentence.
L — All hard-coded English templates + currency symbols in the JS
controllers go through Stimulus values now:
- goal_stepper_controller: new {currency, summaryWithDate, summaryNoDate,
accountCountOne, accountCountOther, suggestedWithDate, suggestedNoDate}
values. Money formatted via Intl.NumberFormat(undefined, { style:
"currency", currency: this.currencyValue, maximumFractionDigits: 0 }).
- goal_projection_chart_controller: _fmtMoney upgraded to Intl.NumberFormat
(was $/€/£ ternary fallback that lost JPY/INR/CHF/...).
Locale: new goals.form_stepper.step2.review.{summary_*,account_count,
suggested_*}. Old funding_accounts / suggested_monthly keys retained
(unused by the new ERB) so any translator paths in flight don't break.
Verified live via Playwright: step-2 review reads "Save $1,003/mo
across 2 accounts to hit it on time." for a $12,000 / 12-month / 2-
account goal.
Chart had no way to read the value at a specific date — users had to
infer Saved amounts from line position relative to the y-axis labels
added in the previous commit.
- Transparent <rect> overlay covers the plot area + catches pointer.
- pointermove uses d3.bisector to snap to the nearest saved series
point, draws a dashed crosshair + a saved-line dot + a projection-line
dot (linearly interpolated between today and target).
- HTML tooltip lives inside the chart root (cleared on next _draw)
showing "date / Saved: $X / Projected: $Y". Clamps to viewport so it
doesn't overflow the card.
- pointerleave hides everything.
Pointer events unify mouse + touch — single handler covers both
desktop hover and mobile tap-and-drag. No keyboard nav yet; tracked as
follow-up (Stimulus controller is the right home but won't ship in
this round).
Chart had no value anchor on the y-axis; users had to read the target
line label to know what amount the saved line represented. Add 3
right-aligned y-ticks ($0, $25K, $50K-style K/M shorthand) plus faint
borderSubdued gridlines at the same y values. Left margin widens to 44
when room allows.
Mobile (<320px chart inner-width) keeps the original tight 16px left
margin and skips the y-axis entirely so the short-window readout
stays uncluttered.
Verified live: desktop reads $0/$20K/$40K + Target $50,000; 375px
viewport drops the y-axis text + keeps target line + x-ticks only.