Files

256 lines
7.1 KiB
JavaScript

let chartInstance = null;
document.querySelectorAll('[data-dollar]').forEach(function (input) {
input.addEventListener('input', function () {
var raw = this.value.replace(/[^0-9]/g, '');
if (raw === '') {
this.value = '';
return;
}
this.value = parseInt(raw, 10).toLocaleString('en-US');
});
input.addEventListener('blur', function () {
var raw = this.value.replace(/[^0-9]/g, '');
if (raw === '') {
this.value = '0';
} else {
this.value = parseInt(raw, 10).toLocaleString('en-US');
}
});
});
document.getElementById('calculator-form').addEventListener('submit', function (e) {
e.preventDefault();
const resultsInflation = runProjection(true);
const resultsNoInflation = runProjection(false);
renderTable(resultsInflation, 'results-table-inflation');
renderTable(resultsNoInflation, 'results-table-noinflation');
renderChart(resultsInflation);
document.getElementById('results').classList.remove('hidden');
});
function getVal(id) {
var el = document.getElementById(id);
var raw = el.value.replace(/[^0-9.]/g, '');
return parseFloat(raw) || 0;
}
function fmt(n) {
return n.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
}
function runProjection(useInflation) {
const years = 30;
const inflationFactor = useInflation ? 1 + getVal('inflation') / 100 : 1;
const growthFactor = 1 + getVal('ror') / 100;
const taxRate = getVal('tax-rate') / 100;
let pretax = getVal('pretax-value');
let aftertax = getVal('aftertax-value');
let roth = getVal('roth-value');
let realEstate = getVal('realestate-value');
let pretaxSpend = getVal('pretax-spending');
let aftertaxSpend = getVal('aftertax-spending');
let rothSpend = getVal('roth-spending');
const rows = [];
for (let y = 0; y <= years; y++) {
const total = Math.round(pretax + aftertax + roth + realEstate);
const prevTotal = rows.length > 0 ? rows[rows.length - 1].total : total;
rows.push({
year: y,
pretax: Math.round(pretax),
aftertax: Math.round(aftertax),
roth: Math.round(roth),
realEstate: Math.round(realEstate),
total: total,
yoyGrowth: Math.round(total - prevTotal),
pretaxSpend: Math.round(pretaxSpend),
aftertaxSpend: Math.round(aftertaxSpend),
rothSpend: Math.round(rothSpend),
});
if (y === years) break;
pretax = pretax * growthFactor;
aftertax = aftertax * growthFactor;
roth = roth * growthFactor;
realEstate = realEstate * growthFactor;
var cascadeSpend = 0;
var grossPreTaxSpend = pretaxSpend / (1 - taxRate);
if (grossPreTaxSpend > pretax) {
cascadeSpend += pretaxSpend - pretax * (1 - taxRate);
pretax = 0;
} else {
pretax -= grossPreTaxSpend;
pretax = Math.max(0, pretax);
}
if (aftertaxSpend > aftertax) {
cascadeSpend += aftertaxSpend - aftertax;
aftertax = 0;
} else {
aftertax -= aftertaxSpend;
aftertax = Math.max(0, aftertax);
}
if (rothSpend > roth) {
cascadeSpend += rothSpend - roth;
roth = 0;
} else {
roth -= rothSpend;
roth = Math.max(0, roth);
}
if (cascadeSpend > 0) {
var grossCascade = cascadeSpend / (1 - taxRate);
if (aftertax > 0) {
var aftertaxDraw = Math.min(cascadeSpend, aftertax);
aftertax -= aftertaxDraw;
aftertax = Math.max(0, aftertax);
cascadeSpend -= aftertaxDraw;
}
if (cascadeSpend > 0 && pretax > 0) {
var pretaxDraw = Math.min(cascadeSpend / (1 - taxRate), pretax);
pretax -= pretaxDraw;
pretax = Math.max(0, pretax);
cascadeSpend -= pretaxDraw * (1 - taxRate);
}
if (cascadeSpend > 0 && roth > 0) {
var rothDraw = Math.min(cascadeSpend, roth);
roth -= rothDraw;
roth = Math.max(0, roth);
}
}
pretaxSpend *= inflationFactor;
aftertaxSpend *= inflationFactor;
rothSpend *= inflationFactor;
}
return rows;
}
function renderTable(rows, tableId) {
const tbody = document.querySelector('#' + tableId + ' tbody');
tbody.innerHTML = '';
rows.forEach(function (r) {
const growthColor = r.yoyGrowth >= 0 ? 'color:#16a34a' : 'color:#dc2626';
const growthPrefix = r.yoyGrowth >= 0 ? '+$' : '-$';
const growthStr = r.year === 0 ? '—' : '<span style="' + growthColor + '">' + growthPrefix + fmt(Math.abs(r.yoyGrowth)) + '</span>';
const tr = document.createElement('tr');
tr.innerHTML =
'<td>' + r.year + '</td>' +
'<td>$' + fmt(r.pretax) + '</td>' +
'<td>$' + fmt(r.aftertax) + '</td>' +
'<td>$' + fmt(r.roth) + '</td>' +
'<td>$' + fmt(r.realEstate) + '</td>' +
'<td>$' + fmt(r.total) + '</td>' +
'<td>' + growthStr + '</td>' +
'<td>$' + fmt(r.pretaxSpend) + '</td>' +
'<td>$' + fmt(r.aftertaxSpend) + '</td>' +
'<td>$' + fmt(r.rothSpend) + '</td>';
tbody.appendChild(tr);
});
}
function renderChart(rows) {
const labels = rows.map(function (r) { return 'Year ' + r.year; });
const datasets = [
{
label: 'Pre-tax',
data: rows.map(function (r) { return r.pretax; }),
borderColor: '#2563eb',
backgroundColor: 'rgba(37, 99, 235, 0.08)',
fill: true,
tension: 0.3,
},
{
label: 'After-tax',
data: rows.map(function (r) { return r.aftertax; }),
borderColor: '#16a34a',
backgroundColor: 'rgba(22, 163, 74, 0.08)',
fill: true,
tension: 0.3,
},
{
label: 'Roth',
data: rows.map(function (r) { return r.roth; }),
borderColor: '#dc2626',
backgroundColor: 'rgba(220, 38, 38, 0.08)',
fill: true,
tension: 0.3,
},
{
label: 'Real Estate',
data: rows.map(function (r) { return r.realEstate; }),
borderColor: '#f59e0b',
backgroundColor: 'rgba(245, 158, 11, 0.08)',
fill: true,
tension: 0.3,
},
{
label: 'Total Net Worth',
data: rows.map(function (r) { return r.total; }),
borderColor: '#7c3aed',
backgroundColor: 'transparent',
borderWidth: 2.5,
tension: 0.3,
},
];
if (chartInstance) {
chartInstance.destroy();
}
const ctx = document.getElementById('projection-chart').getContext('2d');
chartInstance = new Chart(ctx, {
type: 'line',
data: { labels: labels, datasets: datasets },
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false,
},
plugins: {
tooltip: {
callbacks: {
label: function (context) {
return context.dataset.label + ': $' + fmt(context.parsed.y);
},
},
},
legend: {
position: 'bottom',
labels: {
usePointStyle: true,
pointStyle: 'circle',
padding: 16,
font: { size: 12 },
},
},
},
scales: {
x: {
grid: { display: false },
ticks: { maxTicksLimit: 11 },
},
y: {
ticks: {
callback: function (v) { return '$' + fmt(v); },
},
},
},
},
});
}