Skip to content
Open
Show file tree
Hide file tree
Changes from 31 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
7d21a06
feat(savings): add savings_goals and savings_contributions tables
gariasf Apr 27, 2026
334c580
feat(savings): add SavingsGoal and SavingsContribution models
gariasf Apr 27, 2026
c04c9df
feat(savings): wire Budget#monthly_surplus and Family#savings_summary…
gariasf Apr 27, 2026
199ba99
feat(savings): add AutoFundJob and monthly cron orchestrator
gariasf Apr 27, 2026
13c11bd
feat(savings): link SavingsGoal to a backing asset Account
gariasf Apr 27, 2026
92e499b
feat(savings): add controllers, routes, and placeholder views
gariasf Apr 27, 2026
f9a23ba
feat(savings): real UI — progress ring, goal cards, forms, budget sub…
gariasf Apr 27, 2026
284d172
fix(savings): UX polish and demo seed data
gariasf Apr 27, 2026
ac682b2
fix(savings): surface "New goal" on the budget page itself
gariasf Apr 27, 2026
67bcd4a
fix(savings): IA cleanup, fix dead nav buttons, add breadcrumbs
gariasf Apr 27, 2026
9800512
fix(savings): drop "Apply to budget month" field from contribution form
gariasf Apr 27, 2026
8abeae1
fix(savings): label icon-only buttons + reorder index tabs
gariasf Apr 27, 2026
4f069fc
fix(savings): align with Sure design tokens and idiomatic patterns
gariasf Apr 27, 2026
e34e1ea
fix(savings): render new/edit/contribution forms as modals
gariasf Apr 27, 2026
eab5cdf
fix(savings): redesign with Sure conventions, dropping PR #833 guidance
gariasf Apr 27, 2026
2e80ce5
test(savings): cover gaps + harden mass-assignment / advisory-lock SQL
gariasf Apr 27, 2026
e15d2bd
fix(savings): render ring labels via HTML overlay, matching budget donut
gariasf Apr 27, 2026
37d4f9a
fix(savings): plug multi-currency + account-swap edge cases, add edge…
gariasf Apr 27, 2026
b5ea5fe
fix(savings): honor family month_start_day in budget resolution
gariasf Apr 27, 2026
d9e51a1
fix(savings): address coderabbit review (real bugs + cleanup)
gariasf Apr 27, 2026
5efffc5
fix(savings): savepoint each AutoFundJob create + drop dead || 0
gariasf Apr 27, 2026
3cfc687
i18n(savings): route all user-facing strings through t()
gariasf Apr 27, 2026
69c8112
fix(savings): correct breadcrumbs when re-rendering :new / :edit
gariasf Apr 27, 2026
657aeb2
fix(savings): cascade budget delete + gate auto-fund button on fundab…
gariasf Apr 28, 2026
4a584c7
chore(savings): align form input types with Sure conventions + CLAUDE…
gariasf Apr 28, 2026
4e19261
perf(savings): memoize per-instance derived values + eager-load goal.…
gariasf Apr 28, 2026
2efa44a
perf(savings): grouped balance prefetch + bulk-enqueue the auto-fund …
gariasf Apr 28, 2026
e40341a
fix(savings): unify advisory-lock key so manual writes serialize with…
gariasf Apr 28, 2026
254d668
fix(savings): handle narrow-viewport overflow on tab row and budget-c…
gariasf Apr 28, 2026
8ddc496
Merge remote-tracking branch 'upstream/main' into feature/savings-goals
gariasf May 1, 2026
8f790af
Merge branch 'main' into feature/savings-goals
gariasf May 4, 2026
602615e
Merge branch 'main' into feature/savings-goals
gariasf May 7, 2026
44811fc
Merge branch 'main' into feature/savings-goals
gariasf May 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ The application is built around financial data management with these key relatio
- **Account** types: checking, savings, credit cards, investments, crypto, loans, properties
- **Transaction** → belongs to **Category**, can have **Tags** and **Rules**
- **Investment accounts** → have **Holdings** → track **Securities** via **Trades**
- **Family** → has many **Budgets** (per month) and **Savings Goals** → which receive **Savings Contributions** (sources: initial / manual / auto)

### API Architecture
The application provides both internal and external APIs:
Expand Down
19 changes: 19 additions & 0 deletions app/components/savings/goal_card_component.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<%= link_to savings_goal_path(goal),
class: "block bg-container rounded-xl shadow-border-xs p-5 hover:shadow-border-sm transition" do %>
<div class="flex items-start gap-4">
<%= render Savings::ProgressRingComponent.new(percent: goal.progress_percent, size: 72, stroke: 6, color: goal.color) %>
<div class="grow min-w-0">
<div class="flex items-center justify-between gap-2">
<h3 class="text-primary font-medium truncate"><%= goal.name %></h3>
<span class="text-xs px-2 py-0.5 rounded-full font-medium <%= state_badge_classes %>">
<%= state_label %>
</span>
</div>
<p class="text-secondary text-sm mt-1 truncate"><%= target_summary %></p>
<p class="text-primary text-sm mt-2 font-medium">
<%= helpers.format_money goal.current_balance_money %>
<span class="text-secondary font-normal">/ <%= helpers.format_money target_amount_money %></span>
</p>
</div>
</div>
<% end %>
30 changes: 30 additions & 0 deletions app/components/savings/goal_card_component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
class Savings::GoalCardComponent < ApplicationComponent
attr_reader :goal

def initialize(goal:)
@goal = goal
end

def state_badge_classes
case goal.state
when "active" then "bg-success/10 text-success"
when "paused" then "bg-warning/10 text-warning"
when "completed" then "bg-success/20 text-success"
when "archived" then "bg-container-inset text-secondary"
else "bg-container-inset text-secondary"
end
end

def target_summary
return goal.account.name if goal.target_date.nil?
"#{I18n.l(goal.target_date, format: '%b %Y')} · #{goal.account.name}"
end

def state_label
I18n.t("savings_goals.states.#{goal.state}", default: goal.state.titleize)
end

def target_amount_money
Money.new(goal.target_amount, goal.currency)
end
end
33 changes: 33 additions & 0 deletions app/components/savings/progress_ring_component.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<%# Wrapper sets ring footprint; SVG is absolutely positioned so text overlay
can flex-centre regardless of ring size. Same overlay pattern Sure uses
for the budget donut (`app/views/budgets/_budget_donut.html.erb`). %>
<%= tag.div class: "relative shrink-0 inline-flex items-center justify-center",
style: "width: #{size}px; height: #{size}px;" do %>
<%= tag.svg width: size, height: size, viewBox: "0 0 #{size} #{size}",
role: "img", "aria-label": "#{percent}% complete",
class: "absolute inset-0" do %>
<%= tag.circle cx: size / 2.0, cy: size / 2.0, r: radius,
fill: "transparent",
class: "stroke-current text-gray-300",
"stroke-width": stroke %>
<%= tag.circle cx: size / 2.0, cy: size / 2.0, r: radius,
fill: "transparent",
style: "stroke: #{fill_stroke_color}",
"stroke-width": stroke,
"stroke-linecap": "round",
"stroke-dasharray": circumference,
"stroke-dashoffset": offset,
transform: "rotate(-90 #{size / 2.0} #{size / 2.0})" %>
<% end %>

<%# HTML text overlay — Tailwind typography, easy to control across
ring sizes. First line is the primary value; subsequent lines are
small secondary captions. %>
<div class="relative flex flex-col items-center justify-center text-center leading-tight px-2">
<% label_lines.each_with_index do |line, idx| %>
<span class="<%= idx.zero? ? "text-primary font-medium #{primary_size_class}" : "text-secondary text-xs" %>">
<%= line %>
</span>
<% end %>
</div>
<% end %>
51 changes: 51 additions & 0 deletions app/components/savings/progress_ring_component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
class Savings::ProgressRingComponent < ApplicationComponent
attr_reader :percent, :size, :stroke, :color, :label_lines

# `label_lines` is an array of strings rendered as <tspan> rows inside the
# ring. Defaults to `["#{percent}%"]` so existing call sites (goal cards,
# budget summary card) keep their compact percent display. The goal show
# page passes a richer ["$1,250", "of $6,000", "21%"] for the larger ring.
def initialize(percent:, size: 80, stroke: 6, color: nil, label_lines: nil)
@percent = percent.to_i.clamp(0, 100)
@size = size
@stroke = stroke
@color = color
@label_lines = Array(label_lines || [ "#{@percent}%" ])
end

def radius
(size - stroke) / 2.0
end

def circumference
2 * Math::PI * radius
end

def offset
circumference - (circumference * percent / 100.0)
end

# CSS vars resolve fine inside an inline `style=` attribute (Sure's
# shared/_progress_circle uses the same trick), unlike raw SVG `stroke=`
# attribute values where the spec is fussier.
def fill_stroke_color
return color if color.present?
case percent
when 0..24 then "var(--color-gray-400)" # barely started
when 25..74 then "var(--color-blue-500)" # in progress
else "var(--color-success)" # near or at target
end
end

# Scales the primary line's font size with the ring size so a 72px goal
# card shows a compact "21%" while the 180px show-page ring shows a
# full "$1,250" without spilling out of the inner area.
def primary_size_class
case size
when 0..80 then "text-xs"
when 81..120 then "text-sm"
when 121..160 then "text-base"
else "text-lg"
end
end
end
22 changes: 22 additions & 0 deletions app/controllers/budgets/savings_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
module Budgets
class SavingsController < ApplicationController
before_action :set_budget

def auto_fund
SavingsGoals::AutoFundJob.perform_later(Current.family.id, @budget.id)
redirect_to budget_path(Budget.date_to_param(@budget.start_date)),
notice: t("budgets.savings.auto_fund.success")
end

private
# Pass `family:` so families with a non-default `month_start_day`
# parse the route param to their own boundary (e.g. the 15th)
# instead of falling through to `beginning_of_month`. Mirrors the
# upstream BudgetsController#set_budget signature.
def set_budget
start_date = Budget.param_to_date(params[:budget_month_year], family: Current.family)
@budget = Budget.find_or_bootstrap(Current.family, start_date: start_date, user: Current.user)
raise ActiveRecord::RecordNotFound unless @budget
end
end
end
1 change: 1 addition & 0 deletions app/controllers/budgets_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ def index

def show
@source_budget = @budget.most_recent_initialized_budget unless @budget.initialized?
@savings_summary = Current.family.savings_summary_for(@budget)
end

def edit
Expand Down
71 changes: 71 additions & 0 deletions app/controllers/savings_contributions_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
class SavingsContributionsController < ApplicationController
before_action :set_savings_goal
before_action :set_contribution, only: :destroy
before_action :set_breadcrumbs


def new
@contribution = @savings_goal.savings_contributions.new(contributed_at: Date.current)
end

def create
@contribution = @savings_goal.savings_contributions.new(contribution_params)
@contribution.source = "manual"
@contribution.contributed_at ||= Date.current

if save_with_advisory_lock(@contribution)
flash[:notice] = t("savings_contributions.create.success")
respond_to do |format|
format.html { redirect_to savings_goal_path(@savings_goal) }
format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, savings_goal_path(@savings_goal)) }
end
else
render :new, status: :unprocessable_entity
end
end

def destroy
@contribution.destroy
redirect_to savings_goal_path(@savings_goal), notice: t("savings_contributions.destroy.success")
end

private
def set_breadcrumbs
@breadcrumbs = [
[ t("breadcrumbs.home"), root_path ],
[ t("savings_goals.index.title"), savings_goals_path ],
[ @savings_goal.name, savings_goal_path(@savings_goal) ],
[ t("savings_goals.show.contributions.add"), nil ]
]
end

def set_savings_goal
@savings_goal = Current.family.savings_goals.find(params[:savings_goal_id])
end

def set_contribution
@contribution = @savings_goal.savings_contributions.find(params[:id])
end

def contribution_params
params.require(:savings_contribution).permit(:amount, :notes, :contributed_at)
end

# Wraps the create in a Postgres advisory xact lock so concurrent
# contribution attempts on the same family serialize cleanly. The key
# comes from SavingsGoal.advisory_lock_key_for so manual contributions
# and AutoFundJob mutually exclude on the same family -- a separate
# key here would let an auto-fund insert race a manual contribution
# off a stale `remaining_amount` snapshot and overfund the goal.
def save_with_advisory_lock(contribution)
key = SavingsGoal.advisory_lock_key_for(Current.family.id)
saved = false
Family.transaction do
ActiveRecord::Base.connection.execute(
ActiveRecord::Base.sanitize_sql_array([ "SELECT pg_advisory_xact_lock(?)", key ])
)
saved = contribution.save
end
saved
end
end
Loading
Loading