Remote branch added a beta_gated_nav_item helper + 'Gating the main nav'
docs section. Main concurrently renamed the beta-features gate to
preview-features (concern, predicate, JSONB key, locale flash). Rename
the new helper / partial local / pill marker to match preview naming and
port the nav-gating docs into gating-a-preview-feature.md so the
improvement survives the rename.
Resolved conflicts:
- db/schema.rb: take the later schema version (2026_05_19_100000).
- docs/llm-guides/gating-a-beta-feature.md: accept main's deletion;
port the 'Gating the main nav' section into the preview guide.
Renames carried through to keep the gate wired end-to-end:
- application_helper.rb: beta_gated_nav_item → preview_gated_nav_item;
beta_features_enabled? → preview_features_enabled?; beta: → preview:.
- _nav_item.html.erb: beta: local → preview: local; shared.beta i18n
key → shared.preview.
- application.html.erb: caller renamed to preview_gated_nav_item.
- goals/index.html.erb: pill label uses shared.preview.
- shared/en.yml: 'beta: Beta' → 'preview: Preview'.
- goals_controller, goal_pledges_controller: require_beta_features! →
require_preview_features!.
- goals_controller_test, goal_pledges_controller_test: flip the
preference key, flash matcher, and test names to 'preview'.
* refactor: rename beta features gate to preview features
Renames the opt-in gate introduced in PR #1829 from "beta" to "preview".
Same shape (per-user JSONB toggle, `before_action` concern, marker pill)
just retitled so the surface speaks the language Sure uses elsewhere
("preview" reads as in-progress, "beta" had baggage with provider
maturity copy and external testing programs).
Renames:
- BetaGateable -> PreviewGateable
- require_beta_features! -> require_preview_features!
- beta_features_enabled? -> preview_features_enabled?
- preferences["beta_features_enabled"] -> preferences["preview_features_enabled"]
- DS::Pill default label "Beta" -> "Preview"
- Settings -> Preferences toggle copy "beta features" -> "preview features"
- config/locales/views/beta/ -> config/locales/views/preview/
- docs/llm-guides/gating-a-beta-feature.md -> gating-a-preview-feature.md
Includes a data migration that copies any existing
`beta_features_enabled` JSONB key into `preview_features_enabled` so early
opt-ins survive the rename, then removes the old key. The migration is
fully reversible.
Provider maturity copy ("maturity.beta = Beta" under Settings -> Bank
sync) is intentionally untouched - that's a separate concept describing
a provider's integration stability, not Sure's feature gate.
* review: apply CodeRabbit findings on PR #1837
- Settings::PreferencesController#update now routes the
`preview_features_enabled` input through strong params and casts via
ActiveModel::Type::Boolean instead of reading raw params and string-
comparing to "1". Matches Sure's controller convention for permitted
params and avoids stringly-typed boolean handling.
- Rename migration now wraps the destination JSONB key write in COALESCE
so a row that somehow ends up with both keys keeps the destination
value instead of having it overwritten by the source. Up and down
paths get the same defensive shape.
* 📝 CodeRabbit Chat: Implement requested code changes
* 📝 CodeRabbit Chat: Implement requested code changes
* fix: restore all missing translation keys; rename beta→preview label
* fix: restore all missing sections (appearances, debugs, llm_usages, providers, etc.); rename beta→preview
* fix: restore missing keys (member_removal_failed, confirm_delete, etc.); add preview section
* fix(i18n/ca): use 'està en vista prèvia' instead of 'és una vista prèvia'
* fix(i18n/ca): use 'en desenvolupament'; drop article in preview title
* fix(i18n/es): use 'en desarrollo' instead of 'en progreso'
* fix(i18n/ca): use 'funcions experimentals' instead of 'vista prèvia'
* fix(i18n/es): use 'funciones experimentales' instead of 'vista previa'
* fix(i18n/ca): use 'funcions experimentals' in preferences.show.preview
* fix(i18n/es): use 'funciones experimentales' in preferences.show.preview
* fix(i18n/ca): use 'Experimental' pill label instead of 'Vista prèvia'
* fix(i18n/es): use 'Experimental' pill label instead of 'Vista previa'
---------
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
* feat(i18n): complete Catalan translations + extract residual hardcoded strings
CA coverage
- All view/model/breadcrumb/doorkeeper/mailer locale files for ca: 0 missing
keys (was ~3,400). Translations follow informal "tu" register, sentence case,
domain glossary (Compte/Saldo/Transacció/Posició/Operació/Pressupost/...).
- Catalan pluralization test: ca uses one/other; mirrors
test/lib/polish_pluralization_test.rb.
- 8 LanguageTool-flagged grammar fixes applied (Connexió òrfena, Secret de
l'API, comma-pero, apostrophe elisions, etc).
Hardcoded string extraction (also fixes EN parity)
- UI::Account::Chart#title + chart.html.erb view tabs -> UI.account.chart.*
- UI::Account::BalanceReconciliation labels + tooltips ->
UI.account.balance_reconciliation.{labels,tooltips}.*
- transactions/_transfer_match.html.erb (Auto-matched, A/M, Confirm/Reject
match, Payment/Transfer is confirmed) -> transactions.transfer_match.*
- AccountOrder labels (Name/Balance asc/desc) -> account_order.* keys with
fallback to existing hardcoded labels.
- Depository::SUBTYPES surface in account list -> depositories.subtypes.*.*
- User role badge -> users.roles.* (admin / member / super_admin).
- 110+ country names -> countries.* (config/locales/countries.ca.yml).
Breadcrumb locale fix
- Breadcrumbable was a before_action that ran before Localize's around_action
switched I18n.locale, so default crumbs rendered in EN even when locale=ca.
- Convert to helper_method that defers translation to render-time (when
I18n.locale is already correct). Add all missing breadcrumb keys to ca + en.
- Layouts switched from @breadcrumbs to breadcrumbs helper.
Locale-aware helpers / formatters
- ApplicationHelper#localized_ordinal: ordinalize that respects ca
(1r/2n/3r/4t/Nè). Wired into preferences month_start_day select.
- Family#moniker_label / moniker_label_plural: translate the default "Family"/
"Group" monikers via shared.family_moniker.* with fallback to the family's
custom override.
- Budget#name: use I18n.l for month_year/short/long instead of strftime("%B %Y")
so the budget header date follows the active locale.
Tooling
- script/lt_check_ca.rb: batched LanguageTool checker (premium endpoint when
LT_USERNAME/LT_API_KEY are set, free fallback otherwise), picky mode,
motherTongue=en for false-friend detection.
- lib/tasks/i18n_screenshot.rake: dev-only rake to set user.locale=ca and
role=super_admin on the demo user so the i18n surfaces can be walked.
Out of scope (pre-existing, not introduced here)
- Native browser file input "Choose Files / No file chosen" (browser locale).
- D3.js client-side chart x-axis dates (JS-side Intl.DateTimeFormat needed).
- Sankey/donut labels = seed category names (data, not i18n).
- 2 rails-i18n datetime/errors interpolation warnings inherited from
config/locales/defaults/ca.yml.
* fix(i18n): apply idiomatic Catalan review (3-agent + native review)
Three parallel review agents flagged 203 findings (31 high / 73 medium / 99 low)
across all 111 ca.yml files. This commit applies the high-severity bugs plus a
curated subset of medium-impact fixes.
Grammar / agreement
- provider_sync_summary.health.stale_pending: `(exclòs)` -> `(exclosa/excloses)`
to agree with feminine `transacció(s)`.
- accounts.confirm_unlink.warning_no_sync: added reflexive `es` -
`el compte ja no es sincronitzarà`.
- sophtron_setup_required.heading: `no configurats` -> `sense configurar`
(avoids broken agreement across "ID" masc. + "clau" fem.).
- admin.sso_providers.form.errors_title: split into one/other pluralization
keys (en + ca); singular `ha impedit` was wrong for count > 1.
Brand consistency
- IndexaCapital -> Indexa Capital (37 occurrences across one file).
- Lunchflow -> Lunch Flow in two remaining places.
Anglicisms / domain mistranslations
- kraken_items setup_accounts.instructions: `ompliments d'operacions`
(lit. dental/food fillings) -> `execucions d'operacions`.
- settings kraken_panel.read_only_title: `Sincronització d'intercanvi`
(swap/trade) -> `Sincronització només de lectura amb l'exchange`.
- transactions convert_to_trade.security_custom + security_not_listed_hint:
`cotització` (price quote) -> `ticker` (the EN field IS a ticker symbol).
- loans.form.rate_type: `Tipus d'interès` collided with sibling
interest_rate -> `Modalitat del tipus`.
- brex_items.provider_panel.sandbox_note_html: `L'staging` (broken
contraction) -> `el staging`.
Idiom traps
- coinbase/binance/kraken wait_for_sync: `acabi de sincronitzar` is
ambiguous in CA (`acabar de + inf` reads as "has just done X") ->
`acabi la sincronització`.
- chats.ai_greeting.there: `a tothom` -> `''` (the EN fallback "Hey there"
is singular; literal CA `tothom` is plural and wrong for 1:1 chat).
- transactions.split_parent_row.split_label: `Divideix` (imperative) is
wrong as a status badge -> `Divisió` (noun).
- transactions.keep_both (2 occurrences): infinitive `mantenir ambdues` ->
imperative `mantén-les totes dues` to match the sibling Yes/No buttons.
- rules.clear_ai_cache: `Reinicia` (restart) -> `Buida` (empty/clear),
which matches the success notice (`s'està netejant`).
Moniker gender breakage (cross-file)
%{moniker} is interpolated downcased from family.moniker_label and may
resolve to feminine `família`/`llar` or masculine `grup`. Strings that
hard-code a gendered article ('al teu %{moniker}', 'aquesta %{moniker}',
'aquest/a %{moniker}') broke on at least one branch. Restructured the
affected sentences to drop the gendered determiner:
- account_sharings.show.no_members
- merchants.family_empty / family_title / provider_empty
- registrations.new.join_family_title
- settings.preferences.show.currencies_subtitle / sharing_subtitle
- simplefin_items.select_existing_account.no_accounts_found
- invitations.new.subtitle
- invitation_mailer.invite_email.subject (mailers/) + body (views/)
- snaptrade_items.providers.snaptrade.free_tier_warning
Terminology consistency
- models/account_statement/ca.yml attributes aligned with view-side
forms: `Saldo d'obertura`/`Saldo de tancament` ->
`Saldo inicial`/`Saldo final`; `Suggeriment de...` -> `Pista de...`.
- account_statements.coverage.status.not_expected:
`No s'esperava` -> `No previst` (status label, not past action).
- account_statements.index.empty_unmatched: aligned with the section's
own label `Safata sense aparellar`.
- imports.create.document_provider_not_configured + document_upload_failed:
`arxiu vectorial` -> `magatzem vectorial` (correct TermCat term).
- coinstats_items blockchain gender: `els blockchains` / `un blockchain` ->
`les blockchains` / `una blockchain` (feminine per TermCat).
- accounts.account.remove_default: `Treu el predeterminat` ->
`Treu com a predeterminat` (pairs with sibling `Estableix com a
predeterminat`).
- accounts.tax_treatments.tax_deferred: `Diferit fiscalment` (lit. calque)
-> `Tributació diferida` (standard CA tax-accounting term).
- settings.payments.show.currently_on_plan: `Actualment al` ->
`Actualment al pla:` (was a fragment).
Out of scope (review flagged, not applied here)
- LOW-severity stylistic preferences (Veure vs Mostra, etc).
- `models/category/ca.yml` default category names — seeded at family
creation, not via I18n at runtime, so changes wouldn't affect existing
families.
- `models/period/ca.yml` short labels mixing EN (MTD/YTD) and CA (STD/MA)
— needs a one-convention decision separately.
* fix(i18n,ca): drop gendered article in period_activity + tighten cash-flow terms
- pages.dashboard.investment_summary.period_activity: 'Activitat del
%{period}' contracted 'del' = 'de el' (masc.sg.). %{period} resolves
to mixed forms ('Setmana en curs' fem, 'Últims 30 dies' pl., 'Any en
curs' apostrophe), so hard-coded 'del' was wrong on most labels.
Replaced with 'Activitat — %{period}' (em-dash) to skip the
contraction entirely.
- pages.dashboard.outflows_donut.title / total_outflows: switched from
bare 'Sortides' / 'Total de sortides' to 'Sortides de caixa' /
'Total de sortides de caixa' to match TermCat's precise term
('sortida de caixa' = cash outflow).
* fix(i18n,ca): rephrase transfer source/destination amount labels
'Import d'origen' / 'Import de destinació' were literal calques of
'Source amount' / 'Destination amount'. In a multi-currency transfer
form (sender/receiver in different currencies) the natural CA pair is
'Import enviat' / 'Import rebut'.
* fix(i18n,ca): 'Dades en brut' -> 'Dades sense processar'
The literal calque of 'Raw data' read as too technical for personal-
finance UI. 'Dades sense processar' is the more natural Catalan
equivalent for raw/unprocessed data files.
* fix(i18n): localize Import col_sep label + separator options
The CSV upload form rendered 'Col sep' (the auto-humanized attribute
name) plus hardcoded English 'Comma (,)' / 'Semicolon (;)' options
from Import::SEPARATORS.
- activerecord.attributes.import.col_sep added (en + ca: 'Column
separator' / 'Separador de columnes').
- Import.separator_options class method returns translated tuples;
view switched from Import::SEPARATORS to Import.separator_options.
- activerecord.attributes.import.col_seps.{comma,semicolon} added so
the option labels follow the active locale.
* fix(i18n,ca): drop moniker apposition in sharing/currencies section titles
- sharing_title 'Compartició de %{moniker}' rendered as 'Compartició
de Família' (a noun-noun apposition that's odd in CA) -> 'Compartició
de comptes'.
- sharing_subtitle replaced '%{moniker}' with 'entre els membres' so
the sentence reads naturally and doesn't depend on moniker gender.
- currencies_title 'Divises de %{moniker}' had the same apposition
-> 'Divises'. Subtitle no longer references moniker either.
* fix(i18n,ca): keep 'Self Hosting' untranslated
Reverted 'Autoallotjament' / 'autoallotjada' / 'autoallotjats' usages
to the original English 'Self Hosting' (sidebar label, breadcrumbs,
hostings page title, chat assistant settings hint, redis configuration
subheading, LLM usages cost-estimates description).
The brand-style term reads more naturally in EN for technical users
configuring their own deployment.
* fix(i18n,ca): lowercase 'self hosting' (sentence case in labels)
* fix(i18n): extract budget_categories stepper + allocation_progress strings
Hardcoded English strings on the budget category editor:
- 'Setup' / 'Categories' stepper labels in budgets/_budget_nav.html.erb
- 'X% set' / '> 100% set' / 'left to allocate' / 'Budget exceeded by ...'
in budget_categories/_allocation_progress.erb
- '/m avg' caption + 'Shared' placeholder + 'Leave empty to share
parent's budget' tooltip in budget_categories/_budget_category_form
and _uncategorized_budget_category_form
Extracted to:
- budgets.budget_nav.{setup,categories}
- budget_categories.allocation_progress.{percent_set,over_set,left_to_allocate,budget_exceeded_html}
- budget_categories.budget_category_form.{monthly_average,shared_placeholder,shared_title}
CA translations added; EN keys mirror the prior literals.
* chore(i18n): drop translation tooling from PR
These were dev-only helpers used during the Catalan translation pass:
- script/lt_check_ca.rb: LanguageTool API checker (premium/free
endpoint, picky mode, batching). Useful for ongoing locale QA but
shouldn't ship in this feature PR.
- lib/tasks/i18n_screenshot.rake: rake task that flips user.locale and
role on the demo user for walking the i18n surfaces locally.
Both stay available locally; pulled out of the PR scope.
* fix(i18n): apply PR review feedback (CodeRabbit + Codex)
- balance_reconciliation crypto_items: use :end_balance_crypto tooltip
(was :end_balance_investment). Added new UI.account.balance_reconciliation.tooltips.end_balance_crypto key in en + ca.
- doorkeeper.ca.yml confidentiality.no: was YAML boolean false, now string 'No'.
- views/categories: 'Poor contrast, choose darker color or' continued with hardcoded 'auto-adjust.' button text; extracted to categories.form.auto_adjust key (en + ca).
- imports.create.document_upload_failed: 'a l'magatzem' was broken
contraction -> 'al magatzem'.
- invitation_mailer body + mailer subject: 'unir-se' -> 'unir-te' (was
3rd person, should be 2nd to match the rest of the copy).
- 7 strings across mercury_items / sophtron_items / simplefin_items /
lunchflow_items / brex_items / indexa_capital_items / other_assets:
'se sincronitzaran' -> 'es sincronitzaran', 'se segueixen' ->
'es segueixen' (correct reflexive pronoun before consonants).
- settings.providers.status: key was 'false' (YAML-coerced), now 'off'
to match settings/en.yml status.off used in view lookups.
- sophtron_items.sophtron_setup_required.message: stripped trailing
blank line from the quoted scalar.
- settings/profiles/show.html.erb: switched 'family_moniker ==
"Group"' branch checks to 'Current.family&.moniker == "Group"'.
After Family#moniker_label started returning translated values,
callers using the display label for branching would render the
household copy for group families in ca. Compare the stored sentinel
instead.
- Did not apply CodeRabbit's webauthn 'eliminada' -> 'desada' suggestion:
the key is wired to the destroy action (verified at
settings/webauthn_credentials_controller.rb:55), so 'eliminada' is
correct.
Cloudflare preview entrypoint, FamilyResetJob, and the Settings
"Reset + load sample data" flow all go through generate_new_user_data_for!,
which seeded categories/accounts/transactions/budget but not goals. Move
generate_goals! inside this method (alongside the same call already in
generate_default_data!) so every sample-data surface gets the full
state-coverage matrix.
- Switch the goal_accounts → accounts FK from on_delete: :cascade to
:restrict. `Goal#must_have_at_least_one_linked_account` is enforced
at write time; the cascade let a raw DELETE silently orphan a Goal
whose only link pointed at the deleted account. Normal Rails
Account#destroy still cleans up via `dependent: :destroy`, but the
restrict guarantees the DB rejects any path that bypasses the
association.
- projection_payload: required_monthly is now monthly_target_amount&.to_f
so open-ended (no-target-date) goals serialize required_monthly: null
instead of 0, matching the absence of a required pace.
- index page + sidebar nav-rail dot now read the Beta label via
t("shared.beta") (and a new shared.beta locale key) instead of the
hardcoded "Beta" literal.
- _status_callout uses the view-helper t(...) instead of I18n.t(...)
for the status label so it follows the same convention as the rest
of the goals views.
- goal_projection_chart: read the computed style before stamping
position: relative so a stylesheet-defined position (fixed/sticky/
absolute) isn't clobbered.
- preview-deploy: add `set -euo pipefail` around the wrangler
container lookup so a curl/jq failure fails the job instead of
producing an empty CONTAINER_ID and silently skipping cleanup.
- Family#savings_inflow_windows wraps the current/prior 30d sums in a
single helper that memoizes the linked-account-id lookup. The KPI tile
on the goals index used to run the join+pluck twice per request.
- Replace two instance_variable_set pokes and one any_instance.stubs in
the goal/controller tests. Refetching the goal exercises the real
request lifecycle and stops the tests from leaning on implementation
details. The 'All caught up' assertion now relies on a real reached
state (target 1 vs the depository fixture's 5000 balance) rather than
stubbing :status.
- Add tests covering: hex format validation on Goal#color, AASM cache
reset (display_status reads the new state on the same instance after
pause!), negative pledge amount rejection, expire! no-op on already-
expired pledge, cancel! NotOpenError on non-open pledge, sweep job
idempotency on a second pass, and strong-params rejection of state /
family_id on goal create.
* fix(ibkr): resolve weekend balance oscillations and improve data processing
Address issues where IBKR weekend/holiday data caused incorrect balance
calculations and improve the robustness of IBKR account processing.
- Fix historical balance oscillations by ignoring anomalous weekend rows
and filling gaps by carrying forward the last known trading day value.
- Normalize report dates to the last trading day to ensure consistency.
- Improve `HoldingsProcessor` to skip individual bad lots instead of
failing the entire group.
- Refactor `ActivitiesProcessor` to accumulate fee counts locally via
return values instead of using instance variables.
- Add support for accounting parentheses notation in `DataHelpers`.
- Memoize the account object in `IbkrAccount::Processor` to reduce
database queries.
- Update tests to reflect date normalization and improved precision
assertions.
* fix(ibkr): derive historical cash from materializer balances, not equity summary
Real IBKR Flex exports do not include a reliable cash/stock breakdown in
EquitySummaryByReportDateInBase — only the total is consistently present.
The previous implementation parsed the missing cash field as zero and wrote
cash_balance=0 for every historical date, causing negative and wildly
incorrect cash values throughout the account history.
Instead, read the materializer's already-computed cash_balance for each date
(derived from holdings via the reverse calculator) and use only IBKR's total
as an authoritative balance anchor. This is consistent with how present-day
balances are handled and requires no weekend/holiday filtering since IBKR does
not emit weekend rows and holiday totals are legitimate data points.
Also accept equity summary rows without an explicit currency field (some Flex
configurations omit it) and explicitly reject BASE_SUMMARY aggregate rows.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* style: simplify boolean coercion in import_commission_transaction
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(ibkr): cover trailing weekend gap and align qty with valid lots
HistoricalBalancesSync: extend fill_gaps to account.current_anchor_date
so days after the last equity summary row (e.g. Saturday/Sunday when a
sync runs over the weekend) are also overridden rather than left with the
materializer's stale total=cash value.
HoldingsProcessor: replace separate quantity sum + weighted_cost_basis_for
with a single valid_lots method that computes both from the same set of
parseable lots. Previously a lot with a valid position but unparseable
cost_basis_price was excluded from the cost basis calculation but still
counted in quantity, producing inconsistent qty/cost_basis values.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* review: address PR feedback on ibkr fix branch
- Remove all "Fix N:" review-artifact comment labels
- Add Sentry.capture_message for silenced anchor repair failure so it surfaces in production monitoring
- Add Rails.logger.warn for zero/nil total rows skipped in HistoricalBalancesSync
- Document normalize_to_last_trading_day holiday limitation and why gap-fill covers it
- Rewrite two non-obvious comments to stand alone without the label prefix
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* style: remove alignment padding in balance_rows hash
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(ibkr): address two P1 review findings
- Allow zero and negative equity summary totals through HistoricalBalancesSync
so fully-liquidated and margin accounts are not silently skipped (which would
cause fill_gaps to propagate a stale non-zero total forward).
- Remove normalize_to_last_trading_day from HoldingsProcessor: shifting weekend
report_dates to Friday caused Balance::SyncCache#get_holdings_value to find
no holdings on Saturday/Sunday (exact-date lookup), collapsing non_cash to
zero — reintroducing the very oscillation the fix was meant to prevent.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* test(ibkr): add tests for historical balances sync and data helpers
- Add test case to verify non-cash balance calculation in historical balances sync
- Add test case to ensure rows with unparseable or nil totals are skipped
- Add new test file for IBKR data helpers
* fix(ibkr): prevent date range overflow during historical sync
Adjust the calculation of `last_date` in `HistoricalBalancesSync` to
ensure it does not exceed the current anchor date or today's date.
This prevents the sync process from attempting to fetch or process
future dates, which was causing oscillations in weekend data.
Also remove the conditional check for Sentry before capturing
error messages in the account processor.
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
- 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.
* add missing Hungarian translations for newly extracted strings
Replace hard-coded UI strings with I18n lookups across controllers, models and views (breadcrumbs, dashboard, reports, settings, transactions, balance sheet, MFA status). Update models to use translations for category defaults, account/display names, classification group and period labels; remove a few hardcoded display_name methods. Add and update numerous locale files (English and extensive Hungarian translations, plus model/view/doorkeeper entries) to provide the required keys. These changes centralize copy for localization and prepare the app for Hungarian/English UI text.
* Pluralize account type labels; tidy Crypto model
Update English locale account type labels to use plural forms for consistency (Investment(s), Properties, Vehicles, Other Assets, Credit Cards, Loans, Other Liabilities). Also remove an extra blank line in app/models/crypto.rb to tidy up formatting.
* Back to singular
* fix(i18n): separate singular and group account labels
* Update _accountable_group.html.erb
* Use I18n plural names for account types
Change Accountable#display_name to look up pluralized account type names via I18n (accounts.types_plural.<underscored_class>) with a fallback to the legacy display logic. Add legacy_display_name helper to preserve previous behavior (singular for Depository and Crypto, pluralized otherwise). Add corresponding types_plural entries in English and Hungarian locale files for various account types.
---------
Co-authored-by: Juan José Mata <jjmata@jjmata.com>
Co-authored-by: sure-admin <sure-admin@splashblot.com>
* 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>
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
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 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).
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.
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.
* 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.
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.
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>
Two audit fixes that pair well.
PF audit B20: pace, family velocity, and the funding widget's
30/90-day totals all summed Entry amounts over the linked accounts
*including provider-pending transactions*. A pending Plaid/SimpleFIN
deposit inflated pace today; the next sync that reversed or dropped
it silently shrunk pace tomorrow, with no signal to the user.
Worse, the reconciler could match a pending transaction and flip
the pledge to "matched" before the underlying entry vanished.
`.merge(Transaction.excluding_pending)` on the three Entry queries
(Goal#pace, Family#savings_inflow_velocity, the funding widget's
`inflow_totals_map`) brings the existing
`Transaction::PENDING_PROVIDERS`-aware scope into play. Single-line
fix across the three call sites.
UX audit: funding-account rows used `grid-cols-[24px_1fr_48px_120px]`
at every breakpoint. On a 375pt iPhone viewport that left ~50px for
the name column after `p-5` padding + container chrome — name
truncated to "Ban…" and the per-row % column squeezed against the
weight/totals stack. The percent number is also already encoded in
the distribution bar above the rows; on mobile it can disappear
without losing signal.
Drop the % column at < sm:
- mobile grid: `grid-cols-[24px_minmax(0,1fr)_auto]` (avatar / name /
totals)
- sm+: original 4-column layout with the per-row %
- per-row balance subline + accountable label now also drops `.00`
cents (consistency with the rest of the page).
The show page repeated the same data multiple times across surfaces
that should each say one thing once. Per-screen counts before this
commit:
- Account % distribution: 4 places (distribution bar + dot-legend
strip + 5-bar weight pill + % column)
- Current balance: 3 places (ring, funding heading total, ring
"of $X" subline)
- Target amount: 3 places (header, ring subline, catch-up body)
- Target date: 3 places (header, catch-up body, chart axis)
- Pace: 2 places (catch-up body, projection subtitle)
- ".00" cents: every monetary string
This pass:
- Funding widget drops the dot-legend strip (color/name/% triplet
redundant with the distribution bar's color + the per-row avatar
color) and the 5-bar weight pill (rendered as "1-of-5 sliver" for
low-weight accounts — read as a glitch; the % number next to it
covered the same fact). Row grid shrinks from 5 to 4 columns.
- Funding section heading drops `· $187,031` — the ring card
already carries the total balance.
- Catch-up alert reframes:
Title was "Save $26,621/mo to stay on track" (the *full* required
rate, with the misleading "stay on track" while the pill says
"Behind"). Now "Save $20,002/mo more to catch up" using
`catch_up_delta_money` — the user's actual delta over current
pace.
Body collapsed from two with-date / no-date variants to a single
"Current pace $X/mo · required $Y/mo to hit your target." Drops
the target date duplication since the header already says it.
Pledge CTA pre-fills with the *delta*, not the full required —
so accepting it once funds the gap instead of stacking the full
required rate on top of existing pace.
Secondary link "Or adjust your target" → "Adjust target instead"
(less defeatist framing).
- Projection chart subtitle "At $X/mo you'll miss your target date."
drops the pace duplication (catch-up above already states pace).
New: "Falling short at current pace." Diagnostic only.
- All money on the show page uses `format(precision: 0)`. The ".00"
cents added no information at goal-tracking scale.
- Header `Record pledge` demotes to `outline` variant when status is
`:behind` — the catch-up alert below owns the primary action.
One primary action per surface.
Also adjacent fixes:
- Funding widget keys avatar / distribution color off `account.id`,
not `account.name`. Renaming an account no longer recolors it
retroactively; two accounts with name-hash collisions no longer
share a color (Ruby idiom audit finding).
- `Goals::StatusPillComponent`: add `:completed` variant with
`circle-check-big` icon. `Goal#display_status` now returns
`:completed` when `goal.completed?` so a manually-completed
goal (e.g. user stopped at 80%) reads "Completed" rather than
falling through to `:on_track`/`:behind` and lying on the index.
Locale: drop `body_with_date` (folded into `body`),
`projection.behind` no longer carries interpolation args (caller
doesn't pass them either), `projection.no_pace` plain-language
rewrite ("inflow" → "deposits"), add `status.completed: "Completed"`.
Loose ends from the V1 → V2 refactor that the architecture commit
didn't sweep.
- Demo generator (B14): the `goal_spec[:contributions]` arrays
+ the `wedding_contribs` / `house_contribs` builders still
shipped in the file, but the seeding loop that consumed them
was deleted alongside `GoalContribution`. Dead data. Strip both
the per-goal arrays and the two locals. Goal balance/pace in
the demo family now derives from the linked depository
accounts' own seeded entries elsewhere in the generator.
- Goal stepper controller (B16): the `static targets` declaration
still listed `initialContributionAmount` and
`initialContributionAccountSelect`, and `refreshAccountSelect`
+ its two callsites still ran every time a linked-account
checkbox flipped. The HTML targets disappeared with the V2
stepper rebuild, so `has*Target` guards short-circuited and the
method was a no-op — but it was still dispatched on every
change. Drop the targets, the method, and the two callsites.
- Chart series rescue (B25): `Goal#balance_series_values` and
`FundingAccountsBreakdownComponent#sparkline_map` both swallowed
`StandardError` with a `Rails.logger.warn(…)`. The chart then
degraded to "target line only" silently. Promote the log to
`error` level and forward to Sentry when present (matching the
pattern in `Account::Syncer`, `Sync`, `PlaidItem`). Fallback to
empty result still preserved so the surface degrades instead of
500-ing.
The show template carried a 17-line `if/elsif` chain computing
`projection_summary` inline, plus a `Money.new([…, 0].max, …)`
expression building the catch-up delta on the fly. CLAUDE.md's
"skinny controllers, fat models" convention pushes both onto Goal.
- `Goal#projection_summary`: returns the localized,
`html_safe`-aware string for the chart subtitle and the chart's
`aria-description`. Memoized so the two callsites in show.html.erb
share one computation.
- `Goal#catch_up_delta_money`: clamped-at-zero monthly delta between
pace and the required monthly target. Used by the catch-up
callout body. Previously the view computed
`Money.new([req - pace, 0].max, currency)` — same math, but
duplicated inline.
show.html.erb drops both blocks and reads `@goal.projection_summary`
/ `@goal.catch_up_delta_money` directly.
Also: V15 — the celebration card used `bg-green-500/10` directly.
Swap to `bg-success/10` (DS semantic token, same Tailwind-4 alpha
syntax DS::Alert already uses) so the celebration palette tracks
the rest of the success surface.
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.