Skip to content

Commit 699b0d5

Browse files
gariasfjjmatasure-admin
authored
feat(ds): extract DS::ProgressRing primitive; migrate goal card (#1899) (#2112)
* feat(savings): add savings goals Adds a standalone Savings goals feature: a piggy-bank style tracker that lets a family set a target, link one or more Depository accounts as funding sources, and log manual contributions over time. Supersedes #1569 (closed) — same intent, redesigned per reviewer + Discord feedback. What this adds: - New `/savings_goals` sidebar entry (piggy-bank icon) with index, show, state-filtered tabs (all/active/paused/completed/archived), and a 2-step modal stepper for creation (Identity → Review). - Multi-account funding via a `SavingsGoalAccount` join: a goal requires ≥1 linked Depository account (checking/savings/HSA/CD/money-market), and all linked accounts must share the goal's currency. - Tracker balance model: goal balance = SUM(contributions.amount). No auto-flow from account balances. Contributions are pure logical records and don't move money between accounts. - Manual contributions modal scoped to the goal's linked accounts. Initial contributions seeded at creation can't be deleted; manual ones can. - AASM lifecycle: active / paused / completed / archived. Hard-delete only after archive. - Status pills (On track / Behind / Reached / No date) derived from pace vs target_date. - AI Assistant tool `create_savings_goal` lets the sidebar chat create a goal end-to-end from a natural-language prompt; soft errors carry the available-accounts list back to the LLM (mirrors the existing `import_bank_statement` pattern). - Family-scoped throughout (`Current.family`-only access, account family-scoping enforced both in controllers and the AI tool). - Demo data seed wires up 4 sample goals across the Depository accounts. Intentionally out of scope (separate PRs / v1.1): - Auto-fund from budget surplus + Sidekiq cron + budget-show card. - Dashboard "Savings goals" widget. - "Behind pace" projection chart on the detail page. - `evaluate_savings_goal_feasibility` LLM tool (level-setting before create_savings_goal). - Spend-less goals inside Budgets. - Family-member-private goals (deferred investigation). * fix(savings): DS conformance pass on stepper, ring, card, status pill - StatusPill: use functional `text-success` / `text-warning` tokens with matching icon colors and `px-2 py-1`, mirroring `app/views/budget_categories/_budget_category.html.erb:29-43`. - ProgressRing: rework center text to match `_budget_donut.html.erb` (small "Saved" label, `text-3xl font-medium` headline, "of $X" underline). Stroke color now derives from goal.status (yellow when behind, blue on track, green reached, gray for no-date). - GoalCard bar: track height + transition match budget category bar (`h-1.5`, `transition-all duration-500`, `inline-size`). - Index/show layouts: render page header inline (`<h1>` + actions). The default application layout doesn't yield `:page_actions`, so the CTA + kebab menu wouldn't appear when emitted via `content_for`. - Stepper review summary: target the actual form inputs by `name` rather than relying on the `data-target` Stimulus attribute, since `money_field` puts the attribute on the wrapper. Step 1 validation scoped to the step 1 panel. - Demo generator: filter Depository accounts via `where(accountable_type: "Depository")` — Rails delegated_type generates the `depository?` predicate, not a `.depository` scope. * feat(savings): rebuild UI to match Claude Design + adopt shared donut-chart Previous savings goals UI looked nothing like the Claude Design output (see sure-design-context/design/savings-goals/project/goals/*.jsx) and the hand-rolled ring did not match the segmented D3 donut used at app/views/budgets/_budget_donut.html.erb. This rewires the surface end to end. Donut chart: - SavingsGoal#to_donut_segments_json returns the same segment shape as Budget#to_donut_segments_json: filled portion in goal color, unused remainder as `var(--budget-unallocated-fill)`. Visual identity is now the same: segmented arc with cornerRadius and gap, courtesy of the shared `donut-chart` Stimulus controller and D3. - ProgressRingComponent renders a `data-controller="donut-chart"` div with the same default-content/inner-text pattern as `_budget_donut`. Index page (matches GoalsIndex.jsx): - Page header: title + "Save toward what matters." subtitle + "New goal" primary CTA right-aligned. - Summary strip card: total saved / target, overall bar, active goals, on-track ratio, behind count. - State filter rendered as DS::Tabs-style pill nav (`bg-surface-inset p-1 rounded-lg`, white-pill active state). - Cards rebuilt: avatar (44px, rounded-xl, white initial on goal color) + name + secondary line ("N days left · by date" / "No target date" / "Completed" / "Past due"), status pill with leading dot, big $current/$target line + percent, bar in status colour, AccountStack (overlapping initials) + "N accounts" + "to go". Goal detail (matches GoalDetail.jsx): - Header: 64px avatar + h1 name + status pill + "Target $X by date · N days left" subline + Edit (outline) + Add contribution (primary) + kebab (DS::Menu for AASM transitions). - Donut-chart ring card with stats overlay. - 4-col stat row (Avg monthly, Total contributions, Target date, Started) with mono numerals and "Needs $X/mo" / "Above target pace" sub-captions where relevant. - Two-col bottom: contributions list (avatar + account · date · source · green +$amount) and funding accounts breakdown (stacked bar + per-account row with $ and % of saved). New components: Savings::AccountStackComponent (overlapping account initials with ring-2 ring-container). StatusPillComponent now uses a leading colored dot instead of an icon. GoalAvatarComponent radii match Claude Design (rounded-md/lg/xl/2xl) and white initial. Locale: new keys under savings_goals.{index.subtitle, index.summary.*, goal_card.{accounts,days_left,completed,past_due,no_target_date}, show.header.*, show.ring.{of,to_go}, show.stats.*, show.funding_balance, show.of_saved, show.notes}. * feat(savings): match Claude Design — projection chart, target-icon modal, grouped funding accounts Brings the savings goals UI closer to the Claude Design reference shared by the user. Changes: - Sidebar nav label: "Savings goals" → "Savings". - Status pill copy: "Behind" → "Behind pace" (matches Pill component from GoalsCommon.jsx). - Empty state rewritten with a large target icon, "No goals yet" heading, and the descriptive body copy from the design. Goal detail page (matches GoalDetail.jsx): - New "← All goals" back link above the header. - 2-column hero: ring card on the left (320px column), Projection card on the right. - Projection card uses a new D3 Stimulus controller (`savings-goal-projection-chart`) that draws: · saved area + line from goal creation → today (solid, primary) · dashed projection segment from today → target date (yellow when behind, green when on track) · horizontal dashed target line with label · today marker (vertical dashed line + dot) Data shape comes from `SavingsGoal#projection_payload`. - Card subtitle generates a contextual sentence ("At $X/mo you'll fall short. Bump to $Y/mo to hit it on time." / "At your current pace you'll reach this goal around Month YYYY." / "Goal reached. Nice work.") with a strong tag highlighting the actionable figure. - Stat row now shows Linked balance (sum across linked accounts) + "N accounts" sub-caption instead of duplicate "Target date" stat. New goal modal (matches the design images 2 + 3): - DS::Dialog custom header: DS::FilledIcon target glyph + title + step subtitle ("Step 1 of 2 · Goal details" / "Step 2 of 2 · Review & start") that updates as the user advances. - Connected stepper at top of body: numbered circles connected by a bar, step-1 circle flips to ✓ when complete. - Step 1 heading "What are you saving for?" + supporting copy. - Name field paired with a target glyph affordance on its left. - Target amount + Target date in a 2-col grid. - Funding accounts list now grouped by account subtype with uppercase section headers (CHECKING / SAVINGS / HSA / CD / MONEY MARKET / OTHER), each row showing avatar + name + subtype + balance. - Step 2 heading "Looks good?" + Review card (goal target + funding accounts summary + suggested monthly = target/months_remaining), and a disclosure for the optional initial contribution. - Footer: "Cancel" left text-button (closes modal) / "Back" left text when on step 2; "Continue →" or "Create goal →" right arrow button. Demo generator: Depository accounts now set `subtype` ("checking" / "savings") on the accountable so they group correctly in the modal. Tests: all green, 35 runs in the savings suite, 92 assertions. * feat(savings): rebuild index to match Claude Design - Page header: title "Savings" + "Your savings accounts and the goals you're working toward." Removed the top-right New goal button (moves into the Goals section). - Hero card: "Total in savings" with sum-of-savings-subtype balance, 30-day delta vs last 30 days (Family#savings_balance_30d_delta), 3-stat sub-row (Accounts / Active goals / Saved toward goals), and a D3 sparkline area chart on the right (new `savings-sparkline` Stimulus controller, sourced from Family#savings_balance_series). - Accounts section: lists Depository accounts with subtype = "savings" as cards (blue avatar, name, subtype, balance, "Funds N goals"). New Savings::AccountCardComponent. - Goals section header: "Goals" + "Save toward what matters." + "New goal" button right-aligned to the section (not the page header). - Removed state-filter pill nav. Active goals render in the main grid; Completed goals get a "Completed · N" divider w/ check-circle icon and their own grid below. - Goal card layout reworked: horizontal bar replaced with a 64px donut ring on the right side of the card header (ring colour tracks goal.status — yellow=behind, primary=on-track, green=reached). Pill is inline with the goal name. - Status pill copy: "Behind pace" → "Behind". - Filter bar (copied from settings/providers): search input + status chips (All / On track / Behind / No date). Hidden when ≤ 6 active goals. Powered by `savings-goals-filter` Stimulus controller — toggles `.hidden` on cards by goal name + status. - Family#savings_subtype_accounts, total_savings_balance, savings_balance_series, savings_balance_30d_delta helpers; controller computes hero payload + account-goal counts for the cards. * fix(savings): refine hero spacing, goal/account card padding, sparkline negative range - 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. * fix(savings): add bottom padding so last card clears the mobile fixed bottom-nav * feat(savings): add "ONGOING · N" + "COMPLETED · N" section dividers 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. * fix(savings): bump space between ONGOING/COMPLETED header and goal grid * fix(savings): rebalance spacing — moves the gap onto the grid, not the header 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. * fix(savings_goals): equalize ONGOING/COMPLETED header spacing across cards and empty state 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. * fix(savings_goals): update ONGOING count when filtering by status or search 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. * feat(savings_goals): replace hero card with KPI strip + differentiate empty states 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:). * feat(savings_goals): status pill icons + paused variant, attention-first sort, paused chip, rename "No date" to "Open-ended" 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. * feat(savings_goals): goal card pace + status-driven footer 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. * feat(savings_goals): drop Accounts section from index 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. * feat(savings_goals/new): drop required asterisks, hide currency, collapse notes, clean footer 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. * feat(savings_goals/new): live previewable name avatar + ghost cancel + circular header icon 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. * feat(savings_goals/new): deterministic account avatar color via name hash 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. * feat(savings_goals/new): inline per-field error state in stepper 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. * feat(savings_goals/new): checkbox styling, visible stepper, DS::Disclosure for notes, drop redundant cancel 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. * fix(savings_goals): status pill icons inherit pill's semantic color 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. * fix(savings_goals/new): name field uses .form-field wrapper for floating label 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. * fix(savings_goals/new): stepper line uses border-secondary token 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). * fix(savings_goals/new): notes textarea uses .form-field wrapper 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. * fix(savings_goals/new): step 2 back button inline + form-field wrappers on initial-contribution inputs 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. * feat(savings_goals/new): step 2 initial-contribution amount uses money_field 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. * feat(savings_goals/show): cleanup — drop misleading stats, hide add-contrib when reached, last-contribution recency 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. * feat(savings_goals/show): catch-up callout for behind goals 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. * feat(savings_goals/show): status-specific hero card + paused/archived banners 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. * feat(savings_goals/show): combo monthly-pace stat card 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. * feat(savings_goals/show): scrollable contributions list + hide pace card for reached goals 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. * fix(savings_goals/show): chart contrast, breadcrumbs, contributions list polish 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. * feat(savings_goals): demo variety, breadcrumb naming, ring token, list pattern, header split, tone down behind noise 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. * fix(savings_goals): ring on_track color, contributions horizontal scroll, modal restored on back, chart saved fill 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. * fix(savings_goals/show): chart axis includes backdated contributions, legend uses real colors 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. * fix(savings_goals): neutral ring percent, chart start vertical-line, contribution select wrapper, deterministic account colors 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). * fix(savings_goals/show): contributions list + funding accounts breakdown use per-account deterministic color 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). * fix(savings_goals): projection chart redraws when container resizes (Turbo navigation) 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. * perf(savings_goals/show): preload + memoize cut show from 17 to 5 queries - 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(*) * a11y(savings_goals): ARIA semantics + unique IDs + h2 hierarchy - 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. * fix(savings_goals/new): trap Enter on step 1; add funding-accounts hint - 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. * feat(savings_goals/chart): theme-follow palette, axis format, Today + ETA labels - 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. * chore(savings_goals/stepper): use Number.parseFloat / Number.NaN per Biome biome lint --write picked these up while auditing the chart changes. Pure rename, no behaviour change. * fix(savings_goals/show): dedupe ring labels, DS::Button banners, status-pill contrast - 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. * fix(savings_goals/empty_state): pass return_to so user lands back on /savings_goals after adding an account 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. * fix(savings_goals): use display_status for inactive goals; hide pace + projection - 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. * fix(savings_goals/show): stack header on mobile so long names + actions don't collide - 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. * fix(savings_goals/show): drop row hover so the kebab's own hover registers 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. * feat(savings_goals/index): collapsed Archived section + archived-aware card - 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. * refactor: rename Savings Goals feature to Goals User-facing rename + structural rename. Feature is now called just "Goals" everywhere — page title, sidebar nav, modal headings, flash messages, AI assistant tool. Code identifiers follow: - Models: SavingsGoal → Goal, SavingsContribution → GoalContribution, SavingsGoalAccount → GoalAccount. - Tables: savings_goals → goals, savings_contributions → goal_contributions, savings_goal_accounts → goal_accounts. FK columns savings_goal_id → goal_id. New migration db/migrate/20260511100003_rename_savings_to_goals.rb uses rename_table + rename_column; PG handles index renaming and FK redirection automatically. - Controllers: SavingsGoalsController → GoalsController, SavingsContributionsController → GoalContributionsController. - Routes: /savings_goals → /goals, nested /goals/:id/contributions (resource name shifts; old route name aliases dropped). - ViewComponent namespace: Savings::* → Goals::*. Component class names drop their redundant "Goal" prefix where the namespace already carries it: Savings::GoalCardComponent → Goals::CardComponent, Savings::GoalAvatarComponent → Goals::AvatarComponent. Others keep their names (Goals::ProgressRingComponent, Goals::StatusPillComponent, Goals::AccountStackComponent, Goals::FundingAccountsBreakdownComponent). - Stimulus controllers: savings_goal_* → goal_*, savings_goals_filter → goals_filter. Stimulus identifiers in data-controller / data-* attributes follow. - Locale keys: savings_goals: → goals: (top level), savings_contributions: → goal_contributions: (top level). All t() callers updated. - AI assistant tool: Assistant::Function::CreateSavingsGoal → Assistant::Function::CreateGoal, tool name "create_savings_goal" → "create_goal", description / response text updated. - Sidebar nav label "Savings" → "Goals". Goals/show + index page title "Savings" → "Goals". Empty goals_section heading/subtitle dropped (duplicated the page title post-rename). Original migrations create_savings_goals / create_savings_goal_accounts / create_savings_contributions remain untouched so historical replay still works; the rename migration runs on top. * perf+ux(goals): grouped state-count + friendly 404 for stale/cross-family deep links - 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. * ux(goals): unify "Open" for no-target-date status across all surfaces 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. * ux(goals/show): catch-up consolidation + confirm dialogs on archive / mark complete 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. * a11y(goals/card): scope link accessible name to title + status summary 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. * feat(goal_contributions/new): live impact preview below amount field 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. * feat(goals/edit): funding-accounts editor in the edit modal 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. * fix(goal_contributions/preview): rename templateNonZero -> templateNonzero so Stimulus matches the data attribute Stimulus converts the JS value name templateNonZero to a kebab-cased attribute by splitting on each capital letter, giving data-...-template-non-zero-value. Rails' dataset helper converts the Ruby key :goal_contribution_preview_template_nonzero_value to data-...-template-nonzero-value (no hyphen between non and zero). Result: the Stimulus controller resolved templateNonzeroValue to "" and the preview pane went blank as soon as the user typed an amount. Renaming the JS value to templateNonzero closes the conversion gap. Verified live via Playwright: at $500 the preview reads "Will bring you to 28% saved ($13,750 of $50,000)."; at $40,000 it flips to "Will reach your $50,000 target." * feat(goals/chart): y-axis labels + horizontal gridlines Chart had no value anchor on the y-axis; users had to read the target line label to know what amount the saved line represented. Add 3 right-aligned y-ticks ($0, $25K, $50K-style K/M shorthand) plus faint borderSubdued gridlines at the same y values. Left margin widens to 44 when room allows. Mobile (<320px chart inner-width) keeps the original tight 16px left margin and skips the y-axis entirely so the short-window readout stays uncluttered. Verified live: desktop reads $0/$20K/$40K + Target $50,000; 375px viewport drops the y-axis text + keeps target line + x-ticks only. * feat(goals/show): status-aware chart variants for empty + no-target-date 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}. * feat(goals/chart): hover crosshair + dot + tooltip Chart had no way to read the value at a specific date — users had to infer Saved amounts from line position relative to the y-axis labels added in the previous commit. - Transparent <rect> overlay covers the plot area + catches pointer. - pointermove uses d3.bisector to snap to the nearest saved series point, draws a dashed crosshair + a saved-line dot + a projection-line dot (linearly interpolated between today and target). - HTML tooltip lives inside the chart root (cleared on next _draw) showing "date / Saved: $X / Projected: $Y". Clamps to viewport so it doesn't overflow the card. - pointerleave hides everything. Pointer events unify mouse + touch — single handler covers both desktop hover and mobile tap-and-drag. No keyboard nav yet; tracked as follow-up (Stimulus controller is the right home but won't ship in this round). * feat(goals/stepper+chart): Step 2 derived projection + JS i18n + Intl.NumberFormat 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. * feat(goals/new): standalone page render when not in a Turbo frame 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. * fix(goals/chart): drop title-tooltip, step projection cursor, dot follows projection line, collision-aware y-ticks, clean tooltip Four chart fixes in one pass. 1) Browser was rendering the <title> child as a native hover tooltip that fought with the custom crosshair tooltip. Drop <title>; use aria-label on the <svg role="img"> instead — same SR accessible name, no native tooltip side-effect. 2/3) The hover crosshair clamped at today: bisector ran the saved series, which ends at today, so future hovers stuck the dot at the last saved point. Now the controller forks: - Past hover: snap to nearest contribution via bisector. - Future hover: snap to whole-week intervals along the projection segment ([today, target_date]) and place the dot at the interpolated y on the dashed line. Movement steps cleanly week by week instead of pixel-by-pixel jitter. 4) Tooltip drops the redundant line: - Past: "<date> · Saved: $X" (no Projected — there isn't one). - Future: "<date> · Projected: $X" (no Saved — it's the future). 5) Y-axis tick label suppressed when its value falls within 5% of the target line so "$2.5K" and "Target · $2,400" stop overlapping near the right edge. Gridline stays; only the y-axis label drops. Verified live via Playwright on House downpayment goal: <title> absent, aria-label populated, past tooltip "Feb 10, 2026 · Saved: $11,750", future tooltip "Nov 29, 2027 · Projected: $32,235", neighbouring future x snaps to "Dec 13, 2027 · $32,704" (2-week jump across the snapping boundary). * ux(goals): catch-up rework, dark-mode pill contrast, color disclosure, stepper continue-right - 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. * ux(goals/show): catch-up alert anchors all three numbers + scoped scrollbar on contributions list 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. * ux(goals): color picker uses Sure's inline-disclosure pattern; drop em-dash in catch-up body 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. * ux(goals/show): catch-up CTA pre-fills + secondary "Adjust your target" link 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). * ux(goals/show): balance-sheet-style funding widget; drop redundant stat row 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}. * feat(goals): extract shared color_icon_picker controller; add icon to goals; tinted avatar 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…
1 parent c274c5d commit 699b0d5

6 files changed

Lines changed: 220 additions & 43 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<%= tag.div class: "relative inline-flex shrink-0",
2+
style: "width: #{size}px; height: #{size}px;",
3+
**wrapper_aria do %>
4+
<svg width="<%= size %>" height="<%= size %>" viewBox="0 0 <%= size %> <%= size %>" aria-hidden="true">
5+
<circle cx="<%= center %>" cy="<%= center %>" r="<%= radius %>"
6+
fill="none" stroke="<%= track_color %>" stroke-width="<%= stroke_width %>" />
7+
<circle cx="<%= center %>" cy="<%= center %>" r="<%= radius %>"
8+
fill="none" stroke="<%= stroke_color %>" stroke-width="<%= stroke_width %>"
9+
stroke-linecap="round"
10+
stroke-dasharray="<%= circumference %>"
11+
stroke-dashoffset="<%= dash_offset %>"
12+
transform="rotate(-90 <%= center %> <%= center %>)" />
13+
</svg>
14+
<% if show_percent %>
15+
<div class="absolute inset-0 flex items-center justify-center font-medium text-primary tabular-nums"
16+
style="font-size: <%= percent_font_px %>px;" aria-hidden="true">
17+
<%= clamped_percent %>%
18+
</div>
19+
<% end %>
20+
<% end %>

app/components/DS/progress_ring.rb

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# A single-arc circular progress ring, decoupled from any domain model.
2+
#
3+
# Extracted from the goal card's inline <svg> (issue #1899) so goals, loans,
4+
# sub-account funding, etc. stop each hand-rolling the same two-circle SVG with
5+
# slightly different chrome / colors / a11y. Pass a percent and a tone; the
6+
# component owns the geometry (radius, circumference, dash offset) and the
7+
# accessible progressbar markup.
8+
#
9+
# Not a segmented donut — that's the `donut-chart` Stimulus controller's job
10+
# (budget/dashboard breakdowns, and the goals/show ring). This is the simple
11+
# "X% of one thing" ring.
12+
class DS::ProgressRing < DesignSystemComponent
13+
TONES = {
14+
success: "var(--color-success)",
15+
warning: "var(--color-warning)",
16+
destructive: "var(--color-destructive)",
17+
neutral: "var(--color-gray-400)"
18+
}.freeze
19+
20+
# Track (unfilled remainder) color. Reuses the existing token to keep the
21+
# goal card pixel-identical. TODO(#1899 follow-up): rename this to a generic
22+
# --color-progress-track-fill in the token source — that change also touches
23+
# the budget donut surfaces, so it's deferred out of this extraction.
24+
DEFAULT_TRACK = "var(--budget-unused-fill)".freeze
25+
26+
attr_reader :size, :stroke_width, :label, :show_percent
27+
28+
def initialize(percent:, size: 64, stroke_width: 6, tone: :neutral, label: nil, show_percent: true, track: DEFAULT_TRACK)
29+
@percent = percent
30+
@size = size
31+
@stroke_width = stroke_width
32+
@tone = tone.to_sym
33+
@label = label
34+
@show_percent = show_percent
35+
@track = track
36+
end
37+
38+
def clamped_percent
39+
[ [ @percent.to_i, 0 ].max, 100 ].min
40+
end
41+
42+
def stroke_color
43+
TONES.fetch(@tone, TONES[:neutral])
44+
end
45+
46+
def track_color
47+
@track
48+
end
49+
50+
def center
51+
size / 2.0
52+
end
53+
54+
def radius
55+
(size - stroke_width) / 2.0
56+
end
57+
58+
def circumference
59+
2 * Math::PI * radius
60+
end
61+
62+
# Length of the dash gap that hides the unfilled portion of the arc.
63+
def dash_offset
64+
circumference * (1 - clamped_percent / 100.0)
65+
end
66+
67+
# Center label scales with the ring so 64px reads ~11px (the goal card's size)
68+
# and a 180px ring reads ~30px without a per-callsite font class.
69+
def percent_font_px
70+
(size * 0.17).round
71+
end
72+
73+
# role=progressbar + value/label only when a label is supplied; otherwise the
74+
# ring is decorative (aria-hidden via the inner svg) and the caller labels it.
75+
def wrapper_aria
76+
return {} if label.blank?
77+
78+
{ role: "progressbar", aria: { valuenow: clamped_percent, valuemin: 0, valuemax: 100, label: label } }
79+
end
80+
end

app/components/goals/card_component.html.erb

Lines changed: 1 addition & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -18,29 +18,7 @@
1818
<p class="text-xs text-subdued truncate"><%= secondary_line %></p>
1919
</div>
2020

21-
<div class="shrink-0 relative" style="width: <%= Goals::CardComponent::RING_SIZE %>px; height: <%= Goals::CardComponent::RING_SIZE %>px;">
22-
<svg width="<%= Goals::CardComponent::RING_SIZE %>" height="<%= Goals::CardComponent::RING_SIZE %>" viewBox="0 0 <%= Goals::CardComponent::RING_SIZE %> <%= Goals::CardComponent::RING_SIZE %>" aria-hidden="true">
23-
<circle cx="<%= Goals::CardComponent::RING_SIZE / 2.0 %>"
24-
cy="<%= Goals::CardComponent::RING_SIZE / 2.0 %>"
25-
r="<%= ring_radius %>"
26-
fill="none"
27-
stroke="var(--budget-unused-fill)"
28-
stroke-width="<%= Goals::CardComponent::RING_STROKE %>" />
29-
<circle cx="<%= Goals::CardComponent::RING_SIZE / 2.0 %>"
30-
cy="<%= Goals::CardComponent::RING_SIZE / 2.0 %>"
31-
r="<%= ring_radius %>"
32-
fill="none"
33-
stroke="<%= ring_color %>"
34-
stroke-width="<%= Goals::CardComponent::RING_STROKE %>"
35-
stroke-linecap="round"
36-
stroke-dasharray="<%= ring_circumference %>"
37-
stroke-dashoffset="<%= ring_offset %>"
38-
transform="rotate(-90 <%= Goals::CardComponent::RING_SIZE / 2.0 %> <%= Goals::CardComponent::RING_SIZE / 2.0 %>)" />
39-
</svg>
40-
<div class="absolute inset-0 flex items-center justify-center text-[11px] font-medium text-primary tabular-nums" aria-hidden="true">
41-
<%= progress_percent %>%
42-
</div>
43-
</div>
21+
<%= render DS::ProgressRing.new(percent: progress_percent, tone: ring_tone) %>
4422
</div>
4523

4624
<div class="mt-5">

app/components/goals/card_component.rb

Lines changed: 6 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,4 @@
11
class Goals::CardComponent < ApplicationComponent
2-
RING_SIZE = 64
3-
RING_STROKE = 6
4-
52
def initialize(goal:, filterable: true)
63
@goal = goal
74
@filterable = filterable
@@ -13,11 +10,13 @@ def progress_percent
1310
goal.progress_percent
1411
end
1512

16-
def ring_color
13+
# Maps goal status to a DS::ProgressRing tone (the ring geometry/colors now
14+
# live in that primitive — see #1899).
15+
def ring_tone
1716
case goal.status
18-
when :reached, :on_track then "var(--color-success)"
19-
when :behind then "var(--color-warning)"
20-
else "var(--color-gray-400)"
17+
when :reached, :on_track then :success
18+
when :behind then :warning
19+
else :neutral
2120
end
2221
end
2322

@@ -66,19 +65,6 @@ def secondary_line
6665
end
6766
end
6867

69-
def ring_circumference
70-
@ring_circumference ||= 2 * Math::PI * ring_radius
71-
end
72-
73-
def ring_radius
74-
@ring_radius ||= (RING_SIZE - RING_STROKE) / 2.0
75-
end
76-
77-
def ring_offset
78-
pct = [ [ progress_percent.to_i, 0 ].max, 100 ].min
79-
ring_circumference * (1 - pct / 100.0)
80-
end
81-
8268
def pace_line
8369
return nil if goal.archived? || goal.paused? || goal.completed? || goal.status == :reached
8470

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
require "test_helper"
2+
3+
class DS::ProgressRingTest < ViewComponent::TestCase
4+
test "renders a track circle and a progress arc" do
5+
render_inline(DS::ProgressRing.new(percent: 50, tone: :success))
6+
assert_selector "svg circle", count: 2
7+
end
8+
9+
test "renders the center percent by default and clamps it" do
10+
render_inline(DS::ProgressRing.new(percent: 140))
11+
assert_text "100%"
12+
13+
render_inline(DS::ProgressRing.new(percent: -10))
14+
assert_text "0%"
15+
end
16+
17+
test "show_percent: false omits the center label" do
18+
render_inline(DS::ProgressRing.new(percent: 40, show_percent: false))
19+
assert_no_text "40%"
20+
end
21+
22+
test "exposes a progressbar role and value only when labelled" do
23+
render_inline(DS::ProgressRing.new(percent: 30, label: "Goal progress"))
24+
assert_selector "[role='progressbar'][aria-valuenow='30'][aria-label='Goal progress']"
25+
26+
render_inline(DS::ProgressRing.new(percent: 30))
27+
assert_no_selector "[role='progressbar']"
28+
end
29+
30+
test "tone selects the arc stroke color token" do
31+
assert_equal "var(--color-success)", DS::ProgressRing.new(percent: 1, tone: :success).stroke_color
32+
assert_equal "var(--color-warning)", DS::ProgressRing.new(percent: 1, tone: :warning).stroke_color
33+
assert_equal "var(--color-destructive)", DS::ProgressRing.new(percent: 1, tone: :destructive).stroke_color
34+
# Unknown tone falls back to neutral.
35+
assert_equal "var(--color-gray-400)", DS::ProgressRing.new(percent: 1, tone: :bogus).stroke_color
36+
end
37+
38+
test "dash offset runs from full circumference at 0% to zero at 100%" do
39+
ring = DS::ProgressRing.new(percent: 0)
40+
assert_in_delta ring.circumference, ring.dash_offset, 0.001
41+
42+
full = DS::ProgressRing.new(percent: 100)
43+
assert_in_delta 0.0, full.dash_offset, 0.001
44+
end
45+
end
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
class ProgressRingComponentPreview < ViewComponent::Preview
2+
# @param percent number
3+
# @param size number
4+
# @param stroke_width number
5+
# @param tone select ["success", "warning", "destructive", "neutral"]
6+
# @param show_percent toggle
7+
# @param label text
8+
def default(percent: 65, size: 64, stroke_width: 6, tone: "neutral", show_percent: true, label: nil)
9+
render DS::ProgressRing.new(
10+
percent: percent,
11+
size: size,
12+
stroke_width: stroke_width,
13+
tone: tone.to_sym,
14+
show_percent: show_percent,
15+
label: label.presence
16+
)
17+
end
18+
19+
# @!group Tones (50%)
20+
def success
21+
render DS::ProgressRing.new(percent: 50, tone: :success)
22+
end
23+
24+
def warning
25+
render DS::ProgressRing.new(percent: 50, tone: :warning)
26+
end
27+
28+
def destructive
29+
render DS::ProgressRing.new(percent: 50, tone: :destructive)
30+
end
31+
32+
def neutral
33+
render DS::ProgressRing.new(percent: 50, tone: :neutral)
34+
end
35+
# @!endgroup
36+
37+
# @!group Sizes
38+
def small_48
39+
render DS::ProgressRing.new(percent: 72, size: 48, stroke_width: 5, tone: :success)
40+
end
41+
42+
def medium_64
43+
render DS::ProgressRing.new(percent: 72, size: 64, stroke_width: 6, tone: :success)
44+
end
45+
46+
def large_180
47+
render DS::ProgressRing.new(percent: 72, size: 180, stroke_width: 10, tone: :success)
48+
end
49+
# @!endgroup
50+
51+
# @!group Edges
52+
def empty_0
53+
render DS::ProgressRing.new(percent: 0, tone: :neutral)
54+
end
55+
56+
def full_100
57+
render DS::ProgressRing.new(percent: 100, tone: :success)
58+
end
59+
60+
def clamps_over_100
61+
render DS::ProgressRing.new(percent: 140, tone: :success)
62+
end
63+
64+
def without_center_percent
65+
render DS::ProgressRing.new(percent: 40, tone: :warning, show_percent: false)
66+
end
67+
# @!endgroup
68+
end

0 commit comments

Comments
 (0)