From 1cdee5d05b103e9464f28b7ba82c3e05485c3dc5 Mon Sep 17 00:00:00 2001 From: scosno Date: Fri, 15 May 2026 11:28:49 -0400 Subject: [PATCH] initial version --- index.html | 165 ++++++++++++++++++++++++++++++++++++++++ script.js | 219 +++++++++++++++++++++++++++++++++++++++++++++++++++++ style.css | 215 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 599 insertions(+) create mode 100644 index.html create mode 100644 script.js create mode 100644 style.css diff --git a/index.html b/index.html new file mode 100644 index 0000000..f1bc221 --- /dev/null +++ b/index.html @@ -0,0 +1,165 @@ + + + + + + Net Worth Projection Calculator + + + + +
+

Net Worth Projection Calculator

+

Simulate your net worth over a 30-year period

+ +
+
+

Portfolio Values

+
+
+ +
+ $ + +
+
+
+ +
+ $ + +
+
+
+ +
+ $ + +
+
+
+ +
+ $ + +
+
+
+
+ +
+

Assumptions

+
+
+ +
+ + % +
+
+
+ +
+ + % +
+
+
+ +
+ + % +
+
+
+
+ +
+

Annual Spending (Year 1)

+
+
+ +
+ $ + +
+
+
+ +
+ $ + +
+
+
+ +
+ $ + +
+
+
+
+ + +
+ + +
+ + + + diff --git a/script.js b/script.js new file mode 100644 index 0000000..b94b29d --- /dev/null +++ b/script.js @@ -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 ? '—' : '' + growthPrefix + fmt(Math.abs(r.yoyGrowth)) + ''; + const tr = document.createElement('tr'); + tr.innerHTML = + '' + r.year + '' + + '$' + fmt(r.pretax) + '' + + '$' + fmt(r.aftertax) + '' + + '$' + fmt(r.roth) + '' + + '$' + fmt(r.realEstate) + '' + + '$' + fmt(r.total) + '' + + '' + growthStr + '' + + '$' + fmt(r.pretaxSpend) + '' + + '$' + fmt(r.aftertaxSpend) + '' + + '$' + fmt(r.rothSpend) + ''; + 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); }, + }, + }, + }, + }, + }); +} diff --git a/style.css b/style.css new file mode 100644 index 0000000..1bbd53f --- /dev/null +++ b/style.css @@ -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; + } +}