diff --git a/app/controllers/savings_goals_controller.rb b/app/controllers/savings_goals_controller.rb index 12e516427..4ab275ee7 100644 --- a/app/controllers/savings_goals_controller.rb +++ b/app/controllers/savings_goals_controller.rb @@ -15,7 +15,7 @@ class SavingsGoalsController < ApplicationController @linkable_account_count = Current.family.accounts.where(accountable_type: "Depository").visible.count @savings_accounts = Current.family.savings_subtype_accounts @account_goal_counts = goal_count_per_account(@savings_accounts) - @hero = hero_payload(all_goals) + @kpi = kpi_payload(@active_goals) @show_search = @active_goals.size > 6 end @@ -155,21 +155,41 @@ class SavingsGoalsController < ApplicationController end end - def hero_payload(all_goals) + def kpi_payload(active_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 + today = Date.current + + velocity_30d = family.contribution_velocity(range: (today - 30)..today) + velocity_prior_30d = family.contribution_velocity(range: (today - 60)..(today - 31)) + delta_amount = velocity_30d - velocity_prior_30d + delta_percent = velocity_prior_30d.zero? ? nil : ((delta_amount / velocity_prior_30d) * 100).round(1) + velocity_direction = if delta_amount.positive? then :up + elsif delta_amount.negative? then :down + else :flat + end + + behind = active_goals.select { |g| g.status == :behind } + on_track = active_goals.select { |g| g.status == :on_track } + no_date = active_goals.select { |g| g.status == :no_target_date } + paused = active_goals.select(&:paused?) + needs = behind.sum { |g| g.monthly_target_amount.to_d } + { 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) + velocity_30d: velocity_30d, + velocity_30d_money: Money.new(velocity_30d.abs, currency), + velocity_prior_30d_money: Money.new(velocity_prior_30d, currency), + velocity_30d_sign: velocity_direction == :down ? "−" : (velocity_direction == :up ? "+" : ""), + velocity_delta_amount_money: Money.new(delta_amount.abs, currency), + velocity_delta_percent: delta_percent, + velocity_direction: velocity_direction, + needs_this_month_money: Money.new(needs, currency), + behind_count: behind.size, + on_track_count: on_track.size, + no_date_count: no_date.size, + paused_count: paused.size, + active_total: active_goals.size } end diff --git a/app/javascript/controllers/savings_goals_filter_controller.js b/app/javascript/controllers/savings_goals_filter_controller.js index 53a8029d2..f14bb969d 100644 --- a/app/javascript/controllers/savings_goals_filter_controller.js +++ b/app/javascript/controllers/savings_goals_filter_controller.js @@ -5,8 +5,24 @@ import { Controller } from "@hotwired/stimulus"; // 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", "grid", "count"]; - static values = { status: { type: String, default: "all" } }; + static targets = [ + "input", + "chip", + "card", + "empty", + "emptyCopy", + "emptyClearSearch", + "emptyClearFilter", + "grid", + "count", + ]; + static values = { + status: { type: String, default: "all" }, + emptyQuery: { type: String, default: "" }, + emptyFilter: { type: String, default: "" }, + emptyBoth: { type: String, default: "" }, + emptyDefault: { type: String, default: "" }, + }; connect() { this.syncChipState(); @@ -38,6 +54,46 @@ export default class extends Controller { if (this.hasCountTarget) { this.countTarget.textContent = visible; } + + this.updateEmptyState(visible, query, active); + } + + updateEmptyState(visible, query, active) { + if (visible > 0 || !this.hasEmptyCopyTarget) return; + const rawQuery = this.hasInputTarget ? this.inputTarget.value.trim() : ""; + const hasQuery = rawQuery.length > 0; + const hasFilter = active !== "all"; + let copy; + if (hasQuery && hasFilter) { + copy = this.emptyBothValue.replace("__QUERY__", rawQuery); + } else if (hasQuery) { + copy = this.emptyQueryValue.replace("__QUERY__", rawQuery); + } else if (hasFilter) { + copy = this.emptyFilterValue; + } else { + copy = this.emptyDefaultValue; + } + this.emptyCopyTarget.textContent = copy; + if (this.hasEmptyClearSearchTarget) { + this.emptyClearSearchTarget.classList.toggle("hidden", !hasQuery); + } + if (this.hasEmptyClearFilterTarget) { + this.emptyClearFilterTarget.classList.toggle("hidden", !hasFilter); + } + } + + clearSearch() { + if (this.hasInputTarget) { + this.inputTarget.value = ""; + this.inputTarget.focus(); + } + this.filter(); + } + + clearFilter() { + this.statusValue = "all"; + this.syncChipState(); + this.filter(); } selectChip(event) { diff --git a/app/javascript/controllers/savings_sparkline_controller.js b/app/javascript/controllers/savings_sparkline_controller.js deleted file mode 100644 index 2b0912a4b..000000000 --- a/app/javascript/controllers/savings_sparkline_controller.js +++ /dev/null @@ -1,110 +0,0 @@ -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)); - // Don't clamp to 0 — savings totals can be negative under certain - // demo / edge conditions, and clamping pushes the line off-canvas. - const range = yMax - yMin; - const padding = range > 0 ? range * 0.15 : Math.abs(yMax) * 0.05 || 1; - const y = d3 - .scaleLinear() - .domain([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 db400598c..78408be95 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -54,51 +54,11 @@ class Family < ApplicationRecord 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 } + # Sum of contribution amounts within the given date range, returned as + # a BigDecimal in the family's primary currency. Powers the savings + # goals "Contributed · last 30d" KPI. + def contribution_velocity(range:) + savings_contributions.where(contributed_at: range).sum(:amount).to_d end has_many :llm_usages, dependent: :destroy diff --git a/app/views/savings_goals/index.html.erb b/app/views/savings_goals/index.html.erb index 747d68f3a..d813eca56 100644 --- a/app/views/savings_goals/index.html.erb +++ b/app/views/savings_goals/index.html.erb @@ -7,39 +7,64 @@ <% if @counts["all"].zero? && @savings_accounts.empty? %> <%= render "empty_state", linkable_account_count: @linkable_account_count %> <% else %> - <%# 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) %> + <%# KPI strip %> +

+ <%# Velocity %> +
+

<%= t(".kpi.velocity_label") %>

+

+ <%= @kpi[:velocity_30d_sign] %><%= @kpi[:velocity_30d_money].format %> +

+ <% if @kpi[:velocity_direction] == :flat %> +

+ <% if @kpi[:velocity_prior_30d_money].zero? && @kpi[:velocity_30d_money].zero? %> + <%= t(".kpi.velocity_delta_zero_base") %> + <% else %> + <%= t(".kpi.velocity_delta_flat") %> + <% end %> +

+ <% elsif @kpi[:velocity_delta_percent].nil? %> +

<%= t(".kpi.velocity_delta_zero_base") %>

+ <% else %> +

mt-1 tabular-nums"> + <%= t(".kpi.velocity_delta_#{@kpi[:velocity_direction]}", percent: @kpi[:velocity_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 %>

-
-
- <% if @hero[:sparkline_series].size >= 2 %> -
- <% end %> + <%# Needs this month %> +
+

<%= t(".kpi.needs_this_month_label") %>

+

<%= @kpi[:needs_this_month_money].format %>

+

+ <% if @kpi[:behind_count].zero? %> + <%= t(".kpi.needs_this_month_zero_sub") %> + <% else %> + <%= t(".kpi.needs_this_month_sub", count: @kpi[:behind_count]) %> + <% end %> +

+
+ + <%# On-track count %> +
+

<%= t(".kpi.on_track_label") %>

+

+ <%= t(".kpi.on_track_value", on_track: @kpi[:on_track_count], total: @kpi[:active_total]) %> +

+

+ <% + parts = [] + parts << t(".kpi.on_track_sub_parts.behind", count: @kpi[:behind_count]) if @kpi[:behind_count].positive? + parts << t(".kpi.on_track_sub_parts.no_date", count: @kpi[:no_date_count]) if @kpi[:no_date_count].positive? + parts << t(".kpi.on_track_sub_parts.paused", count: @kpi[:paused_count]) if @kpi[:paused_count].positive? + %> + <% if parts.any? %> + <%= parts.join(" · ") %> + <% else %> + <%= t(".kpi.on_track_sub_all_good") %> + <% end %> +

+
<%# Accounts section %> @@ -58,7 +83,11 @@ <% end %> <%# Goals section %> -
+
" + data-savings-goals-filter-empty-filter-value="<%= t(".search.empty_with_filter") %>" + data-savings-goals-filter-empty-both-value="<%= t(".search.empty_with_both", query: "__QUERY__") %>" + data-savings-goals-filter-empty-default-value="<%= t(".search.empty") %>">

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

@@ -117,7 +146,21 @@ <% end %>
<% else %>
diff --git a/config/locales/views/savings_goals/en.yml b/config/locales/views/savings_goals/en.yml index 38d1c57fc..6849ebd87 100644 --- a/config/locales/views/savings_goals/en.yml +++ b/config/locales/views/savings_goals/en.yml @@ -6,14 +6,30 @@ en: subtitle: Your savings accounts and the goals you're working toward. new_goal: New goal 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 + kpi: + velocity_label: Contributed · last 30d + velocity_delta_up: "↑ %{percent}%% vs. prior 30d" + velocity_delta_down: "↓ %{percent}%% vs. prior 30d" + velocity_delta_flat: vs. prior 30d + velocity_delta_zero_base: First 30 days of activity + needs_this_month_label: Needs this month + needs_this_month_sub: + one: across 1 goal behind pace + other: "across %{count} goals behind pace" + needs_this_month_zero_sub: No goals behind pace + on_track_label: Goals on track + on_track_value: "%{on_track} of %{total}" + on_track_sub_parts: + behind: + one: 1 behind + other: "%{count} behind" + no_date: + one: 1 open-ended + other: "%{count} open-ended" + paused: + one: 1 paused + other: "%{count} paused" + on_track_sub_all_good: All active goals on pace accounts_section: heading: Accounts subtitle: Your savings and emergency reserves @@ -28,6 +44,11 @@ en: placeholder: Search goals… aria_label: Search savings goals empty: No goals match. + empty_with_query: "No goals match \"%{query}\"." + empty_with_filter: No goals match this filter. + empty_with_both: "No goals match \"%{query}\" with this filter." + clear_search: Clear search + show_all: Show all chips: all: All on_track: On track