Whole card was wrapped in <%= link_to ... %>, so screen readers
concatenated every nested text node into one accessible name (~60 words
on a typical card: avatar initial + name + status pill + percent +
balance + target + pace + accounts + footer).
- Outer wrapper now <div> carrying the filter-target + goal-name +
goal-status data attrs.
- Inner <a> wraps only the goal name. aria-label = "<name>, <status>,
<percent>% of <target>" — concise SR sentence.
- `before:absolute before:inset-0` makes the inner link's hit area span
the whole card so sighted users keep the existing click affordance.
- Ring SVG + percent overlay marked aria-hidden (decorative — same info
already in the aria-label).
- New locale key goals.goal_card.aria_progress.
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.
One status, four phrasings before this commit:
- status pill: "No date"
- filter chip: "Open-ended"
- goal-card secondary line: "No target date"
- goal-card footer: "No deadline set"
Plus KPI strip sub-count: "open-ended". Pick "Open" everywhere so the
status reads identically wherever it surfaces.
- index: STATE_FILTERS count loop replaced with single Current.family.goals.group(:state).count + per-state lookup. 5 SQL queries -> 1.
- GoalsController + GoalContributionsController: rescue_from ActiveRecord::RecordNotFound -> redirect_to goals_path with a flash. Affects stale deep links AND cross-family access (previously bare 404 -> Chrome error page). Test for cross-family access updated to assert the redirect + flash key.
- New locale key goals.errors.not_found.
- 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.
- MutationObserver on <html>[data-theme] re-runs _draw() when the user
toggles theme so the chart's hex-resolved-at-draw-time colors follow
the surrounding dark/light card surface (previously stuck on initial
palette until a full page reload).
- Axis tick format: "%b %y" → "%b '%y" (Jan '26) to disambiguate from
a day-of-month; tick divisor 110 → 80 so 375-wide mobile gets enough
ticks; post-process to drop adjacent equal labels for short windows.
- Today vertical line: small "Today" label above it on widths >= 320.
- Projection segment: end dot in the projection color + a short-format
label ("$42K" or "Short $7.9K"). Labels suppressed at < 320 width to
avoid colliding with the Target line label.
- New _fmtMoneyShort helper: K/M shorthand. Plain prefix; full i18n /
Intl.NumberFormat tracked as a long-term follow-up.
- 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.
- set_savings_goal: with_current_balance + includes(savings_contributions: :account, linked_accounts: []) so contributions / accounts / current balance don't re-query inside helpers and view partials
- SavingsGoal#status + #average_monthly_contribution: defined?(@ivar) memoization so the 5+ callsites per show (header banner, projection_summary, donut, goal-card pace, stats_for) don't recompute the exists?/MIN/SUM triplet each time
- SavingsGoal#projection_payload: sort loaded contributions in Ruby instead of running a fresh ORDER BY
- SavingsGoalsController#show: replace .chronological re-query with in-memory sort over the preloaded association
- funding_breakdown_for: group_by + transform_values off the loaded collection instead of an extra GROUP BY SQL
- stats_for: contributions_count uses .size to read the loaded cache instead of issuing COUNT(*)
After submitting a new contribution, Turbo's redirect-replace stream
swaps the page; the chart's data-controller reconnects but the
container's clientWidth can momentarily be 0 (parent grid hasn't laid
out yet). The early-bail in _draw left the SVG empty and nothing
triggered another draw, so the chart stayed blank.
Add a ResizeObserver to the controller. The observer fires once the
container settles into a real size and re-runs _draw, which now paints
the chart correctly after the post-submit navigation.
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.
Pass color: "current" to the icon helper so triangle-alert / circle-check
/ star / infinity / pause render in the same color as the pill's text
(text-success / text-warning / text-secondary). The icon helper defaults
to text-secondary, which made all icons grey regardless of pill variant.
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.
Replace the big square DS::FilledIcon next to the name input with a
small Savings::GoalAvatarComponent that previews the goal's avatar
(seeded color + first character of the typed name, updates live via
new stepper#nameChanged action).
Switch the modal header's target avatar from FilledIcon(size: lg,
rounded: false) → (size: md, rounded: true) — matches the goal-avatar
shape used elsewhere on the page.
Replace the hand-rolled <button> for Cancel/Back with DS::Button
variant: "ghost". Stepper now drills into the button's inner span to
swap the label, same pattern already used for the Continue/Create
button on the right.
Drop the now-unused footerLeftLabel target.
P1 of modal refactor — visual fidelity baseline against the Claude
Design reference and refactoring-ui rules.
Drop required: true on name + target_amount (suppresses both the red `*`
indicator and the browser-default HTML5 validation tooltip). Client-side
validation moves into the Stimulus stepper in a follow-up commit.
Pass hide_currency: true on the money_field so single-currency families
don't see a redundant inline currency dropdown.
Wrap the Notes textarea in a <details> disclosure ("Add notes (optional)"
summary) so step 1 isn't padded with rarely-used fields.
Drop the footer top border-subdued divider so the action row floats
against the dialog's existing padding boundary.
Drop the view-layer SavingsGoal::COLORS.sample fallback on hidden color
field — the controller already seeds @savings_goal.color.
The Accounts grid duplicated the sidebar account list. Removing it gives
the Goals section more breathing room and the page a tighter narrative:
header → KPIs → Goals.
Delete Savings::AccountCardComponent, Family#savings_subtype_accounts,
the @savings_accounts / @account_goal_counts controller refs, and the
related locale keys. Sidebar still shows the savings-subtype Depository
accounts under "Cash" — no information is lost.
Each card now answers "what's my next move" without clicking into the
detail page. Under the amount/target row, a pace line shows actual avg
contributions vs the monthly target. The footer (previously "$X left")
switches by status:
- behind → "Save $Y/mo to catch up"
- on_track → "Last contribution Nd ago" (or "today" / "No contributions yet")
- reached / completed → "Goal reached"
- no_target_date → "No deadline set"
- paused → "Paused"
Add SavingsGoal#last_contribution_at and #last_contribution_days_ago.
Both these methods and average_monthly_contribution now respect a loaded
:savings_contributions association so the index page doesn't N+1.
Controller eager-loads :savings_contributions + :linked_accounts.
P4: status pills now carry an icon alongside the colored tint
(circle-check / triangle-alert / star / infinity / pause), so color is
no longer the sole signal. Drop the redundant dot.
P4: default sort on the active goals list becomes attention-first —
behind → on_track → no_target_date → paused, alphabetical within bucket.
The user opens the page and lands on the goals that need them.
P5: add a Paused filter chip + render paused goal cards with opacity-75
so they read as inactive at a glance. Rename "No date" chip to
"Open-ended" — clearer to non-jargon readers.
P1: drop the sparkline + the single mixed hero. Hero became 3 separate
KPI cards (Contributed last 30d, Needs this month, Goals on track),
matching the Transactions page pattern. Each KPI answers a question the
user opens the page asking — saving rate, this-month action, overall
health.
P3: empty state copy + CTA now reflect the reason it is empty. Search
returns 0 → "No goals match X" + Clear search. Chip set to non-all → "No
goals match this filter" + Show all. Both → both reasons + both
buttons.
Drop: total_savings_balance, savings_balance_series,
savings_balance_30d_delta on Family (no other consumers).
Add: Family#contribution_velocity(range:).
The "ONGOING · N" badge was server-rendered with @active_goals.size and
never re-synced when the Stimulus filter hid cards. Add a count target
and update it alongside the existing empty/grid toggles.
Move section gap from per-child mt-3 to a single mb-4 on the header, and
toggle the grid wrapper hidden when no cards are visible. The previous
markup gave inconsistent ONGOING-tag-to-content distance because the
empty card sat below a 0-height grid, stacking margins differently than
the cards layout.
Previous attempt put `mb-5` on the section header so the goal grid sat
~20px below it, but that also pushed the "No goals match" empty card
down because it shares the same header. Margin collapse meant the
empty card's own `mt-3` was getting added to the new big header `mb`.
Rework: header back to `mb-2.5`, grid gets `mt-3` of its own. Empty
card keeps its `mt-3`. Both children collapse to ~12px below the
header now, which matches the breathing room the empty card had before
this thread of edits.
Same pattern as the bank-providers page's `AVAILABLE · 3` header (see
`app/views/settings/providers/_search_filters.html.erb` references):
small uppercase tracking-wide secondary label, separator dot, tabular
count. Replaces the prior "Completed · 1" inline label with a more
consistent treatment and adds an "Ongoing · N" header above the active
goals grid.
Name choice: "Ongoing" rather than "Active" because the grid includes
both `active` and `paused` AASM states; "ongoing" reads as still-in-
progress for both. Parallel to the existing "Completed" sibling.
- Sparkline (`savings-sparkline` controller): dropped the `Math.max(0,
yMin)` clamp on the y-axis domain so negative balances (or any series
that dips into negative territory) render fully instead of being
cropped off the canvas.
- Hero card: padding `p-6` → `p-7`, column ratio
`[minmax(0,1fr)_minmax(0,1.6fr)]` so the chart breathes, min height
bumped to 220px, sparkline container `h-full min-h-[200px]` so it
fills the card vertically. Stats row now sits at the bottom of the
text column via `mt-auto pt-6`; labels promoted to `text-xs`, values
to `text-lg`.
- Section vertical rhythm: outer `space-y-6` → `space-y-8`.
- Goal card: padding `p-[18px]` → `p-6`. Internal gap from header row
to amount line `mt-3.5` → `mt-5`. Account-row gap `mt-3` → `mt-4`.
- Account card: padding `p-5` → `p-6`.
- Status pill "Behind" dot: `bg-yellow-500` → `bg-yellow-600` for a
warmer/ambery tone matching the Claude Design reference.
- Goal card donut "behind" stroke: `var(--color-yellow-500)` →
`var(--color-yellow-600)` to match the pill.