Skip to content
Open
Show file tree
Hide file tree
Changes from 20 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
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?
"#{goal.target_date.strftime('%b %Y')} · #{goal.account.name}"
end

def state_label
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: "Auto-funding has been queued."
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
70 changes: 70 additions & 0 deletions app/controllers/savings_contributions_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
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] = "Contribution added."
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: "Contribution removed."
end

private
def set_breadcrumbs
@breadcrumbs = [
[ "Home", root_path ],
[ "Savings goals", savings_goals_path ],
[ @savings_goal.name, savings_goal_path(@savings_goal) ],
[ "Add contribution", 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
# partial unique index on (savings_goal_id, budget_id) for source=auto
# handles auto-vs-auto races at the DB level; this lock keeps manual
# contributions tidy too.
def save_with_advisory_lock(contribution)
key = Digest::SHA1.hexdigest("savings_contribution:#{Current.family.id}").to_i(16) % (2**63)
Comment thread
gariasf marked this conversation as resolved.
Outdated
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
150 changes: 150 additions & 0 deletions app/controllers/savings_goals_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
class SavingsGoalsController < ApplicationController
before_action :set_savings_goal, only: %i[show edit update destroy pause resume complete archive unarchive]
before_action :set_backing_accounts, only: %i[new create edit update]
before_action :set_breadcrumbs


def index
state = params[:state].presence_in(%w[all active paused completed archived]) || "all"
scope = Current.family.savings_goals.alphabetically
@savings_goals = state == "all" ? scope : scope.where(state: state)
@state = state
end

def show
end

def new
@savings_goal = Current.family.savings_goals.new(state: "active")
end

def create
@savings_goal = Current.family.savings_goals.new(savings_goal_params)
@savings_goal.account = lookup_account(params.dig(:savings_goal, :account_id))

begin
ActiveRecord::Base.transaction do
@savings_goal.save!
handle_initial_contribution(@savings_goal)
end
rescue ActiveRecord::RecordInvalid
return render :new, status: :unprocessable_entity
Comment thread
coderabbitai[bot] marked this conversation as resolved.
end

flash[:notice] = "Savings goal created."
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
end

def edit
end

def update
submitted_account_id = params.dig(:savings_goal, :account_id)
if submitted_account_id.present?
candidate = lookup_account(submitted_account_id)
# Only swap to a non-nil family-scoped account. A foreign account_id
# returns nil from `lookup_account`; assigning nil would null the
# `belongs_to :account` association and block unrelated attribute
# changes (e.g. a name edit) in the same request. Silently ignoring
# the foreign id keeps the rest of the update flowing through.
@savings_goal.account = candidate if candidate
end
Comment thread
gariasf marked this conversation as resolved.

if @savings_goal.update(savings_goal_params)
flash[:notice] = "Savings goal updated."
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 :edit, status: :unprocessable_entity
end
end

def destroy
@savings_goal.destroy
redirect_to savings_goals_path, notice: "Savings goal deleted."
end

def pause
transition!(:pause!, "Goal paused.")
end

def resume
transition!(:resume!, "Goal resumed.")
end

def complete
transition!(:complete!, "Goal marked as completed.")
end

def archive
transition!(:archive!, "Goal archived.")
end

def unarchive
transition!(:unarchive!, "Goal restored to active.")
end

private
def set_breadcrumbs
crumbs = [ [ "Home", root_path ], [ "Savings goals", savings_goals_path ] ]
if @savings_goal&.persisted?
crumbs << [ @savings_goal.name, savings_goal_path(@savings_goal) ] if action_name != "show"
crumbs << [ @savings_goal.name, nil ] if action_name == "show"
crumbs << [ "Edit", nil ] if action_name == "edit"
elsif action_name == "new"
crumbs << [ "New goal", nil ]
else
crumbs.last[1] = nil
end
@breadcrumbs = crumbs
end

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

def set_backing_accounts
@backing_accounts = Current.family.accounts
.where(classification: "asset",
accountable_type: %w[Depository Investment OtherAsset])
.alphabetically
end

def savings_goal_params
# `account_id` is intentionally omitted from the permit list. We
# assign `@savings_goal.account` manually via `lookup_account`, which
# scopes the lookup to `Current.family.accounts`. Permitting account_id
# here would let mass-assignment bypass that check.
params.require(:savings_goal).permit(
:name, :target_amount, :target_date, :color, :icon, :notes
)
end

# Scopes the lookup so a foreign account_id never silently associates.
def lookup_account(account_id)
return nil if account_id.blank?
Current.family.accounts.find_by(id: account_id)
end

def handle_initial_contribution(goal)
amount = params.dig(:savings_goal, :initial_contribution).to_d
return unless amount.positive?
goal.savings_contributions.create!(
amount: amount,
source: "initial",
contributed_at: Date.current
)
end

def transition!(event, message)
@savings_goal.public_send(event)
redirect_to savings_goal_path(@savings_goal), notice: message
rescue AASM::InvalidTransition => e
redirect_to savings_goal_path(@savings_goal), alert: e.message
end
end
Loading