Finance Core
Goal Pacing
A debt payoff simulation needed month-by-month interest accrual, a cascading budget, and a convergence cap. A goal needs almost none of that — GoalMath is a single pure function, and the reason it can be that small is that a goal's inputs are already stable, stated numbers rather than noisy transaction history.
Current amount: a linked balance, or a number someone typed
A goal tracks its progress from exactly one of two sources — never a blend:
// Progress source: the linked account's live balance, or the manually
// tracked amount. Never adds to net worth or any KPI — a read-only overlay.
var currentAmount: Decimal {
linkedAccount?.effectiveBalance ?? manualCurrentAmount
}
Linking a goal to a real savings account means its progress is always exactly that account's balance — no separate number to keep in sync by hand. A goal with nothing linked falls back to a manually logged figure. Either way, a goal never feeds back into net worth or any dashboard KPI; it's explicitly a read-only overlay on money that's already counted elsewhere.
One function, one struct
Everything a goal screen shows — how much is left, whether the pace works, when it'll actually finish — comes out of a single call:
public struct GoalProjection {
public var remaining: Decimal
public var progressFraction: Double
public var monthsToTarget: Int? // nil without a target date
public var requiredMonthly: Decimal? // nil without a target date
public var projectedCompletion: Date? // nil when plannedMonthly is 0
public var onTrack: Bool? // nil without a target date
}
public enum GoalMath {
public static func project(
target: Decimal, current: Decimal, plannedMonthly: Decimal,
targetDate: Date?, asOf: Date, calendar: Calendar = .current
) -> GoalProjection {
guard target > 0 else {
return GoalProjection(remaining: 0, progressFraction: 1, onTrack: true)
}
let remaining = Money.clampNonNegative(target - current)
guard remaining > 0 else {
return GoalProjection(remaining: 0, progressFraction: 1, onTrack: true)
}
let progressFraction = min(1, max(0, (current / target).doubleValue))
var monthsToTarget: Int?
var requiredMonthly: Decimal?
var onTrack: Bool?
if let targetDate {
let months = calendar.dateComponents([.month], from: asOf, to: targetDate).month ?? 0
let clamped = max(1, months)
monthsToTarget = clamped
let required = Money.rounded(remaining / Decimal(clamped))
requiredMonthly = required
onTrack = plannedMonthly >= required
}
var projectedCompletion: Date?
if plannedMonthly > 0 {
let monthsNeeded = max(1, Int((remaining / plannedMonthly).doubleValue.rounded(.up)))
projectedCompletion = calendar.date(byAdding: .month, value: monthsNeeded, to: asOf)
}
return GoalProjection(
remaining: remaining, progressFraction: progressFraction,
monthsToTarget: monthsToTarget, requiredMonthly: requiredMonthly,
projectedCompletion: projectedCompletion, onTrack: onTrack
)
}
}
max(1, months) is doing the same job it did the first time this pattern showed up in this series: a goal three weeks out has zero whole months between now and the deadline, and treating that as "the entire remaining amount is due within this one month" is both correct and avoids a division by zero.
"On track" is a plain boolean, on purpose
onTrack is plannedMonthly >= requiredMonthly — nothing softer than that. No tolerance band, no "within 10% counts as on track." That's a deliberate difference from how the dashboard's KPI trends are handled elsewhere in this series, and the reason is what each side is actually comparing. A dashboard trend compares this month's measured behavior — real transactions, naturally noisy — against last month's, so a raw comparison would flicker on nothing but timing noise. A goal compares two numbers that don't have that problem: plannedMonthly is a figure the person typed in when they set the goal, and requiredMonthly is a deterministic function of the target, the current amount, and the date. Neither one jitters day to day, so there's nothing for a tolerance band to protect against.
That also means plannedMonthly is a stated intention, not a measured rate — GoalMath never looks at a goal's actual contribution history at all. "Are you meeting the pace you said you would" and "are you actually saving what you think you are" are different questions; the second one is what the dashboard's savings rate is for, built from real transactions in the previous posts. A goal only ever answers the first.
A projected date, run the same shape in reverse
projectedCompletion asks "at the stated monthly pace, when does this actually finish" — the reciprocal of requiredMonthly's "at this deadline, what pace is needed." It's guarded by plannedMonthly > 0, not by whether a target date exists at all, so an open-ended goal (no deadline) still gets a projected finish date as long as someone has committed a monthly amount to it — the two guards in this function protect two independent things: targetDate gates the required-pace numbers, plannedMonthly gates the projection.
Next
Subscriptions, debt due dates, and goal target dates are three independent sources of "something happens on this date." The next post covers how Tally's Calendar screen actually puts them on one grid — and the one place a genuine asymmetry shows up between how carefully two of those sources guard against double-logging.