DEV Community

Joe Lin for BeGoodTool.com

Posted on

Why a Compound Interest Chart Should Simulate Contributions Period by Period

A compound-interest formula can give you a final number quickly, but it can also hide whether deposits happen monthly, annually, or not at all. I built the calculator around a small simulation loop so the chart and yearly table explain where the final balance came from.

Normalize the inputs before doing finance math

The computed result converts the form values and clamps the horizon:

const P = Number(principal.value) || 0;
const r = (Number(annualRate.value) || 0) / 100;
const Y = Math.min(
  Math.max(Math.floor(Number(years.value) || 0), 1),
  80
);
const pmt = Number(contribution.value) || 0;
const n = Number(compoundFreq.value) || 1;
const contribPerYear = contributionFreq.value === "monthly" ? 12 : 1;
if (P <= 0 && pmt <= 0) return null;
Enter fullscreen mode Exit fullscreen mode

The || 0 fallback keeps empty form fields from poisoning the chart with NaN. The one-to-eighty-year boundary is a product decision as much as a safety guard: a browser tool should not accidentally render millions of rows because someone pasted an unexpected value.

A period loop makes deposit timing explicit

The main calculation is intentionally straightforward:

let balance = P;
const periodsPerYear = n;
const ratePerPeriod = r / n;
const contribPerPeriod =
  (pmt * contribPerYear) / periodsPerYear;

for (let y = 1; y <= Y; y++) {
  for (let p = 0; p < periodsPerYear; p++) {
    balance =
      balance * (1 + ratePerPeriod) + contribPerPeriod;
  }
  const principalSoFar = P + pmt * contribPerYear * y;
  rows.push({
    year: y,
    principal: principalSoFar,
    interest: balance - principalSoFar,
    balance,
  });
}
Enter fullscreen mode Exit fullscreen mode

This assumes each contribution is distributed evenly across compounding periods. With monthly deposits and monthly compounding, that is easy to understand. With annual contributions and twelve compounding periods, the contribution is divided into twelve equal pieces. That is not the only convention available—depositing at the beginning or end of a period changes the answer—but it is consistent and visible.

The table is a useful invariant

For every row, balance is the simulated account value, principal is the initial amount plus deposits, and interest is the difference. At the end, the same relationship produces the summary:

const totalPrincipal = P + pmt * contribPerYear * Y;
const totalInterest = finalAmount - totalPrincipal;
const multiple =
  totalPrincipal > 0
    ? (finalAmount / totalPrincipal).toFixed(2)
    : "—";
Enter fullscreen mode Exit fullscreen mode

I like this approach because a user can inspect the yearly breakdown instead of trusting a single “you will have X” number. The Chart.js renderer maps the rows into principal and balance datasets, so the graph and table share one source of truth.

Rendering has a browser boundary

The chart code checks typeof window === "undefined" and chartCanvas.value before creating a Chart.js instance. It updates an existing chart when possible rather than creating a new canvas object on every reactive change. That matters in a Vue view with server rendering: chart construction belongs after mount, not during SSR.

The limitation is that this is a nominal, constant-rate projection. It does not model taxes, fees, inflation, variable returns, contribution timing inside a period, or withdrawals. A zero or negative annual rate can still be represented by the loop, but the result should not be read as a forecast. The tool is best used to compare assumptions consistently.

Why the loop is easier to audit than a single formula

There are closed-form annuity formulas for many contribution schedules, but the loop makes the product rules readable. contribPerYear says how many deposits the selected frequency represents, periodsPerYear says how often interest is applied, and the inner loop shows their interaction. When a user changes monthly contributions to annual contributions, the values change in one obvious place rather than in a formula with several timing conventions embedded inside it.

The loop also gives the chart a natural row boundary. Every row is created after one complete simulated year, so the table can report principal, interest, and balance without attempting to reverse-engineer those values from a final total. If a row looks surprising, I can log the period values and inspect the exact sequence of multiplications and deposits.

That transparency is useful even when the model is simple. Financial calculators often look authoritative because the final number has many digits. Showing the principal line and the balance line is a reminder that growth is an assumption multiplied repeatedly, not a promise. I would rather expose that assumption than hide it behind a more impressive formula.

One more practical detail is the handling of an existing Chart.js instance. The renderer updates labels and datasets when growthChart already exists, and creates it only when the canvas is ready. That avoids stacking canvases after every keystroke. When the component unmounts, destroying the chart is just as important as creating it; otherwise a reactive page can retain event handlers and canvas state after navigation.

This also gives the result a useful debugging shape. If the final amount looks wrong, I can compare the annual rows before inspecting the chart at all. A sudden jump in one row usually points to an input-frequency assumption or a period calculation, while a visually distorted chart may only be a rendering problem. Keeping the numerical rows independent from Chart.js means the finance logic remains understandable in a plain JavaScript test, without needing a canvas or a browser.

The calculator therefore answers a narrow question well: what happens when this starting amount, contribution pattern, rate, and compounding frequency are repeated for this bounded number of years? That is more honest than presenting one universal “compound interest formula” while silently assuming a deposit schedule. Changing one assumption at a time is the safest way to use the output.

I turned this simulation into a small free tool: Compound Interest Calculator.

Top comments (1)

Collapse
 
arhancanli profile image
Arhan Canli •

The part worth checking is the annual-contribution case. With contribPerPeriod = pmt*contribPerYear/periodsPerYear, an annual deposit of 1,200 at 7% compounded monthly becomes twelve deposits of 100 at month-ends. I ran that loop for 10 years: 17,308.48. A real once-a-year deposit gives 16,760.16 if made at year end and 17,971.76 if made at year start. So the table's "annual" row sits between the two conventions and is about 3% above the end-of-year one, a gap that grows with the horizon and rate. Principal is 12,000 in all three, so the interest column hides it.

You mention timing inside a period as a limitation. A small "deposit at start/end" toggle, and labelling annual contributions as "spread monthly" when they're split like this, would make the table match what a user typing "1200 once a year" expects.

Also worth a line: nominal 7% compounded monthly is an effective 7.23% a year, so users comparing with a bank's APY figure will see a different number.