krz/401k
A 401k projection web app.
clone: git clone https://gitbay.org/krz/401k.git
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;