krz/401k

A 401k projection web app.

clone: git clone https://gitbay.org/krz/401k.git

main: assets/ts/app.ts · raw

  1declare const Plotly: {
  2  newPlot(
  3    el: string | HTMLElement,
  4    data: object[],
  5    layout: object,
  6    config?: object
  7  ): void;
  8};
  9
 10interface GraphColors {
 11  bgColor: string;
 12  fgColor: string;
 13  gridColor: string;
 14  lineColor: string;
 15}
 16
 17function formatMoney(num: number): string {
 18  return num.toFixed(2).replace(/\d(?=(\d{3})+\.)/g, "$&,");
 19}
 20
 21function getInputValue(id: string): number {
 22  const el = document.getElementById(id) as HTMLInputElement | null;
 23  return el ? parseFloat(el.value) : NaN;
 24}
 25
 26function getThemeColors(): GraphColors {
 27  const dark = window.matchMedia("(prefers-color-scheme: dark)").matches;
 28  return dark
 29    ? { bgColor: "#1a1d27", fgColor: "#e8eaf0", gridColor: "#2d3141", lineColor: "#3d4258" }
 30    : { bgColor: "#ffffff", fgColor: "#1a1d23", gridColor: "#e2e6ea", lineColor: "#c8cdd5" };
 31}
 32
 33function buildAxisLayout(title: string, colors: GraphColors): object {
 34  const { bgColor, fgColor, gridColor, lineColor } = colors;
 35  return {
 36    title: { text: title, font: { color: fgColor, size: 13 } },
 37    paper_bgcolor: bgColor,
 38    plot_bgcolor: bgColor,
 39    font: { color: fgColor, family: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" },
 40    margin: { t: 40, r: 16, b: 48, l: 60 },
 41    yaxis: { gridcolor: gridColor, zerolinecolor: gridColor, linecolor: lineColor },
 42    xaxis: { title: { text: "Months", font: { size: 11 } }, gridcolor: gridColor, zerolinecolor: gridColor, linecolor: lineColor },
 43  };
 44}
 45
 46class Data {
 47  balance: number;
 48  contribution: number;
 49  returnRate: number;
 50  inflationRate: number;
 51
 52  constructor(balance: number, contribution: number, returnRate: number, inflationRate: number) {
 53    this.balance = balance;
 54    this.contribution = contribution;
 55    this.returnRate = returnRate;
 56    this.inflationRate = inflationRate;
 57  }
 58
 59  summaryRow(): string {
 60    return `<tr>
 61      <td>$${formatMoney(this.balance)}</td>
 62      <td>$${formatMoney(this.contribution)}</td>
 63      <td>${this.returnRate.toFixed(2)}%</td>
 64      <td>${this.inflationRate.toFixed(2)}%</td>
 65    </tr>`;
 66  }
 67
 68  adjustedRate(): number {
 69    return (1 + this.returnRate / 100) / (1 + this.inflationRate / 100) - 1;
 70  }
 71}
 72
 73function showResults(data: Data, monthsArr: number[], balanceArr: number[], interestArr: number[]): void {
 74  const infoTbody = document.querySelector("#infoTable tbody") as HTMLTableSectionElement;
 75  infoTbody.innerHTML = data.summaryRow();
 76
 77  document.querySelectorAll<HTMLElement>(".table-section").forEach((el) => (el.style.display = "block"));
 78
 79  const graphCard = document.getElementById("graphCard") as HTMLElement;
 80  graphCard.style.display = "block";
 81
 82  const colors = getThemeColors();
 83  const balanceTrace = { x: monthsArr, y: balanceArr, type: "scatter", line: { color: "#4f6ef7", width: 2 } };
 84  const interestTrace = { x: monthsArr, y: interestArr, type: "scatter", line: { color: "#22c55e", width: 2 } };
 85
 86  Plotly.newPlot("balChartContainer", [balanceTrace], buildAxisLayout("Total Balance", colors), { responsive: true });
 87  Plotly.newPlot("intChartContainer", [interestTrace], buildAxisLayout("Accrued Interest", colors), { responsive: true });
 88}
 89
 90function runCalculation(data: Data, stopCondition: (balance: number, month: number) => boolean): void {
 91  document.querySelectorAll("tbody").forEach((el) => (el.innerHTML = ""));
 92
 93  const resultsTbody = document.querySelector(".resultsTable tbody") as HTMLTableSectionElement;
 94  const adjustedRate = data.adjustedRate();
 95  const monthlyContr = data.contribution;
 96
 97  const monthsArr: number[] = [];
 98  const balanceArr: number[] = [];
 99  const interestArr: number[] = [];
100
101  let pVal = data.balance;
102  let i = 0;
103
104  while (!stopCondition(pVal, i)) {
105    const month = i + 1;
106    const interest = pVal * (adjustedRate / 12);
107    const newBalance = pVal + interest + monthlyContr;
108
109    const row = document.createElement("tr");
110    row.innerHTML = `<td>${month}</td><td>$${formatMoney(interest)}</td><td>$${formatMoney(monthlyContr)}</td><td>$${formatMoney(newBalance)}</td>`;
111    resultsTbody.appendChild(row);
112
113    if (month === 1 || month % 12 === 0) {
114      interestArr.push(Math.round(interest * 100) / 100);
115      balanceArr.push(Math.round(newBalance * 100) / 100);
116      monthsArr.push(month);
117    }
118
119    pVal = newBalance;
120    i++;
121  }
122
123  showResults(data, monthsArr, balanceArr, interestArr);
124}
125
126function retirementYears(): void {
127  const data = new Data(
128    getInputValue("begBalance"),
129    getInputValue("monthlyContr"),
130    getInputValue("returnRate"),
131    getInputValue("inflationRate")
132  );
133  const years = getInputValue("years");
134  const totalMonths = years * 12;
135  runCalculation(data, (_bal, i) => i >= totalMonths);
136  document.getElementById("graphCard")?.scrollIntoView({ behavior: "smooth" });
137}
138
139function retirementMoney(): void {
140  const data = new Data(
141    getInputValue("begBalance"),
142    getInputValue("monthlyContr"),
143    getInputValue("returnRate"),
144    getInputValue("inflationRate")
145  );
146  const target = getInputValue("money");
147  runCalculation(data, (bal) => bal >= target);
148  document.getElementById("graphCard")?.scrollIntoView({ behavior: "smooth" });
149}
150
151(window as Window & typeof globalThis & { retirementYears: () => void; retirementMoney: () => void }).retirementYears = retirementYears;
152(window as Window & typeof globalThis & { retirementYears: () => void; retirementMoney: () => void }).retirementMoney = retirementMoney;