initial version
This commit is contained in:
+165
@@ -0,0 +1,165 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Net Worth Projection Calculator</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Net Worth Projection Calculator</h1>
|
||||
<p class="subtitle">Simulate your net worth over a 30-year period</p>
|
||||
|
||||
<form id="calculator-form">
|
||||
<div class="card">
|
||||
<h2>Portfolio Values</h2>
|
||||
<div class="input-grid">
|
||||
<div class="input-group">
|
||||
<label for="pretax-value">Pre-tax Market Value</label>
|
||||
<div class="input-wrapper">
|
||||
<span class="input-prefix">$</span>
|
||||
<input type="text" id="pretax-value" inputmode="numeric" data-dollar value="1,500,000" min="0" step="1000">
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label for="aftertax-value">After-tax Market Value</label>
|
||||
<div class="input-wrapper">
|
||||
<span class="input-prefix">$</span>
|
||||
<input type="text" id="aftertax-value" inputmode="numeric" data-dollar value="1,500,000" min="0" step="1000">
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label for="roth-value">Roth Market Value</label>
|
||||
<div class="input-wrapper">
|
||||
<span class="input-prefix">$</span>
|
||||
<input type="text" id="roth-value" inputmode="numeric" data-dollar value="500,000" min="0" step="1000">
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label for="realestate-value">Real Estate Value</label>
|
||||
<div class="input-wrapper">
|
||||
<span class="input-prefix">$</span>
|
||||
<input type="text" id="realestate-value" inputmode="numeric" data-dollar value="1,200,000" min="0" step="1000">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Assumptions</h2>
|
||||
<div class="input-grid">
|
||||
<div class="input-group">
|
||||
<label for="inflation">Inflation Factor</label>
|
||||
<div class="input-wrapper">
|
||||
<input type="number" id="inflation" value="3.0" min="0" max="20" step="0.1">
|
||||
<span class="input-suffix">%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label for="ror">Rate of Return</label>
|
||||
<div class="input-wrapper">
|
||||
<input type="number" id="ror" value="7.0" min="-20" max="50" step="0.1">
|
||||
<span class="input-suffix">%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label for="tax-rate">Pre-tax Withdrawal Tax Rate</label>
|
||||
<div class="input-wrapper">
|
||||
<input type="number" id="tax-rate" value="25.0" min="0" max="100" step="0.1">
|
||||
<span class="input-suffix">%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Annual Spending (Year 1)</h2>
|
||||
<div class="input-grid">
|
||||
<div class="input-group">
|
||||
<label for="pretax-spending">Pre-tax Spending</label>
|
||||
<div class="input-wrapper">
|
||||
<span class="input-prefix">$</span>
|
||||
<input type="text" id="pretax-spending" inputmode="numeric" data-dollar value="75,000" min="0" step="1000">
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label for="aftertax-spending">After-tax Spending</label>
|
||||
<div class="input-wrapper">
|
||||
<span class="input-prefix">$</span>
|
||||
<input type="text" id="aftertax-spending" inputmode="numeric" data-dollar value="100,000" min="0" step="1000">
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label for="roth-spending">Roth Spending</label>
|
||||
<div class="input-wrapper">
|
||||
<span class="input-prefix">$</span>
|
||||
<input type="text" id="roth-spending" inputmode="numeric" data-dollar value="0" min="0" step="1000">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-calculate">Calculate</button>
|
||||
</form>
|
||||
|
||||
<div id="results" class="results hidden">
|
||||
<div class="card">
|
||||
<h2>Net Worth Projection</h2>
|
||||
<div class="chart-container">
|
||||
<canvas id="projection-chart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Year-by-Year Breakdown (With Inflation)</h2>
|
||||
<div class="table-wrapper">
|
||||
<table id="results-table-inflation">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Year</th>
|
||||
<th>Pre-tax</th>
|
||||
<th>After-tax</th>
|
||||
<th>Roth</th>
|
||||
<th>Real Estate</th>
|
||||
<th>Total Net Worth</th>
|
||||
<th>YoY Growth</th>
|
||||
<th>Pre-tax Spend</th>
|
||||
<th>After-tax Spend</th>
|
||||
<th>Roth Spend</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Year-by-Year Breakdown (No Inflation)</h2>
|
||||
<div class="table-wrapper">
|
||||
<table id="results-table-noinflation">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Year</th>
|
||||
<th>Pre-tax</th>
|
||||
<th>After-tax</th>
|
||||
<th>Roth</th>
|
||||
<th>Real Estate</th>
|
||||
<th>Total Net Worth</th>
|
||||
<th>YoY Growth</th>
|
||||
<th>Pre-tax Spend</th>
|
||||
<th>After-tax Spend</th>
|
||||
<th>Roth Spend</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="script.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,219 @@
|
||||
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;
|
||||
|
||||
pretaxSpend = Math.min(pretaxSpend, pretax * (1 - taxRate));
|
||||
pretax -= pretaxSpend / (1 - taxRate);
|
||||
pretax = Math.max(0, pretax);
|
||||
|
||||
aftertaxSpend = Math.min(aftertaxSpend, aftertax);
|
||||
aftertax -= aftertaxSpend;
|
||||
aftertax = Math.max(0, aftertax);
|
||||
|
||||
rothSpend = Math.min(rothSpend, roth);
|
||||
roth -= rothSpend;
|
||||
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); },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg: #f4f6f9;
|
||||
--card-bg: #ffffff;
|
||||
--text: #1a1a2e;
|
||||
--text-muted: #6b7280;
|
||||
--primary: #2563eb;
|
||||
--primary-hover: #1d4ed8;
|
||||
--border: #e2e8f0;
|
||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.07), 0 2px 4px rgba(0, 0, 0, 0.06);
|
||||
--radius: 12px;
|
||||
--radius-sm: 8px;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.6;
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 860px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 1.75rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--card-bg);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.input-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.input-group label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.35rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.input-wrapper:focus-within {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
|
||||
}
|
||||
|
||||
.input-wrapper input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
padding: 0.55rem 0.75rem;
|
||||
font-size: 0.95rem;
|
||||
font-family: inherit;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.input-prefix,
|
||||
.input-suffix {
|
||||
padding: 0 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg);
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.input-prefix {
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.input-suffix {
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.btn-calculate {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.8rem;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
box-shadow: var(--shadow-md);
|
||||
transition: background 0.15s, transform 0.1s;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.btn-calculate:hover {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
.btn-calculate:active {
|
||||
transform: scale(0.99);
|
||||
}
|
||||
|
||||
.results {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.results.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 380px;
|
||||
}
|
||||
|
||||
.table-wrapper {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
thead th {
|
||||
background: var(--bg);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
padding: 0.6rem 0.5rem;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
thead th:first-child {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
tbody td {
|
||||
padding: 0.5rem;
|
||||
text-align: right;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
tbody td:first-child {
|
||||
text-align: left;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: rgba(37, 99, 235, 0.03);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
body {
|
||||
padding: 1rem 0.5rem;
|
||||
}
|
||||
|
||||
.input-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
height: 260px;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user