From 998cfd61d64818492e142deac3174f5f6451a706 Mon Sep 17 00:00:00 2001 From: Guillem Arias Fauste Date: Tue, 2 Jun 2026 23:59:55 +0200 Subject: [PATCH 01/10] refactor(charts): extract shared chart-tooltip className to one source (#2106) Closes #2011. time-series and sankey each created their cursor-following tooltip with a duplicated className literal that had already drifted apart: time-series was missing `text-primary` and `z-50`. Move the visual contract into app/javascript/utils/chart_tooltip.js as CHART_TOOLTIP_CLASSES and have both controllers reference it. Each keeps its own behavioural classes (time-series its initial `opacity-0`; both `top-0`; sankey toggles opacity via inline style). `privacy-sensitive` stays bundled so future copies can't drop it. Also exports a createChartTooltip factory for the raw-DOM idiom. goal_projection_chart_controller is not in main yet (it lands with the goals work in #1798); it migrates to the same symbol there. --- .../controllers/sankey_chart_controller.js | 8 +++--- .../time_series_chart_controller.js | 7 +++--- app/javascript/utils/chart_tooltip.js | 25 +++++++++++++++++++ 3 files changed, 32 insertions(+), 8 deletions(-) create mode 100644 app/javascript/utils/chart_tooltip.js diff --git a/app/javascript/controllers/sankey_chart_controller.js b/app/javascript/controllers/sankey_chart_controller.js index cb24edeb9..b7d12c036 100644 --- a/app/javascript/controllers/sankey_chart_controller.js +++ b/app/javascript/controllers/sankey_chart_controller.js @@ -1,6 +1,7 @@ import { Controller } from "@hotwired/stimulus"; import * as d3 from "d3"; import { sankey } from "d3-sankey"; +import { CHART_TOOLTIP_CLASSES } from "utils/chart_tooltip"; import { sankeyNodeHasChildren, zoomSankeyData } from "utils/sankey_zoom"; // Connects to data-controller="sankey-chart" @@ -509,10 +510,9 @@ export default class extends Controller { this.tooltip = d3 .select(dialog || document.body) .append("div") - .attr( - "class", - "bg-container text-primary text-sm font-sans p-2 border border-secondary rounded-lg pointer-events-none absolute z-50 top-0 privacy-sensitive", - ) + // Shared visual contract + this chart's positioning class; opacity is + // toggled via inline style below. + .attr("class", `${CHART_TOOLTIP_CLASSES} top-0`) .style("opacity", 0) .style("pointer-events", "none"); } diff --git a/app/javascript/controllers/time_series_chart_controller.js b/app/javascript/controllers/time_series_chart_controller.js index d4f4988af..1bf6f8d89 100644 --- a/app/javascript/controllers/time_series_chart_controller.js +++ b/app/javascript/controllers/time_series_chart_controller.js @@ -1,5 +1,6 @@ import { Controller } from "@hotwired/stimulus"; import * as d3 from "d3"; +import { CHART_TOOLTIP_CLASSES } from "utils/chart_tooltip"; const parseLocalDate = d3.timeParse("%Y-%m-%d"); @@ -287,10 +288,8 @@ export default class extends Controller { this._d3Tooltip = d3 .select(`#${this.element.id}`) .append("div") - .attr( - "class", - "bg-container text-sm font-sans absolute p-2 border border-secondary rounded-lg pointer-events-none opacity-0 top-0 privacy-sensitive", - ); + // Shared visual contract + this chart's initial-hidden / positioning classes. + .attr("class", `${CHART_TOOLTIP_CLASSES} opacity-0 top-0`); } _trackMouseForShowingTooltip() { diff --git a/app/javascript/utils/chart_tooltip.js b/app/javascript/utils/chart_tooltip.js new file mode 100644 index 000000000..3b3b6f145 --- /dev/null +++ b/app/javascript/utils/chart_tooltip.js @@ -0,0 +1,25 @@ +// Single source of truth for the cursor-following tooltip used by the chart +// controllers (time-series, sankey, and goal-projection once it lands from the +// goals work). Keeping the visual contract here stops the bg / text / border / +// privacy-sensitive classes from drifting apart across the controllers, the way +// they had before (time-series was missing `text-primary` and `z-50`). +// +// This is the VISUAL contract only. Callers append their own behavioural +// classes (initial `opacity-0`, `top-0`, …) or set them via inline styles, +// because how each chart shows/hides and positions its tooltip differs. +// +// Not to be confused with DS::Tooltip — that is the info-icon hint primitive +// (bg-inverse, aria-describedby, anchored to a static trigger). This is a +// data-card surface created and updated inside D3 handler code. +export const CHART_TOOLTIP_CLASSES = + "bg-container text-primary text-sm font-sans absolute p-2 border border-secondary rounded-lg pointer-events-none z-50 privacy-sensitive"; + +// Convenience factory for the raw-DOM idiom (no d3.select). Creates a hidden +// tooltip div carrying the shared contract and appends it to `parent`. +export function createChartTooltip(parent) { + const tooltip = document.createElement("div"); + tooltip.className = CHART_TOOLTIP_CLASSES; + tooltip.style.display = "none"; + parent.appendChild(tooltip); + return tooltip; +} From d22ffe5994980940357e6622b03230729c3ddcff Mon Sep 17 00:00:00 2001 From: Guillem Arias Fauste Date: Wed, 3 Jun 2026 00:01:38 +0200 Subject: [PATCH 02/10] fix(ds-pill): default show_dot per mode (badges clean, markers keep dot) (#2107) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #2001. DS::Pill defaulted show_dot: true for both modes, so every status/category badge got a leading dot by default — redundant with the pill shape + tone + label already carrying the signal, and noisy in dense lists. More than half the marker:false callsites were already passing show_dot: false to fight it. The default is now mode-aware: marker: true keeps the dot (stage markers), marker: false (badges) is dot-less. An explicit show_dot: still wins. Only one in-tree callsite relied on the old default without an icon and wants the dot: settings/providers/_status_pill (live connection state) — pinned with show_dot: true. The enable_banking "Beta" badge loses its dot, which is the desired outcome (ref #1997). Icon-bearing transaction badges are unaffected (an icon already suppresses the dot). Left the now-redundant show_dot: false overrides in place to avoid churn and conflicts with in-flight pill-migration branches; they're harmless (explicit false == new default). Adds tests pinning the per-mode default resolution; updates the Lookbook preview to show the opt-in dot vs the clean default. --- app/components/DS/pill.rb | 13 ++++++++++-- .../settings/providers/_status_pill.html.erb | 3 +++ test/components/DS/pill_test.rb | 20 +++++++++++++++++++ .../previews/pill_component_preview.rb | 6 +++++- 4 files changed, 39 insertions(+), 3 deletions(-) diff --git a/app/components/DS/pill.rb b/app/components/DS/pill.rb index a868d8baa..b04ef61d6 100644 --- a/app/components/DS/pill.rb +++ b/app/components/DS/pill.rb @@ -32,6 +32,13 @@ class DS::Pill < DesignSystemComponent # # Other options: # + # - `show_dot:` defaults per mode. Stage markers (`marker: true`) keep + # their dot; status / category badges (`marker: false`) are clean by + # default — the pill shape + tone + label already carry the signal, so + # a leading dot is usually redundant and noisy in dense lists. Pass + # `show_dot: true` to opt a badge back in where the dot is genuinely + # additive: live / temporal status ("Syncing", "Active"), or a single + # sparse pill where the dot anchors it as a discrete element. # - `dot_only: true` renders only the colored dot (no label, no border). # Use on the collapsed sidebar nav, where there's no room for the label. # - `icon:` overrides the dot with a Lucide icon (sized xs, current color). @@ -44,13 +51,15 @@ class DS::Pill < DesignSystemComponent # - Sure has full violet / indigo / fuchsia / amber / green / gray / # red ramps in the design system; this component picks named tokens # at render time. No raw hex. - def initialize(label: nil, tone: :violet, style: :soft, size: :sm, show_dot: true, dot_only: false, title: nil, icon: nil, marker: true, custom_color: nil) + def initialize(label: nil, tone: :violet, style: :soft, size: :sm, show_dot: nil, dot_only: false, title: nil, icon: nil, marker: true, custom_color: nil) resolved_tone = SEMANTIC_TONE_ALIASES.fetch(tone.to_sym, tone.to_sym) @label = label || I18n.t("ds.pill.default_label", default: "Beta") @tone = TONES.include?(resolved_tone) ? resolved_tone : :violet @style = STYLES.include?(style.to_sym) ? style.to_sym : :soft @size = SIZES.include?(size.to_sym) ? size.to_sym : :sm - @show_dot = show_dot + # Default per mode: markers keep their dot, badges are dot-less. An + # explicit show_dot: true/false always wins. + @show_dot = show_dot.nil? ? marker : show_dot @dot_only = dot_only @title = title @icon = icon diff --git a/app/views/settings/providers/_status_pill.html.erb b/app/views/settings/providers/_status_pill.html.erb index efabfd989..e0fefc018 100644 --- a/app/views/settings/providers/_status_pill.html.erb +++ b/app/views/settings/providers/_status_pill.html.erb @@ -7,9 +7,12 @@ else :neutral end %> +<%# Provider connection state is genuine live status — keep the indicator dot + (badge mode is dot-less by default; this is a deliberate opt-in). %> <%= render DS::Pill.new( label: t("settings.providers.status.#{status}"), tone: tone, marker: false, + show_dot: true, size: :sm ) %> diff --git a/test/components/DS/pill_test.rb b/test/components/DS/pill_test.rb index a066e6e94..b66b0a200 100644 --- a/test/components/DS/pill_test.rb +++ b/test/components/DS/pill_test.rb @@ -51,6 +51,26 @@ class DS::PillTest < ViewComponent::TestCase assert_includes pill.palette[:bg], "color-red-50" end + test "marker mode shows the dot by default" do + render_inline(DS::Pill.new(label: "Beta", tone: :violet)) + assert_selector "span.inline-block.rounded-full" + end + + test "badge mode (marker: false) is dot-less by default" do + render_inline(DS::Pill.new(label: "Member", tone: :neutral, marker: false)) + assert_no_selector "span.inline-block.rounded-full" + end + + test "badge mode opts back into the dot with show_dot: true" do + render_inline(DS::Pill.new(label: "Active", tone: :success, marker: false, show_dot: true)) + assert_selector "span.inline-block.rounded-full" + end + + test "marker mode can drop the dot with show_dot: false" do + render_inline(DS::Pill.new(label: "Beta", tone: :violet, show_dot: false)) + assert_no_selector "span.inline-block.rounded-full" + end + test "custom color renders dynamic badge styles" do render_inline(DS::Pill.new(label: "Groceries", marker: false, custom_color: "#f97316")) diff --git a/test/components/previews/pill_component_preview.rb b/test/components/previews/pill_component_preview.rb index ae036179c..fce55a602 100644 --- a/test/components/previews/pill_component_preview.rb +++ b/test/components/previews/pill_component_preview.rb @@ -39,8 +39,12 @@ class PillComponentPreview < ViewComponent::Preview # @!endgroup # @!group Status badges (marker: false, semantic tones) + # Badge mode is dot-less by default — tone + label carry the signal. Opt the + # dot back in with show_dot: true only where it's genuinely additive (live / + # temporal status, or a single sparse pill). status_active below shows the + # opt-in; status_pending / status_archived show the clean default. def status_active - render DS::Pill.new(label: "Active", tone: :success, marker: false) + render DS::Pill.new(label: "Active", tone: :success, marker: false, show_dot: true) end def status_pending From e232818e97bc499258a71802879ab5f0b51972e6 Mon Sep 17 00:00:00 2001 From: Guillem Arias Fauste Date: Wed, 3 Jun 2026 00:03:15 +0200 Subject: [PATCH 03/10] chore(ds-pill): migrate budget-category status badges to DS::Pill (#1751) (#2111) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bounded slice of #1751: the three over/near-limit/good status badges in budget_categories/_budget_category were hand-rolled pill spans using raw palette colors (bg-red-500/10 text-red-500, bg-yellow-500/10, bg-green-500/10), breaking the design-token rule. Their icon(color: "red"/"yellow"/"green") args were also no-ops — the icon helper has no such keys, so the glyphs just inherited the span's text color. Replace all three with DS::Pill(marker: false, tone:, icon:), which renders the icon + label on a semantic soft-tone background using DS tokens (AA contrast). Scoped to one budget file on purpose — avoids the transactions / providers / transfer_match callsites covered by in-flight pill-migration work. --- .../budget_categories/_budget_category.html.erb | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/app/views/budget_categories/_budget_category.html.erb b/app/views/budget_categories/_budget_category.html.erb index ff209f009..6b55b971e 100644 --- a/app/views/budget_categories/_budget_category.html.erb +++ b/app/views/budget_categories/_budget_category.html.erb @@ -26,20 +26,11 @@

<%= budget_category.category.name %>

<% if budget_category.over_budget? %> - - <%= icon("alert-circle", size: "sm", color: "red") %> - <%= t("reports.budget_performance.status.over") %> - + <%= render DS::Pill.new(label: t("reports.budget_performance.status.over"), tone: :error, marker: false, icon: "alert-circle") %> <% elsif budget_category.near_limit? %> - - <%= icon("alert-triangle", size: "sm", color: "yellow") %> - <%= t("reports.budget_performance.status.warning") %> - + <%= render DS::Pill.new(label: t("reports.budget_performance.status.warning"), tone: :warning, marker: false, icon: "alert-triangle") %> <% else %> - - <%= icon("check-circle", size: "sm", color: "green") %> - <%= t("reports.budget_performance.status.good") %> - + <%= render DS::Pill.new(label: t("reports.budget_performance.status.good"), tone: :success, marker: false, icon: "check-circle") %> <% end %>
From c274c5d8bbce80a50a378dca16a72a6562193aaa Mon Sep 17 00:00:00 2001 From: Guillem Arias Fauste Date: Wed, 3 Jun 2026 00:04:32 +0200 Subject: [PATCH 04/10] fix(recurring): match transfer pairs so Cleaner stops mis-retiring transfers (#2110) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #1590. Implements Option A (the proper fix), replacing the interim skip. A recurring transfer's name is seeded as "Transfer to {dest}", but future occurrences carry arbitrary names (user free-text, importer wording, the auto-matcher), so the name-based matching_transactions returned [] and the Cleaner retired still-active transfers at the 6-month threshold. main worked around this by skipping transfer rows entirely (Option B) — which also meant a genuinely-stopped transfer never got retired. matching_transactions now detects the Transfer *pair* for transfer rows: an outflow on the source account paired with an inflow on the destination account, within the usual amount/cadence window. The Cleaner no longer skips transfers: - a transfer whose pair still occurs keeps surfacing recent matches → stays active - a transfer whose pair has stopped → correctly retired The amount / day-of-month scopes are extracted and shared between the name-based and pair-based paths. The Identifier's separate transfer skip (auto-identifying pairs from history) is intentionally untouched — that's the out-of-scope feature the issue defers. --- app/models/recurring_transaction.rb | 71 ++++++++++++++------- app/models/recurring_transaction/cleaner.rb | 11 ++-- test/models/recurring_transaction_test.rb | 60 +++++++++++++++-- 3 files changed, 106 insertions(+), 36 deletions(-) diff --git a/app/models/recurring_transaction.rb b/app/models/recurring_transaction.rb index 090d31549..f05df6fcb 100644 --- a/app/models/recurring_transaction.rb +++ b/app/models/recurring_transaction.rb @@ -260,29 +260,15 @@ class RecurringTransaction < ApplicationRecord # Find matching transactions for this recurring pattern def matching_transactions - # For manual recurring with amount variance, match within range - # For automatic recurring, match exact amount - base = account.present? ? account.entries : family.entries + # Recurring transfers can't be matched by single-account name/amount — + # future occurrences carry arbitrary names — so match the Transfer pair. + return transfer_matching_transactions if transfer? - entries = if manual? && has_amount_variance? - base - .where(entryable_type: "Transaction") - .where(currency: currency) - .where("entries.amount BETWEEN ? AND ?", expected_amount_min, expected_amount_max) - .where("EXTRACT(DAY FROM entries.date) BETWEEN ? AND ?", - [ expected_day_of_month - 2, 1 ].max, - [ expected_day_of_month + 2, 31 ].min) - .order(date: :desc) - else - base - .where(entryable_type: "Transaction") - .where(currency: currency) - .where("entries.amount = ?", amount) - .where("EXTRACT(DAY FROM entries.date) BETWEEN ? AND ?", - [ expected_day_of_month - 2, 1 ].max, - [ expected_day_of_month + 2, 31 ].min) - .order(date: :desc) - end + # Amount/cadence-scoped Transaction entries on this account (or family). + base = account.present? ? account.entries : family.entries + entries = day_of_month_scope( + amount_window_scope(base.where(entryable_type: "Transaction").where(currency: currency)) + ).order(date: :desc) # Filter by merchant or name if merchant_id.present? @@ -401,6 +387,47 @@ class RecurringTransaction < ApplicationRecord end private + # Issue #1590: a recurring transfer's future occurrences rarely share the + # seed's name (user free-text, importer wording, the auto-matcher's + # "Transfer to ..."), so name-based matching returns [] and the Cleaner + # would wrongly inactivate a still-active transfer. Match the Transfer + # *pair* instead — an outflow on the source account paired with an inflow + # on the destination account, within the usual amount/cadence window — and + # return the outflow entries (the occurrence-date carrier, consistent with + # create_from_transfer). + def transfer_matching_transactions + return Entry.none unless account && destination_account + + outflow_entries = day_of_month_scope( + amount_window_scope(account.entries.where(entryable_type: "Transaction").where(currency: currency)) + ).order(date: :desc) + + paired_outflow_transaction_ids = Transfer + .where(outflow_transaction_id: outflow_entries.select(:entryable_id)) + .where(inflow_transaction_id: + destination_account.entries.where(entryable_type: "Transaction").select(:entryable_id)) + .pluck(:outflow_transaction_id) + + outflow_entries.where(entryable_id: paired_outflow_transaction_ids) + end + + # Transaction entries whose amount fits the pattern: exact, or within the + # configured variance band for manual recurring rows. + def amount_window_scope(relation) + if manual? && has_amount_variance? + relation.where("entries.amount BETWEEN ? AND ?", expected_amount_min, expected_amount_max) + else + relation.where("entries.amount = ?", amount) + end + end + + # Entries whose day-of-month lands within ±2 days of the expected day. + def day_of_month_scope(relation) + relation.where("EXTRACT(DAY FROM entries.date) BETWEEN ? AND ?", + [ expected_day_of_month - 2, 1 ].max, + [ expected_day_of_month + 2, 31 ].min) + end + def monetizable_currency currency end diff --git a/app/models/recurring_transaction/cleaner.rb b/app/models/recurring_transaction/cleaner.rb index dd22dcb89..ec5dfd917 100644 --- a/app/models/recurring_transaction/cleaner.rb +++ b/app/models/recurring_transaction/cleaner.rb @@ -9,18 +9,15 @@ class RecurringTransaction # Mark recurring transactions as inactive if they haven't occurred recently # Uses 2 months for automatic recurring, 6 months for manual recurring. # - # Transfer rows (destination_account_id present) are skipped: their - # `matching_transactions` helper looks at single-account name/amount - # which never matches a Transfer pair, so the Cleaner would - # incorrectly mark a still-recurring transfer inactive at the - # 6-month threshold. Issue #1590 tracks pair-detection-aware - # matching for recurring transfers. + # Transfer rows (destination_account_id present) are included: as of issue + # #1590, `matching_transactions` detects the Transfer pair, so a still-active + # transfer keeps surfacing recent matches and stays active, while one whose + # pair has genuinely stopped is correctly retired. def cleanup_stale_transactions stale_count = 0 family.recurring_transactions .active - .where(destination_account_id: nil) .find_each do |recurring_transaction| next unless recurring_transaction.should_be_inactive? diff --git a/test/models/recurring_transaction_test.rb b/test/models/recurring_transaction_test.rb index 20c8a28ae..75d8ce596 100644 --- a/test/models/recurring_transaction_test.rb +++ b/test/models/recurring_transaction_test.rb @@ -998,12 +998,9 @@ class RecurringTransactionTest < ActiveSupport::TestCase assert_not RecurringTransaction.exists?(rt.id) end - test "Cleaner skips recurring transfers so they aren't mistakenly marked inactive" do - # `matching_transactions` is single-account name/amount-based and never - # matches a Transfer pair, so without the skip the recurring transfer - # would flip to inactive at the 6-month threshold even when the user - # is still doing the transfer monthly. Issue #1590 tracks the proper - # pair-detection fix. + test "Cleaner keeps a recurring transfer active when its pair still occurs (issue #1590)" do + # The seed name rarely matches future occurrences, so pair detection (not + # name matching) is what keeps a live transfer active past the threshold. rt = @family.recurring_transactions.create!( account: @account, destination_account: accounts(:credit_card), name: "Transfer to CC", amount: 250, currency: "USD", @@ -1012,12 +1009,61 @@ class RecurringTransactionTest < ActiveSupport::TestCase next_expected_date: 5.days.from_now.to_date, manual: true ) - assert rt.should_be_inactive?, "guard sanity: row would be marked inactive without the skip" + assert rt.should_be_inactive?, "guard sanity: stale last_occurrence_date" + + # A fresh transfer pair this cycle, carrying a *different* free-text name. + date = 1.month.ago.beginning_of_month + 4.days # day-of-month 5 + outflow = @account.entries.create!( + date: date, amount: 250, currency: "USD", name: "rent transfer", + entryable: Transaction.new(kind: "funds_movement") + ) + inflow = accounts(:credit_card).entries.create!( + date: date, amount: -250, currency: "USD", name: "rent transfer", + entryable: Transaction.new(kind: "funds_movement") + ) + Transfer.create!(outflow_transaction: outflow.entryable, inflow_transaction: inflow.entryable) RecurringTransaction.cleanup_stale_for(@family) assert_equal "active", rt.reload.status end + test "Cleaner retires a recurring transfer whose pair has stopped" do + # No matching Transfer pair → genuinely stale → should be retired. This is + # the correctness the pair-detection (vs the old blanket skip) buys us. + rt = @family.recurring_transactions.create!( + account: @account, destination_account: accounts(:credit_card), + name: "Transfer to CC", amount: 250, currency: "USD", + expected_day_of_month: 5, + last_occurrence_date: 7.months.ago.to_date, + next_expected_date: 5.days.from_now.to_date, + manual: true + ) + + RecurringTransaction.cleanup_stale_for(@family) + assert_equal "inactive", rt.reload.status + end + + test "matching_transactions finds the transfer pair regardless of occurrence name" do + rt = @family.recurring_transactions.create!( + account: @account, destination_account: accounts(:credit_card), + name: "Transfer to CC", amount: 250, currency: "USD", + expected_day_of_month: 5, last_occurrence_date: Date.current, + next_expected_date: 1.month.from_now.to_date, manual: true + ) + date = Date.current.beginning_of_month + 4.days # day-of-month 5 + outflow = @account.entries.create!( + date: date, amount: 250, currency: "USD", name: "an importer's wording", + entryable: Transaction.new(kind: "funds_movement") + ) + inflow = accounts(:credit_card).entries.create!( + date: date, amount: -250, currency: "USD", name: "an importer's wording", + entryable: Transaction.new(kind: "funds_movement") + ) + Transfer.create!(outflow_transaction: outflow.entryable, inflow_transaction: inflow.entryable) + + assert_includes rt.matching_transactions.map(&:id), outflow.id + end + test "Identifier#update_manual_recurring_transactions skips recurring transfers" do # Same reasoning as the Cleaner skip. Without the guard, the helper # would call find_matching_transaction_entries (single-account, by From 699b0d59da23ad8d8c03e68bdca9f3b299c29caa Mon Sep 17 00:00:00 2001 From: Guillem Arias Fauste Date: Wed, 3 Jun 2026 00:05:44 +0200 Subject: [PATCH 05/10] feat(ds): extract DS::ProgressRing primitive; migrate goal card (#1899) (#2112) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 (`

` + 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
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