- color_icon_picker: listeners were attached in initialize() with inline
arrows, so they had no removable reference and no disconnect() cleanup.
Every Turbo navigation that re-rendered the picker stacked another
listener on the same node and left the Pickr instance alive. Move
attachment to connect(), store bound references, and clean up in
disconnect() (including destroyAndRemove on the Pickr).
- goals_filter: replaceState fired on every keystroke. Debounce the URL
sync 200 ms so a typing burst collapses into a single update. The
visible filtering stays real-time. Clear the timer in disconnect()
so a pending sync doesn't fire on an unmounted controller.
- 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.
- Goal: `display_status` and `projection_summary` memoize a value that
depends on the AASM state column. Without resetting them after a
transition the same instance keeps returning the pre-transition value.
Hook `after_all_transitions :reset_state_dependent_caches!` undoes the
memos so post-`archive!` / post-`pause!` reads see the new state.
- SweepExpiredGoalPledgesJob: the inner rescue covered per-pledge failures
but not cursor-phase failures (DB blip, OOM mid-batch). Add an outer
rescue that reports + re-raises so Sentry sees the failure and Sidekiq
retries the job.
- Add hex-format validation on Goal#color so submissions can't smuggle
arbitrary CSS into the style attribute on the avatar / picker preview.
The picker accepts custom hexes, so format validation (not inclusion)
is the right shape — anything not matching #RRGGBB is rejected at
the model boundary.
- Fix the on_delete in the down block of drop_goal_contributions to
match the original cascade. Restoring with restrict was a schema
drift that would have shifted referential behavior after a rollback.
Goals-specific styling doesn't belong in the global stylesheet. Extract
the avatar tint + theme-aware text color into a topical goals.css and
@import it alongside the other feature CSS files.
Wraps the conditional + dot wiring into a single call so adding a new
beta nav entry doesn't require remembering to set `beta: true` by hand
or duplicating the `beta_features_enabled?` check. Naming mirrors the
existing `BetaGateable` concern.
- Bind CSS `color-scheme` to Sure's `data-theme` attribute so the pill's
`light-dark()` resolves to the side that matches the active theme. In
the dark theme it was previously falling back to the light branch.
- Darken light-mode pill text 30% with black on top of the 700 stop so
the 10–11px uppercase label reads against the violet-50 background.
- Drop the Beta pill from the Goal detail page header. A single goal is
not the feature; the pill belongs on the feature index, not on each
record.
Pass beta: true on gated nav items so the nav_item partial renders a
violet dot-only pill in the top-right of the icon. The doc covers the
dot_only usage; the nav itself was never wired up before merge.
Add require_beta_features! to GoalsController and GoalPledgesController,
hide the Goals nav item for non-beta users, and tag index/show headers
with the Beta pill marker. Update controller tests to enable the
preference in setup and assert the redirect for users without access.
* feat: beta features toggle + Beta pill primitive
Adds the infrastructure for self-service beta opt-in. No call sites yet:
this PR is meant to land first so feature PRs (Goals, etc.) can ship
behind the gate incrementally.
User opts in via a single toggle at the bottom of Settings → Preferences.
The flag persists in the existing `users.preferences` JSONB column under
`beta_features_enabled` — same shape as `dashboard_two_column` and
`show_split_grouped`, so no migration is needed.
Controllers gate a beta feature by adding `before_action
:require_beta_features!` from the new `BetaGateable` concern (included in
ApplicationController). Views use the `beta_features_enabled?` helper to
hide / show nav items, banners, etc. Logged-out callers always return
false.
Ships `DS::BetaPill`, a small inline marker for tagging features as
Beta / Canary in nav, headers, and lists. Five tones (violet by default,
indigo, fuchsia, amber, gray) map to existing Sure color tokens — no raw
hex. Three styles (soft / filled / outline) and two sizes (sm / md) cover
the surfaces in the design handoff. The `dot_only:` mode renders just
the colored dot for use on a collapsed sidebar.
* review: rename to DS::Pill, fix CR/Codex nits, add tests
CodeRabbit + Codex review feedback:
- Rename DS::BetaPill → DS::Pill. The component was already generic in
shape (tones, styles, sizes); the name was misleading scope. "Beta"
becomes the default label (still i18n-driven). Goals' StatusPill can
later refactor onto this primitive without a third pill.
- Localize the default pill label via i18n (`ds.pill.default_label`)
instead of hard-coding English.
- Add role="img" to the dot-only span so the aria-label is consistently
exposed to assistive tech.
- Wrap the Preferences toggle row in <label for="…"> so the title and
description become an honest click target for the toggle (matches the
cursor-pointer affordance).
- Drop arbitrary Tailwind values (py-[3px], gap-[5px], tracking-[…]) in
favor of scale tokens. text-[10/11px] stays because the pill is
intentionally sub-12px (Sure's smallest scale token is text-xs / 12px)
to read as a marker, not a label.
- Add User#beta_features_enabled? predicate tests covering default-off,
explicit-true, and non-boolean truthy values.
Won't fix:
- Palette refs (`--color-violet-*` etc.). Sure has no semantic Beta/
Canary tokens; introducing them in this PR would be a design-system
change beyond the scope. The component centralizes palette use in one
`palette` method, matching the existing pattern in
Goals::StatusPillComponent.
* review: consistent title fallback in full-pill branch
* docs: how to gate a feature behind the beta toggle
* docs: unwrap doc lines to match existing style
* chore(preview): run Cloudflare PR previews on basic instances (#1831)
* fix(preview): use Rails health endpoint for container ping (#1823)
* fix(preview): use Rails health endpoint for container ping
* fix(preview): point container ping to localhost/up
---------
Co-authored-by: Sure Admin (bot) <sure-admin@splashblot.com>
When every active goal already hit its target, the "Goals on track"
tile read "0 of 2 · 2 reached" — logically correct but emotionally
upside-down. Reached goals aren't being tracked toward pace anymore;
they belong in the trophy column, not in the fraction.
- New `tracked_total` excludes reached and paused goals from the
denominator. Paused stops the pace clock on purpose; reached has
already cleared it.
- When `tracked_total` hits zero and at least one goal is reached, the
tile swaps to a celebratory empty state ("All caught up · N reached")
instead of trying to render a fraction with no denominator.
- Drop "reached" from the subline when the fraction is calculable. The
fraction is a needle, "N reached" is a trophy — surfacing them
together muddied the message. Reached only appears in the all-caught-
up empty state from here on.
Active-first / reached-last grid order already drops out of the
existing ACTIVE_STATUS_RANK sort (reached defaults to the lowest rank
so it naturally lands after behind / on_track / no_target_date /
paused).
Three issues raised on PR #1798 review:
- ProviderImportAdapter now memoizes account.goal_accounts.exists?
per-account so a bulk historical import on an unlinked account
short-circuits the reconciler instead of paying one SELECT per row.
Linked accounts still hit the per-row reconciler with no change.
- goal_projection_chart_controller.js reads Today / Projected /
Saved labels via Stimulus values fed from
goals.show.projection.* locale keys instead of inlining English.
- goal_test.rb now covers Goal#pace with real inflows, asserting
the 90-day window cutoff plus the Transaction.excluding_pending
and entries.excluded = false filters.
* Extract hardcoded strings to i18n
Replace numerous hardcoded English strings with I18n lookups (t / I18n.t) across controllers, views, helpers, and components, and convert model validation error messages to symbol keys. Added multiple locale files under config/locales for models and views. This centralizes user-facing notices/alerts, UI text, import/validation messages, and prepares the app for localization and easier translation maintenance.
* Update en.yml
* Update preview-cleanup.yml
* Revert "Update preview-cleanup.yml"
This reverts commit 1ba6d3c34c.
* test: align i18n assertions with translated messages
* Standardize balance error key and tweak locales
Replace SophtronAccount's :requires_balance error key with :no_balance and update related locale strings for sophtron, plaid, and simplefin accounts to use the new key and clearer copy. Also switch the QIF upload redirect notice to use a relative translation key (t('.qif_uploaded')), remove an unused SSO providers help line, and fix a trailing-newline/whitespace issue in the subscriptions locale. These changes standardize validation keys and improve translation consistency and messaging.
---------
Co-authored-by: KiloClaw <kiloclaw@openclaw.ai>
Header collapses to title + kebab. The status pill and the `Record pledge`
button leave the title row. Status moves into a one-line callout below the
subtitle that doubles as the catch-up demand when behind, the
reach-date when on track, or a prompt for a target date when missing.
`Record pledge` is now the only pledge entry point on the page and lives
under the ring. Behind goals pre-fill it with the catch-up delta.
The standalone catch-up alert card is gone — its title is the callout, its
pace breakdown moves into the projection chart's subtitle, and its CTA
is the ring-adjacent button. The "Adjust target instead" link is
absorbed into the kebab's existing Edit item.
Pending-pledge banner switches from a warning Alert to a neutral
container chip. It is informational state, not a warning. Title carries
the relative pledged-at meta inline; verbose auto-confirms body stays
but in subdued size.
Projection chart drops the today-line pending stub (vertical line +
dashed marker + "+ pending $X" text). That data already lives in the
pending banner above the chart; the duplicate annotation clutters the
today line, the small dashed circle reads as misaligned at small pending
amounts, and the label overlaps the projection trajectory. Shortfall
label gets a paint-order halo so it stays legible across the dashed
projection line.
- 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.
- 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
Completed goals previously lived in a dedicated section below the
active grid that was always visible regardless of which chip the
user selected. They were the only state without chip filter
representation.
Fold completed into the main grid in controller-side order:
active goals first (sorted by status rank), completed after
(alphabetical). Drop the separate "Completed" section. The
`data-goal-status="completed"` on each card (from
`Goal#display_status`) makes them filter naturally when the new
`completed` chip is selected.
Archived stays in its own collapsed-by-default `<details>` section
below — the visual-hide-by-default is the point there and a chip
wouldn't preserve that.
`@active_goals` keeps its meaning (active-only) for the KPI strip,
the pending-pledges callout, and the search-visibility check
needs `@grid_goals` so search shows up at six combined cards.
Section heading: "Ongoing" → "Goals". The heading now covers the
combined active + completed list, and "Ongoing" misrepresented
what's below it.
User asked for demo seed variety so every goal state surfaces on at
least one card. Previous seed only spanned 4 AASM states; the
computed status (:reached / :on_track / :behind / :no_target_date)
and the edge-state copy paths (past-due target_date, open pledge
banner, "Last pledge matched") were absent.
New seed coverage matrix:
AASM states (column):
active → Vacation in Italy, Wedding fund, Emergency fund,
House downpayment, Coffee gear, Tax prep buffer
paused → Sabbatical
completed → Paid-off car
archived → Old laptop fund
Computed status (active goals):
:behind → Vacation in Italy, House downpayment, Tax prep buffer
:on_track-ish → Wedding fund (12-month timeline + small target)
:no_target_date → Emergency fund
:reached → Coffee gear (target 150 below any plausible
account balance — progress hits 100% live)
Edge surfaces:
Past-due active → Tax prep buffer (target_date 2.months.ago,
exercises "was due" header copy and the
months_remaining = 0 branch in
monthly_target_amount)
Open pledge banner → Vacation in Italy + House downpayment each
ship a single open pledge. The show-page
banner renders; the index pending-pledges
callout renders because @any_pending_pledge
flips true.
Matched pledge → Wedding fund: after the main seed loop,
find_by(name: "Wedding fund") + locate the
most recent non-claimed primary-account
inflow Transaction (>= 30 days, amount < 0
per Sure's sign convention), create a
matched-status pledge against it, stamp
the Transaction's extra->goal->pledge_id
per the partial-unique-index invariant.
The show-header then renders "Last pledge
matched N days ago" via
Goal#last_matched_pledge_at.
Implementation notes:
- Pledges spec embeds inside each goal_spec as an optional `pledges:`
array. The loop creates them after goal.save! using the goal's
linked_accounts as the default account; the GoalPledge#
account_must_be_linked_to_goal validation passes because every
spec's account is one of the goal's linked accounts.
- The matched-pledge seed is split into a dedicated helper
(`seed_matched_pledge_demo_for_wedding!`) because it depends on
Transactions seeded earlier in the demo flow. Both no-Wedding-
goal and no-recent-inflow guards bail cleanly so older demo
variants still work.
- All seed targets are intentional. Goal#status reads the live
linked-account balance + 90-day inflow at render time, so the
demo statuses adapt to whatever the rest of the demo seeded.
The targets are sized so the *intended* status is the most
likely one for typical demo data.
Local DB unaffected: this is the demo-family generator only, run
via `Demo::Generator.new.generate_default_data!` against a fresh
family.
User clarification on the "too big edit icon" finding: the header
Edit button itself (not its icon) is what felt wrong. Investigation
showed the wider Sure pattern.
Every other "Edit" affordance in Sure lives inside a DS::Menu kebab:
- app/views/categories/_category.html.erb
- app/views/rules/_rule.html.erb
- app/views/family_merchants/_family_merchant.html.erb
- app/views/chats/_chat_nav.html.erb
- app/views/accounts/show/_menu.html.erb
- app/views/transactions/show.html.erb
Header rows reserve top-level buttons for primary actions (e.g.
"New transaction", "Record pledge"). The goal show page was the
outlier — Edit as an outline button next to Record pledge, which
left two competing CTAs in the header.
Move `menu.with_item(text: t(".edit"), icon: "pencil", ...)` to
the top of the kebab list. Header now has a single primary CTA
(Record pledge, demoted to outline when status is :behind) + the
kebab. Matches every other Sure resource page; eliminates the
"button-too-big" framing entirely without resorting to ad-hoc
icon-size overrides (the `[&>svg]:w-4 [&>svg]:h-4` arbitrary
selector hack would have been a one-off, nobody else uses it).
Avatar pen toggle on the new-goal color picker stays reverted to
its original w-6 h-6 + border-2 form, per the user.
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.
Second pass on user-facing strings after the em-dash sweep and
yellow-pill demotion. Voice/abbreviation/edge-value parity.
Voice consistency:
- `index.pending_pledges_callout` reframed from "Sure is watching
your linked accounts" (system-as-watcher voice) to "You have
pending pledges. Sure will confirm them on the next sync."
(user-actor, system-action). Matches the surrounding
user-centric voice on the KPI strip and the helper-text pattern
("Sure will look for…", "Sure will catch it") used elsewhere.
- `goal_pledges.new.helper_manual` flipped pronoun "We'll record"
to "Sure will record" so the modal's two helper lines share a
single narrator. The transfer-helper already says "Sure will
look for"; this matches.
- `form_stepper.errors.*` dropped the apologetic "Please …" voice
("Please give your goal a name.") for the terse imperative
the rest of the feature uses ("Give your goal a name." / "Set
a target above zero." / "Pick at least one funding account.").
Parallelism:
- `kpi.velocity_delta_zero_base` was the only `velocity_delta_*`
string spelling out "30 days" while siblings used `30d`. Switch
to "First 30d of activity" so the sub-tile reads in one unit.
- `Depository` titlecase in `at_least_one_linked_account_required`,
`must_be_depository`, and `no_depository_accounts` collapsed to
lowercase. Common noun, not a UI label. Matches the empty-state
body in `funding_accounts.empty.body` which was already lowercase.
Test fixture for `must_be_depository` updated.
- `projection.reached` was the same string as `celebration.heading`
("Goal reached. Nice work."), making the celebration moment feel
templated. The projection slot is the chart's empty state when
there's nothing to project; rephrase to "You've hit the target.
No projection needed." Celebration keeps the warm tone.
Edge value:
- `celebration.body` was "You hit your $X target." When the user
marks a goal complete at sub-100% (a flow the new
`confirm_complete_body_short` already warns about), this lied
about the achievement. Rewrite to "Goal closed at %{saved} of
%{target}. Keep it as a record, or archive it now." Interpolation
now passes both `saved` and `target` from the show template, so
the celebration card honors the actual saved amount whether the
user hit, overshot, or stopped short.
Notes deferred (verify-only, not string changes):
- `goal_card.footer_catch_up` is interpolated with
`catch_up_delta_money` in `CardComponent#footer_line`; the show-
page guard `.amount.positive?` already lives there. No copy
change needed.
- `pending_pledge.title.zero` bucket fires only when `count: 0`
reaches the I18n call; `GoalPledge#days_left` clamps at 0, so
the friendlier "expires today" copy is reachable.
- `paused_banner.title` / `inactive.heading_paused` duplicate
strings noted but left in place; consolidation is a separate
refactor.
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 interlocking bugs on the new-goal modal's color/icon preview.
1. Avatar fell back to a literal "?" when icon + name were both
blank — `form.object.name.to_s.strip.first&.upcase || "?"`. User
reported the avatar looked empty on a fresh modal because the
"?" disappears against many palette tints. Categories handle
this by always showing the category icon. Replace the "?"
fallback chain with a default `target` icon (matches the goal
creation header's iconography):
• icon present → render that icon
• icon blank, name → render first letter
• icon blank, no name → render default "target" icon
2. Picking a color via the Pickr color picker called
`updateAvatarColors(color)` which inlined `style.backgroundColor`
+ `style.color = color` — overriding the `.goal-avatar` class's
`color-mix(in oklab, var(--avatar-color) 55%, black)` rule. The
class handles theme-aware contrast (darken text in light mode,
full color in dark mode); the inline override killed it and
text rendered at the same lightness as the 10% tint background.
Update only the `--avatar-color` CSS variable; let the class
continue computing the resolved colors.
Wire the avatar to the goal-stepper controller properly:
`_color_picker.html.erb` gains `data-goal-stepper-target="avatarPreview"`
on the span. `nameChanged` now updates the avatar directly (the
previous selector queried `[data-testid="goal-avatar"]` which
doesn't exist on the color_picker span) and:
- swaps to the first letter as the user types,
- restores the default-icon HTML (captured at connect) when the
name is cleared,
- bails when the user has explicitly checked an icon radio (don't
undo their choice).
Ruby idiom audit edge case. `linked_accounts.sum { |a| a.balance.to_d }`
trusted the model's validation that all linked accounts share the
goal's currency. The invariant holds at write-time, but direct DB
writes, an account-currency edit outside goal validation, or future
code that bypasses the validation chain could drift it. The naive
sum would silently add raw EUR + USD numbers and surface the result
as goal.currency.
Filter `linked_accounts.select { |a| a.currency == currency }` and
log/report-to-Sentry when the filtered count differs. The sum stays
correct (no FX, no mixing) and the operator gets visibility into
the drift.
Same pattern as `Family#savings_inflow_velocity` already uses for
the family-level rollup.
UX audit: `app/views/goal_pledges/new.html.erb` unconditionally
renders the form in a DS::Dialog wrapper — when the user lands on
`/goals/:id/pledges/new` directly (F5, bookmark, stale deep-link),
the dialog renders as a freestanding modal over an otherwise-empty
page. Compare to `goals/new.html.erb` which has a
`turbo_frame_request?` branch with a full-page fallback.
Pledges aren't usefully standalone — the modal only makes sense
in goal context. Redirect non-frame GETs back to the goal show
page instead of rendering the broken-looking standalone dialog.
If we want a deep-linkable "open the pledge modal" experience
later, that lands as a `?open=pledge` query on the show page that
auto-fires the modal — out of scope here.
Behavioural audit edge case. A user clicking "Mark complete" at
80% saw the same generic confirm body as at 105% ("It leaves the
Ongoing list…"). Sunk-cost-fallacy inversion + premature closure:
once labelled complete, the goal anchors success on the truncated
amount; future similar goals get smaller targets (regression to
the lower aspiration).
When `progress_percent < 100`, swap the confirm body to a
specific one — "You're at 80% — $X of $Y. Marking complete records
this as your achievement instead of the original target. Continue,
or close this and adjust the target instead?" Doesn't block the
action (some "stop short" cases are healthy — CFP literature
explicitly endorses changing your mind), but makes the trade-off
visible. Keep the original copy for the ≥ 100% case.
`confirm_complete_body_short` is a new locale key; the kebab-menu
builder picks between it and `confirm_complete_body` per-render.
* Add blocked count to rule run summary
* test(rules): cover rule run blocked counts
* fix(rules): derive blocked count from modified rows
Blocked rule transactions are the processed rows that were not modified. This keeps the displayed queued / processed / modified / blocked summary aligned when a run has already processed all matching rows but some were skipped by enrichment locks.
* fix(rules): count processed rows for rule jobs
Synchronous rule actions return the number of rows they modified, but rule-run processed counts should represent the number of matched transactions the job attempted to process. Using queued matches for processed preserves the distinction between processed and modified rows, which lets locked manual edits appear as blocked instead of making processed collapse to modified.
This changes RuleJob counter semantics, so it was committed separately from the derived blocked-count display change.
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.
UX audit finding. The catch-up alert demanded $X/mo without
accounting for pledges the user had already recorded. The user
recorded a $20k pledge → catch-up still demanded a fresh $20k →
double-counting → stacked yellow CTAs telling them to do the
thing they'd just done.
Goal#catch_up_delta_money now subtracts `open_pledges.sum(amount)`
from the demand:
delta = max(monthly_target − pace − sum(open_pledges), 0)
Uses the in-memory preloaded `open_pledges` collection (controllers
already eager-load it), so no extra query. The clamp at zero keeps
"$0/mo more" from rendering when pending pledges fully cover the
gap.
Alert branch in show.html.erb now also gates on
`@goal.catch_up_delta_money.amount.positive?` — when the demand
zeroes out via pending pledges, suppress the alert entirely.
Status pill stays `:behind` (because `pace < required`), but the
action surface goes quiet because the user already took it.
UX audit pair.
Header "Last saved N days ago" was rendered from
`Goal#last_matched_pledge_at` — date of the most recent
*pledge-matched* entry — but the funding column on the same page
shows "$X last 30d" computed off *all* entries. A user who
deposited cash without recording a pledge saw "Funding · last 30d:
$200" while the header still read "Last saved 47 days ago." Two
adjacent figures contradicted each other.
Rename the locale strings (used on both the index card and the
show header) to reflect the actual data source:
- footer_no_pledges: "No matched pledges yet"
- footer_last_today: "Last pledge matched today"
- footer_last_days: "Last pledge matched N days ago"
Two open pledges into the same account previously rendered as
two near-identical yellow banners with no way to tell which one
the Cancel button targeted. Add a relative-time line below the
body — "Pledged 2 hours ago" / "Pledged about a minute ago" —
using `time_ago_in_words(pledge.created_at)`. Discriminator without
changing the title or the action surface.
RUI audit: form-stepper progress line used `border-secondary` for
the inactive state — same weight as the active step's border, so
the active-step circle didn't visually pop against the line
connecting it to the inactive step. Recede passive states.
Swap to `border-subdued` (the DS's quieter divider) for the
inactive (step 1) line state. The active state stays `border-inverse`.
JS toggle in `goal_stepper_controller.js#updateStepperState` follows.
PF audit edge case. Calendar-month math undercounted near the
deadline: May 30 with a June 1 target returned `1` ("save $5k
this month"), then June 1 morning returned `0` (falls through to
`remaining_amount` charged as one-month-required). Users saw a
$5k/mo required rate for a 2-day window, then $5k flat on
deadline day — a cliff that doesn't match reality.
Replace calendar-month delta with `(target_date - Date.current) / 30.0`
so a 2-day-out deadline reports ~0.07 months and `monthly_target_amount`
scales proportionally. `[..., 0.0].max` keeps the past-due case
zero-clamped.
Two Ruby idiom audit fixes.
The Reconciler's outer `rescue StandardError` was logging at error
level and moving on. Pipeline-protective (we don't want a Goal
reconcile failure to break the Plaid/SimpleFIN/etc importer it's
hooked into) but invisible — real bugs hid behind a warn log
forever. Add `Sentry.capture_exception(e) if defined?(Sentry)`
alongside the log, matching the pattern in `Account::Syncer`,
`Sync`, `PlaidItem`, and the chart-series rescues this branch
already added. Keep the rescue's protective function.
`member do patch :extend end` shadows `Module#extend` — the
controller action name competes with Ruby's most-common
mixin entry point. `before_action :foo, only: %i[extend destroy]`
reads as "extend this controller with :foo, only: …" to a casual
reader, and stack traces against `def extend` look misleading.
Rename to `:renew` (matches the existing copy: the button says
"Extend 7 days," but the API verb is "renew the watching window"):
- config/routes.rb: `patch :renew`
- GoalPledgesController#extend → #renew
- locale `goal_pledges.extend` → `goal_pledges.renew`
- banner `extend_goal_pledge_path` → `renew_goal_pledge_path`
- test refs updated
The user-facing button text is unchanged.
After the first sync claims a pending entry (setting auto_claimed_pending_ids),
subsequent syncs find the entry by booked external_id as an existing record.
pending_match is never entered so pending_entry_date stays nil, causing
`nil || date` to silently overwrite the preserved pending date with the
booked settlement date.
Fix by checking auto_claimed_pending_ids on the existing entry — its presence
signals a prior auto-claim, so entry.date (the original pending date) is kept.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
The ring card's secondary line read "of $400,000 · $212,969 to go"
— the "of $400,000" half duplicates the header's "Target $400,000
by Jan 10, 2027". Same number, two places, ~80px apart vertically.
Drop the "of $target" half. Subtitle now reads "$212,969 to go" —
single new fact, complementary to the header.