commit 9b9fd6a76ad74a34ce642ef41f4079ed6ca971a8 Author: Johannes Date: Fri Mar 20 01:51:03 2026 +0100 initial: rent vs buy calculator diff --git a/calc.js b/calc.js new file mode 100644 index 0000000..87f0166 --- /dev/null +++ b/calc.js @@ -0,0 +1,121 @@ +/** + * calc.js — pure calculation engine, no DOM + */ + +/** + * Run the full rent vs buy simulation month by month. + * Returns arrays (one value per year) plus summary stats. + * + * @param {Object} p - input parameters + * @returns {Object} result + */ +function run_simulation(p) { + const months = p.years * 12; + + // --- mortgage setup --- + const loan = p.home_price * (1 - p.down_pct / 100); + const r_m = p.interest_rate / 100 / 12; + const n = p.loan_years * 12; + let monthly_payment; + if (r_m === 0) { + monthly_payment = loan / n; + } else { + monthly_payment = loan * (r_m * Math.pow(1 + r_m, n)) / (Math.pow(1 + r_m, n) - 1); + } + + // upfront costs for buyer + const down_payment = p.home_price * (p.down_pct / 100); + const upfront_buy = down_payment + p.home_price * (p.closing_buy_pct / 100); + + // --- state --- + let balance = loan; + let home_value = p.home_price; + let portfolio = upfront_buy; // renter invests this lump sum + let monthly_rent = p.monthly_rent; + + let cum_buy_cost = upfront_buy; // running cash spent on buying + let cum_rent_cost = 0; + + // per-year arrays (index 0 = end of year 1) + const buyer_nw_arr = []; + const renter_nw_arr = []; + const cum_buy_arr = []; + const cum_rent_arr = []; + const labels = []; + + let breakeven_month = null; + + const invest_r_m = p.invest_return / 100 / 12; + const app_r_m = p.appreciation / 100 / 12; + const rent_inc_m = p.rent_increase / 100 / 12; + + for (let m = 1; m <= months; m++) { + // --- buy side --- + const interest_m = balance * r_m; + const principal_m = monthly_payment - interest_m; + balance = Math.max(0, balance - principal_m); + home_value *= (1 + app_r_m); + + const prop_tax_m = home_value * (p.prop_tax_rate / 100) / 12; + const maint_m = home_value * (p.maint_rate / 100) / 12; + const insurance_m = p.insurance / 12; + // German context: no mortgage interest tax deduction for private buyers + const total_buy_m = monthly_payment + prop_tax_m + maint_m + insurance_m; + + cum_buy_cost += total_buy_m; + + const selling_costs = home_value * (p.closing_sell_pct / 100); + const buyer_nw = home_value - selling_costs - balance; + + // --- rent side --- + const renters_ins_m = p.renters_ins / 12; + const total_rent_m = monthly_rent + renters_ins_m; + cum_rent_cost += total_rent_m; + + // renter invests the difference if buying is more expensive + const monthly_delta = Math.max(0, total_buy_m - total_rent_m); + portfolio = portfolio * (1 + invest_r_m) + monthly_delta; + + // apply capital gains tax on portfolio gains when we "cash out" at end + // (we track gross portfolio, deduct tax in summary) + const renter_nw = portfolio; + + // breakeven: when buyer net worth overtakes renter + if (breakeven_month === null && buyer_nw >= renter_nw) { + breakeven_month = m; + } + + // rent increases monthly + monthly_rent *= (1 + rent_inc_m); + + // record yearly snapshots + if (m % 12 === 0) { + const yr = m / 12; + labels.push(`Jahr ${yr}`); + buyer_nw_arr.push(Math.round(buyer_nw)); + // apply Abgeltungssteuer on portfolio gains at cashout + const portfolio_gains = portfolio - upfront_buy; + const tax_on_gains = Math.max(0, portfolio_gains) * (p.tax_rate / 100); + renter_nw_arr.push(Math.round(portfolio - tax_on_gains)); + cum_buy_arr.push(Math.round(cum_buy_cost)); + cum_rent_arr.push(Math.round(cum_rent_cost)); + } + } + + // final net worth at end of horizon + const final_buyer_nw = buyer_nw_arr[buyer_nw_arr.length - 1]; + const final_renter_nw = renter_nw_arr[renter_nw_arr.length - 1]; + + return { + labels, + buyer_nw_arr, + renter_nw_arr, + cum_buy_arr, + cum_rent_arr, + monthly_payment: Math.round(monthly_payment), + final_buyer_nw, + final_renter_nw, + breakeven_month, + winner: final_buyer_nw >= final_renter_nw ? 'buy' : 'rent', + }; +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..9fc6d8c --- /dev/null +++ b/index.html @@ -0,0 +1,282 @@ + + + + + + Kaufen oder Mieten? + + + + + + + +
+
+

Kaufen oder Mieten?

+

Kein Bauchgefühl. Nur Zahlen.

+
+
+ +
+ + +
+
+ +
+ +
+ + Jahre +
+
+
+
+ +
+ +
+ + % +
+
+
+
+ +
+ +
+ + % +
+
+
+
+ + +
+ + +
+

Kaufen

+ +
+ +
+ + +
+
+ +
+ +
+ +
+ + % +
+
+
+ +
+ +
+ +
+ + % +
+
+
+ +
+ +
+ +
+ + Jahre +
+
+
+ +
+ +
+ +
+ + % +
+
+
+ +
+ +
+ +
+ + % +
+
+
+ +
+ +
+ +
+ + % +
+
+
+ +
+ +
+ +
+ + % +
+
+
+ +
+ +
+ +
+ + % +
+
+
+ +
+ +
+ + +
+
+ +
+ + +
+

Mieten

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

Der Mieter investiert das nicht ausgegebene Eigenkapital + Nebenkosten in einen ETF. Wenn Kaufen teurer ist, fließt die monatliche Differenz ebenfalls ins Portfolio.

+
+ +
+
+ + +
+
+
+
Nettovermögen Kauf
+
+
Gesamtausgaben: —
+
+
+
Nettovermögen Miete
+
+
Gesamtausgaben: —
+
+
+
Break-Even
+
+
+
+
+
+
+ + +
+
Nettovermögen im Zeitverlauf
+
+ Kaufen    + Mieten (ETF) +
+
+
+ +
+
Kumulierte Ausgaben (Cash out-of-pocket)
+
+ Kaufen    + Mieten +
+
+
+ + +
+ + +
+ +
+ + + + + + + + + diff --git a/style.css b/style.css new file mode 100644 index 0000000..eeb90bd --- /dev/null +++ b/style.css @@ -0,0 +1,421 @@ +/* ===== CSS custom properties ===== */ +:root { + --bg: #0d1117; + --bg-card: rgba(22, 30, 43, 0.85); + --bg-card-2: rgba(30, 40, 58, 0.7); + --border: rgba(255, 255, 255, 0.08); + --text: #e6edf3; + --text-muted: #8b949e; + --text-hint: #6e7681; + + --buy: #f59e0b; /* amber */ + --buy-glow: rgba(245, 158, 11, 0.18); + --buy-muted: rgba(245, 158, 11, 0.35); + + --rent: #2dd4bf; /* teal */ + --rent-glow: rgba(45, 212, 191, 0.18); + --rent-muted: rgba(45, 212, 191, 0.35); + + --green: #3fb950; + --radius: 14px; + --font: 'Inter', system-ui, sans-serif; +} + +/* ===== Reset & base ===== */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +html { font-size: 16px; scroll-behavior: smooth; } + +body { + font-family: var(--font); + background: var(--bg); + color: var(--text); + min-height: 100vh; + line-height: 1.5; +} + +/* ===== Header ===== */ +header { + background: linear-gradient(135deg, #161b27 0%, #0d1117 100%); + border-bottom: 1px solid var(--border); + padding: 2.5rem 1.5rem 2rem; + text-align: center; +} + +.header-inner { max-width: 900px; margin: 0 auto; } + +h1 { + font-size: clamp(1.8rem, 4vw, 2.8rem); + font-weight: 700; + letter-spacing: -0.02em; + background: linear-gradient(90deg, var(--buy) 0%, var(--rent) 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.subtitle { + margin-top: 0.4rem; + color: var(--text-muted); + font-size: 1.05rem; +} + +/* ===== Main layout ===== */ +main { + max-width: 960px; + margin: 0 auto; + padding: 2rem 1.25rem 4rem; + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +/* ===== Card ===== */ +.card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1.5rem; + backdrop-filter: blur(12px); +} + +/* ===== Shared inputs ===== */ +.inputs-shared { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 1.25rem 2rem; +} + +/* ===== Two-column inputs ===== */ +.inputs-columns { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1.5rem; +} + +@media (max-width: 700px) { + .inputs-columns { grid-template-columns: 1fr; } +} + +.col-header { + font-size: 0.85rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + margin-bottom: 1.25rem; + padding-bottom: 0.6rem; + border-bottom: 2px solid; +} + +.buy-header { color: var(--buy); border-color: var(--buy); } +.rent-header { color: var(--rent); border-color: var(--rent); } + +/* ===== Input group ===== */ +.input-group { + display: flex; + flex-direction: column; + gap: 0.35rem; + margin-bottom: 1.1rem; +} + +.input-group:last-child { margin-bottom: 0; } + +label { + font-size: 0.82rem; + font-weight: 500; + color: var(--text-muted); + display: flex; + align-items: baseline; + gap: 0.4rem; +} + +.hint { + font-size: 0.72rem; + color: var(--text-hint); + font-weight: 400; +} + +/* ===== Slider row ===== */ +.slider-row { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.slider-row input[type="range"] { + flex: 1; + min-width: 0; +} + +/* ===== Number + unit ===== */ +.num-unit { + display: flex; + align-items: center; + gap: 0.25rem; +} + +.num-unit input[type="number"] { + width: 72px; + padding: 0.3rem 0.5rem; + background: rgba(255,255,255,0.06); + border: 1px solid var(--border); + border-radius: 7px; + color: var(--text); + font-family: var(--font); + font-size: 0.9rem; + text-align: right; + transition: border-color 0.15s; +} + +.num-unit input[type="number"]:focus { + outline: none; + border-color: rgba(255,255,255,0.25); +} + +.unit { + font-size: 0.82rem; + color: var(--text-muted); + white-space: nowrap; +} + +/* ===== Euro prefix input ===== */ +.euro-input { + background: rgba(255,255,255,0.06); + border: 1px solid var(--border); + border-radius: 7px; + padding: 0.3rem 0.5rem; + width: fit-content; + display: flex; + align-items: center; + gap: 0.2rem; +} + +.euro-input input[type="number"] { + width: 110px; + background: transparent; + border: none; + padding: 0; +} + +.euro-input input[type="number"]:focus { outline: none; } + +.unit.prefix { color: var(--text-muted); } + +/* ===== Range slider styling ===== */ +input[type="range"] { + -webkit-appearance: none; + appearance: none; + height: 4px; + background: rgba(255,255,255,0.12); + border-radius: 99px; + cursor: pointer; +} + +input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--text); + border: 2px solid var(--bg); + box-shadow: 0 0 0 1px rgba(255,255,255,0.2); + transition: transform 0.1s; +} + +input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.2); } +input[type="range"]::-moz-range-thumb { + width: 16px; height: 16px; + border-radius: 50%; + background: var(--text); + border: 2px solid var(--bg); + cursor: pointer; +} + +/* ===== Hide number spinners ===== */ +input[type="number"]::-webkit-inner-spin-button, +input[type="number"]::-webkit-outer-spin-button { -webkit-appearance: none; } +input[type="number"] { -moz-appearance: textfield; } + +/* ===== Info box ===== */ +.info-box { + background: rgba(255,255,255,0.04); + border: 1px dashed var(--border); + border-radius: 10px; + padding: 0.9rem 1rem; + font-size: 0.8rem; + color: var(--text-hint); + line-height: 1.6; + margin-top: 1rem; +} + +/* ===== Results ===== */ +.results { display: flex; flex-direction: column; gap: 0.75rem; } + +.result-cards { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1rem; +} + +@media (max-width: 600px) { + .result-cards { grid-template-columns: 1fr; } +} + +.result-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1.25rem 1.5rem; + display: flex; + flex-direction: column; + gap: 0.25rem; + transition: box-shadow 0.2s; + backdrop-filter: blur(12px); +} + +.buy-card { border-top: 3px solid var(--buy); } +.rent-card { border-top: 3px solid var(--rent); } +.breakeven-card { border-top: 3px solid var(--text-hint); } + +.result-label { + font-size: 0.78rem; + font-weight: 500; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.result-value { + font-size: 1.65rem; + font-weight: 700; + letter-spacing: -0.02em; + color: var(--text); + transition: color 0.2s; +} + +.buy-card .result-value { color: var(--buy); } +.rent-card .result-value { color: var(--rent); } + +.result-sub { + font-size: 0.8rem; + color: var(--text-muted); + margin-top: 0.1rem; +} + +.winner-badge { + display: inline-block; + font-size: 0.75rem; + font-weight: 600; + padding: 0.15rem 0.6rem; + border-radius: 99px; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.winner-buy { background: var(--buy-muted); color: var(--buy); } +.winner-rent { background: var(--rent-muted); color: var(--rent); } + +.monthly-payment-note { + font-size: 0.83rem; + color: var(--text-hint); + padding-left: 0.25rem; +} + +/* ===== Charts ===== */ +.chart-title { + font-size: 0.9rem; + font-weight: 600; + color: var(--text-muted); + margin-bottom: 0.4rem; +} + +.chart-legend { + font-size: 0.8rem; + color: var(--text-muted); + margin-bottom: 1rem; + display: flex; + align-items: center; + gap: 0.35rem; +} + +.legend-dot { + display: inline-block; + width: 10px; height: 10px; + border-radius: 50%; +} + +.buy-dot { background: var(--buy); } +.rent-dot { background: var(--rent); } + +.chart-wrap { + position: relative; + height: 300px; +} + +/* ===== Table ===== */ +.table-section { overflow: hidden; } + +.table-toggle { + background: none; + border: none; + color: var(--text-muted); + font-family: var(--font); + font-size: 0.88rem; + cursor: pointer; + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.25rem 0; + transition: color 0.15s; +} + +.table-toggle:hover { color: var(--text); } + +.toggle-icon { + display: inline-block; + transition: transform 0.2s; + font-size: 0.7rem; +} + +.table-toggle[aria-expanded="true"] .toggle-icon { transform: rotate(90deg); } + +.table-wrap { + margin-top: 1.25rem; + overflow-x: auto; +} + +table { + width: 100%; + border-collapse: collapse; + font-size: 0.85rem; +} + +th, td { + padding: 0.6rem 1rem; + text-align: right; + border-bottom: 1px solid var(--border); + white-space: nowrap; +} + +th:first-child, td:first-child { text-align: left; } + +th { + color: var(--text-muted); + font-weight: 500; + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +tbody tr:hover { background: rgba(255,255,255,0.03); } + +tr.winner-row td { font-weight: 600; } + +/* ===== Footer ===== */ +footer { + text-align: center; + padding: 2rem 1rem; + color: var(--text-hint); + font-size: 0.8rem; + border-top: 1px solid var(--border); +} diff --git a/ui.js b/ui.js new file mode 100644 index 0000000..a43a192 --- /dev/null +++ b/ui.js @@ -0,0 +1,247 @@ +/** + * ui.js — DOM wiring, event listeners, chart rendering + */ + +// ===== Helpers ===== + +const fmt_eur = (n) => + new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 }).format(n); + +const get_params = () => ({ + years: +document.getElementById('years').value, + invest_return: +document.getElementById('invest_return').value, + tax_rate: +document.getElementById('tax_rate').value, + home_price: +document.getElementById('home_price').value, + down_pct: +document.getElementById('down_pct').value, + interest_rate: +document.getElementById('interest_rate').value, + loan_years: +document.getElementById('loan_years').value, + closing_buy_pct: +document.getElementById('closing_buy_pct').value, + closing_sell_pct:+document.getElementById('closing_sell_pct').value, + appreciation: +document.getElementById('appreciation').value, + prop_tax_rate: +document.getElementById('prop_tax_rate').value, + maint_rate: +document.getElementById('maint_rate').value, + insurance: +document.getElementById('insurance').value, + monthly_rent: +document.getElementById('monthly_rent').value, + rent_increase: +document.getElementById('rent_increase').value, + renters_ins: +document.getElementById('renters_ins').value, +}); + +// ===== Charts ===== + +const CHART_DEFAULTS = { + color: '#8b949e', + borderColor: 'rgba(255,255,255,0.08)', +}; + +const make_chart = (canvas_id, label_buy, label_rent) => { + const ctx = document.getElementById(canvas_id).getContext('2d'); + return new Chart(ctx, { + type: 'line', + data: { + labels: [], + datasets: [ + { + label: label_buy, + data: [], + borderColor: '#f59e0b', + backgroundColor: 'rgba(245,158,11,0.06)', + borderWidth: 2.5, + pointRadius: 3, + pointHoverRadius: 5, + fill: false, + tension: 0.35, + }, + { + label: label_rent, + data: [], + borderColor: '#2dd4bf', + backgroundColor: 'rgba(45,212,191,0.06)', + borderWidth: 2.5, + pointRadius: 3, + pointHoverRadius: 5, + fill: false, + tension: 0.35, + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { display: false }, + tooltip: { + mode: 'index', + intersect: false, + backgroundColor: 'rgba(13,17,23,0.9)', + borderColor: 'rgba(255,255,255,0.12)', + borderWidth: 1, + titleColor: '#8b949e', + bodyColor: '#e6edf3', + padding: 12, + callbacks: { + label: (ctx) => ` ${ctx.dataset.label}: ${fmt_eur(ctx.parsed.y)}`, + }, + }, + annotation: { annotations: {} }, + }, + scales: { + x: { + grid: { color: 'rgba(255,255,255,0.05)' }, + ticks: { color: '#6e7681', font: { size: 11 } }, + }, + y: { + grid: { color: 'rgba(255,255,255,0.05)' }, + ticks: { + color: '#6e7681', + font: { size: 11 }, + callback: (v) => fmt_eur(v), + }, + }, + }, + interaction: { mode: 'nearest', axis: 'x', intersect: false }, + }, + }); +}; + +const chart_nw = make_chart('chart_nw', 'Kaufen', 'Mieten (ETF)'); +const chart_cost = make_chart('chart_cost', 'Kaufen', 'Mieten'); + +const update_chart = (chart, labels, data_buy, data_rent, breakeven_month, years) => { + chart.data.labels = labels; + chart.data.datasets[0].data = data_buy; + chart.data.datasets[1].data = data_rent; + + // breakeven annotation + if (breakeven_month !== null && breakeven_month <= years * 12) { + const be_year = breakeven_month / 12; + chart.options.plugins.annotation.annotations = { + breakeven: { + type: 'line', + xMin: be_year - 1, // labels are "Jahr 1" ... "Jahr N", zero-indexed + xMax: be_year - 1, + borderColor: 'rgba(255,255,255,0.25)', + borderWidth: 1, + borderDash: [5, 4], + label: { + display: true, + content: `Break-Even Jahr ${be_year.toFixed(1)}`, + color: '#8b949e', + backgroundColor: 'rgba(13,17,23,0.85)', + font: { size: 11 }, + position: 'start', + }, + }, + }; + } else { + chart.options.plugins.annotation.annotations = {}; + } + + chart.update('none'); +}; + +// ===== Table ===== + +const build_table = (result) => { + const tbody = document.getElementById('table_body'); + tbody.innerHTML = ''; + for (let i = 0; i < result.labels.length; i++) { + const tr = document.createElement('tr'); + tr.innerHTML = ` + ${result.labels[i]} + ${fmt_eur(result.buyer_nw_arr[i])} + ${fmt_eur(result.renter_nw_arr[i])} + ${fmt_eur(result.cum_buy_arr[i])} + ${fmt_eur(result.cum_rent_arr[i])} + `; + tbody.appendChild(tr); + } +}; + +// ===== Result cards ===== + +const update_results = (result) => { + document.getElementById('res_buyer_nw').textContent = fmt_eur(result.final_buyer_nw); + document.getElementById('res_renter_nw').textContent = fmt_eur(result.final_renter_nw); + document.getElementById('res_buy_cost').textContent = `Gesamtausgaben: ${fmt_eur(result.cum_buy_arr.at(-1))}`; + document.getElementById('res_rent_cost').textContent = `Gesamtausgaben: ${fmt_eur(result.cum_rent_arr.at(-1))}`; + + // breakeven + const be_el = document.getElementById('res_breakeven'); + if (result.breakeven_month !== null) { + const be_yr = (result.breakeven_month / 12).toFixed(1); + be_el.textContent = `Jahr ${be_yr}`; + } else { + be_el.textContent = 'Kein Break-Even'; + } + + // winner badge + const badge_el = document.getElementById('res_winner_badge'); + if (result.winner === 'buy') { + badge_el.innerHTML = 'Kaufen gewinnt'; + } else { + badge_el.innerHTML = 'Mieten gewinnt'; + } + + // monthly payment note + document.getElementById('res_monthly_payment').textContent = + `Monatliche Rate: ${fmt_eur(result.monthly_payment)} · Nettovermögen nach Kapitalertragsteuer`; +}; + +// ===== Main recalc ===== + +const recalc = () => { + const p = get_params(); + const result = run_simulation(p); + + update_results(result); + update_chart(chart_nw, result.labels, result.buyer_nw_arr, result.renter_nw_arr, result.breakeven_month, p.years); + update_chart(chart_cost, result.labels, result.cum_buy_arr, result.cum_rent_arr, null, p.years); + build_table(result); +}; + +// ===== Slider ↔ number sync ===== + +const wire_slider = (base_id) => { + const slider = document.getElementById(`${base_id}_slider`); + const num = document.getElementById(base_id); + if (!slider || !num) return; + + slider.addEventListener('input', () => { + num.value = slider.value; + recalc(); + }); + num.addEventListener('input', () => { + slider.value = num.value; + recalc(); + }); +}; + +const SLIDER_IDS = [ + 'years', 'invest_return', 'tax_rate', + 'down_pct', 'interest_rate', 'loan_years', + 'closing_buy_pct', 'closing_sell_pct', 'appreciation', + 'prop_tax_rate', 'maint_rate', + 'rent_increase', +]; + +SLIDER_IDS.forEach(wire_slider); + +// wire plain number inputs (no slider) +['home_price', 'insurance', 'monthly_rent', 'renters_ins'].forEach((id) => { + document.getElementById(id).addEventListener('input', recalc); +}); + +// ===== Table toggle ===== + +const toggle_btn = document.getElementById('table_toggle'); +const table_wrap = document.getElementById('table_wrap'); + +toggle_btn.addEventListener('click', () => { + const expanded = toggle_btn.getAttribute('aria-expanded') === 'true'; + toggle_btn.setAttribute('aria-expanded', String(!expanded)); + toggle_btn.querySelector('.toggle-icon').style.transform = expanded ? '' : 'rotate(90deg)'; + table_wrap.hidden = expanded; +}); + +// ===== Init ===== +recalc();