* make default of opening_balance_date_label is TODAY
* feat(i18n): add multi-language support for opening balance label
- Use `t("valuations.show.opening_balance")` for all opening balance display (list and detail views)
- Add or update `opening_balance` translation in all major languages under `config/locales/views/valuations/`
- Now "Opening balance" will be localized in all supported languages
* revert -2.years
* Update config/locales/views/valuations/es.yml
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Signed-off-by: Juan José Mata <jjmata@jjmata.com>
* Update config/locales/views/valuations/pt-BR.yml
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Signed-off-by: Juan José Mata <jjmata@jjmata.com>
* Fix indentation for opening_balance in ro.yml
Signed-off-by: Juan José Mata <jjmata@jjmata.com>
* Fix indentation for opening_balance in Turkish locale
Signed-off-by: Juan José Mata <jjmata@jjmata.com>
* Update zh-TW.yml
Signed-off-by: Juan José Mata <jjmata@jjmata.com>
---------
Signed-off-by: Juan José Mata <jjmata@jjmata.com>
Co-authored-by: Juan José Mata <jjmata@jjmata.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Refs #895, discussion #1224.
Adds a "Mark as recurring" entry point on the transfer detail drawer
that creates a `RecurringTransaction` carrying both source and
destination accounts. The recurring index, settings toggle
(`recurring_transactions_disabled`), and projected upcoming feed all
light up automatically once the data shape is there.
Schema:
* `destination_account_id` nullable FK to accounts. `on_delete: :cascade`
matches #20251030172500's precedent for accounts FKs. The existing
`account_id` FK is widened to cascade in the same migration so
Family destruction with a recurring transfer doesn't FK-violate.
* Two predicate-partitioned partial unique indexes per shape:
non-transfer rows (`destination_account_id IS NULL`, original
5-column shape preserved) and transfer rows (6-column shape
including the destination). Postgres treats NULLs as distinct in
unique indexes, so widening would have broken non-transfer dedupe.
* Two CHECK constraints enforcing transfer invariants in PostgreSQL:
`chk_recurring_txns_transfer_requires_source` (destination implies
source) and `chk_recurring_txns_transfer_distinct_accounts`
(destination cannot equal source). Per CLAUDE.md "Enforce null
checks, unique indexes, and simple validations in the database
schema for PostgreSQL".
* `Account` gains an `inbound_recurring_transfers` inverse so the
destroy chain reaches both ends.
Controller / behaviour:
* `transfers#mark_as_recurring` mirrors `transactions#mark_as_recurring`:
i18n flashes (4 new keys: transfer_marked_as_recurring,
transfer_already_exists, transfer_creation_failed,
transfer_feature_disabled), `respond_to format.html`,
`redirect_back_or_to transactions_path`, server-side gate on
`recurring_transactions_disabled?`, and rescue both `RecordInvalid`
and `RecordNotUnique` for the race window between the dedupe
`find_by` and `create_from_transfer`. The `StandardError` rescue
now logs the exception (class, message, transfer/family/user ids)
before surfacing the generic flash so production failures aren't
context-less.
* `RecurringTransaction.accessible_by(user)` now requires
destination_account_id (when present) to be in the user's
accessible set, so a recurring transfer never leaks to a user
without access to BOTH endpoints.
* Model validation gains a `destination_account.blank?` branch in
`transfer_endpoints_consistent` so a dangling
`destination_account_id` (referenced row destroyed) surfaces as a
normal validation error instead of an FK exception on save.
* `Identifier` filter for transfer-kind transactions moved into SQL.
UI:
* Recurring index table and projected feed render transfer rows with
the existing letter-avatar and the row's `name` field
("Transfer to {destination}"). No special pill or icon -- every row
in `/recurring_transactions` is recurring by definition. Amount
column on transfers uses `text-secondary` (muted-but-live) instead
of the income/expense colour, since transfers are zero-net for the
family.
Out of scope (called out in the PR body):
* Auto-creation of future Transfer rows on a schedule
(discussion #1224's primary ask). Behaviour change vs the
current projection-only model.
* Auto-identification of recurring transfer pairs in `Identifier`.
* Frequency model richer than `expected_day_of_month`.
* `Cleaner` for recurring transfers (issue #1590 tracks this).
Tests:
* `RecurringTransaction#transfer?` predicate (with / without
destination).
* `transfer_endpoints_consistent`: rejects same source and
destination, rejects dangling destination_account_id, rejects
cross-family destination.
* `RecurringTransaction.create_from_transfer` happy path;
multi-currency variant stores source-side currency.
* `projected_entry` exposes source / destination on transfer rows.
* `Identifier` skips transfer-kind transactions; creates a pattern
from expense halves while ignoring co-resident transfer halves.
* Destroying the destination account cascades to inbound recurring
transfers (FK + AR association).
* Unique partial index still de-duplicates non-transfer rows after
the destination_account_id widening.
* `transfers#mark_as_recurring` happy path, idempotent on second
call, rejected when `recurring_transactions_disabled`.
Suite: 3261 / 0 / 0 / 24 on the latest upstream/main. Lint clean.
Brakeman clean.
Signed-off-by: Guillem Arias Fauste <gariasf@proton.me>
* fix(design-system): align DS::Alert icon with title
The icon was rendered at size 'sm' (w-4 h-4) and started at the very
top of the flex row (items-start without an offset), which optically
sat above the title's cap when the title was present and slightly
above the message baseline when it wasn't. The hand-rolled alerts
this PR replaced used 'w-5 h-5 mt-0.5' for exactly this reason —
restore the same combination in the component:
- size: sm -> md (w-4/h-4 -> w-5/h-5).
- class adds mt-0.5 so the icon's vertical center lines up with the
bold title's cap-height (and with the body baseline in the title-less
case).
No API change. Visual fix only.
Refs #1731
* fix(design-system): split DS::Alert into title-row + indented body
Replaces the items-start + margin-fudge approach with a two-row
layout that doesn't depend on icon-bounding-box vs text-cap-height
arithmetic:
- Title case: icon and bold title share a flex row with items-center,
so the icon's vertical centre lines up with the title's line. Body
(block content or message) renders below in a separate row, padded
by pl-8 (= icon md width + gap-3) so it indents under the title
text rather than under the icon.
- Block-only case (no title, no message — used by the alpha_vantage
rate-limit alert): keeps the items-start fallback with a small mt-0.5
on the icon so the cap of the first paragraph still sits near the
icon centre.
- Single-line message case: items-center between icon and message, no
fudge needed.
container_classes loses its 'flex items-start gap-3' base since the
outer div is no longer the flex container. Each branch declares its
own flex/items-* combination.
Refs #1731
* fix(design-system): a11y semantics + visual polish on DS::Alert
Builds on the title-row restructure with the items the design / a11y
review surfaced:
- live: keyword (default :none, accepts :status / :polite and
:alert / :assertive) maps to role="status" or role="alert" on the
outer div. Static, page-baked alerts (the migrated callsites in
#1731) keep the default :none and stay role-less. Dynamic surfaces
(flash, validation summaries appearing after a Turbo update) opt
into the live role they need.
- aria-labelledby on the outer div pointing at the title <p> so AT
picks the title as the alert's accessible name when one is set.
- Variant prefix in the title / message via an sr-only span. Screen
reader hears 'Warning: …', 'Error: …', etc.; sighted users see no
change. Variant labels live under ds.alert.variants.* in
config/locales/views/components/en.yml.
- Body text inside titled alerts now defaults to text-secondary
instead of text-primary, so hierarchy reads on weight + colour
rather than weight alone (Refactoring UI: hierarchy needs both).
Single-line message and block-only fallback keep text-primary
since there is no second tier.
- Icon size goes back from md (20px) to sm (16px) — proportionally
closer to text-sm body — and the items-center branches grow
-mt-0.5 to compensate for the cap-centre vs line-centre offset
that flex's items-center alone can't bridge.
- Title weight bumped from font-medium (500) to font-semibold (600)
for clearer prominence against the now-softer body.
No API breakage: existing callers passing only message:/title:/variant:
keep working. The new live: arg defaults to the correct value for
the static migration sites.
Refs #1731
* fix(design-system): drop aria-labelledby when alert has no role; revert body to text-primary
Two corrections after numerical contrast analysis and CodeRabbit feedback:
1. aria-labelledby was being emitted on every titled alert, but the
default live: :none leaves the outer <div> with no role. ARIA spec
only honours the labelling relationship on elements with a host
role, so on a generic <div> the attribute is invalid and
accessibility validators flag it. Now only emitted when aria_role
is set (live: :status or :alert). Static, page-baked callsites
stay role-less and label-less; dynamic callers that opt into a
live role get the proper accessible-name relationship.
2. text-secondary on bg-{variant}/10 in light mode lands at
~4.07-4.25:1 contrast — below WCAG AA's 4.5:1 for normal text.
Reverting the body wrapper to text-primary brings it back to
AAA (~15:1). Loses some of the Refactoring UI body-vs-title
colour hierarchy; the title's font-semibold weight + larger
optical mass against an otherwise plain body still reads as
hierarchy. Single-line message and block-only fallback already
used text-primary, so this just unifies the three branches.
The remaining contrast gap — text-success (green-600) icon on
bg-success/10 light surface at 2.77:1 — is documented in the PR
description; fixing it cleanly needs a token-level bump
(--color-success: green-600 -> green-700 in light mode) which is
out of scope for this PR.
Refs #1731
* fix(settings/providers): use DS::Alert title:+message: instead of inline content_tag
Three callsites added in #1710 passed block-level markup (`<p>`/`<h2>`)
through `message:` via `safe_join + content_tag`. The post-#1731 alert
template wraps `message:` in a `<p>`, which makes nesting a `<p>` or
`<h2>` invalid HTML — browsers auto-close the outer paragraph and the
indented body row collapses.
Each of the three is semantically a title + body pair, so swap them
to the proper `title:` + `message:` API. No new strings — the i18n
keys (`*.no_withdraw_title` / `_body`, `encryption_error.title` /
`.message`) already split that way; the inline assembly was the
artefact.
The encryption-error block loses an explicit `<h2>` wrapper around
the title; DS::Alert's title is a `<p>`. The visual hierarchy and
sr-only variant prefix are unchanged. Worth tracking heading semantics
as a follow-up against DS::Alert (a `heading_level:` arg) rather than
bringing back the manual markup.
* fix(design-system): make :destructive variant alias explicit in DS::Alert locale
Add `destructive: Error` to `ds.alert.variants` and drop the implicit
`:destructive -> :error` aliasing in `DS::Alert#variant_label`. Both the
locale file and the component now self-document the variant set; lookup
is direct, no conditional needed.
Per @jjmata review on #1734.
Avatar letter/icon now uses `--avatar-color` CSS variable + the new
`.goal-avatar` class. Light mode darkens the text to 55% color + 45%
black so pale palette entries (cyan-300, green-300) stay readable on
the 10%-mix tint over white (~4.5:1). Dark mode reverts to the full
brand color via [data-theme="dark"] .goal-avatar override so the text
doesn't disappear against the near-black tinted surface. Verified
live: #805dee renders as a darker oklab in light mode and full
rgb(128,93,238) in dark mode.
Picker popup compacted:
- 80 (320px) wide, max-h-[60vh] overflow-y-auto so it never spills
off-screen.
- Anchored below the avatar + horizontally centered to it (top-full
left-1/2 -translate-x-1/2) so it doesn't drift off to the right
edge of the form on narrow modals.
- Icon grid max-h-40 (160px, ~5 rows) with the in-house `scrollbar`
utility for a thin gray thumb that works in both themes.
- Section headers (Color / Icon) styled `uppercase tracking-wide`
for visual hierarchy.
Verified popup at 320x310px in edit modal, no vertical overflow.
Previously sat next to the name input via `flex items-start gap-3` so
the picker avatar competed with the input for horizontal space. Move
to its own row, centered (`flex justify-center`), positioned just
before the name field. Mirrors the categories form layout where the
avatar is the focal element above the name input.
Same change applied to the edit form: picker comes first, then name.
Stepper step 1 order is now: heading · picker · name · amount/date ·
funding accounts · notes.
User requested replacing the in-house color disclosure with the
categories color+icon popover. Done as a controller extraction so
categories and goals share one Stimulus controller (user's option:
"Extract a shared color_icon_picker_controller.js").
- `git mv` app/javascript/controllers/category_controller.js to
color_icon_picker_controller.js. Categories form + color_avatar
partial updated to use the new identifier (data-controller=
"color-icon-picker", target/action selectors renamed).
- Goal model gains an icon column (migration
20260511190000_add_icon_to_goals.rb) + ICONS = Category.icon_codes
+ inclusion validation. GoalsController permits :icon in
goal_params + goal_update_params.
- Goals::AvatarComponent now renders icon when present (falls back to
first-letter initial), and adopts the Categories tinted-bg + colored
-content style (bg = `color-mix(in oklab, COLOR 10%, transparent)`,
text/icon = COLOR). Matches the picker's live preview so what the
user sees during selection equals the saved state.
- New goals/_color_picker.html.erb mirrors categories/_form's popover:
avatar + pen overlay summary + popup with color row (+ rainbow
custom-hex trigger) + icon grid. Pickr / contrast validation / auto-
adjust all inherited from the shared controller.
- Stepper step 1 layout: drop the inline letter-avatar (data-goal-
stepper-target="avatarPreview") in favour of the picker avatar next
to the name input. Step 1's tail no longer renders a separate color
partial. Edit form passes icons local through.
Verified live: new goal modal renders 11 color radios (10 presets +
custom) + 141 icon radios + pen-summary; categories form still
operational (no console errors) under the renamed controller.
Lower half of the goal detail used to be: (stat row: monthly pace +
total contributions) + (bottom row: contributions list + funding
breakdown card). Two of those four pieces were redundant:
- Total Contributions stat duplicated the count badge that already
sits beside the Contributions heading below.
- Monthly Pace stat repeated the same numbers the catch-up alert
surfaces above and the chart subtitle reads.
Adopt the dashboard Balance Sheet pattern (app/views/pages/dashboard/_
balance_sheet.html.erb) for the funding widget: inline header with
total ("Funding accounts · $13,250"), thin gap-separated segment bar,
color-dot legend with percent, and a bg-container-inset table with the
shared `pages/dashboard/group_weight` 5-stick weight indicator + value
column.
New show.html.erb bottom: just two full-width sections — funding
widget, then chronological contributions list. Both rendered only when
the goal has contributions (matches the empty-state branch added
earlier).
Locale: goals.show.funding_table.{name, weight, value}.
The "Add $1,531.25" CTA used to open the contribution modal with an
empty amount field — label was a hint, not a default. Now passes the
catch-up amount via ?amount= and the contributions controller seeds
@contribution.amount from params. One click brings the user to the
modal already populated.
Adds a secondary text link below the primary CTA: "Or adjust your
target" → opens the edit modal (Turbo frame). Behavioural-econ choice
architecture: gives the rebaseline path explicitly so users who can't
realistically catch up don't feel forced into the contribution.
Trade-off: lets the alert respect autonomy — commit or recalibrate,
both fine. Action paralysis kept low by visual hierarchy (primary
button vs muted text link).
Found the actual Sure pattern in app/views/accounts/_form.html.erb:27-47
("Additional details" section in the account-creation flow):
<details class="group">
<summary class="cursor-pointer text-sm text-secondary hover:text-primary flex items-center gap-1 py-2">
<%= icon "chevron-right", class: "group-open:rotate-90 transition-transform" %>
...
</summary>
<div class="space-y-2 mt-2 pl-4 border-l border-primary">...</div>
</details>
It's an inline expand (no absolute popup), chevron rotates 90° on
open, body indented with a vertical primary-color rule. My previous
partial was an absolute-positioned popover lifted from
categories/_form.html.erb — not what Sure uses for collapsible form
sections.
Rewrite _color_picker.html.erb to match: chevron + color-preview disc
+ "Color" label in the summary; swatches in an inline indented body.
Catch-up body also drops the em-dash. Was:
"You're saving $X/mo today — $Y/mo short of the pace to finish by $date."
Now two sentences:
"Your current pace is $X/mo. You need an extra $Y/mo to finish by $date."
Two short clauses, no compound separator, each conveys a single number.
Frames the gap as "extra" rather than "short", which behavioral-econ
research suggests reads as more attainable.
Alert previous pass led with delta ("Behind by $750/mo") but the user
still had to reconcile that with the $1,000/mo CTA — the relationship
between current pace, gap, and required rate was implicit.
Make every number visible in the sentence:
- Title: "Save $1,000/mo to stay on track" — leads with the action +
required rate. Reduces decision load: the headline is what to do.
- Body: "You're saving $250/mo today — $750/mo short of the pace to
finish by September 11, 2026." — current pace + gap + deadline.
User can now mentally verify: $250 + $750 = $1,000. The catch-up
amount in title + body + CTA is no longer disconnected from the
current pace number; the body is the bridge.
Adds `scrollbar` utility (defined in app/assets/tailwind/application.css
as 4px gray-300 thumb) to the contributions list container. Browser-
default scrollbar was rendering as a thick dark bar in light mode on
some OSes; the in-house utility renders a thin gray thumb consistently
across themes.
- catch_up alert: title now leads with the new info (delta) and body
states the required rate. Was "Save $1,000/mo to catch up" + "Currently
$750/mo behind" — confusingly double-stated. Now "Behind by $750/mo" +
"Save $1,000/mo to stay on track for {date}." Locale keys swap the
%{amount}/%{delta} placement.
- Goals::StatusPillComponent: each variant carries a theme-dark: text
override so the dark-700 text doesn't disappear against the dark-mode
tinted surface. Verified in dark mode: Paused pill text is now
rgb(231,231,231) (gray-200) instead of rgb(54,54,54) (gray-700).
Pre-existing token contrast fix tracked at we-promise/sure#1736 stays
the long-term path; this is the local workaround that doesn't drop
4.5:1 in either theme.
- New goals/_color_picker.html.erb partial: <details> disclosure with
current-color preview in the summary + swatch grid in the popover.
Mirrors the categories form's pen-icon-overlay pattern in spirit
(collapsed by default; user clicks to expand). Both _form_edit and
_form_stepper render the partial; the stepper's hidden color field is
replaced by the visible disclosure.
- Stepper footer: change `justify-between` to `flex items-center` plus
`ml-auto` on the Continue wrapper. Continue now sits right-aligned in
step 1 (where Back is hidden) and stays right in step 2 with Back
taking the left edge.
Direct nav to /goals/new used to render the index page with an empty
modal frame because the entire template was wrapped in DS::Dialog.
The URL was effectively un-shareable.
Branch on turbo_frame_request? — Turbo Frame requests still render
the DS::Dialog wrapper (the existing in-modal flow on the index page
keeps working). Non-frame requests render a standalone page-level
header (h1 + subtitle + icon) followed by the form_stepper partial.
Same Stimulus controller, same data-goal-stepper-modal-subtitle
selector, so the stepper's subtitle update path works identically.
Controller sets @breadcrumbs so the standalone variant gets the
Home > Goals > New goal trail.
Verified both paths via Playwright: direct GET renders standalone
form with h1 "New goal" + no dialog; click-from-index opens the
DS::Dialog with the stepper inside.
B — Step 2 of the create stepper used to echo Step 1 fields back at
the user in three labelled rows (Funding accounts: 2 · $123,456 balance;
Suggested monthly: $1,003/mo over 12 months). Replaces those rows with
a single derived sentence:
"Save $1,003/mo across 2 accounts to hit it on time."
If no target date is set: "Set a target date to project a finish line."
The previous "Suggested monthly" + "Funding accounts" rows are dropped;
review block shows only Name, "$12,000 by May 11 2027", and the
derived insight sentence.
L — All hard-coded English templates + currency symbols in the JS
controllers go through Stimulus values now:
- goal_stepper_controller: new {currency, summaryWithDate, summaryNoDate,
accountCountOne, accountCountOther, suggestedWithDate, suggestedNoDate}
values. Money formatted via Intl.NumberFormat(undefined, { style:
"currency", currency: this.currencyValue, maximumFractionDigits: 0 }).
- goal_projection_chart_controller: _fmtMoney upgraded to Intl.NumberFormat
(was $/€/£ ternary fallback that lost JPY/INR/CHF/...).
Locale: new goals.form_stepper.step2.review.{summary_*,account_count,
suggested_*}. Old funding_accounts / suggested_monthly keys retained
(unused by the new ERB) so any translator paths in flight don't break.
Verified live via Playwright: step-2 review reads "Save $1,003/mo
across 2 accounts to hit it on time." for a $12,000 / 12-month / 2-
account goal.
Empty state (goal with zero contributions) was rendering a flat-at-$0
saved series and a flat-at-$0 projection that looked broken to anyone
opening a freshly created goal. Now show.html.erb branches on
@goal.goal_contributions.empty? and renders a piggy-bank + "Add a
contribution" CTA card before the chart card. Brand-new goals get a
clean inline call-to-action instead of a misleading line at zero.
No-target-date goals (target_amount set, target_date null) used to
render a standalone "Set a target date" prompt card and hide history
entirely. Now they render the chart with the saved history + the
target horizontal line (no projection segment, no projection legend
item), plus a secondary "Set target date" callout below the chart
linking into the edit modal. History is informative even without a
deadline.
Locale: new goals.show.empty.{heading,body,cta}.
Previously a user who linked the wrong account at creation had to
delete + recreate the goal. Now the edit modal carries the same
funding-accounts checkbox group as Step 1 of the stepper, pre-checked
with the goal's current links.
- GoalsController#edit loads @linkable_accounts + @currently_linked_account_ids.
- #update accepts account_ids; when supplied, runs the create / update
inside a Goal.transaction and syncs linked accounts via
sync_linked_accounts! (set-diff: destroy_all unselected goal_accounts,
create the new ones). Validates at least one account before touching
goal_accounts so the user gets a clean re-render.
- Removing an account preserves the goal's existing contributions —
GoalContribution#account_must_be_linked_to_goal only fires on save,
so historical rows stay valid.
- _form_edit partial accepts new locals; edit.html.erb threads them
through.
- 3 new controller tests: identity-only patch leaves links intact;
account_ids patch replaces the link set; empty account_ids
re-renders with error.
Add-contribution modal previously offered zero feedback on what the
typed amount would do to goal progress. Now renders "Currently X%
saved (Y of Z)." at rest and updates on each keystroke to
"Will bring you to X% saved (Y of Z)." or "Will reach your Z target."
when the contribution would close the gap.
- New goal_contribution_preview_controller.js consumes current balance
+ target + currency + three localized template strings as Stimulus
values. Intl.NumberFormat for currency formatting (locale-correct
out of the box; fallback to currencyValue prefix on environments
that don't support it).
- ERB form-level data-controller wires the values; amount input uses
amount_data: to thread the Stimulus target / action through the
money_field helper.
- Locale: goal_contributions.new.preview_{zero,nonzero,reached} with
{percent}, {current}, {newTotal}, {target} placeholders.
A — Catch-up math triple-encoding. The catch-up amount (e.g. $1,531/mo)
was rendered verbatim in three places: the banner title, the projection
card subtitle, and the pace stat ("target $X/mo"). Only new information
anywhere was the buried "Behind by $Y/mo" delta in pace-card subtext.
- Banner body now carries the delta: "...currently $Y/mo behind."
- Projection sentence drops the "Bump to %{required}/mo" restatement;
reduces to "At %{current}/mo you'll miss your target date." Chart
aria-description benefits from the simpler phrasing too.
- Pace stat drops the "· target $X/mo" sub-line. Pair becomes
"$avg/mo" + "Behind by $delta/mo" — same delta now in the banner,
surfaced twice intentionally (alert vs at-a-glance stat).
K — Destructive transition confirms. Pause / Resume stay no-confirm
(recoverable). Mark complete (irreversible via UI; no may_uncomplete?
event exists) and Archive (goal disappears from active list) now wear
CustomConfirm. New locale keys: goals.show.confirm_{complete,archive}_
{title,body,cta}.
Locale catch_up body strings now interpolate %{delta} alongside %{date};
projection.behind drops %{required}. Controller#projection_summary still
passes both keys — extras are ignored by I18n.
- Controller: @archived_goals exposes state=archived rows already pulled
by the all_goals load. No extra query (sliced from the existing array).
- Index template: <details> disclosure under "Completed" so archived
goals are reachable from the list without cluttering the active /
completed sections. Collapsed by default.
- GoalCardComponent: uses display_status for the data attribute (so the
card on the index reads as Archived instead of Behind), opacity-75
applies to archived too, footer_line short-circuits to "Archived" and
pace_line returns nil. Matches the show-page archived semantics
shipped earlier.
- Locale: new savings_goals.index.archived_section.heading and
savings_goals.goal_card.footer_archived.
Both the row and the kebab carried hover backgrounds (bg-surface-hover
on the row, bg-container-inset-hover on the icon button). The two are
near-identical shades, so the button's hover state was visually
invisible on top of the row's. Removing the row hover lets the kebab
be the sole affordance.
- header: flex-col on <sm (stacks title block + action group), flex-row
from sm up. Action group keeps its row on desktop, wraps below the
title block on phones.
- Title row: flex-wrap so the status pill drops to a second line on
narrow widths instead of squeezing the h1.
- h1: drop truncate, use break-words + min-w-0 inside the flex chain so
long names like "Investment property downpayment" wrap legibly
instead of clipping to "House …" on 375px.
- Action group: flex-wrap + sm:shrink-0 keeps Edit / Add contribution /
kebab readable when they have to share a line with the title block
at the sm breakpoint.
- SavingsGoal#display_status returns :archived / :paused before falling
through to the visualization status. Memoized like #status. The plain
#status method keeps its meaning (visualization vs. target/pace) so
callers that genuinely want "is this on track" — KPI sort, goal-card
ring color, projection_payload — keep working unchanged.
- Savings::StatusPillComponent: status_key uses display_status; new
:archived variant (bg-surface-inset / text-gray-700 / archive icon).
Previously an archived goal showed "Behind" on the detail page while
the archived banner said the goal was archived — conflicting signal.
- show.html.erb: paused/archived goals render a static recap card
(current saved vs target) instead of the projection chart. Pace stat
(avg vs required monthly) is also hidden — extrapolating "Behind by
$X/mo" against a goal that isn't accepting contributions is misleading.
- New locale keys: savings_goals.status.archived,
savings_goals.show.inactive.{heading_paused, heading_archived, body}.
- Tests cover display_status for archived / paused / active goals.
The "Add an account" CTA on the no-depository-accounts empty state now
appends ?return_to=/savings_goals. StoreLocation already stashes the
param into session via the global before_action; the consuming side
(subtype #create actions) honouring it is tracked at
we-promise/sure#1766.
- progress_ring_component: drop the in-ring "$saved / of $target" lines.
The same money pair already renders directly below the ring in
show.html.erb (now the single source). Inside the ring keeps only
"Saved" + percent.
- show.html.erb: replace 3 hand-rolled button_to CTAs (Paused banner
"Resume goal", Archived banner "Restore goal", celebration card
"Archive goal") with DS::Button so focus/hover/disabled match the
rest of the app. variant: primary/outline, size: sm, method: :patch.
- status_pill_component: swap text-success / text-warning / text-secondary
to text-green-700 / text-yellow-700 / text-gray-700 so all 5 light-mode
pill variants pass WCAG 4.5:1. Local override pending the upstream DS
token fix tracked at we-promise/sure#1736.
- Pressing Enter inside a step-1 input (Name, Target amount, Target
date) used to fire the form-implicit-submission against the sr-only
submit button, jumping straight to POST /savings_goals and skipping
step 2 entirely (no initial contribution, no review).
- New blockEnter action on the form re-routes Enter to next() when
currentStep === 1, mirroring the Continue button. Notes textarea is
exempt so newlines work.
- Add an inline hint under the funding-accounts label so users know up
front what the field controls; previously the only feedback was a
tiny "must pick one" error after Continue.
- Projection chart SVG: role=img + <title> + <desc> wired through new
ariaLabelValue / ariaDescriptionValue Stimulus values. Show.html.erb
passes a localized chart label and a strip_tags'd projection summary.
- Progress ring container: role=progressbar + aria-valuenow/min/max +
aria-label so screen readers announce "Goal 27% complete. $13,250 of
$50,000 saved." instead of four disjoint spans.
- Funding-account checkboxes (stepper step 1): explicit per-account id
("savings_goal_account_ids_<id>") so each row has a unique DOM id;
duplicate-id HTML violation gone.
- show.html.erb: <h3> -> <h2> at six section headings (celebration,
no-target-date, projection, contributions, funding accounts, notes)
so the heading hierarchy is h1 -> h2, not h1 -> h3.
- goal_avatar + account_stack components: aria-hidden=true on the
decorative wrappers; the textual goal/account name beside them is
always read separately so the SR no longer prefixes every entry with
the avatar initial.
- New locale keys: savings_goals.show.ring.aria_label and
savings_goals.show.projection.aria_label.
Both used goal.color for every account avatar, so every linked account
ended up the same color as the goal. Sure's convention elsewhere
(accounts/_logo.html.erb) is accountable.color (type color: Depository
→ purple) — but savings goals only link Depository accounts, so that
would still collapse to one color. Reuse the deterministic
Savings::GoalAvatarComponent.color_for(name) helper from the index card
stack instead. Same account always resolves to the same color across
processes, and multiple accounts on the same goal read as distinct.
Funding-accounts breakdown bar at the top now also colors each segment
by account so the proportions are visibly typed (not a single goal-
color block).
Ring percentage no longer takes the warning yellow tint when behind —
the colored ring stroke + status pill + catch-up alert already signal
the state, doubling it on the percent number was noise. Reached stays
green (celebratory), everything else uses text-primary (white/dark).
Chart vertical line at the left edge was the (start_date, $0) point
the controller prepended to the saved series. When start_date equals
the first contribution date (now common after the earlier earliest-
contribution fix), this drew a vertical jump from $0 to first
contribution at x=start. Skip the prepend when there's no temporal
gap so the line starts at the first real point.
Add Contribution modal — wrap the source-account select in the styled
form-field via f.select instead of label_tag + bare select_tag. Match
the rest of Sure's form controls. Also pass hide_currency on the
amount field so single-currency families don't see a redundant USD
dropdown.
Account avatar colors — replace Ruby String#hash (randomized per
process by Ruby for DoS protection) with a deterministic MD5-based
pick from Savings::GoalAvatarComponent::PALETTE. Same account name
now resolves to the same color across processes and across
components. Apply via a new Savings::GoalAvatarComponent.color_for
helper used by both the form stepper account list and the goal-card
AccountStackComponent (which was hardcoding blue-500 for every avatar
in the stack, hence Chase + Ally looking identical on the wedding
card).
projection_payload's start_date was created_at, but demo seeds (and
manual imports) can have contributions backdated before created_at —
those points were getting clipped/pushed left of the chart's x-domain
and the saved-series line couldn't render. Use min(created_at,
earliest contribution date) so the axis spans the full history.
Legend "saved" line stroke was var(--text-primary) which doesn't
resolve (Tailwind utility, not CSS var) → invisible swatch. Wrap in
text-primary span + stroke="currentColor".
Legend "projection" line was hardcoded yellow — chart paints green for
on_track goals → mismatch. Pick legend color based on goal status so
it matches what the chart actually draws.
Ring on on_track / no_target_date goal cards rendered with no progress
arc — ring_color returned var(--text-primary) / var(--text-subdued)
which aren't real CSS custom properties (Sure's text tokens are Tailwind
utilities). Switch to var(--color-green-500) / var(--color-gray-400)
which ARE CSS vars from the Tailwind palette and resolve at SVG fill
time.
Contributions list had a horizontal scrollbar because the rows used
-mx-3 px-3 to extend the hover background, which pushed content beyond
the card padding. Drop the negative-margin trick and add
overflow-x-hidden to the scroll container. Rows still hover-highlight
inside the card bounds.
Modal cache restoration — Turbo cached pages with open <dialog> elements
inside <turbo-frame id="modal">. After dismissing the new-goal modal
and navigating to a goal detail page, browser back restored the cached
index page WITH the dialog still in the modal frame; the dialog's
Stimulus controller then ran auto-open and reopened it. Now the dialog
close handler empties the parent modal turbo-frame so the cache
snapshot is clean.
Chart saved-fill — bump area gradient stop-opacity 0.10 → 0.22 so the
contribution history is more visible against the dark canvas. Chart
was rendering correctly but the white-at-10%-opacity gradient was too
faint to read on top of the dashed projection.
Demo — extend generate_savings_goals! with three more goals to exercise
status-specific UX: Wedding fund (on_track w/ 6 months of contributions
matching required pace), Sabbatical (paused), Old laptop fund (archived).
House downpayment gains 12 contributions so the scrollable list has real
density. Total now 7 demo goals covering behind / on_track / no_date /
paused / archived / reached.
Breadcrumbs — set @breadcrumbs on index too (it was relying on the
Rails-derived "Savings goals" label). Both views now read "Home →
Savings → ..." consistently, matching the sidebar nav text and H1.
Ring token — goal-card ring stroke switched from var(--color-gray-200)
(a hard light color identical in both themes) to
var(--budget-unallocated-fill) which is gray-50 light / gray-700 dark,
matching the detail page's progress ring.
Contributions list — replace the inline hover-revealed delete-X with
DS::Menu kebab, matching tags/_tag.html.erb and categories/_category.
Each row also gets hover:bg-surface-hover with a px-3 -mx-3 negative
margin to extend the hover area across the card padding. Non-manual
contributions render a 9x9 spacer so the right column stays aligned.
Header sub split — drop the long "·" chain into two lines: primary fact
(target / days left) in text-secondary, recency note in text-subdued
underneath. Less wall-of-text.
Behind noise — pill, ring, catch-up alert and projection chart already
signal "behind". The Monthly-pace combo card's "Behind by $X/mo" delta
no longer renders in text-warning — it switches to text-subdued so the
warning palette doesn't repeat across the page. The catch-up alert stays
loud because it's the primary action; the rest stays informational.
CustomConfirm wired with destructive: true on the contribution delete so
the confirm button gets the outline-destructive treatment.
Chart — Sure's "text-X" / "border-X" tokens are Tailwind utility
classes, not CSS custom properties, so var(--text-secondary) etc.
resolved to empty inside SVG attributes. Read data-theme on draw and
pass real hex colors (textPrimary, textSecondary, borderSubdued,
containerBg) into d3 fills/strokes. "Target · $X" label and axis tick
labels now have proper contrast in both themes.
Breadcrumbs — set @breadcrumbs in the show action so the layout renders
Home › Savings › <goal name> with the middle entry clickable back to
the index. Matches the convention used by imports / reports / family
exports.
Contributions list — drop the broken divide-y divide-subdued (Tailwind
divide- utilities don't pick up Sure's semantic border tokens). Switch
to space-y-3 rows matching the funding-accounts breakdown component.
Drop the border-b separator under the heading; the card now reads as
one continuous panel. Move the delete X to hover-revealed and reserve
an inert spacer for non-manual rows so the right column stays aligned.
D10 — Drop the silent .limit(50) on the contributions query and put the
list in a max-h-[480px] scrollable container. Goals with many
contributions now show all of them without truncation, but the page
stays compact via the inner scroll.
Polish — reached / completed goals no longer render the combo monthly-
pace card. After the goal is hit, comparing actual vs required pace is
moot (and target $0/mo · Above target pace was awkward filler). Only the
Total contributions card remains in the stat row.
D12 (clickable contribution rows) deferred — adding a dedicated
contribution detail/edit route adds enough scope to warrant its own
ticket. The per-row delete X already covers the only mutation people
need from this view.
D7 — Merge the separate Avg-monthly and Target-pace cards into one
wider "Monthly pace" card spanning 2/3 of the stat row. Shows actual
$/mo + target $/mo inline, with a delta line below:
- behind → "Behind by $X/mo" (text-warning)
- on/ahead → "Above target pace" (text-success)
- no target_date → "No required pace"
Total contributions stays as a separate, smaller card at 1/3 width.
The action pyramid finally points at the actionable stat — pace is
visually primary, raw count secondary.
D6 — Paused goals render a top info banner ("This goal is paused" +
[Resume goal]) before the hero. Archived goals get the same treatment
with a Restore CTA when applicable.
D8 — No-target-date goals replace the empty projection chart with a
focused prompt card: calendar-plus icon, "Add a target date" heading,
short copy, and a "Set target date" CTA that opens the edit modal.
Stops wasting the right half of the hero on an unrenderable chart.
D9 — Reached / completed goals replace the projection chart with a
celebration card: party-popper green icon, "Goal reached. Nice work."
heading, target-hit confirmation copy, and an "Archive goal" CTA when
the state machine allows it.
The original projection chart still renders for behind / on_track goals
with a real target_date — that's the only case where it adds value.
For behind (non-paused) goals with a target_date, render a DS::Alert
warning under the header surfacing the actionable insight from the
index card: "Save $X/mo to catch up · Bump your monthly contribution
to stay on track for <date> · [+ Add $X]".
The CTA prefills the contribution flow with the target monthly amount
in the button label so the user sees exactly what to commit to.
Mirrors the goal-card footer pattern shipped during the index refactor —
the detail page now carries the pace narrative forward instead of
hiding it inside the projection paragraph.
D1 — Drop the "Linked balance" stat. It summed the linked accounts'
total balances (e.g. $204K) rather than the amount saved toward the
goal ($1K), so it overstated progress by ~200x. Replace with a
"Target pace" card showing required $/mo.
D2 — Drop the redundant "← All goals" link. The breadcrumb nav above
the header already shows Home › Savings Goals.
D3 — Hide the Add-contribution button on reached / completed goals.
Logically you don't keep contributing after the goal is done.
D5 — Add last-contribution recency to the header sub ("Last
contribution 3d ago"), matching the goal card footer pattern from
the index refactor.
D11 — Stat row now 3 cards instead of 4 (avg monthly, total
contributions, target pace). Drop the "Started" card — low-signal for
new users.
Add virtual attr_accessors for initial_contribution_amount and
initial_contribution_account_id on SavingsGoal so the form builder can
bind to them without the model needing real columns.
Replace the raw number_field_tag with f.money_field hide_currency: true
so the field shows the currency symbol prefix and step-aware precision,
matching the Target amount field in step 1.
DS::Button treats "hidden" as a display override class — adding
class: "hidden" stripped the base inline-flex display, so when Stimulus
later removed the hidden class the icon and text stacked vertically.
Wrap the Back button in a hidden <div> and toggle the wrapper instead;
the button keeps its inline-flex base.
The Amount + From-account inputs in step 2 used label_tag +
number_field_tag / select_tag directly with no .form-field wrapper, so
they rendered as bare inputs (same issue Name had in step 1). Wrap
each in .form-field > .form-field__body with form-field__label and
form-field__input classes — matches the styled_form_with pattern used
by Target amount / Target date in step 1.
Passing label: false to f.text_area stripped the .form-field container,
leaving a naked textarea with no border or visible label inside the
DS::Disclosure. Pass a real label ("Notes (optional)") so styled_form_with
wraps it like every other textarea in Sure (transactions/_form.html.erb,
trades/show.html.erb, etc.). Bump rows to 3.
bg-secondary is not a registered Sure utility, so the previous h-0.5
bg-secondary div rendered with no background — the line was just a
transparent slot, barely visible in either theme.
Replace with border-t-2 border-secondary on the connector div, and
toggle border-inverse / border-secondary on step transition. Both
classes are real Sure tokens with proper light/dark variants
(alpha-black-200 / alpha-white-300 for border-secondary).
Drop the outer <label>Name</label> heading + label: false on the
text_field, and pass label: t(...) + container_class: "flex-1" so
styled_form_builder wraps the name input in Sure's standard .form-field
component. Label now sits inside the input box, matching the new
transaction modal pattern (and every other styled_form_with form across
Sure).
The avatar still sits as a sibling outside the box, flex-aligned center.
Use Sure's .checkbox checkbox--light classes on the funding-account
check_box_tag — matches transactions / entries / settings pages.
Stepper line: 2px tall bg-secondary in resting state (was 1px bg-subdued
which disappeared in dark mode). Step 2 inactive circle: border-secondary
outline instead of bg-container-inset fill — visible in both themes.
Notes collapse switches from raw <details> to DS::Disclosure for
consistency with the rest of Sure's DS.
Drop the footer Cancel button — the close X in the modal header already
handles that, and double cancel was redundant. The footer-left slot now
only renders Back (with arrow-left icon) and only on step 2.
Drop the top-of-form server-error banner (kept only when model.errors
on :base, which is rare) and stop relying on input.reportValidity()
browser-default tooltips.
Stepper validates step 1 manually when Continue is clicked:
- name empty → red ring on input + "Please give your goal a name." below
- target_amount ≤ 0 → red ring + "Set a target amount greater than zero."
- no funding account checked → "Select at least one funding account."
Each error clears as soon as the user fixes the field — typing in the
name field clears the name error, entering an amount > 0 clears the
amount error, checking any account clears the accounts error.
Drop the now-unused flashLinkedAccountsRequired() shake; replaced by
the per-field error pattern.
Replace the hardcoded var(--color-blue-500) on every funding-account
row's avatar with a deterministic pick from Category::COLORS based on
the account name's hash. Rows now read at a glance instead of melting
into one blue column.