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.
The pending-pledge banner on goals#show and the pending-pledge
callout on goals#index were inlined yellow divs:
bg-yellow-50 dark:bg-yellow-950/30 border-yellow-200 \
dark:border-yellow-800
Raw color tokens violate the "functional tokens only" rule in
CLAUDE.md ("`text-primary` not `text-white`, `bg-container` not
`bg-white`"), drift from the DS warning palette (`bg-warning/10`
+ `border-warning/20`), and miss accessibility plumbing (aria role,
sr-only variant label) that DS::Alert provides for free.
Swap both surfaces for `DS::Alert.new(variant: "warning",
live: :polite)`:
- Index callout: a one-line message variant (no title).
- Show banner: title + body + footer with DS::Button (outline
Extend, ghost Cancel). Cancel button gets a CustomConfirm dialog
— the V2 affordance destroyed the pledge on a single click with
no second-chance prompt; one accidental click and the user lost
the record. The Extend / Cancel buttons drop the hand-built
`text-xs px-2.5 py-1 rounded-md shadow-border-xs bg-container`
styling, picking up DS::Button's `outline` / `ghost` variants
and `size: "sm"` instead.
Locale: tighten the title from "Pending: $200 into Savings" +
separate body line "… $0.50 or ±1%) · 6 days left." to
"Watching for $200 into Savings · 6 days left" with a short body
("Auto-confirms when Sure spots a matching deposit on the next
sync"). The old copy was ~130 chars and wrapped 2-3 lines inside
the banner flexbox, pushing the catch-up callout below the fold
on common viewports. Drop the hardcoded "$0.50" from the body
(currency-aware copy lands in Commit I).
Add `confirm_cancel_{title,body,cta}` strings for the
CustomConfirm dialog.
Two semantic shifts in V2 that drove the worst on-screen confusion.
B3/B4 — `Goal#pace` excluded `Transaction::TRANSFER_KINDS`. When a
user tapped "I just transferred…" and the deposit landed, the linked
account's balance went up but pace did not: pace ignored transfer-
kind entries, so the goal stayed `:behind` against `monthly_target`
and the catch-up callout kept demanding $X/mo even though the user
had just moved the money in. Same root cause hit any long-time saver
whose 90-day net was zero — pace=0, status=:behind, projection says
"At $0.00/mo you'll miss your target date" while the ring sits at
80%.
Drop the transfer-kind exclusion. Pace is now net inflow into linked
accounts over 90 days. Transfers between linked accounts already net
out (both legs land inside the same account set); transfers from
outside (checking → linked savings) net positive, which is exactly
the case the pledge flow records.
B19 — `Family#savings_inflow_velocity` summed entry amounts across
every depository account linked to any goal regardless of currency,
then rendered the result in the family's primary currency. A family
with one USD goal and one EUR goal saw `usd_inflow + eur_inflow`
reported as USD with no FX conversion. Scope the account set to the
family's primary currency until proper FX-conversion lands. Also
let the result go negative (net outflow) — clamping to ≥0 lost
signal; the controller decides how to render the sign.
V20 (controller) — `velocity_30d_sign` was wired off the *delta*
direction, so a $1,234 down-month rendered as "−$1,234 ↓ 27% vs
prior 30d". The minus read as a loss but $1,234 was the (positive)
contribution. Re-wire the headline sign off the headline value
itself; the delta-direction stays on the subline as ↑/↓ N%. With
the family-rollup change above, the headline can now legitimately
be negative — UI now says "−$200 ↓ 50% vs prior 30d" when the
family had net outflow.
B21 — KPI tile `on_track_count` lumped `:reached` goals into "on
track", inflating the numerator while the sort order placed reached
goals at the bottom of the list. Split `reached_count` out and
render it as its own segment in the on-track subline ("1 reached ·
1 behind · 1 paused").
Test: rename the pace=zero test to match its new premise (no
transactions vs. no non-transfer entries). The fixture still has no
entries, so the assertion holds.
Behavioural fixes touching Goal, GoalPledge, the reconciler and the
goals controller. No schema change.
B5 — connected-account detection covered only Plaid. SimpleFIN, Brex,
Enable Banking, IBKR, Kraken, SnapTrade and Lunchflow users got
"manual_save" pledges by default; their auto-synced Transactions then
failed to match (reconciler matches Transactions to "transfer" pledges
only). Pledges sat in the yellow banner until expiry. Switch the
detection to !Account#manual?, which mirrors the existing
`Account.manual` scope (no account_providers, no plaid_account_id, no
simplefin_account_id). Add `Account#manual?` so the per-instance and
per-query checks can't drift.
B7 — `extend!` widens `expires_at` but `matches?` was anchored on
`created_at ± 5d`, so an extension that pushed the expiry past day 5
didn't actually buy any match runway. Widen the upper bound to
`max(created_at + 5d, expires_at)`. The lower bound stays at
`created_at − 5d`.
B8 — `Goal#open_pledges` returned `status: open` regardless of expiry.
Between a pledge timing out (day 7) and the 15-min sweep job marking
it `expired`, the show page rendered a ghost yellow banner with
"0 days left" that the reconciler would no longer touch. Add
`expires_at >= NOW` to the scope so the visible state matches the
match-eligible state.
B9 — Double-click on Record pledge produced two identical open
pledges, which then stacked as two yellow banners. Add a create-time
validation rejecting duplicates against (goal_id, account_id, amount,
status=open, expires_at >= NOW).
B10 — The reconciler used `transaction.with_lock` but didn't lock the
pledge. Two concurrent reconcile attempts on different transactions
could both target the same pledge; one would lose to the partial
unique index on `transactions.extra->'goal'->>'pledge_id'` and the
RecordNotUnique was caught by the outer StandardError rescue, which
silently dropped the other transaction's match attempt entirely.
Lock the pledge first, re-check `status_open?` inside the lock, and
catch RecordNotUnique alongside RecordInvalid/NotOpenError in the
reconciler — so on a lost race we fall through to the next candidate
pledge instead of exiting the loop. Extract the Valuation-match path
to `GoalPledge#resolve_with_valuation!` so it goes through the same
locked status-recheck.
B12 — When a goal is destroyed, `dependent: :destroy` reaped pledges
but left `transactions.extra["goal"]["pledge_id"]` pointing at the
now-deleted UUIDs. The partial unique index on that JSON path then
indexed stale references. Add a `before_destroy` on GoalPledge that
clears the matching transaction's `extra` if it still points back to
the pledge.
B6 — `last_matched_pledge_at` used `goal_pledges.maximum(:updated_at)`
on matched rows. Any backfill or sync-resync that touches a matched
pledge bumped `updated_at`, so a single resync set every goal's "Last
saved N days ago" header back to "today". Switch to the entry's
`date` via a join through `matched_transaction_id`, which reflects the
date the money actually moved.
B22 — `scope :chronological` ordered DESC, the opposite of what the
name promises. Rename to `:reverse_chronological` and update the one
caller in `goals#show`. (Other models' `chronological` scopes are
unrelated and ordered correctly.)
Also: preload `account_providers` on `linked_accounts` in the index
and show controllers so `Account#manual?` walks the in-memory
collection instead of triggering N queries.
Tests: add fixture-backed coverage for extend-widens-match-window,
post-extend rejection beyond expiry, and the duplicate-pledge
validation. Existing assertions still hold against the new
`matches?` window math.
The v2 rewrite dropped the velocity_delta_percent / velocity_direction
keys that powered the 'Contributed last 30d' card's '↑ 27.2% vs. prior 30d'
line and the 'Goals on track' multi-part subtitle ('1 behind · 1 paused').
Restore both, sourcing velocity from Family#savings_inflow_velocity with
explicit current-window and prior-window ranges.
The explicit .order(created_at: :asc).find_each emitted an AR warning
that broke the strict logger mock in BrexEntry::ProcessorTest.
find_each forces its own primary-key order anyway.
Regenerate schema.rb after the three v2 migrations so CI's db:schema:load
picks up goal_pledges, the dropped goal_contributions, and the partial
unique pledge_id index.
Brakeman:
- Drop :account_id and :kind from goal_pledge permit; look the account
up via @goal.linked_accounts.find_by(id:) instead and set kind
server-side from goal.any_connected_account?.
- Rename goals.show.projection.on_track to .on_track_html so I18n
marks the result html_safe automatically; drop the unconditional
.html_safe call in show.html.erb.
Pledge modal: rewrite app/views/goal_pledges/new.html.erb to use
DS::Dialog (the Sure convention for create modals — matches
categories/transfers).
Error handling: replace `raise ActiveRecord::RecordInvalid, "string"`
in GoalPledge#extend!/cancel! with a dedicated GoalPledge::NotOpenError;
the controller rescues that specifically.
Tests: rewrite the "pace is zero" test to create a fresh account with
no entries (the fixture's depository accounts carry transaction history
that produces a non-zero pace). All goal tests now green (73 runs,
157 assertions, 0 failures).
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.
* Add period navigation arrows to reports view
* Fix accessibility: render disabled next arrow as span instead of anchor
* Add tests for period navigation arrows and localized strings
* Refactor period navigation: move date logic to controller
* Fix test assertions: tighten selectors and remove debug code
* Redesign period navigation arrows to match budget screen style
* custom period test assert next period
* Add YTD tests and fix indentation in period navigation tests
* Add period picker menu to reports navigation
* Fix accessibility: use disabled button for next arrow
* fix a test that was lost in the repos update
* Use i18n for period navigation labels
* Add accessible labels to period picker navigation links
* Use i18n for quarter and YTD labels in period picker
* Add accessible labels to active period navigation chevrons
* Tighten custom period navigation test assertions
* Add comment clarifying build_period_navigation dependency on setup_report_data
* Replace link_to with DS::Link in period picker navigation
Use Date#quarter instead of manual quarter calculation
Remove border from month/quarter/year display in period picker
* feat(balance): persist daily balance snapshots for linked accounts (SnapTrade, Plaid)
When updating a linked account's balance, the previous day's current_anchor
is now preserved as a reconciliation valuation before being replaced. This
creates a chain of API-reported balance waypoints over time. The
ReverseCalculator has been updated to treat these reconciliation valuations
as reset points during reverse syncs, ensuring historical balances accurately
reflect the known API-reported values even with incomplete transaction history.
* fix(balance): don't treat current_anchor as reconciliation waypoint
The ReverseCalculator was incorrectly treating the current_anchor valuation
(on Date.current) as a reconciliation waypoint, causing it to reset the
balance and ignore same-day transactions. This fix adds a check to ensure
only true reconciliation entries (entryable.reconciliation?) trigger the
reset behavior.
Additionally, set_current_balance_for_linked_account is now wrapped in a
database transaction to ensure atomicity when preserving stale anchors and
creating/updating the current anchor. Logging has been improved to use
debug level for amount details.
A regression test was added to verify that same-day flows are correctly
processed when a current_anchor exists on the current date.
* test(account): ensure preserved valuations use correct historical date
Add validation that valuation entries created during balance
preservation are dated as of yesterday. This prevents future-dated
entries and maintains temporal accuracy in financial snapshots.
* refactor: remove redundant transaction block and unused method comment in current balance manager
* refactor(account): remove redundant valuations reload in CurrentBalanceManager and add regression test for consecutive reconciliation waypoints
* refactor: remove redundant transaction block and update anchor rotation log to include entry ID
* feat(statements): add account statement vault
Add web-only statement uploads, account linking, duplicate detection, and per-account coverage/reconciliation checks without mutating transactions. Extend ActiveStorage authorization and targeted tests for family/account scoping.
* fix(statements): return deleted account statements to inbox
Preserve linked statement records when an account is deleted by moving them back to the unmatched inbox, then expand coverage for upload validation, sanitized parser metadata, unavailable reconciliation, and missing-month coverage.
* fix(statements): harden vault upload review flows
Address review and security findings in the statement vault by preserving sanitized parser metadata, failing closed on orphaned statement blobs, avoiding account_id mass assignment permits, and adding regression coverage for link/delete edge cases.
* fix(statements): harden vault upload and access controls
* fix(statements): address vault hardening review
* fix(statements): address vault review feedback
Prioritize SHA-256 duplicate detection while preserving MD5 fallback for legacy rows.
Remove free-form account notes from statement matching, document direct account-destroy unlinking, and add year-selectable historical coverage with muted out-of-range months.
* fix(statements): harden vault review follow-ups
Clarify legacy MD5 checksum use, whitelist statement balance helper dispatch, and preserve sanitized parser metadata.
Hide statement management controls from read-only viewers while keeping server-side authorization unchanged.
* fix(statements): repair settings system coverage
Allow the changelog provider lookup in the self-hosting settings system test, include Statement Vault in settings navigation coverage, and align the feature title casing. Update the devcontainer so ActiveStorage and parallel system tests can run in the documented environment.
* fix(statements): move vault beside accounts
Place Statement Vault with account settings instead of between Imports and Exports. Keep settings footer ordering and system navigation coverage aligned, including the non-admin visibility guard.
* fix(statements): address vault review cleanup
Resolve CodeRabbit review feedback for statement upload validation, duplicate race handling, account statement matching semantics, metadata detection, ActiveStorage authorization tests, and small UI/style cleanups.
* fix(statements): address vault cleanup review
* fix(statements): deduplicate vault style helpers
* fix(statements): close vault review follow-ups
* fix(statements): refresh schema after upstream rebase
* fix(statements): process vault uploads sequentially
* fix(statements): close vault review follow-ups
* fix(statements): scope vault index to accessible accounts
* fix(statements): harden statement vault readiness
Squash the statement vault migration hardening into the feature migration, tighten Active Storage authorization edge cases, bound CSV metadata detection, and add real PDF fixture coverage for stored statements.
Validation: targeted statement/auth/controller/provider tests, full Rails suite, system tests, RuboCop, Biome, Brakeman, Zeitwerk, importmap audit, npm audit, ERB lint, CodeRabbit, and Codex Security all passed locally.
* fix(statements): close vault review follow-ups
Move statement unlinking to after account destroy commit, keep Kraken account creation on the shared crypto helper, and add statement metadata length limits with DB checks.
Validation: fresh devcontainer with fresh DB via db:prepare, focused account/statement/Kraken/Binance tests, RuboCop, Brakeman, Zeitwerk, git diff --check, CodeRabbit, and Codex Security passed before commit.
* fix(statements): address vault scan follow-ups
Move statement tab data setup out of the ERB partial, harden reconciliation labels and coverage initialization, and tighten statement schema constraints.
Validation: CodeRabbit and Codex Security reviewed the current PR diff; Rails focused tests, full Rails tests, system tests, RuboCop, Brakeman, Zeitwerk, ERB lint, npm lint, importmap audit, npm audit, and git diff --check passed.
* fix(statements): defer vault tab loading
---------
Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
* fix(exports): align CSV roundtrip contracts
* fix(exports): version CSV export contract
* fix(exports): stabilize CSV export values
* fix(imports): preserve legacy CSV roundtrip contracts
* fix(imports): escape pipe characters in CSV tags
---------
Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
* fix(simplefin): treat Vanguard/Fidelity cost_basis as total when needed
PR #1692 normalized SimpleFIN holdings cost_basis under the assumption
that the `cost_basis` / `basis` keys carry a per-share value (per the
SimpleFIN spec) and only `total_cost` / `value` carry a total position
cost. Vanguard and Fidelity violate the spec — they populate
`cost_basis` with the *total* (see the payload in #1182). After PR
#1692 those holdings get stored with cost_basis = total, and
Holding#calculate_trend then computes previous = qty × avg_cost, so the
"previous" value is inflated by a factor of qty and an entire
investment account renders a phantom return of roughly -(1 − 1/qty),
i.e. -97% to -99%.
Fix: sanity-check raw cost_basis against the holding's market share
price. Let share_price = market_value / qty; the geometric midpoint
between "raw is per-share" (raw ≈ share_price) and "raw is total"
(raw ≈ qty × share_price) is share_price × √qty. If raw is above the
midpoint it is divided by qty; otherwise it is kept as per-share.
Falls back to the pre-fix behaviour (trust the spec) when market_value
or qty is unavailable, so confidently-correct readings are never made
worse.
Verified against the reported Vanguard payload (qty=139, cost_basis=
22004.40, market_value=22626.42): normalize_cost_basis now returns
$158.31/share, matching 22004.40 / 139, and the phantom -99% return
collapses to a realistic ~+2.8%. Per-share readings ($45 cost on a $50
share price) remain untouched.
Closes#1718. Refs #1182, #1692.
* fixup: replace cost_basis heuristic with institution allowlist
Codex and @EdeAbreu23 flagged a real false-positive in the previous
geometric-midpoint heuristic: a legitimate per-share `cost_basis` on a
holding with a large unrealized loss (e.g. 100 shares with $100/share
basis now worth $5/share) trips `share_price × √qty` and gets divided
to $1/share — corrupting any standards-compliant brokerage with a big
loss.
Adopt @EdeAbreu23's safer shape:
- total_cost / value: always divide by qty (unchanged from #1692).
- cost_basis / basis: keep as-is by default.
- Only divide cost_basis / basis when the holding's SimpleFIN account
is connected to a known-misbehaving institution. Allowlist starts
with `vanguard` and `fidelity`, matched case-insensitively against
the account's stored org name and domain. Easy to extend as more
brokerages turn up.
Trades a small maintenance cost (curated list) for zero risk of
corrupting compliant providers.
Verified against five scenarios (all expected):
Vanguard total in cost_basis (allowlist) → +2.83%
Fidelity total in basis (allowlist) → +33.33%
Big-loss per-share (Codex case) → -95.0% (preserved)
Honest per-share, small loss → +11.11% (unchanged)
total_cost on any institution → +11.11% (unchanged)
---------
Co-authored-by: plind-junior <plind-junior@users.noreply.github.com>
* fix(enable-banking): gracefully skip PDNG fetch for ASPSPs that don't support it
Some banks reject the PDNG transaction status filter with a 422 validation
error, causing the entire account sync to fail including booked transactions.
Wrap the pending transaction fetch in a rescue block to catch
validation errors from the provider. If the ASPSP does not support
the "PDNG" status, the error is logged and the process continues
without pending transactions instead of failing the entire import.
* fix(enable-banking): gate PDNG fallback on transactionStatus error detail
Tighten the rescue added in the previous commit so it only silences
422s that explicitly mention transactionStatus in the API error body.
Any other validation error (bad date_from, malformed headers, etc.)
re-raises and fails the sync as before, preventing silent data loss.
Tests added for both branches: ASPSP-rejects-PDNG (success) and
unrelated-validation-error (failure).
* fix(enable-banking): clear pending flag and prevent stale re-import after auto-claim
When a booked transaction claims a pending entry via the amount/date heuristic
(find_pending_transaction), two bugs caused the entry to remain incorrectly pending
and the old pending transaction to reappear on subsequent syncs.
Bug 1: The extra["enable_banking"]["pending"] flag was never cleared on the claimed
entry. For simple booked transactions with nil extra the deep-merge path is skipped
entirely, so the pending badge persisted forever.
Bug 2: After the claim the old pending external_id (e.g. PDNG_123) stayed in the
stored raw_transactions_payload. The importer's C4 filter only removes pending
entries whose transaction_id matches a BOOK id — Enable Banking issues completely
different ids for pending vs booked transactions — so PDNG_123 was never pruned.
On the next sync find_or_initialize_by(PDNG_123) couldn't find the claimed entry
(now keyed as BOOK_456) and created a fresh pending duplicate with no category.
Fix: on claim, explicitly clear all providers' pending keys from extra in-memory,
and store the displaced pending external_id in extra["auto_claimed_pending_ids"].
The Processor now queries this field alongside manual_merge to build the excluded_ids
set, so the stale pending data is skipped on every future sync.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(enable-banking): preserve pending date when claiming transactions
When a pending transaction is claimed by a booked transaction, the
original pending date is now preserved instead of being overwritten
by the booked transaction's date. This ensures historical accuracy
for transactions that were originally recorded on a different date.
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
OidcIdentity#sync_user_attributes! runs on every SSO sign-in and
overwrote user.first_name / user.last_name with whatever the IdP sent,
because the precedence was `auth.info.* || user.*` — the IdP always
won when it supplied a value. A user who edited their first name to
"Adam" inside Sure had it reset to the IdP value "Ben" on the next
login, while the last name only "stuck" when the IdP happened not to
return a last_name (#1103).
Swap the precedence to `user.* || auth.info.*` so the IdP fills only
when Sure has nothing on file (first link or admin-blanked field).
Edits inside Sure are then authoritative for every subsequent login.
The audit copy on the OidcIdentity record itself is unchanged, so the
IdP-reported name is still available for debugging.
Closes#1103.
Co-authored-by: plind-junior <plind-junior@users.noreply.github.com>
* make default of opening_balance_date_label is TODAY
* feat(i18n): add multi-language support for opening balance label
- Use `t("valuations.show.opening_balance")` for all opening balance display (list and detail views)
- Add or update `opening_balance` translation in all major languages under `config/locales/views/valuations/`
- Now "Opening balance" will be localized in all supported languages
* revert -2.years
* Update config/locales/views/valuations/es.yml
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Signed-off-by: Juan José Mata <jjmata@jjmata.com>
* Update config/locales/views/valuations/pt-BR.yml
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Signed-off-by: Juan José Mata <jjmata@jjmata.com>
* Fix indentation for opening_balance in ro.yml
Signed-off-by: Juan José Mata <jjmata@jjmata.com>
* Fix indentation for opening_balance in Turkish locale
Signed-off-by: Juan José Mata <jjmata@jjmata.com>
* Update zh-TW.yml
Signed-off-by: Juan José Mata <jjmata@jjmata.com>
---------
Signed-off-by: Juan José Mata <jjmata@jjmata.com>
Co-authored-by: Juan José Mata <jjmata@jjmata.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
DS::Dialog#close_button called I18n.t("common.close") but no
`common.close` key exists in any locale file, so every modal rendered
the literal string "Translation missing: en.common.close" as both the
`title` and `aria-label` of the X close button — visible to screen
readers and as a hover tooltip.
Switch to `ds.dialog.close` to mirror the existing `ds.alert.*`
namespace under config/locales/views/components/*.yml, and add the
English string. Other locales fall back to English (fallbacks=true in
config/application.rb) until translated.
Closes#1763.
Co-authored-by: plind-junior <plind-junior@users.noreply.github.com>
* fix(binance): support CRYPTO: prefix and USD stablecoins
Holdings processors (CoinStats, Coinbase, Kraken, SimpleFIN, Lunchflow,
Binance) store crypto securities with a "CRYPTO:" prefix, but
Provider::BinancePublic#parse_ticker only accepted Binance-search-style
tickers like "BTCUSD". As a result, every fetched price for tickers
like CRYPTO:USDT, CRYPTO:USDC, CRYPTO:SOL, CRYPTO:TRUMP, CRYPTO:KAITO
failed with "Unsupported Binance ticker".
- Strip the CRYPTO: prefix in parse_ticker.
- Short-circuit USD-pegged stablecoins (USDT, USDC, BUSD, DAI, FDUSD,
TUSD, USDP, PYUSD) to a synthetic flat 1.0 USD price. Binance has no
self-pair (USDTUSDT is invalid), and the few stablecoin/USDT pairs
that do exist hover at ~1.0 with sub-cent noise.
- Default prefixed bare base assets (CRYPTO:SOL etc.) to the …USDT
pair (USD). Only when prefixed, so unprefixed garbage like BTCBNB /
BTCGBP still returns nil and the existing rejection tests still pass.
- fetch_security_info returns links: nil for stablecoins rather than a
broken /trade/ URL.
Closes#1441.
* fix(binance): strip CRYPTO: prefix in search_securities
Security::Resolver calls search_provider with the raw holdings-processor
symbol (CRYPTO:SOL, CRYPTO:USDT) before any price fetch. Without prefix
handling here, first-time crypto imports never resolve to an online
Binance security and the new stablecoin/prefix paths in parse_ticker
were unreachable for that flow.
- Strip CRYPTO: from the search query.
- Short-circuit USD stablecoins to a synthetic search result (no
exchangeInfo call, no Binance self-pair to find).
- Teach parse_ticker the "{stablecoin}USD" form produced by the
synthetic result so price fetches route to stablecoin_prices.
---------
Co-authored-by: plind-junior <plind-junior@users.noreply.github.com>
* Constrain Lunchflow base URL to trusted endpoint
Prevent SSRF by ignoring user-provided Lunchflow base_url values unless they match the canonical Lunchflow HTTPS endpoint. Add model tests covering invalid host/scheme and valid canonicalization behavior.
* Linter
* Scope SnapTrade orphan cleanup to current family
Restrict orphaned user listing and deletion to SnapTrade user IDs that belong to the current family namespace. Add model tests to prevent cross-family enumeration/deletion regressions.
* Update test/models/snaptrade_item_test.rb
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Signed-off-by: Juan José Mata <jjmata@jjmata.com>
* test: fix snaptrade orphaned users assertion
* style: fix snaptrade test array spacing
---------
Signed-off-by: Juan José Mata <jjmata@jjmata.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: KiloClaw <kiloclaw@openclaw.ai>
Refs #895, discussion #1224.
Adds a "Mark as recurring" entry point on the transfer detail drawer
that creates a `RecurringTransaction` carrying both source and
destination accounts. The recurring index, settings toggle
(`recurring_transactions_disabled`), and projected upcoming feed all
light up automatically once the data shape is there.
Schema:
* `destination_account_id` nullable FK to accounts. `on_delete: :cascade`
matches #20251030172500's precedent for accounts FKs. The existing
`account_id` FK is widened to cascade in the same migration so
Family destruction with a recurring transfer doesn't FK-violate.
* Two predicate-partitioned partial unique indexes per shape:
non-transfer rows (`destination_account_id IS NULL`, original
5-column shape preserved) and transfer rows (6-column shape
including the destination). Postgres treats NULLs as distinct in
unique indexes, so widening would have broken non-transfer dedupe.
* Two CHECK constraints enforcing transfer invariants in PostgreSQL:
`chk_recurring_txns_transfer_requires_source` (destination implies
source) and `chk_recurring_txns_transfer_distinct_accounts`
(destination cannot equal source). Per CLAUDE.md "Enforce null
checks, unique indexes, and simple validations in the database
schema for PostgreSQL".
* `Account` gains an `inbound_recurring_transfers` inverse so the
destroy chain reaches both ends.
Controller / behaviour:
* `transfers#mark_as_recurring` mirrors `transactions#mark_as_recurring`:
i18n flashes (4 new keys: transfer_marked_as_recurring,
transfer_already_exists, transfer_creation_failed,
transfer_feature_disabled), `respond_to format.html`,
`redirect_back_or_to transactions_path`, server-side gate on
`recurring_transactions_disabled?`, and rescue both `RecordInvalid`
and `RecordNotUnique` for the race window between the dedupe
`find_by` and `create_from_transfer`. The `StandardError` rescue
now logs the exception (class, message, transfer/family/user ids)
before surfacing the generic flash so production failures aren't
context-less.
* `RecurringTransaction.accessible_by(user)` now requires
destination_account_id (when present) to be in the user's
accessible set, so a recurring transfer never leaks to a user
without access to BOTH endpoints.
* Model validation gains a `destination_account.blank?` branch in
`transfer_endpoints_consistent` so a dangling
`destination_account_id` (referenced row destroyed) surfaces as a
normal validation error instead of an FK exception on save.
* `Identifier` filter for transfer-kind transactions moved into SQL.
UI:
* Recurring index table and projected feed render transfer rows with
the existing letter-avatar and the row's `name` field
("Transfer to {destination}"). No special pill or icon -- every row
in `/recurring_transactions` is recurring by definition. Amount
column on transfers uses `text-secondary` (muted-but-live) instead
of the income/expense colour, since transfers are zero-net for the
family.
Out of scope (called out in the PR body):
* Auto-creation of future Transfer rows on a schedule
(discussion #1224's primary ask). Behaviour change vs the
current projection-only model.
* Auto-identification of recurring transfer pairs in `Identifier`.
* Frequency model richer than `expected_day_of_month`.
* `Cleaner` for recurring transfers (issue #1590 tracks this).
Tests:
* `RecurringTransaction#transfer?` predicate (with / without
destination).
* `transfer_endpoints_consistent`: rejects same source and
destination, rejects dangling destination_account_id, rejects
cross-family destination.
* `RecurringTransaction.create_from_transfer` happy path;
multi-currency variant stores source-side currency.
* `projected_entry` exposes source / destination on transfer rows.
* `Identifier` skips transfer-kind transactions; creates a pattern
from expense halves while ignoring co-resident transfer halves.
* Destroying the destination account cascades to inbound recurring
transfers (FK + AR association).
* Unique partial index still de-duplicates non-transfer rows after
the destination_account_id widening.
* `transfers#mark_as_recurring` happy path, idempotent on second
call, rejected when `recurring_transactions_disabled`.
Suite: 3261 / 0 / 0 / 24 on the latest upstream/main. Lint clean.
Brakeman clean.
Signed-off-by: Guillem Arias Fauste <gariasf@proton.me>
* fix(enable-banking): handle transactions missing transaction_id and entry_reference
Some ASPSPs omit both transaction_id and entry_reference from their transaction payloads, which is valid per the PSD2/Berlin Group spec. Previously, every such transaction raised an ArgumentError and was silently dropped during sync.
compute_external_id now falls back to a deterministic MD5 fingerprint (prefixed enable_banking_content_) derived from date, amount, currency, direction, counterparty, and remittance info. This fingerprint is stable across re-syncs, so duplicate imports are still correctly prevented. An ArgumentError is only raised for truly empty/unidentifiable payloads.
The importer is updated in three places to use compute_external_id
consistently: the pending pre-filter (before combining with booked),
the C4 stored-pending cleanup, and the new_transactions dedup. This means ID-less pending entries are now also removed when their settled booked counterpart arrives.
Tests cover compute_external_id directly (all 5 cases), end-to-end
fingerprint import, idempotency, and importer storage/dedup behaviour for ID-less transactions including the pending→booked settlement path.
* fix(enable-banking): implement dual-strategy matching for transaction settlement
When a stored pending row had only entry_reference (no transaction_id) and
the settled BOOK row arrived with a new transaction_id, compute_external_id
produced different fingerprints for each side (enable_banking_<ref> vs
enable_banking_<txn_id>). The fingerprint-only comparison introduced in the
previous commit never matched, leaving the stale pending entry in
raw_transactions_payload. Both rows were then imported as separate visible
transactions.
Restore a book_entry_refs set alongside book_fingerprints in both the
pending pre-filter and the C4 stored-pending cleanup. A pending entry is
now removed when either its fingerprint or its entry_reference matches a
booked counterpart — covering same-ID settlement, content-fingerprint
settlement, and the entry_reference cross-match settlement path.
Also updates the ArgumentError message in external_id to accurately
reflect that transaction_id, entry_reference, and content fingerprint
are all accepted identifiers, and aligns build_transaction_content_key
to use transaction_date as a fallback (matching compute_external_id).
Adds a regression test that stores a pending-only row and asserts it is removed when the booked counterpart arrives with a new transaction_id.
* fix(design-system): align DS::Alert icon with title
The icon was rendered at size 'sm' (w-4 h-4) and started at the very
top of the flex row (items-start without an offset), which optically
sat above the title's cap when the title was present and slightly
above the message baseline when it wasn't. The hand-rolled alerts
this PR replaced used 'w-5 h-5 mt-0.5' for exactly this reason —
restore the same combination in the component:
- size: sm -> md (w-4/h-4 -> w-5/h-5).
- class adds mt-0.5 so the icon's vertical center lines up with the
bold title's cap-height (and with the body baseline in the title-less
case).
No API change. Visual fix only.
Refs #1731
* fix(design-system): split DS::Alert into title-row + indented body
Replaces the items-start + margin-fudge approach with a two-row
layout that doesn't depend on icon-bounding-box vs text-cap-height
arithmetic:
- Title case: icon and bold title share a flex row with items-center,
so the icon's vertical centre lines up with the title's line. Body
(block content or message) renders below in a separate row, padded
by pl-8 (= icon md width + gap-3) so it indents under the title
text rather than under the icon.
- Block-only case (no title, no message — used by the alpha_vantage
rate-limit alert): keeps the items-start fallback with a small mt-0.5
on the icon so the cap of the first paragraph still sits near the
icon centre.
- Single-line message case: items-center between icon and message, no
fudge needed.
container_classes loses its 'flex items-start gap-3' base since the
outer div is no longer the flex container. Each branch declares its
own flex/items-* combination.
Refs #1731
* fix(design-system): a11y semantics + visual polish on DS::Alert
Builds on the title-row restructure with the items the design / a11y
review surfaced:
- live: keyword (default :none, accepts :status / :polite and
:alert / :assertive) maps to role="status" or role="alert" on the
outer div. Static, page-baked alerts (the migrated callsites in
#1731) keep the default :none and stay role-less. Dynamic surfaces
(flash, validation summaries appearing after a Turbo update) opt
into the live role they need.
- aria-labelledby on the outer div pointing at the title <p> so AT
picks the title as the alert's accessible name when one is set.
- Variant prefix in the title / message via an sr-only span. Screen
reader hears 'Warning: …', 'Error: …', etc.; sighted users see no
change. Variant labels live under ds.alert.variants.* in
config/locales/views/components/en.yml.
- Body text inside titled alerts now defaults to text-secondary
instead of text-primary, so hierarchy reads on weight + colour
rather than weight alone (Refactoring UI: hierarchy needs both).
Single-line message and block-only fallback keep text-primary
since there is no second tier.
- Icon size goes back from md (20px) to sm (16px) — proportionally
closer to text-sm body — and the items-center branches grow
-mt-0.5 to compensate for the cap-centre vs line-centre offset
that flex's items-center alone can't bridge.
- Title weight bumped from font-medium (500) to font-semibold (600)
for clearer prominence against the now-softer body.
No API breakage: existing callers passing only message:/title:/variant:
keep working. The new live: arg defaults to the correct value for
the static migration sites.
Refs #1731
* fix(design-system): drop aria-labelledby when alert has no role; revert body to text-primary
Two corrections after numerical contrast analysis and CodeRabbit feedback:
1. aria-labelledby was being emitted on every titled alert, but the
default live: :none leaves the outer <div> with no role. ARIA spec
only honours the labelling relationship on elements with a host
role, so on a generic <div> the attribute is invalid and
accessibility validators flag it. Now only emitted when aria_role
is set (live: :status or :alert). Static, page-baked callsites
stay role-less and label-less; dynamic callers that opt into a
live role get the proper accessible-name relationship.
2. text-secondary on bg-{variant}/10 in light mode lands at
~4.07-4.25:1 contrast — below WCAG AA's 4.5:1 for normal text.
Reverting the body wrapper to text-primary brings it back to
AAA (~15:1). Loses some of the Refactoring UI body-vs-title
colour hierarchy; the title's font-semibold weight + larger
optical mass against an otherwise plain body still reads as
hierarchy. Single-line message and block-only fallback already
used text-primary, so this just unifies the three branches.
The remaining contrast gap — text-success (green-600) icon on
bg-success/10 light surface at 2.77:1 — is documented in the PR
description; fixing it cleanly needs a token-level bump
(--color-success: green-600 -> green-700 in light mode) which is
out of scope for this PR.
Refs #1731
* fix(settings/providers): use DS::Alert title:+message: instead of inline content_tag
Three callsites added in #1710 passed block-level markup (`<p>`/`<h2>`)
through `message:` via `safe_join + content_tag`. The post-#1731 alert
template wraps `message:` in a `<p>`, which makes nesting a `<p>` or
`<h2>` invalid HTML — browsers auto-close the outer paragraph and the
indented body row collapses.
Each of the three is semantically a title + body pair, so swap them
to the proper `title:` + `message:` API. No new strings — the i18n
keys (`*.no_withdraw_title` / `_body`, `encryption_error.title` /
`.message`) already split that way; the inline assembly was the
artefact.
The encryption-error block loses an explicit `<h2>` wrapper around
the title; DS::Alert's title is a `<p>`. The visual hierarchy and
sr-only variant prefix are unchanged. Worth tracking heading semantics
as a follow-up against DS::Alert (a `heading_level:` arg) rather than
bringing back the manual markup.
* fix(design-system): make :destructive variant alias explicit in DS::Alert locale
Add `destructive: Error` to `ds.alert.variants` and drop the implicit
`:destructive -> :error` aliasing in `DS::Alert#variant_label`. Both the
locale file and the component now self-document the variant set; lookup
is direct, no conditional needed.
Per @jjmata review on #1734.
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).
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.
Previously sat next to the name input via `flex items-start gap-3` so
the picker avatar competed with the input for horizontal space. Move
to its own row, centered (`flex justify-center`), positioned just
before the name field. Mirrors the categories form layout where the
avatar is the focal element above the name input.
Same change applied to the edit form: picker comes first, then name.
Stepper step 1 order is now: heading · picker · name · amount/date ·
funding accounts · notes.
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}.
The "Add $1,531.25" CTA used to open the contribution modal with an
empty amount field — label was a hint, not a default. Now passes the
catch-up amount via ?amount= and the contributions controller seeds
@contribution.amount from params. One click brings the user to the
modal already populated.
Adds a secondary text link below the primary CTA: "Or adjust your
target" → opens the edit modal (Turbo frame). Behavioural-econ choice
architecture: gives the rebaseline path explicitly so users who can't
realistically catch up don't feel forced into the contribution.
Trade-off: lets the alert respect autonomy — commit or recalibrate,
both fine. Action paralysis kept low by visual hierarchy (primary
button vs muted text link).
Found the actual Sure pattern in app/views/accounts/_form.html.erb:27-47
("Additional details" section in the account-creation flow):
<details class="group">
<summary class="cursor-pointer text-sm text-secondary hover:text-primary flex items-center gap-1 py-2">
<%= icon "chevron-right", class: "group-open:rotate-90 transition-transform" %>
...
</summary>
<div class="space-y-2 mt-2 pl-4 border-l border-primary">...</div>
</details>
It's an inline expand (no absolute popup), chevron rotates 90° on
open, body indented with a vertical primary-color rule. My previous
partial was an absolute-positioned popover lifted from
categories/_form.html.erb — not what Sure uses for collapsible form
sections.
Rewrite _color_picker.html.erb to match: chevron + color-preview disc
+ "Color" label in the summary; swatches in an inline indented body.
Catch-up body also drops the em-dash. Was:
"You're saving $X/mo today — $Y/mo short of the pace to finish by $date."
Now two sentences:
"Your current pace is $X/mo. You need an extra $Y/mo to finish by $date."
Two short clauses, no compound separator, each conveys a single number.
Frames the gap as "extra" rather than "short", which behavioral-econ
research suggests reads as more attainable.
Alert previous pass led with delta ("Behind by $750/mo") but the user
still had to reconcile that with the $1,000/mo CTA — the relationship
between current pace, gap, and required rate was implicit.
Make every number visible in the sentence:
- Title: "Save $1,000/mo to stay on track" — leads with the action +
required rate. Reduces decision load: the headline is what to do.
- Body: "You're saving $250/mo today — $750/mo short of the pace to
finish by September 11, 2026." — current pace + gap + deadline.
User can now mentally verify: $250 + $750 = $1,000. The catch-up
amount in title + body + CTA is no longer disconnected from the
current pace number; the body is the bridge.
Adds `scrollbar` utility (defined in app/assets/tailwind/application.css
as 4px gray-300 thumb) to the contributions list container. Browser-
default scrollbar was rendering as a thick dark bar in light mode on
some OSes; the in-house utility renders a thin gray thumb consistently
across themes.
- 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.
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).
Direct nav to /goals/new used to render the index page with an empty
modal frame because the entire template was wrapped in DS::Dialog.
The URL was effectively un-shareable.
Branch on turbo_frame_request? — Turbo Frame requests still render
the DS::Dialog wrapper (the existing in-modal flow on the index page
keeps working). Non-frame requests render a standalone page-level
header (h1 + subtitle + icon) followed by the form_stepper partial.
Same Stimulus controller, same data-goal-stepper-modal-subtitle
selector, so the stepper's subtitle update path works identically.
Controller sets @breadcrumbs so the standalone variant gets the
Home > Goals > New goal trail.
Verified both paths via Playwright: direct GET renders standalone
form with h1 "New goal" + no dialog; click-from-index opens the
DS::Dialog with the stepper inside.
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.