Data plane for Retirement v2 (no FIRE math yet — that is PR3). Five
migrations + four AR models, wired to Goal::Retirement.
Models:
- PensionSource — state/workplace/other source with country, pension
system, tax treatment, payout shape (string-backed + inclusion
validations rather than PG enums, so v2 can add countries without
ALTER TYPE). monetize :amount; end_age required for fixed-term.
- Goal::RetirementStatement — append-only audit journal. default_scope
excludes soft-deleted rows; soft_replace! does soft-delete + insert;
points_delta drives the "—"/signed Δ column; monetize against
projected_currency.
- Goal::RetirementAdjustment — signed today's-money deltas to the
spending target, ordered, applicable_at?(age).
- RetirementBucketEntry — account selection join, unique per plan,
same-family guard.
Goal::Retirement gains the four associations + bucket_accounts and an
ADJUSTMENTS_LIMIT (10) cap. retirement_params jsonb added to goals for
PR3 plan settings.
Namespaced fixture classes mapped via set_fixture_class so the
goal_retirement association resolves. Minimal fixtures + model tests
(112 runs green, incl. goal/family/controller regression sweep). No
new gems.
Lays the foundation for Retirement v2 as a preview feature stacked on
Goals v2. Math, lens UI, pension sources and bucket all defer to later
PRs; this PR ships only the data-model spine and a placeholder landing.
- STI on goals: add `type` (default "Goal") + `user_id` columns;
partial index for `Goal::Retirement` rows; check constraint
requiring an owner on retirement rows. Existing goals backfill to
`type='Goal'`; base `Goal#editable_by?` stays family-scoped.
- `Goal::Retirement` subclass with single-user owner and
`editable_by?` narrowed to owner-only. Parent depository-only
linked-account validations no-op'd; PR2 introduces
`RetirementBucketEntry`.
- `families.retirement_disabled` killswitch (default false) +
`Family#retirement_enabled?(user)` helper as tier 2 of the gate.
Tier 1 is the existing `PreviewGateable` flow.
- `RetirementController#show`: `require_preview_features!` then
`ensure_module_enabled!` then a placeholder body. Unknown to users
without preview features; 404 when the family killswitch is on (the
feature behaves as if it does not exist).
- Sidebar: new `sun`-icon entry after Goals, hidden unless the user has
preview features AND the family has retirement enabled, so the
killswitch hides the nav rather than leaving a link that 404s.
- Locales: EN copy for nav, breadcrumb, page header, placeholder body,
and the new `owner.must_belong_to_family` validation message under
the goal model. DE deferred to PR4.
- Tests: STI roundtrip, owner presence + family-membership
validations, `editable_by?` on both Goal and Goal::Retirement, gate
matrix on the controller, nav-item visibility under both preview and
family flags, base-row STI backfill.
Stack ahead: PR2 ships the data plane (PensionSource, statements,
adjustments, bucket entries); PR3 wires the `Retirement::Fire::*`
forecast engine + WHAT-IF Turbo Stream slider loop; PR4 lands the
single combined-page UI per Claude's 2026-05-29 design (glide chart
with hover-tooltip income breakdown, no separate stacked-area chart).
The 2-step stepper on the create modal carried a review step whose only
real signal was a derived "Save $X/mo to hit it on time" hint. Name,
amount, and date are all visible in step 1, so the review step was
re-displaying form values the user just typed.
Collapses both flows into a single panel:
- `_form_stepper.html.erb` + `_form_edit.html.erb` → single
`_form.html.erb` driven by `goal.persisted?` for URL / method /
submit label.
- `goal_stepper_controller.js` → `goal_form_controller.js`. Drops the
step1Panel / step2Panel / step1Indicator / step2Indicator /
step1Circle / step2Circle / stepperLine / reviewName / reviewSummary
/ reviewSuggested / footerLeftButton / footerRightButton / submitButton
target plumbing and the next / back / blockEnter / updateStepperState
/ updateFooter / updateReview methods. Keeps name-validation,
amount-validation, accounts-required validation, avatar-preview-from-
name, and the suggested-pace computation — that one now writes into
an inline `<p data-goal-form-target="suggested">` below the
target_date field instead of the review card.
- `new.html.erb`: drops the `Step 1 of 2 · Goal details` subtitle
target. New `goals.new.subtitle` replaces the two step subtitles.
- `edit.html.erb`: renders the same `form` partial.
- `_color_picker.html.erb`: `data-goal-stepper-target="avatarPreview"`
→ `data-goal-form-target="avatarPreview"` (same Stimulus target,
renamed for the new controller scope).
- `funding_accounts_breakdown_component.rb`: i18n key path moves to
`goals.form.subtypes.*` matching the locale restructure.
- `en.yml`: `goals.form_stepper.step1.fields.*` → `goals.form.fields.*`.
`step2.*` and the `back` / `continue` / `cancel` keys drop. New
`goals.form.create` ("Create goal") + `goals.form.save` ("Save
changes") drive the submit-button label.
UX delta: the user no longer sees a "Step 1 of 2 / Step 2 of 2" beat.
The form is short enough that everything fits in one panel; the only
value-add from the old step 2 — the suggested-pace hint — now updates
live inline as the amount / date / account-count changes.
All 20 `test/controllers/goals_controller_test.rb` tests still pass.
`bundle exec erb_lint` clean on the touched templates.
Pulls in #1857 (DS::Disclosure :card_inset), #1858 (:inline variant),
#1902 (DS::Pill marker:false + semantic tones + :red palette), #1903
(settings/debugs token fix), plus #1878 (entry.date guard) and other
minor fixes that landed.
Resolves one conflict in app/components/DS/pill.rb: takes main's new
extended API (marker: flag, SEMANTIC_TONE_ALIASES, :red tone, updated
docstring) and preserves the goals-branch color-mix(...30% black)
text treatment that was added for light-mode contrast. Applies the
same color-mix to the new :red tone for consistency.
* feat(design-system): add :card_inset variant + migrate ibkr_panel and settings/_section
Wraps up the disclosure migration cluster from #1715 §6:
1. **New `:card_inset` variant** on `DS::Disclosure`. Same contract
as `:card` but uses `bg-surface-inset rounded-xl p-4` (no shadow)
for inset sub-panels embedded inside a parent card surface.
2. **Migrate `_ibkr_panel.html.erb`** — the "flex query details"
disclosure (`<details class="group bg-surface-inset rounded-xl p-4">`)
was the one panel skipped from #1856 because it used the inset
surface. Now uses `DS::Disclosure(variant: :card_inset)`. Chevron
gets the `motion-safe:transition-transform motion-safe:duration-150`
treatment along the way.
3. **Migrate `settings/_section.html.erb`** — the global "collapsible
settings card" primitive backing 19 callsites via the
`settings_section(...)` helper. The collapsible branch's
`<details class="group bg-container shadow-border-xs rounded-xl p-4">`
becomes `DS::Disclosure(variant: :card, open: open, data: ...)`.
While here:
- Update `disclosure.html.erb` to spread `**opts` onto the `<details>`
element via `tag.details`. Previously opts were captured but never
applied; the `settings/_section` migration needs `data-controller`
+ `data-auto-open-param-value` to flow through to the rendered
`<details>`.
- Non-collapsible branch in `settings/_section.html.erb` stays as
raw `<section>` — different semantics (not expandable), DS::Disclosure
can't replace because it always renders `<details>`.
API:
DS::Disclosure.new(
variant: :card | :card_inset | :default,
open: bool,
data: { controller: "...", ... } # forwarded to <details>
)
* fix(review): merge caller class in DS::Disclosure + i18n plaid deletion
- DS::Disclosure: extract caller class: from opts and merge via class_names
before forwarding to tag.details. Prevents the latent duplicate keyword
arg error when callers pass class: alongside the variant-derived classes.
- plaid_items/_plaid_item: localize "(deletion in progress...)" via
t('.deletion_in_progress') + add en locale key, matching lunchflow /
mercury / sophtron / coinstats convention.
* fix(panels): replace text-white and bg-gray-tint-10 with semantic tokens
`text-white` → `text-inverse` on the EnableBanking reauthorize button
(`bg-warning` background); `bg-gray-tint-10` → `bg-container-inset` on
the IndexaCapital item avatar wrapper. Both flagged by sure-design as
non-functional palette tokens.
Pre-existing on main; surfaced by the re-indentation that this PR
applied during the disclosure migration.
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'.
* feat(dashboard): zoom into cashflow sankey categories
Click a category node on the dashboard cashflow Sankey to focus on it and
its descendants only; a back button restores the full view. Clicking the
Cash Flow node zooms to the expense (outbound) side.
- Pure utility (app/javascript/utils/sankey_zoom.js) computes the
descendant subgraph from a clicked node, with direction inferred by
reachability from the cash flow node (outbound for expense, inbound
for income).
- Stable node ids emitted from the controller so the JS can identify
nodes across re-renders.
- Stimulus controller adds chart + zoomOutButton targets, fade
transition, and only sets a pointer cursor when a node has children.
- Node:test coverage for expense, income, cash-flow, and malformed-data
cases; \"type\": \"module\" added to package.json so the .js util is
ESM-compatible under Node.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(dashboard): extract cashflow sankey chart partial
Deduplicate sankey chart markup between inline and expanded dialog views,
and reset zoom state when chart data changes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(js): rename sankey_zoom util to .mjs to drop project-wide ESM flag
Removes "type": "module" from package.json to avoid implicitly switching
every .js file in the project to ESM (a future footgun for any .js config
file added by Biome, Vite, etc.). Renames the utility to .mjs so node --test
can import the ES module directly, and adds an explicit importmap pin since
pin_all_from only globs .js/.jsm.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(assets): register .mjs MIME type for Propshaft
Propshaft derives Content-Type from Mime::Type.lookup_by_extension, which
returns nil for :mjs by default. Browsers refuse to execute ES modules
served with an empty Content-Type, breaking the sankey_zoom util loaded
via importmap.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(design-system): split DS::Menu into strict action-list + new DS::Popover for mixed content
Closes#1743.
DS::Menu used to absorb both action-list dropdowns (row context menus,
"more actions") AND mixed-content panels (user-account dropdown,
filter forms, picker pop-ups). The two shapes carry incompatible a11y
contracts:
- **Action list**: `role="menu"` container, `role="menuitem"` children,
Up/Down arrow nav per WAI-ARIA APG.
- **Mixed content**: NO menu role — `role="menu"` restricts AT users
to menuitem-only navigation and breaks any panel with forms,
headings, or generic groupings.
This PR splits the component:
## DS::Menu (tightened)
Strict action-list primitive. Variants reduced to `:icon` and
`:button` (no `:avatar`). `custom_content` slot removed. Bakes in:
- `role="menu"` on the panel, `aria-haspopup="menu"` +
`aria-expanded` + `aria-controls` on the trigger.
- `role="menuitem"` + `tabindex="-1"` on every DS::MenuItem; the
controller installs roving tabindex (first item gets `tabindex="0"`
when the menu opens) and handles ArrowUp/Down/Home/End +
Escape + Enter/Space activation.
- `role="separator"` on the divider variant.
- Stable per-instance `menu-<8-char hex>` id so the trigger's
`aria-controls` resolves correctly.
`DS::Menu.new(variant: :avatar, ...)` now raises ArgumentError
pointing at DS::Popover.
## DS::Popover (new)
Positioned panel for **mixed**, **non-action-list** content: account
menus, picker forms, filter forms, embedded controls. Slots: `button`,
`header`, `custom_content`. Variants: `:icon`, `:button`, `:avatar`.
NO `role="menu"` — the panel announces as a generic dialog-popup
(`aria-haspopup="dialog"`, `aria-expanded`, `aria-controls`).
Mirrors DS::Menu's floating-ui positioning + Escape/outside-click
lifecycle in its own Stimulus controller (`DS--popover`). Avatar
variant ships a focus ring + bumped touch target (44×44 via `w-11
h-11` per #1738).
## Migrated callsites (7 → DS::Popover)
- `app/views/users/_user_menu.html.erb` — avatar trigger + profile
header + nav links (items kept as DS::MenuItem inside
`custom_content` for visual parity)
- `app/views/categories/_menu.html.erb` — turbo-framed category picker
- `app/views/budgets/_budget_header.html.erb` — budget picker
- `app/views/reports/index.html.erb` — period picker
- `app/views/holdings/_cost_basis_cell.html.erb` — cost-basis edit form
- `app/views/transactions/searches/_form.html.erb` — filter form
- `app/components/UI/account/activity_feed.html.erb:70` — status
checkboxes (the row-level "new" menu on line 9 stays as DS::Menu)
The other 33 DS::Menu callsites stay as-is — pure action lists.
Locale: `ds.popover.avatar_default_label` + `users.user_menu.aria_label`
keys added (en only; other locales handled in a separate i18n pass).
* fix(test): update sidebar user-menu selector for Menu→Popover migration
The user-menu now renders as `DS::Popover` (variant: :avatar) instead
of `DS::Menu` after the menu split, so its trigger carries
`data-DS--popover-target="button"` rather than the old
`data-DS--menu-target`. Update the sidebar-driven settings test helper
to match — every system test that drives Settings via the sidebar
gates on this selector.
* fix(review): DS::Popover/Menu trigger a11y + caller-attr preservation
- popover.rb / menu.rb: button slot now merges (not overwrites) caller-
provided data and aria hashes, sets aria-haspopup/expanded/controls on
the :button variant, defaults type="button" on block-rendered buttons.
- menu.rb / menu.html.erb: drop renders_one :header (strict-menu API
shouldn't expose an arbitrary-markup escape hatch); preview updated.
- menu_controller.js: handle Enter/Space activation on focused menuitem
so keyboard navigation matches the ARIA menu pattern.
- cost_basis_cell / transactions/searches/_menu: retarget cancel button
data-action from DS--menu#close to DS--popover#close (host controller
changed in the migration).
* fix: apply CodeRabbit auto-fixes
Fixed 1 file(s) based on 1 unresolved review comment.
Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
* fix(review): MenuItem roving: false for DS::Popover usage
Codex P1 on #1850: \`DS::MenuItem\` hard-codes \`tabindex=\"-1\"\` and
\`role=\"menuitem\"\` for both link and button variants — correct
inside \`DS::Menu\` (which provides arrow-key roving and announces
\`role=\"menu\"\`), but breaks every \`DS::MenuItem\` rendered inside
\`DS::Popover\` (\`app/views/users/_user_menu.html.erb\`). Popover has
no roving handler, so Tab skips every item — Settings, Changelog,
Feedback, Contact, Log out become keyboard-unreachable.
Add a \`roving:\` keyword (default \`true\`) to \`DS::MenuItem\` that
gates both \`tabindex=\"-1\"\` and \`role=\"menuitem\"\`. \`DS::Menu\`
callers keep the default (roving menu semantics intact). Pass
\`roving: false\` from \`_user_menu.html.erb\` so user-menu items land
in the normal Tab order. Existing \`menu.with_item(...)\` callers in
the design system still default to \`true\`, so no behavior change for
\`DS::Menu\` consumers.
* fix(review): make menuitem_attrs authoritative on roving
CodeRabbit Major on #1850: \`merged_opts\` was splatted AFTER
\`menuitem_attrs\` in \`DS::MenuItem#wrapper\`, so a stray
\`role: :button\` or \`tabindex: 0\` from a \`menu.with_item(..., role: …)\`
caller could silently downgrade the \`DS::Menu\` ARIA contract that
\`menuitem_attrs\` enforces.
Strip \`:role\` and \`:tabindex\` from \`merged_opts\` whenever
\`roving\` is enabled, then splat \`menuitem_attrs\` last. When
\`roving: false\` (popover usage in \`_user_menu.html.erb\`) callers
keep full control — Tab order and explicit ARIA stay tunable by the
caller.
---------
Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
* fix(design-system): DS::Tooltip a11y — focusable trigger, keyboard parity, Esc dismiss
Closes#1747. Five fixes on the tooltip primitive.
1. **Tooltip anchor not in a11y tree.** The trigger was a bare
Lucide icon, which Lucide renders with `aria-hidden="true"`.
The tooltip target had `role="tooltip"` but nothing referenced
it, so AT users had no way to discover the description. Wrap
the icon in a focusable `<button type="button">` with
`aria-describedby="<tooltip-id>"` so the underlying icon stays
`aria-hidden` and the button picks up the description binding.
2. **Stable per-instance id.** Each DS::Tooltip now mints a
`tooltip-<8-char hex>` id wired between the trigger's
`aria-describedby` and the tooltip's `id`.
3. **Keyboard parity.** Hover-only triggers locked keyboard-only
users out. Add `focusin` / `focusout` listeners on the
controller element so Tab onto the trigger reveals the
tooltip, Tab away dismisses it.
4. **Esc-to-dismiss.** Matches the WAI-ARIA tooltip pattern.
`Escape` while the tooltip is open closes it without removing
focus from the trigger.
5. **Resize-safe width cap.** Replace the hard-coded
`max-w-[200px]` with `max-w-[20rem]` so the tooltip scales
with the user's root font-size setting (large-text accessibility
pref). Slightly wider visual cap (320px @ default) but no longer
clips on text-zoom.
Plus: docstring note that tooltip content must be non-interactive
(no buttons / links / form controls inside) — `aria-describedby`
exposes content as a description, not as an interactive subtree.
Callers needing actions should reach for a popover/menu primitive.
API unchanged. Existing 30+ DS::Tooltip callsites work without
modification — they all pass `text:`-only payloads, which still
render correctly under the new markup.
* fix(review): as: option + alpha focus-ring on DS::Tooltip
Addresses two AI review findings on #1845:
1. **Button-inside-summary spec violation.** Wrapping the icon in
`<button>` regressed keyboard/AT behavior at 13 callsites where
DS::Tooltip lives inside a `<summary>` (8 provider items, lunchflow
disclosure, activity_date, 4 simplefin badges). HTML's content
model forbids interactive content inside `<summary>`; browsers
and AT can drop focus or conflate activation with the disclosure
toggle. Add `as:` parameter — default `:button` preserves the
standalone a11y wrap; `:span` renders a non-focusable wrapper for
summary-nested usage. `focusin` bubbles up to the controller from
the ancestor `<summary>`, so keyboard tooltips still appear on
tab. Migrate the 13 in-summary callsites to `as: :span`.
2. **Raw palette focus ring → alpha tokens.** Swap
`outline-gray-900 theme-dark:focus-visible:outline-white` to the
established focus-ring pattern `focus-visible:ring-2
focus-visible:ring-alpha-black-300
theme-dark:focus-visible:ring-alpha-white-300` — matches the
DS::Toggle fix landed in #1843 review and provider_card /
form-field tokens.
* fix(review): bind tooltip focus on ancestor <summary>
Codex P2 follow-up on #1845: \`as: :span\` renders a non-focusable
trigger inside the disclosure \`<summary>\`. Keyboard users hit Tab
and focus lands on the summary itself; \`focusin\` fires on the
summary and bubbles UP — never down to a descendant span — so the
existing listener on \`this.element\` never fires and the tooltip
stays hidden for keyboard-only users on every in-summary row
(provider _item partials, lunchflow disclosure, activity_date,
simplefin badges). My earlier reply that the focusin "bubbles up to
the Stimulus controller on the outer span" was wrong about the
direction; \`focusin\` only bubbles upward.
In \`addEventListeners\`, resolve \`this.element.closest("summary")\`
and bind \`focusin\` / \`focusout\` / \`keydown\` on it too. Track the
ancestor on the controller and undo the bindings in
\`removeEventListeners\` so reconnect-on-Turbo cycles don't leak.
Update the template comment to reflect the actual mechanism.
* docs(ds-tooltip): correct as=:span comment to match controller mechanism
---------
Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
* fix(design-system): DS::Link a11y — distinguishable default,
icon-only label, external-link hardening
Closes#1739. DS::Link extends Buttonish, so the styled variants
(`:primary`, `:secondary`, `:icon`, `:ghost`, etc.) inherit the
Buttonish styling pipeline. The `default` variant is the bare
inline link, which had multiple a11y gaps:
1. **WCAG 1.4.1 — color is not the only difference.** The default
variant had `container_classes: ""`, so a link rendered as plain
text-color text with no underline, no weight change, nothing.
Color-only differentiation fails WCAG 1.4.1 for low-vision and
colorblind users. Now: `text-link underline underline-offset-2
hover:no-underline` — underlined at rest, underline removed on
hover for a polish hint, plus the `text-link` token (blue-600
light / blue-500 dark) for color.
2. **Focus ring.** `<a>` doesn't pick up the `button` focus rule
from base.css (#1738). Add `focus-visible:outline-2
outline-offset-2 outline-gray-900 theme-dark:outline-white`
directly on the default variant. The Buttonish-derived variants
render as buttons visually but as `<a>` in markup — out of scope
here; covered by their own callsites styling.
3. **Icon-only accessible name.** Mirror the DS::Button fix from
#1738: derive a humanized `aria-label` from the icon key when
the caller doesn't provide one, so AT users hear "More
horizontal" instead of just the URL.
4. **External-link hardening.** `target="_blank"` without
`rel="noopener"` exposes `window.opener` to the new tab
(reverse-tabnabbing). Always set `noopener noreferrer` when the
target is `_blank`. Authors can override by passing `rel:`
explicitly.
5. **sr-only "(opens in new tab)" hint.** Append an `sr-only` span
after the link text when `target="_blank"` so AT users hear the
navigation behavior. Visual indication (e.g. an external-link
icon) stays at the caller's discretion.
Locale key: `ds.link.opens_in_new_tab` (en only — other locales in
a separate translation pass per repo norm).
API unchanged. No existing callsites use `target="_blank"` or
icon-only links, so no migration needed.
* fix(review): fold new-tab cue into icon-only aria-label
When an icon-only DS::Link also targets `_blank`, the generated
`aria-label` was overriding the descendant accessible name, masking
the sr-only "(opens in new tab)" span. Include the cue directly in
the generated label so AT users hear the warning. Also switch
`capitalize` to `humanize` so multi-word icon keys like
`external-link` read as "External link" rather than "External link"
already worked but `humanize` is the more idiomatic Rails choice and
keeps us aligned with the suggested patch.
Flagged by Codex P2 + CodeRabbit on PR #1844.
* fix(review): swap raw outline palette to alpha-ring tokens
Codex P1 follow-up after the ready-for-review transition: the default
\`DS::Link\` focus ring used raw \`outline-gray-900\` +
\`theme-dark:focus-visible:outline-white\`, which violates the DS-hygiene
rule that bans raw Tailwind palette utilities in component styling.
Swap to the established alpha-ring pattern already used by DS::Toggle
(#1843), DS::Tooltip (#1845), provider_card, and form-field —
\`focus-visible:ring-2 focus-visible:ring-alpha-black-300\` +
\`theme-dark:focus-visible:ring-alpha-white-300\`. Same visual contract
(WCAG 1.4.11), theme tokens centralized.
* refactor(design-system): migrate 9 hand-rolled buttons with orphan btn-- classes to DS::Button / DS::Link
Part of #1715 §5. The `btn`, `btn--primary`, `btn--outline`, `btn--ghost`,
`btn--sm` CSS classes have no backing styles anywhere in the codebase
(no .btn definition in app/assets/, no Bootstrap dependency). These
callsites have been rendering unstyled buttons / links since the
underlying CSS was last removed.
Migrate the 9 broken callsites:
- `app/views/transactions/show.html.erb` — duplicate-merge action
buttons (×2): `button_to ... class: "btn btn--primary btn--sm"` /
`class: "btn btn--outline btn--sm"` → DS::Button with href +
variant + size + `data: { turbo_method: :post }`.
- `app/views/snaptrade_items/select_existing_account.html.erb` —
"Go to Provider Settings" link → DS::Link primary sm.
- `app/views/indexa_capital_items/select_existing_account.html.erb` —
same pattern → DS::Link primary sm.
- `app/views/import/confirms/show.html.erb` — Publish button +
Cancel link → DS::Button primary full-width + DS::Link ghost
full-width.
- `app/views/simplefin_items/new.html.erb` — Cancel link
(`class: "btn"` only) + Connect submit → DS::Link secondary +
bare `f.submit` (already routes to DS::Button via
StyledFormBuilder).
- `app/views/settings/providers/_ibkr_panel.html.erb`,
`_snaptrade_panel.html.erb`,
`_indexa_capital_panel.html.erb` — strip the orphan
`class: "btn btn--primary"` from `f.submit` callers; the submit
is already a styled DS::Button via the form builder.
The next PR in this chain (Phase B) will tackle the larger inline-
button cluster (~29 files, 38 instances) — provider panels and
provider-item flows hand-rolling the same
`inline-flex items-center justify-center rounded-lg px-4 py-2
text-sm font-medium text-inverse bg-inverse hover:bg-inverse-hover
focus:outline-none focus:ring-2 focus:ring-primary transition-colors`
string.
* refactor(design-system): migrate 38 hand-rolled provider buttons to DS::Button / DS::Link (#1715 §5 part B)
Bulk sweep of the second cluster from §5. 29 files, 38 button
instances — each one hand-rolled the same long Tailwind string for
the primary action button:
inline-flex items-center justify-center rounded-lg px-4 py-2
text-sm font-medium text-inverse bg-inverse hover:bg-inverse-hover
focus:outline-none focus:ring-2 focus:ring-primary transition-colors
(some variations used `button-bg-primary hover:button-bg-primary-hover`
instead of `bg-inverse hover:bg-inverse-hover` — same intent).
Every instance is now a DS::Button / DS::Link with `variant: :primary`,
which:
- Picks up the new focus-ring + touch-target work from #1840 once
that merges.
- Stops duplicating the long Tailwind string across 29 files —
single source of truth in `DS::Buttonish::VARIANTS[:primary]`.
- Picks up consistent `aria-label` derivation for icon-only forms.
- Removes the misnamed `focus:ring-primary` (no token) — the new
ring comes from `base.css` automatically.
Migration patterns applied:
- `f.submit text, class: "inline-flex …"` inside `styled_form_with`
→ bare `<%= f.submit text %>`. StyledFormBuilder routes through
DS::Button.
- `link_to text, path, class: "inline-flex …"` → DS::Link primary.
- `button_to text, path, method: :X, class: "inline-flex …"` →
DS::Button with `href: path` and `data: { turbo_method: :X }`.
- `submit_tag text, class: "inline-flex …"` inside raw `form_with`
→ DS::Button with `type: :submit`.
Notable adjustments:
- `holdings/show.html.erb` — the form was `form_with` (not styled).
Switched to `styled_form_with` so `f.submit` routes through
DS::Button. `f.combobox` (hotwire_combobox) still works through
the styled builder.
- Two `link_to settings_providers_path` callsites in
`coinstats_items/new.html.erb` + `enable_banking_items/new.html.erb`
had `w-full inline-flex … hidden md:inline-flex` — the responsive
pair conflicted (both `inline-flex` and `hidden md:inline-flex`
on the same element). Migrated to `full_width: true` without the
responsive split; the buttons now render at all breakpoints
consistently. (Pre-existing copy-paste bug, fixed in passing.)
- `enable_banking_panel` add-connection button gained
`icon: "plus"` via the DS::Button API; the explicit `gap-2 …
icon "plus"` markup is now redundant.
Sibling buttons that don't match the primary spec (destructive
trash, secondary outline-bordered, button-bg-secondary-strong on
holdings/show.html.erb, etc.) are intentionally left alone — they
need their own audit pass once #1840 lands and the focus-ring
behavior on those variants is stable.
* fix(review): restore SimpleFIN submit styling + i18n provider_form label
- SimpleFIN new modal: switch form_with -> styled_form_with so f.submit
picks up the DS::Button render via styled builder (Codex #1860).
- _provider_form: replace hardcoded "Save and connect" with t(".save_and_connect")
and add scoped key under settings.providers.provider_form (CodeRabbit).
* 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>
* i18n(en): drop %{count} interpolation in sso_providers errors_title 'one' branch
Per @jjmata review feedback on #1830: when count == 1, English reads
more naturally as 'One error prohibited...' than '1 error prohibited...'.
The 'other' branch keeps %{count} so '2 errors...', '3 errors...' still
interpolate.
* Update en.yml
Signed-off-by: Guillem Arias Fauste <accounts@gariasf.com>
---------
Signed-off-by: Guillem Arias Fauste <accounts@gariasf.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.
- Pending pledges surface in the card footer as '· N pending' tacked
on after the existing footer line (text-subdued). Quiet, semantic,
doesn't compete with the status pill or the avatar.
- The top-of-page 'You have pending pledges' callout was using the
amber DS::Alert warning variant. Pending isn't a warning — it's a
passive 'we're waiting on a sync' state. Switch to the info
variant so the visual weight matches the meaning.
Goals with open + unexpired pledges now carry a small amber DS::Pill
dot at the top-right of the avatar on the index card. Same primitive
+ position pattern as the beta gate dot on the sidebar nav, so the
'small marker' affordance reads consistently across the app.
Pledges are preloaded via the existing .includes(:open_pledges, ...) on
the index query, so the indicator is free at request time.
- 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.
- Color picker had four hardcoded English strings ("Color", "Icon",
"Poor contrast, choose darker color or", "auto-adjust."). Move them
under `goals.color_picker.*` and call them through `t()`. CLAUDE.md
requires every user-facing string go through i18n.
- Status pill duplicated its visible label in `aria-label`, which makes
screen readers ignore the visible text. Drop the override so the
visible label is the accessible name.
* 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>
When every active goal already hit its target, the "Goals on track"
tile read "0 of 2 · 2 reached" — logically correct but emotionally
upside-down. Reached goals aren't being tracked toward pace anymore;
they belong in the trophy column, not in the fraction.
- New `tracked_total` excludes reached and paused goals from the
denominator. Paused stops the pace clock on purpose; reached has
already cleared it.
- When `tracked_total` hits zero and at least one goal is reached, the
tile swaps to a celebratory empty state ("All caught up · N reached")
instead of trying to render a fraction with no denominator.
- Drop "reached" from the subline when the fraction is calculable. The
fraction is a needle, "N reached" is a trophy — surfacing them
together muddied the message. Reached only appears in the all-caught-
up empty state from here on.
Active-first / reached-last grid order already drops out of the
existing ACTIVE_STATUS_RANK sort (reached defaults to the lowest rank
so it naturally lands after behind / on_track / no_target_date /
paused).
Three issues raised on PR #1798 review:
- ProviderImportAdapter now memoizes account.goal_accounts.exists?
per-account so a bulk historical import on an unlinked account
short-circuits the reconciler instead of paying one SELECT per row.
Linked accounts still hit the per-row reconciler with no change.
- goal_projection_chart_controller.js reads Today / Projected /
Saved labels via Stimulus values fed from
goals.show.projection.* locale keys instead of inlining English.
- goal_test.rb now covers Goal#pace with real inflows, asserting
the 90-day window cutoff plus the Transaction.excluding_pending
and entries.excluded = false filters.
* Extract hardcoded strings to i18n
Replace numerous hardcoded English strings with I18n lookups (t / I18n.t) across controllers, views, helpers, and components, and convert model validation error messages to symbol keys. Added multiple locale files under config/locales for models and views. This centralizes user-facing notices/alerts, UI text, import/validation messages, and prepares the app for localization and easier translation maintenance.
* Update en.yml
* Update preview-cleanup.yml
* Revert "Update preview-cleanup.yml"
This reverts commit 1ba6d3c34c.
* test: align i18n assertions with translated messages
* Standardize balance error key and tweak locales
Replace SophtronAccount's :requires_balance error key with :no_balance and update related locale strings for sophtron, plaid, and simplefin accounts to use the new key and clearer copy. Also switch the QIF upload redirect notice to use a relative translation key (t('.qif_uploaded')), remove an unused SSO providers help line, and fix a trailing-newline/whitespace issue in the subscriptions locale. These changes standardize validation keys and improve translation consistency and messaging.
---------
Co-authored-by: KiloClaw <kiloclaw@openclaw.ai>
* feat: add Cloudflare Containers PR preview deployments
Add GitHub workflows to automatically deploy PRs to Cloudflare
Containers after tests pass, with automatic cleanup after 24 hours.
Components:
- workers/preview/: Cloudflare Worker entry point that routes
traffic to the Rails container
- preview-deploy.yml: Deploys PRs after CI passes, comments
preview URL on PR
- preview-cleanup.yml: Cleans up previews on PR close or after
24 hours via scheduled job
The container sleeps after 30 minutes of inactivity and wakes
automatically on the next request.
Required secrets:
- CLOUDFLARE_API_TOKEN
- CLOUDFLARE_ACCOUNT_ID
- CLOUDFLARE_WORKERS_SUBDOMAIN
https://claude.ai/code/session_013EZuzBxWPEEYp3TQptXWdP
* fix: use development environment with embedded PostgreSQL for previews
- Add preview-specific Dockerfile with PostgreSQL server included
- Add docker-entrypoint.sh to start PostgreSQL and run migrations
- Change RAILS_ENV from production to development
- Auto-generate SECRET_KEY_BASE and DATABASE_URL for self-contained previews
https://claude.ai/code/session_013EZuzBxWPEEYp3TQptXWdP
* feat: add Redis to preview container
- Install redis-server in the preview Dockerfile
- Start Redis in the entrypoint before PostgreSQL
- Auto-configure REDIS_URL for Sidekiq background jobs
https://claude.ai/code/session_013EZuzBxWPEEYp3TQptXWdP
* fix: mark GitHub deployment inactive on manual PR cleanup
When using workflow_dispatch with a specific pr_number, the workflow
now also marks the associated GitHub deployment as inactive, mirroring
the behavior of the batch cleanup path.
https://claude.ai/code/session_013EZuzBxWPEEYp3TQptXWdP
* fix: remove npm cache config that requires missing lockfile
The setup-node action's cache feature requires a package-lock.json
which doesn't exist in workers/preview/. Remove the cache configuration
to fix the workflow.
https://claude.ai/code/session_013EZuzBxWPEEYp3TQptXWdP
* fix: only update deployment status when deployment ID exists
Add condition to check steps.deployment.outputs.result exists before
attempting to update deployment status. This prevents a JavaScript
syntax error when the deployment step fails and no ID is available.
https://claude.ai/code/session_013EZuzBxWPEEYp3TQptXWdP
* fix: quote shell variables to fix SC2086 shellcheck warning
Quote the --var argument and GITHUB_OUTPUT redirection to prevent
word splitting issues.
https://claude.ai/code/session_013EZuzBxWPEEYp3TQptXWdP
* fix: add permissions for deployment status operations
Add deployments: write permission to the cleanup workflow so the
GITHUB_TOKEN can list and update deployment statuses.
https://claude.ai/code/session_013EZuzBxWPEEYp3TQptXWdP
* fix: specify build context for Dockerfile in wrangler config
Use object syntax for image config to set build context to repository
root, allowing the Dockerfile to reference files from both the root
(Gemfile, .ruby-version) and workers/preview/ (docker-entrypoint.sh).
https://claude.ai/code/session_013EZuzBxWPEEYp3TQptXWdP
* fix: run wrangler from repo root for correct build context
- Update workflow to run wrangler with --config flag from repo root
- Update wrangler.toml paths (main, image) to be relative to repo root
- Embed entrypoint script directly in Dockerfile using heredoc
- Remove separate docker-entrypoint.sh file
This ensures the Docker build context includes Gemfile, .ruby-version,
and other files at the repo root.
https://claude.ai/code/session_013EZuzBxWPEEYp3TQptXWdP
* fix: move preview Dockerfile to repo root for correct build context
Wrangler resolves paths relative to the config file, not the current
directory. Moving Dockerfile.preview to repo root ensures:
- Build context is the repo root (where Gemfile, .ruby-version are)
- Path in wrangler.toml is ../../Dockerfile.preview (relative to config)
- Worker runs from workers/preview/ directory again
https://claude.ai/code/session_013EZuzBxWPEEYp3TQptXWdP
* fix: use find to locate pg_hba.conf instead of glob in redirection
Shell glob patterns don't work with redirection operators. Use find
to locate the actual pg_hba.conf path before writing to it.
https://claude.ai/code/session_013EZuzBxWPEEYp3TQptXWdP
* fix: enable workers_dev for preview deployments
Add workers_dev = true to make the preview worker accessible via
the workers.dev subdomain.
https://claude.ai/code/session_013EZuzBxWPEEYp3TQptXWdP
* feat: enable observability for container logs
https://claude.ai/code/session_013EZuzBxWPEEYp3TQptXWdP
* fix preview container boot path
* fix: set preview container startup command explicitly
* fix: update preview worker compatibility date
* chore: expose preview container diagnostics
* fix: recover from stale preview container state
* fix: harden preview container startup paths
* chore: report preview startup stages
* fix: bypass stale container helper state during recovery
* fix: allow longer preview container startup
* fix: upgrade preview container runtime
* fix: use supported node version for preview deploy
* fix: use public container startup flow
* fix: simplify preview container startup
* chore: retain preview container diagnostic history
* fix: bypass systemctl redirect for postgres startup
* chore: probe rails readiness from inside preview container
* chore: capture rails process and port diagnostics
* chore: capture rails startup logs on preview timeout
* fix: align preview bind behavior with ipv6 startup model
* chore: capture preview socket state on rails timeout
* chore: capture rails wait state and child processes
* fix: launch preview with puma directly
* fix: run preview in production mode
* chore: probe preview app boot before puma
* fix: disable lookbook routes in production preview
* chore: capture ruby backtrace from hung boot probe
* fix: disable bootsnap in preview runtime
* fix: disable sidekiq web routes in production preview
* chore: trace hung preview boot probe with strace
* fix: json-escape preview telemetry payloads
* fix: pass preview telemetry env vars correctly
* chore: signal ruby child for preview boot backtrace
* fix: allow longer preview cold-start budget
* fix: skip sidekiq web requires in production preview
* chore: deploy hello world preview container
* fix(preview): restore rails image without redundant warmup
* feat(preview): seed demo dataset on boot
* ci(preview): require preview-cf label
* ci(preview): reuse pr workflow checks
* fix(preview): avoid clearing demo data in production boot
* fix(preview): tolerate already-running postgres on boot
* fix(preview): check demo user via psql during boot
* fix(preview): defer heavy demo seed until after boot
* fix(preview): move demo-user creation after rails boot
* fix(preview): fail fast on container lifecycle errors
* fix(preview): validate manual cleanup pr input
* fix(preview): parameterize preview pr number
* ci(preview): use setup-node v6
---------
Co-authored-by: Claude <noreply@anthropic.com>
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.
Completed goals previously lived in a dedicated section below the
active grid that was always visible regardless of which chip the
user selected. They were the only state without chip filter
representation.
Fold completed into the main grid in controller-side order:
active goals first (sorted by status rank), completed after
(alphabetical). Drop the separate "Completed" section. The
`data-goal-status="completed"` on each card (from
`Goal#display_status`) makes them filter naturally when the new
`completed` chip is selected.
Archived stays in its own collapsed-by-default `<details>` section
below — the visual-hide-by-default is the point there and a chip
wouldn't preserve that.
`@active_goals` keeps its meaning (active-only) for the KPI strip,
the pending-pledges callout, and the search-visibility check
needs `@grid_goals` so search shows up at six combined cards.
Section heading: "Ongoing" → "Goals". The heading now covers the
combined active + completed list, and "Ongoing" misrepresented
what's below it.
Second pass on user-facing strings after the em-dash sweep and
yellow-pill demotion. Voice/abbreviation/edge-value parity.
Voice consistency:
- `index.pending_pledges_callout` reframed from "Sure is watching
your linked accounts" (system-as-watcher voice) to "You have
pending pledges. Sure will confirm them on the next sync."
(user-actor, system-action). Matches the surrounding
user-centric voice on the KPI strip and the helper-text pattern
("Sure will look for…", "Sure will catch it") used elsewhere.
- `goal_pledges.new.helper_manual` flipped pronoun "We'll record"
to "Sure will record" so the modal's two helper lines share a
single narrator. The transfer-helper already says "Sure will
look for"; this matches.
- `form_stepper.errors.*` dropped the apologetic "Please …" voice
("Please give your goal a name.") for the terse imperative
the rest of the feature uses ("Give your goal a name." / "Set
a target above zero." / "Pick at least one funding account.").
Parallelism:
- `kpi.velocity_delta_zero_base` was the only `velocity_delta_*`
string spelling out "30 days" while siblings used `30d`. Switch
to "First 30d of activity" so the sub-tile reads in one unit.
- `Depository` titlecase in `at_least_one_linked_account_required`,
`must_be_depository`, and `no_depository_accounts` collapsed to
lowercase. Common noun, not a UI label. Matches the empty-state
body in `funding_accounts.empty.body` which was already lowercase.
Test fixture for `must_be_depository` updated.
- `projection.reached` was the same string as `celebration.heading`
("Goal reached. Nice work."), making the celebration moment feel
templated. The projection slot is the chart's empty state when
there's nothing to project; rephrase to "You've hit the target.
No projection needed." Celebration keeps the warm tone.
Edge value:
- `celebration.body` was "You hit your $X target." When the user
marks a goal complete at sub-100% (a flow the new
`confirm_complete_body_short` already warns about), this lied
about the achievement. Rewrite to "Goal closed at %{saved} of
%{target}. Keep it as a record, or archive it now." Interpolation
now passes both `saved` and `target` from the show template, so
the celebration card honors the actual saved amount whether the
user hit, overshot, or stopped short.
Notes deferred (verify-only, not string changes):
- `goal_card.footer_catch_up` is interpolated with
`catch_up_delta_money` in `CardComponent#footer_line`; the show-
page guard `.amount.positive?` already lives there. No copy
change needed.
- `pending_pledge.title.zero` bucket fires only when `count: 0`
reaches the I18n call; `GoalPledge#days_left` clamps at 0, so
the friendlier "expires today" copy is reachable.
- `paused_banner.title` / `inactive.heading_paused` duplicate
strings noted but left in place; consolidation is a separate
refactor.
Behavioural + RUI audit follow-ups.
The yellow overload finding flagged three concurrent yellow surfaces
on the show page: the "Behind" status pill, the catch-up alert, and
the open-pledge banner(s). Demoting the alert to outline ownership
of the primary CTA addressed one layer, but the pill kept fighting
the alert for hue attention. "Behind" is a state, not a call to
action; the alert owns the action signal.
Switch the pill's classes from `bg-yellow-500/10 text-yellow-700`
to `bg-surface-inset text-yellow-700` (with the same dark-mode
override). Background goes neutral (matches paused/archived chips);
the text keeps the warning hue and the triangle-alert icon stays.
Signal preserved, weight reduced. The yellow alert below now reads
as the primary nudge instead of one of three matching tones.
Also: copy/em-dash sweep across goal surfaces. User-facing strings
that contained em-dashes ("Reaches 70% — $X of $Y", "into your
linked account — Sure will catch it", "You're at 80% — $X of $Y")
read as a stylistic tic; replace with comma/period/period
respectively. Form-stepper review placeholders "—" become "…"
(ellipsis reads as "not yet set" without the typographic weight).
Code comments + log messages also scrubbed for consistency; awkward
sed artifacts (//. its...) restored to readable English.
No locale-key shape changes; pure string-content edits + one
component-style tweak.
Behavioural audit edge case. A user clicking "Mark complete" at
80% saw the same generic confirm body as at 105% ("It leaves the
Ongoing list…"). Sunk-cost-fallacy inversion + premature closure:
once labelled complete, the goal anchors success on the truncated
amount; future similar goals get smaller targets (regression to
the lower aspiration).
When `progress_percent < 100`, swap the confirm body to a
specific one — "You're at 80% — $X of $Y. Marking complete records
this as your achievement instead of the original target. Continue,
or close this and adjust the target instead?" Doesn't block the
action (some "stop short" cases are healthy — CFP literature
explicitly endorses changing your mind), but makes the trade-off
visible. Keep the original copy for the ≥ 100% case.
`confirm_complete_body_short` is a new locale key; the kebab-menu
builder picks between it and `confirm_complete_body` per-render.
* Add blocked count to rule run summary
* test(rules): cover rule run blocked counts
* fix(rules): derive blocked count from modified rows
Blocked rule transactions are the processed rows that were not modified. This keeps the displayed queued / processed / modified / blocked summary aligned when a run has already processed all matching rows but some were skipped by enrichment locks.
* fix(rules): count processed rows for rule jobs
Synchronous rule actions return the number of rows they modified, but rule-run processed counts should represent the number of matched transactions the job attempted to process. Using queued matches for processed preserves the distinction between processed and modified rows, which lets locked manual edits appear as blocked instead of making processed collapse to modified.
This changes RuleJob counter semantics, so it was committed separately from the derived blocked-count display change.
UX audit pair.
Header "Last saved N days ago" was rendered from
`Goal#last_matched_pledge_at` — date of the most recent
*pledge-matched* entry — but the funding column on the same page
shows "$X last 30d" computed off *all* entries. A user who
deposited cash without recording a pledge saw "Funding · last 30d:
$200" while the header still read "Last saved 47 days ago." Two
adjacent figures contradicted each other.
Rename the locale strings (used on both the index card and the
show header) to reflect the actual data source:
- footer_no_pledges: "No matched pledges yet"
- footer_last_today: "Last pledge matched today"
- footer_last_days: "Last pledge matched N days ago"
Two open pledges into the same account previously rendered as
two near-identical yellow banners with no way to tell which one
the Cancel button targeted. Add a relative-time line below the
body — "Pledged 2 hours ago" / "Pledged about a minute ago" —
using `time_ago_in_words(pledge.created_at)`. Discriminator without
changing the title or the action surface.