DEV Community

Joe Lin for BeGoodTool.com

Posted on

How to split one monthly savings budget across competing goals

Saving for an emergency fund, a trip, and a home deposit at the same time creates a deceptively simple UI problem: percentages must be understandable, but each goal also needs a useful date. I built this allocator around explicit sliders so the reader can see exactly what happens when one goal gets more of the monthly budget. It is a cash-flow example for developers and planners who want priorities to remain visible instead of hidden inside an automatic “optimal” allocation.

Percentages are the source of truth

Each goal stores a target, current savings, optional target date, and an allocation percentage. The page derives the total percentage and monthly amount from those values:

const totalPercent = computed(() =>
  goals.value.reduce((sum, goal) =>
    sum + normalizedPercent(goal.percent), 0)
);
const totalAllocatedMonthly = computed(() =>
  ((Number(monthlySavings.value) || 0) *
    totalPercent.value) / 100
);
Enter fullscreen mode Exit fullscreen mode

normalizedPercent rounds a value and clamps it between 0 and 100. The total can therefore be less than, equal to, or greater than 100. The status deliberately distinguishes under-allocation, exactly 100%, and over-allocation. Nothing silently normalizes the sliders, because silently changing a user's chosen priorities would make the result hard to trust. If monthly savings is 30,000 and two goals are 40% and 30%, the visible allocated total is 21,000 and the remaining 9,000 is not secretly assigned anywhere.

That choice also makes edge cases teachable. An over-allocated plan is not mathematically impossible—the UI is simply saying the percentages demand more than the stated monthly budget. A user can fix it by moving sliders, while a developer can test the warning without reverse-engineering a hidden redistribution algorithm.

Completion time comes from the remaining gap

For each goal, the remaining amount is target minus saved, clamped at zero. Monthly allocation is the budget multiplied by the goal percentage; the estimated month count is the ceiling of the gap divided by that allocation:

function remainingFor(goal) {
  return Math.max(0,
    (Number(goal.target) || 0) -
    (Number(goal.saved) || 0));
}

function monthsNeeded(goal) {
  const remaining = remainingFor(goal);
  if (remaining <= 0) return 0;
  const monthly = monthlyFor(goal);
  if (monthly <= 0) return Infinity;
  return Math.ceil(remaining / monthly);
}
Enter fullscreen mode Exit fullscreen mode

If a goal targets 120,000, already has 30,000, and receives 12,000 per month, the gap is 90,000 and the estimate is eight months because Math.ceil(7.5) gives a whole deposit month. An already-reached goal reports zero months. A goal with no allocation reports that it cannot be estimated instead of producing a misleading infinite date. The optional target date is displayed as a comparison, not used to invent extra deposits.

The arithmetic assumes a regular deposit at the same frequency as the percentage. It does not prorate the first month or account for a deposit made on payday versus month-end. That simplicity is useful for explaining the result, but it is exactly why the number should be read as a schedule estimate.

The date field is intentionally not used to reverse-engineer a required percentage. A goal can display a personal target date while the calculator still reports the completion month implied by the current allocation. That avoids a surprising side effect where editing a date silently changes the user's priorities. If the target date is unrealistic, the mismatch is information to discuss: raise the monthly budget, change the percentage, extend the date, or accept that another goal must wait.

Presets change priorities, not the budget

“Split evenly” assigns the same percentage to every goal. The implementation uses integer division and hands the leftover percentage points to the earliest goals, so three goals become 34%, 33%, and 33% rather than 33.333 repeating values. The result is always a clean 100% when there is at least one goal.

“Split by target size” uses each goal's remaining gap as a weight, falling back from a zero remaining gap to the original target:

const weights = goals.value.map((goal) =>
  Math.max(0, remainingFor(goal) ||
    Number(goal.target) || 0));
const total = weights.reduce((sum, value) => sum + value, 0);
if (total <= 0) {
  distributeEvenly();
  return;
}
let used = 0;
goals.value.forEach((goal, index) => {
  goal.percent = index === goals.value.length - 1
    ? Math.max(0, 100 - used)
    : Math.round((weights[index] / total) * 100);
  used += goal.percent;
});
Enter fullscreen mode Exit fullscreen mode

The fallback matters when a goal is complete. Using only the remaining gap would give it zero weight, while a zero-target or fully saved sample could leave every weight at zero. Falling back to the original target preserves a meaningful preference; if even that is unavailable, the preset falls back to an even split. Rounding each early goal can drift from 100, so the last goal receives the exact remainder.

Storage and limitations

Goals and monthly savings are serialized to begoodtool_multiGoalSavingsAllocator_v1 in browser localStorage. Reloading the page preserves the list, but clearing site data, private browsing, or changing devices does not. The sample goals are loaded only when no saved state exists, which prevents a refresh from overwriting personal priorities.

This is allocation arithmetic, not an investment projection. It ignores interest, inflation, taxes, irregular deposits, and whether a target date is compatible with the chosen percentage. A visible under-allocation may be a deliberate cash reserve rather than a mistake. I turned this into a small free tool: Multi-Goal Savings Allocation Calculator.

Top comments (0)