From 3508f705821d32a9e41cf09aa0635b4b0ead05b5 Mon Sep 17 00:00:00 2001 From: Guillem Arias Fauste Date: Tue, 2 Jun 2026 14:00:16 +0200 Subject: [PATCH] feat(goals): balance-derived + pledges (#1798) 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