mirror of
https://github.com/we-promise/sure.git
synced 2026-05-30 07:49:01 +00:00
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:).
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -7,39 +7,64 @@
|
||||
<% if @counts["all"].zero? && @savings_accounts.empty? %>
|
||||
<%= render "empty_state", linkable_account_count: @linkable_account_count %>
|
||||
<% else %>
|
||||
<%# Hero card %>
|
||||
<section class="bg-container rounded-xl shadow-border-xs p-7 grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.6fr)] gap-6 items-stretch min-h-[220px]">
|
||||
<div class="flex flex-col">
|
||||
<p class="text-xs text-secondary"><%= t(".hero.total_in_savings") %></p>
|
||||
<p class="text-4xl font-medium text-primary tabular-nums mt-1 privacy-sensitive"><%= @hero[:total_savings_money].format %></p>
|
||||
<% delta = @hero[:delta] %>
|
||||
<% if delta[:direction] != :flat %>
|
||||
<p class="text-xs <%= delta[:direction] == :up ? "text-success" : "text-destructive" %> mt-1 tabular-nums">
|
||||
<%= t("savings_goals.index.hero.delta_#{delta[:direction]}", amount: @hero[:delta_amount_money].format, percent: delta[:percent].abs) %>
|
||||
<%# KPI strip %>
|
||||
<section class="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<%# Velocity %>
|
||||
<div class="bg-container rounded-xl shadow-border-xs px-5 py-5">
|
||||
<p class="text-[11px] font-medium uppercase tracking-wide text-secondary"><%= t(".kpi.velocity_label") %></p>
|
||||
<p class="text-3xl font-medium text-primary tabular-nums mt-2 privacy-sensitive">
|
||||
<%= @kpi[:velocity_30d_sign] %><%= @kpi[:velocity_30d_money].format %>
|
||||
</p>
|
||||
<% if @kpi[:velocity_direction] == :flat %>
|
||||
<p class="text-xs text-secondary mt-1 tabular-nums">
|
||||
<% 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 %>
|
||||
</p>
|
||||
<% elsif @kpi[:velocity_delta_percent].nil? %>
|
||||
<p class="text-xs text-success mt-1 tabular-nums"><%= t(".kpi.velocity_delta_zero_base") %></p>
|
||||
<% else %>
|
||||
<p class="text-xs <%= @kpi[:velocity_direction] == :up ? "text-success" : "text-destructive" %> mt-1 tabular-nums">
|
||||
<%= t(".kpi.velocity_delta_#{@kpi[:velocity_direction]}", percent: @kpi[:velocity_delta_percent].abs) %>
|
||||
</p>
|
||||
<% end %>
|
||||
|
||||
<div class="grid grid-cols-3 gap-6 mt-auto pt-6">
|
||||
<div>
|
||||
<p class="text-xs text-secondary"><%= t(".hero.accounts") %></p>
|
||||
<p class="text-lg font-medium text-primary mt-1 tabular-nums"><%= @hero[:accounts_count] %></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-secondary"><%= t(".hero.active_goals") %></p>
|
||||
<p class="text-lg font-medium text-primary mt-1 tabular-nums"><%= @hero[:active_goals_count] %></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-secondary"><%= t(".hero.saved_toward_goals") %></p>
|
||||
<p class="text-lg font-medium text-primary mt-1 tabular-nums privacy-sensitive"><%= @hero[:saved_toward_goals_money].format %></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% if @hero[:sparkline_series].size >= 2 %>
|
||||
<div class="h-full min-h-[200px]"
|
||||
data-controller="savings-sparkline"
|
||||
data-savings-sparkline-series-value="<%= @hero[:sparkline_series].to_json %>"></div>
|
||||
<% end %>
|
||||
<%# Needs this month %>
|
||||
<div class="bg-container rounded-xl shadow-border-xs px-5 py-5">
|
||||
<p class="text-[11px] font-medium uppercase tracking-wide text-secondary"><%= t(".kpi.needs_this_month_label") %></p>
|
||||
<p class="text-3xl font-medium text-primary tabular-nums mt-2 privacy-sensitive"><%= @kpi[:needs_this_month_money].format %></p>
|
||||
<p class="text-xs text-secondary mt-1">
|
||||
<% if @kpi[:behind_count].zero? %>
|
||||
<%= t(".kpi.needs_this_month_zero_sub") %>
|
||||
<% else %>
|
||||
<%= t(".kpi.needs_this_month_sub", count: @kpi[:behind_count]) %>
|
||||
<% end %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<%# On-track count %>
|
||||
<div class="bg-container rounded-xl shadow-border-xs px-5 py-5">
|
||||
<p class="text-[11px] font-medium uppercase tracking-wide text-secondary"><%= t(".kpi.on_track_label") %></p>
|
||||
<p class="text-3xl font-medium text-primary tabular-nums mt-2">
|
||||
<%= t(".kpi.on_track_value", on_track: @kpi[:on_track_count], total: @kpi[:active_total]) %>
|
||||
</p>
|
||||
<p class="text-xs text-secondary mt-1">
|
||||
<%
|
||||
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 %>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<%# Accounts section %>
|
||||
@@ -58,7 +83,11 @@
|
||||
<% end %>
|
||||
|
||||
<%# Goals section %>
|
||||
<section data-controller="savings-goals-filter">
|
||||
<section data-controller="savings-goals-filter"
|
||||
data-savings-goals-filter-empty-query-value="<%= t(".search.empty_with_query", query: "__QUERY__") %>"
|
||||
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") %>">
|
||||
<div class="flex items-start justify-between mb-3 gap-3">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-primary"><%= t(".goals_section.heading") %></h2>
|
||||
@@ -117,7 +146,21 @@
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="hidden bg-container rounded-xl shadow-border-xs py-10 text-center" data-savings-goals-filter-target="empty">
|
||||
<p class="text-sm text-secondary"><%= t(".search.empty") %></p>
|
||||
<p class="text-sm text-secondary" data-savings-goals-filter-target="emptyCopy"><%= t(".search.empty") %></p>
|
||||
<div class="mt-3 flex items-center justify-center gap-2">
|
||||
<button type="button"
|
||||
class="hidden text-xs font-medium text-secondary underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-alpha-black-100"
|
||||
data-savings-goals-filter-target="emptyClearSearch"
|
||||
data-action="click->savings-goals-filter#clearSearch">
|
||||
<%= t(".search.clear_search") %>
|
||||
</button>
|
||||
<button type="button"
|
||||
class="hidden text-xs font-medium text-secondary underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-alpha-black-100"
|
||||
data-savings-goals-filter-target="emptyClearFilter"
|
||||
data-action="click->savings-goals-filter#clearFilter">
|
||||
<%= t(".search.show_all") %>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="bg-container rounded-xl shadow-border-xs py-12 text-center">
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user