diff --git a/app/components/savings/account_card_component.html.erb b/app/components/savings/account_card_component.html.erb new file mode 100644 index 000000000..ff62bcdc3 --- /dev/null +++ b/app/components/savings/account_card_component.html.erb @@ -0,0 +1,16 @@ +
+
+ + <%= initial %> + +
+

<%= account.name %>

+

<%= subtype_label %>

+
+
+

+ <%= Money.new(account.balance, account.currency).format %> +

+

<%= funds_label %>

+
diff --git a/app/components/savings/account_card_component.rb b/app/components/savings/account_card_component.rb new file mode 100644 index 000000000..a618f020c --- /dev/null +++ b/app/components/savings/account_card_component.rb @@ -0,0 +1,20 @@ +class Savings::AccountCardComponent < ApplicationComponent + def initialize(account:, goals_count: 0) + @account = account + @goals_count = goals_count + end + + attr_reader :account, :goals_count + + def initial + account.name.to_s.strip.first&.upcase || "?" + end + + def subtype_label + (account.subtype || "savings").to_s.titleize + end + + def funds_label + I18n.t("savings_goals.index.account_card.funds", count: goals_count) + end +end diff --git a/app/components/savings/goal_card_component.html.erb b/app/components/savings/goal_card_component.html.erb index 44a24137a..e2409047a 100644 --- a/app/components/savings/goal_card_component.html.erb +++ b/app/components/savings/goal_card_component.html.erb @@ -1,35 +1,59 @@ <%= link_to savings_goal_path(goal), - class: "group flex flex-col gap-3.5 p-[18px] bg-container rounded-xl shadow-border-xs hover:bg-surface-hover transition-colors" do %> + class: "group block bg-container rounded-xl shadow-border-xs hover:bg-surface-hover transition-colors p-[18px]", + data: { + savings_goals_filter_target: "card", + goal_name: goal.name, + goal_status: goal.status + } do %>
<%= render Savings::GoalAvatarComponent.new(goal: goal, size: "lg") %>
-

<%= goal.name %>

-

<%= secondary_line %>

-
- <%= render Savings::StatusPillComponent.new(goal: goal) %> -
- -
-
-
- <%= goal.current_balance_money.format %> - / <%= goal.target_amount_money.format %> +
+

<%= goal.name %>

+ <%= render Savings::StatusPillComponent.new(goal: goal) %>
- <%= progress_percent %>% +

<%= secondary_line %>

-
-
+ +
+ + + + +
+ <%= progress_percent %>% +
-
+
+
+ <%= goal.current_balance_money.format %> + / <%= goal.target_amount_money.format %> +
+
+ +
<%= render Savings::AccountStackComponent.new(accounts: linked_accounts) %> <%= linked_accounts_count_label %>
- <% if goal.completed? %>—<% else %><%= goal.remaining_amount_money.format %> to go<% end %> + <% if goal.completed? %>—<% else %><%= goal.remaining_amount_money.format %> <%= t("savings_goals.goal_card.left") %><% end %>
<% end %> diff --git a/app/components/savings/goal_card_component.rb b/app/components/savings/goal_card_component.rb index 4556cdbe9..fb3c52795 100644 --- a/app/components/savings/goal_card_component.rb +++ b/app/components/savings/goal_card_component.rb @@ -1,4 +1,7 @@ class Savings::GoalCardComponent < ApplicationComponent + RING_SIZE = 64 + RING_STROKE = 6 + def initialize(goal:) @goal = goal end @@ -9,12 +12,12 @@ class Savings::GoalCardComponent < ApplicationComponent goal.progress_percent end - def bar_color_style + def ring_color case goal.status when :reached then "var(--color-green-600)" when :behind then "var(--color-yellow-500)" when :on_track then "var(--text-primary)" - else "var(--color-gray-400)" + else "var(--text-subdued)" end end @@ -23,8 +26,7 @@ class Savings::GoalCardComponent < ApplicationComponent end def linked_accounts_count_label - n = linked_accounts.size - I18n.t("savings_goals.goal_card.accounts", count: n) + I18n.t("savings_goals.goal_card.accounts", count: linked_accounts.size) end def secondary_line @@ -35,10 +37,23 @@ class Savings::GoalCardComponent < ApplicationComponent else days = (goal.target_date - Date.current).to_i if days >= 0 - I18n.t("savings_goals.goal_card.days_left", count: days, date: I18n.l(goal.target_date, format: :long)) + I18n.t("savings_goals.goal_card.days_left_by", count: days, date: I18n.l(goal.target_date, format: :long)) else I18n.t("savings_goals.goal_card.past_due") end end end + + def ring_circumference + @ring_circumference ||= 2 * Math::PI * ring_radius + end + + def ring_radius + @ring_radius ||= (RING_SIZE - RING_STROKE) / 2.0 + end + + def ring_offset + pct = [ [ progress_percent.to_i, 0 ].max, 100 ].min + ring_circumference * (1 - pct / 100.0) + end end diff --git a/app/controllers/savings_goals_controller.rb b/app/controllers/savings_goals_controller.rb index d8590983a..12e516427 100644 --- a/app/controllers/savings_goals_controller.rb +++ b/app/controllers/savings_goals_controller.rb @@ -4,17 +4,19 @@ class SavingsGoalsController < ApplicationController STATE_FILTERS = %w[all active paused completed archived].freeze def index - @state_filter = STATE_FILTERS.include?(params[:state]) ? params[:state] : "active" - scope = Current.family.savings_goals.with_current_balance.alphabetically - scope = scope.where(state: @state_filter) unless @state_filter == "all" - @savings_goals = scope.to_a - @counts = STATE_FILTERS.each_with_object({}) do |state, h| h[state] = state == "all" ? Current.family.savings_goals.count : Current.family.savings_goals.where(state: state).count end + all_goals = Current.family.savings_goals.with_current_balance.alphabetically.to_a + @active_goals = all_goals.reject { |g| %w[completed archived].include?(g.state) } + @completed_goals = all_goals.select { |g| g.state == "completed" } + @linkable_account_count = Current.family.accounts.where(accountable_type: "Depository").visible.count - @totals = totals_for_family + @savings_accounts = Current.family.savings_subtype_accounts + @account_goal_counts = goal_count_per_account(@savings_accounts) + @hero = hero_payload(all_goals) + @show_search = @active_goals.size > 6 end def show @@ -153,24 +155,35 @@ class SavingsGoalsController < ApplicationController end end - def totals_for_family - goals = Current.family.savings_goals.with_current_balance.to_a - saved = goals.sum { |g| g.current_balance.to_d } - target = goals.sum { |g| g.target_amount.to_d } - currency = Current.family.primary_currency_code - active_goals = goals.select { |g| g.state == "active" } - on_track = active_goals.count { |g| g.status == :on_track || g.status == :reached } - behind = active_goals.count { |g| g.status == :behind } - overall_percent = target.zero? ? 0 : ((saved / target) * 100).round + def hero_payload(all_goals) + family = Current.family + currency = family.primary_currency_code + total_savings = family.total_savings_balance + saved_toward_goals = all_goals.sum { |g| g.current_balance.to_d } + delta = family.savings_balance_30d_delta { - saved: Money.new(saved, currency), - target: Money.new(target, currency), - overall_percent: [ overall_percent, 100 ].min, - on_track_count: on_track, - behind_count: behind + currency: currency, + total_savings_money: Money.new(total_savings, currency), + saved_toward_goals_money: Money.new(saved_toward_goals, currency), + accounts_count: family.savings_subtype_accounts.size, + active_goals_count: @counts["active"].to_i, + delta: delta, + delta_amount_money: Money.new(delta[:amount].abs, currency), + sparkline_series: family.savings_balance_series(days: 30) } end + def goal_count_per_account(accounts) + return {} if accounts.empty? + + SavingsGoalAccount + .where(account_id: accounts.map(&:id)) + .joins(:savings_goal) + .where.not(savings_goals: { state: %w[archived] }) + .group(:account_id) + .count + end + def stats_for(goal) avg = goal.average_monthly_contribution.to_d sub_avg = if goal.monthly_target_amount && goal.monthly_target_amount.to_d > avg diff --git a/app/javascript/controllers/savings_goals_filter_controller.js b/app/javascript/controllers/savings_goals_filter_controller.js new file mode 100644 index 000000000..23558d511 --- /dev/null +++ b/app/javascript/controllers/savings_goals_filter_controller.js @@ -0,0 +1,54 @@ +import { Controller } from "@hotwired/stimulus"; + +// Free-text + status-chip filter for the savings-goals index grid. +// Mirrors the providers-filter pattern. Each card has data-goal-name +// and data-goal-status; the controller toggles `.hidden` on cards +// based on the active query/chip. +export default class extends Controller { + static targets = ["input", "chip", "card", "empty"]; + static values = { status: { type: String, default: "all" } }; + + connect() { + this.syncChipState(); + } + + filter() { + const query = this.hasInputTarget + ? this.inputTarget.value.toLocaleLowerCase().trim() + : ""; + const active = this.statusValue; + let visible = 0; + + this.cardTargets.forEach((card) => { + const name = (card.dataset.goalName || "").toLocaleLowerCase(); + const status = card.dataset.goalStatus || ""; + const matchesQuery = !query || name.includes(query); + const matchesStatus = active === "all" || status === active; + const show = matchesQuery && matchesStatus; + card.classList.toggle("hidden", !show); + if (show) visible++; + }); + + if (this.hasEmptyTarget) { + this.emptyTarget.classList.toggle("hidden", visible > 0); + } + } + + selectChip(event) { + this.statusValue = event.currentTarget.dataset.status || "all"; + this.syncChipState(); + this.filter(); + } + + syncChipState() { + if (!this.hasChipTarget) return; + this.chipTargets.forEach((chip) => { + const active = chip.dataset.status === this.statusValue; + chip.setAttribute("aria-pressed", active); + chip.classList.toggle("bg-container", active); + chip.classList.toggle("shadow-border-xs", active); + chip.classList.toggle("text-primary", active); + chip.classList.toggle("text-secondary", !active); + }); + } +} diff --git a/app/javascript/controllers/savings_sparkline_controller.js b/app/javascript/controllers/savings_sparkline_controller.js new file mode 100644 index 000000000..e0ea79e14 --- /dev/null +++ b/app/javascript/controllers/savings_sparkline_controller.js @@ -0,0 +1,107 @@ +import { Controller } from "@hotwired/stimulus"; +import * as d3 from "d3"; + +// Sparkline area chart for the savings hero card. Tiny, axis-less, +// labelless — just the green line + soft area fill + end dot. +// Data: [{ date: "YYYY-MM-DD", value: Number }, ...] +export default class extends Controller { + static values = { series: Array }; + + connect() { + this._draw(); + this._resize = this._draw.bind(this); + window.addEventListener("resize", this._resize); + } + + disconnect() { + window.removeEventListener("resize", this._resize); + } + + _draw() { + const root = this.element; + root.innerHTML = ""; + + const series = (this.seriesValue || []).map((p) => ({ + date: new Date(p.date), + value: Number(p.value || 0), + })); + if (series.length < 2) return; + + const width = root.clientWidth || 600; + const height = root.clientHeight || 140; + if (width <= 0 || height <= 0) return; + + const margin = { top: 8, right: 12, bottom: 4, left: 8 }; + const innerWidth = width - margin.left - margin.right; + const innerHeight = height - margin.top - margin.bottom; + + const x = d3 + .scaleTime() + .domain(d3.extent(series, (d) => d.date)) + .range([margin.left, margin.left + innerWidth]); + + const yMin = Math.min(...series.map((d) => d.value)); + const yMax = Math.max(...series.map((d) => d.value)); + const padding = (yMax - yMin) * 0.15 || yMax * 0.05 || 1; + const y = d3 + .scaleLinear() + .domain([Math.max(0, yMin - padding), yMax + padding]) + .range([margin.top + innerHeight, margin.top]); + + const svg = d3 + .select(root) + .append("svg") + .attr("width", width) + .attr("height", height) + .attr("viewBox", `0 0 ${width} ${height}`) + .attr("preserveAspectRatio", "none"); + + const gradId = `sparkline-fill-${Math.random().toString(36).slice(2, 8)}`; + const defs = svg.append("defs"); + const grad = defs + .append("linearGradient") + .attr("id", gradId) + .attr("x1", 0).attr("y1", 0).attr("x2", 0).attr("y2", 1); + grad.append("stop").attr("offset", "0%").attr("stop-color", "var(--color-green-500)").attr("stop-opacity", 0.18); + grad.append("stop").attr("offset", "100%").attr("stop-color", "var(--color-green-500)").attr("stop-opacity", 0); + + const area = d3 + .area() + .x((d) => x(d.date)) + .y0(margin.top + innerHeight) + .y1((d) => y(d.value)) + .curve(d3.curveMonotoneX); + + const line = d3 + .line() + .x((d) => x(d.date)) + .y((d) => y(d.value)) + .curve(d3.curveMonotoneX); + + svg + .append("path") + .datum(series) + .attr("fill", `url(#${gradId})`) + .attr("d", area); + + svg + .append("path") + .datum(series) + .attr("fill", "none") + .attr("stroke", "var(--color-green-600)") + .attr("stroke-width", 2) + .attr("stroke-linejoin", "round") + .attr("stroke-linecap", "round") + .attr("d", line); + + const last = series[series.length - 1]; + svg + .append("circle") + .attr("cx", x(last.date)) + .attr("cy", y(last.value)) + .attr("r", 4) + .attr("fill", "var(--color-green-600)") + .attr("stroke", "var(--bg-container)") + .attr("stroke-width", 2); + } +} diff --git a/app/models/family.rb b/app/models/family.rb index 3ccdc380e..db400598c 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -45,6 +45,62 @@ class Family < ApplicationRecord has_many :savings_goals, dependent: :destroy has_many :savings_contributions, through: :savings_goals + # Depository accounts with subtype = "savings". The /savings_goals + # index hero shows the total + sparkline across just these accounts; + # checking / HSA / CD / money-market are intentionally excluded. + def savings_subtype_accounts + accounts.where(accountable_type: "Depository").visible.alphabetically.select do |account| + account.subtype == "savings" + end + end + + # Sum of current balances across savings_subtype_accounts, in the + # family's primary currency. Multi-currency accounts are summed + # naively (FX out of scope for the v1 hero card). + def total_savings_balance + savings_subtype_accounts.sum { |a| a.balance.to_d } + end + + # Returns [{ date: "YYYY-MM-DD", value: Float }, ...] running daily + # totals over the trailing `days` window across savings_subtype_accounts. + # Uses each account's recorded balances; falls back to the current + # balance for any day that has no recorded snapshot. + def savings_balance_series(days: 30) + accs = savings_subtype_accounts + return [] if accs.empty? + + start_date = days.days.ago.to_date + end_date = Date.current + balances_by_account = Balance + .where(account_id: accs.map(&:id), date: start_date..end_date) + .order(:date) + .group_by(&:account_id) + + (start_date..end_date).map do |date| + total = accs.sum do |account| + snapshots = balances_by_account[account.id] || [] + snapshot = snapshots.reverse.find { |b| b.date <= date } + (snapshot&.balance || account.balance).to_d + end + { date: date.to_s, value: total.to_f } + end + end + + # 30-day delta on total savings balance, with arrow + percent helpers + # for the hero card. { amount:, percent:, direction: } where direction + # is :up / :down / :flat. + def savings_balance_30d_delta + series = savings_balance_series(days: 30) + return { amount: 0, percent: 0, direction: :flat } if series.size < 2 + + first = series.first[:value].to_d + last = series.last[:value].to_d + diff = last - first + pct = first.zero? ? 0 : ((diff / first) * 100).round(1) + dir = diff.positive? ? :up : (diff.negative? ? :down : :flat) + { amount: diff, percent: pct.to_f, direction: dir } + end + has_many :llm_usages, dependent: :destroy has_many :recurring_transactions, dependent: :destroy diff --git a/app/views/savings_goals/index.html.erb b/app/views/savings_goals/index.html.erb index dae256782..8ae292476 100644 --- a/app/views/savings_goals/index.html.erb +++ b/app/views/savings_goals/index.html.erb @@ -1,73 +1,140 @@ -
-
-
-

<%= t(".title") %>

-

<%= t(".subtitle") %>

-
- <% if @linkable_account_count > 0 %> - <%= render DS::Link.new( - text: t(".new_goal"), - variant: "primary", - href: new_savings_goal_path, - icon: "plus", - frame: :modal - ) %> - <% end %> +
+
+

<%= t(".title") %>

+

<%= t(".subtitle") %>

- <% if @savings_goals.empty? && @counts["all"].zero? %> + <% if @counts["all"].zero? && @savings_accounts.empty? %> <%= render "empty_state", linkable_account_count: @linkable_account_count %> <% else %> - <% if @counts["all"].positive? %> -
-
-

<%= t(".summary.total_saved") %>

-
- <%= @totals[:saved].format %> - / <%= @totals[:target].format %> + <%# Hero card %> +
+
+

<%= t(".hero.total_in_savings") %>

+

<%= @hero[:total_savings_money].format %>

+ <% delta = @hero[:delta] %> + <% if delta[:direction] != :flat %> +

mt-1 tabular-nums"> + <%= t("savings_goals.index.hero.delta_#{delta[:direction]}", amount: @hero[:delta_amount_money].format, percent: delta[:percent].abs) %> +

+ <% end %> + +
+
+

<%= t(".hero.accounts") %>

+

<%= @hero[:accounts_count] %>

-
-
+
+

<%= t(".hero.active_goals") %>

+

<%= @hero[:active_goals_count] %>

+
+
+

<%= t(".hero.saved_toward_goals") %>

+

<%= @hero[:saved_toward_goals_money].format %>

-
-

<%= t(".summary.active_goals") %>

-

<%= @counts["active"] %>

+
+ + <% if @hero[:sparkline_series].size >= 2 %> +
+ <% end %> +
+ + <%# Accounts section %> + <% if @savings_accounts.any? %> +
+
+

<%= t(".accounts_section.heading") %>

+

<%= t(".accounts_section.subtitle") %>

-
-

<%= t(".summary.on_track") %>

-

<%= @totals[:on_track_count] %> / <%= @counts["active"] %>

-
-
-

<%= t(".summary.behind") %>

-

<%= @totals[:behind_count] %>

+
+ <% @savings_accounts.each do |account| %> + <%= render Savings::AccountCardComponent.new(account: account, goals_count: @account_goal_counts[account.id] || 0) %> + <% end %>
<% end %> - +
- <% if @savings_goals.any? %> -
- <% @savings_goals.each do |goal| %> - <%= render Savings::GoalCardComponent.new(goal: goal) %> - <% end %> -
- <% else %> -
-

<%= t(".empty_filtered", state: t(".tabs.#{@state_filter}").downcase) %>

-
+ <% if @show_search %> +
+
+ " + placeholder="<%= t(".search.placeholder") %>" + class="block w-full border border-secondary rounded-md py-2.5 pl-10 pr-3 bg-container focus:ring-gray-500 sm:text-sm"> +
+ <%= icon "search", class: "text-secondary" %> +
+
+
+ <% %w[all on_track behind no_target_date].each do |status| %> + <% active = status == "all" %> + + <% end %> +
+
+ <% end %> + + <% if @active_goals.any? %> +
+ <% @active_goals.each do |goal| %> + <%= render Savings::GoalCardComponent.new(goal: goal) %> + <% end %> +
+ + <% else %> +
+

<%= t(".empty_filtered") %>

+
+ <% end %> +
+ + <% if @completed_goals.any? %> +
+
+ <%= icon("circle-check-big", size: "sm") %> + <%= t(".completed_section.heading") %> + · + <%= @completed_goals.size %> +
+
+ <% @completed_goals.each do |goal| %> + <%= render Savings::GoalCardComponent.new(goal: goal) %> + <% end %> +
+
<% end %> <% end %>
diff --git a/config/locales/views/savings_goals/en.yml b/config/locales/views/savings_goals/en.yml index ce603b3fd..9966600f4 100644 --- a/config/locales/views/savings_goals/en.yml +++ b/config/locales/views/savings_goals/en.yml @@ -2,21 +2,39 @@ en: savings_goals: index: - title: Savings goals - subtitle: Save toward what matters. + title: Savings + subtitle: Your savings accounts and the goals you're working toward. new_goal: New goal - empty_filtered: No %{state} goals. - tabs: - all: All - active: Active - paused: Paused - completed: Completed - archived: Archived - summary: - total_saved: Total saved across goals + empty_filtered: No goals match. + hero: + total_in_savings: Total in savings + delta_up: "+%{amount} (↑ %{percent}%) vs. last 30 days" + delta_down: "−%{amount} (↓ %{percent}%) vs. last 30 days" + delta_flat: vs. last 30 days + accounts: Accounts active_goals: Active goals + saved_toward_goals: Saved toward goals + accounts_section: + heading: Accounts + subtitle: Your savings and emergency reserves + goals_section: + heading: Goals + subtitle: Save toward what matters. + completed_section: + heading: Completed + search: + placeholder: Search goals… + aria_label: Search savings goals + empty: No goals match. + chips: + all: All on_track: On track behind: Behind + no_target_date: No date + account_card: + funds: + one: Funds 1 goal + other: "Funds %{count} goals" new: heading: New savings goal step1_subtitle: Step 1 of 2 · Goal details @@ -107,7 +125,7 @@ en: archived: Archived status: on_track: On track - behind: Behind pace + behind: Behind reached: Reached no_target_date: No date empty_state: @@ -120,6 +138,7 @@ en: goal_card: no_accounts: No linked accounts n_accounts: "%{first} +%{count}" + left: left accounts: one: 1 account other: "%{count} accounts" @@ -127,6 +146,9 @@ en: completed: Completed past_due: Past due days_left: + one: 1 day left + other: "%{count} days left" + days_left_by: one: 1 day left · by %{date} other: "%{count} days left · by %{date}" form_stepper: