Retire Planner v1: engine (live 2026 CPP/OAS/GIS/RRIF/tax), 17-step UI, 4 SVG charts, vision gallery, tests
No files matched your search
@@ -0,0 +1,611 @@
|
||||
/* Retire Planner UI — vanilla, no build, no network except same-origin vision manifest.
|
||||
All computation via window.RetireEngine. Answers persist in localStorage + URL hash. */
|
||||
'use strict';
|
||||
(function () {
|
||||
var E = window.RetireEngine, C = window.RetireCanada;
|
||||
var LS = 'retire-planner-v1';
|
||||
var $ = function (s) { return document.querySelector(s); };
|
||||
|
||||
var PROVINCES = [['ON', 'Ontario'], ['BC', 'British Columbia'], ['AB', 'Alberta'], ['SK', 'Saskatchewan'],
|
||||
['MB', 'Manitoba'], ['QC', 'Quebec'], ['NS', 'Nova Scotia'], ['NB', 'New Brunswick'],
|
||||
['NL', 'Newfoundland & Labrador'], ['PE', 'PEI'], ['YT', 'Yukon'], ['NT', 'Northwest Territories'], ['NU', 'Nunavut']];
|
||||
|
||||
var INTERESTS = [
|
||||
['rockies', '🏔 Mountains'], ['maritimes', '🌊 Ocean'], ['toronto', '🏙 Big city'],
|
||||
['dock', '🛶 Lake / dock'], ['muskoka', '🌲 Cottage country'], ['prairies', '🌾 Open sky'],
|
||||
['winter', '⛷ Snow / ski'], ['workshop', '🪵 Workshop'], ['garden', '🌺 Garden'],
|
||||
['travel', '✈️ Travel'], ['quebec', '🇫🇷 Québec']
|
||||
];
|
||||
var PROV_SLUGS = { ON: ['toronto', 'muskoka', 'garden'], QC: ['quebec'], BC: ['vancouver'], AB: ['rockies', 'prairies'], MB: ['prairies'], SK: ['prairies'], NB: ['maritimes', 'dock'], NS: ['maritimes', 'dock'], PE: ['maritimes'], NL: ['maritimes'], YT: ['winter', 'rockies'], NT: ['winter'], NU: ['winter'] };
|
||||
var CAPTIONS = {
|
||||
hero: 'The good life — paid for by the plan on this page.', muskoka: 'Cottage country, mortgage-free.',
|
||||
rockies: 'Lake Louise at 68, on your own schedule.', maritimes: 'Lobster season, every season.',
|
||||
prairies: 'Wide skies and a paid-off acreage.', quebec: 'Terrasses, fêtes et liberté financière.',
|
||||
vancouver: 'Ocean sunsets from a paid-for deck.', toronto: 'City life, funded — not scraped.',
|
||||
garden: 'Mornings that start with tomatoes.', travel: 'Long-haul flights, fully deductible… just kidding, paid in cash.',
|
||||
workshop: 'A shop full of projects and zero alarms.', winter: 'Fresh tracks on a Tuesday.', dock: 'First coffee on your own dock.'
|
||||
};
|
||||
var GRADS = ['linear-gradient(135deg,#1e3a8a,#0ea5e9)', 'linear-gradient(135deg,#7c2d12,#f59e0b)',
|
||||
'linear-gradient(135deg,#14532d,#84cc16)', 'linear-gradient(135deg,#4c1d95,#ec4899)',
|
||||
'linear-gradient(135deg,#0f172a,#64748b)'];
|
||||
|
||||
/* ---------- state ---------- */
|
||||
function defaults() {
|
||||
return {
|
||||
province: null, age: 45, household: 'single',
|
||||
partner: { age: 45, income: 0, rrsp: 0, tfsa: 0, nonreg: 0, monthlyContribution: 0 },
|
||||
income: 60000, rrsp: 100000, tfsa: 30000, nonreg: 20000,
|
||||
monthlyContribution: 500, employerMatchMonthly: 0,
|
||||
retireAge: 65, spendingAnnual: 50000,
|
||||
cppStartAge: 65, oasStartAge: 65,
|
||||
pensionAnnual: 0, rentalAnnual: 0, otherAnnual: 0,
|
||||
realReturnPre: 3.5, realReturnPost: 2.5, inflation: 2.0, lifeExp: 95,
|
||||
interests: []
|
||||
};
|
||||
}
|
||||
var state = { screen: 'intro', step: 0, a: defaults(), wi: {} };
|
||||
|
||||
(function load() {
|
||||
var raw = null;
|
||||
var m = location.hash.match(/[#&]p=([^&]+)/);
|
||||
if (m) { try { raw = decodeURIComponent(escape(atob(m[1]))); } catch (e) { raw = null; } }
|
||||
if (!raw) { try { raw = localStorage.getItem(LS); } catch (e) { raw = null; } }
|
||||
if (raw) { try { var o = JSON.parse(raw); Object.assign(state.a, o.a || o); state.step = o.step | 0; if (o.screen) state.screen = o.screen; else if (m) state.screen = 'results'; } catch (e) { /* fresh */ } }
|
||||
})();
|
||||
function save() {
|
||||
var t = { a: state.a, step: state.step, screen: state.screen };
|
||||
try { localStorage.setItem(LS, JSON.stringify(t)); } catch (e) { }
|
||||
}
|
||||
function encState() { return btoa(unescape(encodeURIComponent(JSON.stringify({ a: state.a, step: 0 })))); }
|
||||
|
||||
function inputs() {
|
||||
var a = state.a, w = state.wi;
|
||||
var p = {
|
||||
province: a.province || 'ON', age: +a.age, household: a.household,
|
||||
partner: a.household === 'couple' ? {
|
||||
age: +a.partner.age, income: +a.partner.income || 0, rrsp: +a.partner.rrsp || 0,
|
||||
tfsa: +a.partner.tfsa || 0, nonreg: +a.partner.nonreg || 0,
|
||||
monthlyContribution: +a.partner.monthlyContribution || 0
|
||||
} : null,
|
||||
income: +a.income || 0, rrsp: +a.rrsp || 0, tfsa: +a.tfsa || 0, nonreg: +a.nonreg || 0,
|
||||
monthlyContribution: +(w.ctrb != null ? w.ctrb : a.monthlyContribution) || 0,
|
||||
employerMatchMonthly: +a.employerMatchMonthly || 0,
|
||||
retireAge: +(w.ret != null ? w.ret : a.retireAge),
|
||||
spendingAnnual: +(w.spend != null ? w.spend : a.spendingAnnual) || 0,
|
||||
cppStartAge: +(w.cpp != null ? w.cpp : a.cppStartAge), oasStartAge: +a.oasStartAge,
|
||||
pensionAnnual: +a.pensionAnnual || 0, rentalAnnual: +a.rentalAnnual || 0, otherAnnual: +a.otherAnnual || 0,
|
||||
realReturnPre: (+a.realReturnPre) / 100, realReturnPost: (+a.realReturnPost) / 100,
|
||||
inflation: (+a.inflation) / 100, lifeExp: +a.lifeExp
|
||||
};
|
||||
return p;
|
||||
}
|
||||
|
||||
/* ---------- formatting ---------- */
|
||||
function money(v) { v = Math.round(v); return (v < 0 ? '−$' : '$') + Math.abs(v).toLocaleString('en-CA'); }
|
||||
function k(v) {
|
||||
var s = v < 0 ? '−' : ''; v = Math.abs(v);
|
||||
if (v >= 1e6) return s + '$' + (v / 1e6).toFixed(v >= 1e7 ? 1 : 2) + 'M';
|
||||
if (v >= 1000) return s + '$' + Math.round(v / 1000) + 'k';
|
||||
return s + '$' + Math.round(v);
|
||||
}
|
||||
function pct(v) { return (v * 100).toFixed(0) + '%'; }
|
||||
|
||||
/* ---------- steps ---------- */
|
||||
function couple() { return state.a.household === 'couple'; }
|
||||
var STEPS = [
|
||||
{
|
||||
key: 'province', title: 'Where do you live?', hint: 'Taxes, and your retirement view.',
|
||||
type: 'grid', opts: PROVINCES, get: function () { return state.a.province; },
|
||||
set: function (v) { state.a.province = v; }
|
||||
},
|
||||
{
|
||||
key: 'age', title: 'Your age', hint: 'Years.', type: 'number', min: 18, max: 74,
|
||||
get: function () { return state.a.age; }, set: function (v) { state.a.age = +v; }
|
||||
},
|
||||
{
|
||||
key: 'household', title: 'Household', hint: 'Couple means joint taxes, pension splitting and household GIS.',
|
||||
type: 'opts', opts: [['single', 'Single'], ['couple', 'Couple']],
|
||||
get: function () { return state.a.household; }, set: function (v) { state.a.household = v; }
|
||||
},
|
||||
{
|
||||
key: 'p_age', show: couple, title: 'Partner’s age', type: 'number', min: 18, max: 90,
|
||||
get: function () { return state.a.partner.age; }, set: function (v) { state.a.partner.age = +v; }
|
||||
},
|
||||
{
|
||||
key: 'p_income', show: couple, title: 'Partner’s annual employment income', hint: '$/yr before tax. 0 if not working.',
|
||||
type: 'number', min: 0, max: 1e6, money: true,
|
||||
get: function () { return state.a.partner.income; }, set: function (v) { state.a.partner.income = +v; }
|
||||
},
|
||||
{
|
||||
key: 'p_savings', show: couple, title: 'Partner’s savings', hint: 'Current balances, today’s dollars.',
|
||||
type: 'fields', fields: [
|
||||
{ label: 'RRSP / RRIF', key: 'rrsp' }, { label: 'TFSA', key: 'tfsa' }, { label: 'Non-registered', key: 'nonreg' }
|
||||
],
|
||||
get: function (f) { return state.a.partner[f]; },
|
||||
set: function (f, v) { state.a.partner[f] = +v; }
|
||||
},
|
||||
{
|
||||
key: 'income', title: 'Your annual employment income', hint: '$/yr. Used to estimate your CPP history.',
|
||||
type: 'number', min: 0, max: 1e6, money: true,
|
||||
get: function () { return state.a.income; }, set: function (v) { state.a.income = +v; }
|
||||
},
|
||||
{
|
||||
key: 'rrsp', title: 'RRSP / RRIF balance', type: 'number', min: 0, max: 2e7, money: true,
|
||||
get: function () { return state.a.rrsp; }, set: function (v) { state.a.rrsp = +v; }
|
||||
},
|
||||
{
|
||||
key: 'savings2', title: 'TFSA and non-registered savings', type: 'fields', fields: [
|
||||
{ label: 'TFSA', key: 'tfsa' }, { label: 'Non-registered (cash, stocks, mutual funds)', key: 'nonreg' }
|
||||
],
|
||||
get: function (f) { return state.a[f]; }, set: function (f, v) { state.a[f] = +v; }
|
||||
},
|
||||
{
|
||||
key: 'contrib', title: 'Monthly savings into RRSP', hint: 'Your contribution + any employer match.', type: 'fields', fields: [
|
||||
{ label: 'You contribute $/mo', key: 'monthlyContribution' }, { label: 'Employer match $/mo', key: 'employerMatchMonthly' }
|
||||
],
|
||||
get: function (f) { return state.a[f]; }, set: function (f, v) { state.a[f] = +v; }
|
||||
},
|
||||
{
|
||||
key: 'retire', title: 'Retirement age', hint: 'When you stop earning employment income.', type: 'slider',
|
||||
min: 55, max: 72, step: 1, fmt: function (v) { return v + ' years'; },
|
||||
get: function () { return state.a.retireAge; }, set: function (v) { state.a.retireAge = +v; }
|
||||
},
|
||||
{
|
||||
key: 'spend', title: 'Planned annual spending in retirement', hint: 'Today’s dollars, after tax. Be honest — this is the number that decides everything.',
|
||||
type: 'number', min: 10000, max: 5e5, money: true,
|
||||
get: function () { return state.a.spendingAnnual; }, set: function (v) { state.a.spendingAnnual = +v; }
|
||||
},
|
||||
{
|
||||
key: 'cppstart', title: 'Start CPP at…', hint: function () {
|
||||
var m = { 60: '−36% for life', 65: 'Full rate', 70: '+42% for life' };
|
||||
return 'Age ' + state.a.cppStartAge + ': ' + (m[state.a.cppStartAge] || 'custom adjustment');
|
||||
},
|
||||
type: 'opts', opts: [[60, '60', '−36% for life'], [65, '65', 'full rate'], [70, '70', '+42% for life']],
|
||||
get: function () { return state.a.cppStartAge; }, set: function (v) { state.a.cppStartAge = +v; }
|
||||
},
|
||||
{
|
||||
key: 'oasstart', title: 'Start OAS at…', hint: 'Deferring to 70 is +36% for life, plus a bigger GIS window.',
|
||||
type: 'opts', opts: [[65, '65', 'on eligibility'], [70, '70', '+36% for life']],
|
||||
get: function () { return state.a.oasStartAge; }, set: function (v) { state.a.oasStartAge = +v; }
|
||||
},
|
||||
{
|
||||
key: 'extra', title: 'Other income', hint: 'DB pension, rental, royalties… leave 0 if none.', type: 'fields', fields: [
|
||||
{ label: 'Pension $/yr', key: 'pensionAnnual' }, { label: 'Rental $/yr', key: 'rentalAnnual' }, { label: 'Other $/yr', key: 'otherAnnual' }
|
||||
],
|
||||
get: function (f) { return state.a[f]; }, set: function (f, v) { state.a[f] = +v; }
|
||||
},
|
||||
{
|
||||
key: 'assumptions', title: 'Assumptions', hint: 'Real (above-inflation) returns. Defaults match a balanced portfolio.', type: 'sliders', sliders: [
|
||||
{ label: 'Return before retirement', key: 'realReturnPre', min: 0, max: 8, step: 0.25, fmt: pct2 },
|
||||
{ label: 'Return in retirement', key: 'realReturnPost', min: 0, max: 6, step: 0.25, fmt: pct2 },
|
||||
{ label: 'Inflation', key: 'inflation', min: 0, max: 5, step: 0.25, fmt: pct2 },
|
||||
{ label: 'Plan to age', key: 'lifeExp', min: 80, max: 100, step: 1, fmt: function (v) { return v + ' yrs'; } }
|
||||
],
|
||||
get: function (f) { return state.a[f]; }, set: function (f, v) { state.a[f] = +v; }
|
||||
},
|
||||
{
|
||||
key: 'vision', title: 'What are you retiring toward?', hint: 'Pick a few. We’ll paint your plan with it.',
|
||||
type: 'chips', opts: INTERESTS,
|
||||
get: function () { return state.a.interests; },
|
||||
set: function (v) { state.a.interests = v; }
|
||||
}
|
||||
];
|
||||
function pct2(v) { return v.toFixed(2).replace(/0$/, '') + '%'; }
|
||||
|
||||
function visibleSteps() { return STEPS.filter(function (s) { return !s.show || s.show(); }); }
|
||||
|
||||
/* ---------- quiz rendering ---------- */
|
||||
var scrQuiz = $('#scr-quiz'), stepBody = $('#step-body'), btnNext = $('#btn-next');
|
||||
|
||||
function showScreen(name) {
|
||||
state.screen = name;
|
||||
['intro', 'quiz', 'results'].forEach(function (s) { $('#scr-' + s).hidden = s !== name; });
|
||||
$('#btn-restart').hidden = name === 'intro';
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
|
||||
function renderStep() {
|
||||
var vs = visibleSteps();
|
||||
if (state.step >= vs.length) state.step = vs.length - 1;
|
||||
var s = vs[state.step];
|
||||
$('#prog-bar').style.width = Math.round((state.step / vs.length) * 100) + '%';
|
||||
$('#step-count').textContent = (state.step + 1) + ' / ' + vs.length;
|
||||
$('#btn-back').style.visibility = state.step === 0 ? 'hidden' : 'visible';
|
||||
btnNext.textContent = state.step === vs.length - 1 ? 'See my plan →' : 'Next →';
|
||||
|
||||
var hintTxt = typeof s.hint === 'function' ? s.hint() : (s.hint || '');
|
||||
var h = '<h2>' + s.title + '</h2>' + (hintTxt ? '<p class="hint">' + hintTxt + '</p>' : '');
|
||||
|
||||
if (s.type === 'grid') {
|
||||
h += '<div class="opts">' + s.opts.map(function (o) {
|
||||
return '<button class="opt' + (s.get() === o[0] ? ' sel' : '') + '" data-v="' + o[0] + '">' + o[1] + '</button>';
|
||||
}).join('') + '</div>';
|
||||
} else if (s.type === 'opts') {
|
||||
h += '<div class="opts">' + s.opts.map(function (o) {
|
||||
return '<button class="opt' + (String(s.get()) === String(o[0]) ? ' sel' : '') + '" data-v="' + o[0] + '">' + o[1] + (o[2] ? '<small>' + o[2] + '</small>' : '') + '</button>';
|
||||
}).join('') + '</div>';
|
||||
} else if (s.type === 'chips') {
|
||||
var cur = s.get() || [];
|
||||
h += '<div class="chips-pick">' + s.opts.map(function (o) {
|
||||
return '<button class="opt' + (cur.indexOf(o[0]) >= 0 ? ' sel' : '') + '" data-v="' + o[0] + '">' + o[1] + '</button>';
|
||||
}).join('') + '</div>';
|
||||
} else if (s.type === 'number') {
|
||||
h += '<div class="fld"><input type="number" id="f1" value="' + (s.get() == null ? '' : s.get()) + '" min="' + (s.min != null ? s.min : 0) + '" max="' + (s.max != null ? s.max : 1e9) + '" inputmode="numeric" ' + (s.money ? 'placeholder="0"' : 'placeholder="' + s.max + '"') + '></div>';
|
||||
} else if (s.type === 'fields') {
|
||||
h += s.fields.map(function (f, i) {
|
||||
return '<div class="fld"><label>' + f.label + ' <span class="unit">($)</span></label><input type="number" id="f' + (i + 1) + '" data-k="' + f.key + '" value="' + (s.get(f.key) == null ? '' : s.get(f.key)) + '" min="0" inputmode="numeric" placeholder="0"></div>';
|
||||
}).join('');
|
||||
} else if (s.type === 'slider') {
|
||||
h += '<div class="range-row"><span class="range-val" id="sv">' + s.fmt(s.get()) + '</span></div><input type="range" id="f1" min="' + s.min + '" max="' + s.max + '" step="' + s.step + '" value="' + s.get() + '">';
|
||||
} else if (s.type === 'sliders') {
|
||||
h = s.sliders.map(function (sl) {
|
||||
return '<div class="fld"><label>' + sl.label + ' <span class="unit" style="float:right;font-weight:700;color:var(--blue)">' + sl.fmt(+s.get(sl.key)) + '</span></label><input type="range" data-k="' + sl.key + '" min="' + sl.min + '" max="' + sl.max + '" step="' + sl.step + '" value="' + s.get(sl.key) + '"></div>';
|
||||
}).join('');
|
||||
}
|
||||
stepBody.innerHTML = h;
|
||||
|
||||
stepBody.querySelectorAll('.opt').forEach(function (b) {
|
||||
b.onclick = function () {
|
||||
if (s.type === 'chips') {
|
||||
var arr = (s.get() || []).slice();
|
||||
var i = arr.indexOf(b.dataset.v);
|
||||
if (i >= 0) arr.splice(i, 1); else arr.push(b.dataset.v);
|
||||
s.set(arr); b.classList.toggle('sel');
|
||||
} else {
|
||||
s.set(b.dataset.v);
|
||||
stepBody.querySelectorAll('.opt').forEach(function (x) { x.classList.remove('sel'); });
|
||||
b.classList.add('sel');
|
||||
}
|
||||
save(); liveUpdate(); renderStep();
|
||||
};
|
||||
});
|
||||
var f1 = $('#f1');
|
||||
if (f1) {
|
||||
f1.oninput = function () { s.set(f1.value); save(); liveUpdate(); if (s.type === 'slider') $('#sv').textContent = s.fmt(+f1.value); };
|
||||
if (s.type === 'slider') f1.oninput();
|
||||
}
|
||||
stepBody.querySelectorAll('input[type=number][data-k]').forEach(function (inp) {
|
||||
inp.oninput = function () { s.set(inp.dataset.k, inp.value); save(); liveUpdate(); };
|
||||
});
|
||||
stepBody.querySelectorAll('input[type=range][data-k]').forEach(function (inp) {
|
||||
inp.oninput = function () {
|
||||
s.set(inp.dataset.k, inp.value); save(); liveUpdate();
|
||||
var sl = s.sliders.filter(function (x) { return x.key === inp.dataset.k; })[0];
|
||||
inp.previousElementSibling.lastElementChild.textContent = sl.fmt(+inp.value);
|
||||
};
|
||||
});
|
||||
validate(s);
|
||||
}
|
||||
|
||||
function validate(s) {
|
||||
var ok = true, msg = '';
|
||||
if (s.type === 'number') {
|
||||
var v = +s.get();
|
||||
if (s.get() === null || s.get() === '' || isNaN(v) || v < s.min || v > s.max) {
|
||||
ok = false; msg = 'Enter a number between ' + s.min + ' and ' + s.max + '.';
|
||||
}
|
||||
} else if (s.type === 'grid' && !s.get()) { ok = false; msg = 'Pick a province to continue.'; }
|
||||
btnNext.disabled = !ok;
|
||||
var old = stepBody.querySelector('.err'); if (old) old.remove();
|
||||
if (!ok && msg) { var d = document.createElement('div'); d.className = 'err'; d.textContent = msg; stepBody.appendChild(d); }
|
||||
}
|
||||
|
||||
/* ---------- live panel ---------- */
|
||||
var liveTimer = null;
|
||||
function liveUpdate() { clearTimeout(liveTimer); liveTimer = setTimeout(updatePanel, 120); }
|
||||
|
||||
function updatePanel() {
|
||||
var p = inputs(), proj;
|
||||
try { proj = E.project(p); } catch (e) { return; }
|
||||
var draw = proj.years.filter(function (r) { return r.phase === 'draw'; });
|
||||
var atRet = proj.years.filter(function (r) { return r.age === Math.floor(p.retireAge); })[0] || draw[0];
|
||||
var g67 = proj.years.filter(function (r) { return r.age === 67; })[0] || { cpp: 0, oasGross: 0 };
|
||||
var chips = [
|
||||
['Savings at retirement', money(atRet ? atRet.totalBal : 0), ''],
|
||||
['CPP + OAS at 67', money(g67.cpp + g67.oasGross) + '/yr', ''],
|
||||
['Money lasts to', proj.depletionAge ? 'runs out ~' + proj.depletionAge : (p.lifeExp + '+'), proj.depletionAge ? 'bad' : 'good'],
|
||||
['Lifetime tax', k(proj.totalTax + proj.totalClawback), '']
|
||||
];
|
||||
$('#panel-chips').innerHTML = chips.map(function (c) {
|
||||
return '<div class="chip"><div class="k">' + c[0] + '</div><div class="v ' + c[2] + '">' + c[1] + '</div></div>';
|
||||
}).join('');
|
||||
|
||||
var ins = '';
|
||||
if (proj.depletionAge) ins = '⚠️ On this path you run out of money around age <b>' + proj.depletionAge + '</b>. Keep answering — the plan ends with three concrete ways to close the gap.';
|
||||
else if (p.cppStartAge < 65) ins = 'CPP at ' + p.cppStartAge + ' is permanently reduced — deferring to 70 is a +42% inflation-proof raise. Move the slider later and watch the panel.';
|
||||
else if (p.oasStartAge >= 70) ins = 'OAS at ' + p.oasStartAge + ': +36% for life, plus a bigger GIS window in between. Good trade for most people.';
|
||||
else if (p.province === 'QC') ins = 'ℹ️ Québec: QPP is <b>not</b> modelled — add your QPP estimate as “Pension $/yr”. QPP typically replaces ~35% of income.';
|
||||
else if (p.tfsa < 15000 && p.rrsp > 200000) ins = 'You’ll hit the RRIF-minimum tax cliff at 71. TFSA room now softens it later — check the gap cards.';
|
||||
else if (p.household === 'couple') ins = 'With pension splitting + household GIS, your joint taxes drop — the panel shows the joint total.';
|
||||
else ins = 'Live numbers — change any answer and this panel moves.';
|
||||
$('#panel-insight').innerHTML = ins;
|
||||
}
|
||||
|
||||
/* ---------- charts (hand-rolled inline SVG) ---------- */
|
||||
var W = 640, H = 270, L = 52, R = 12, T = 14, B = 30;
|
||||
function sc(ages, a0, a1) { return function (a) { return L + (a - a0) / (a1 - a0) * (W - L - R); }; }
|
||||
function sy(ymax) { return function (v) { return H - B - Math.max(0, Math.min(1, v / ymax)) * (H - T - B); }; }
|
||||
function pathX(ages, ys, X, Y) {
|
||||
return ys.map(function (v, i) { return (i ? 'L' : 'M') + X(ages[i]).toFixed(1) + ' ' + Y(v).toFixed(1); }).join('');
|
||||
}
|
||||
function areaPath(ages, vals, X, Y) {
|
||||
var p = 'M' + X(ages[0]).toFixed(1) + ' ' + Y(0).toFixed(1);
|
||||
ages.forEach(function (a, i) { p += 'L' + X(a).toFixed(1) + ' ' + Y(vals[i]).toFixed(1); });
|
||||
return p + 'L' + X(ages[ages.length - 1]).toFixed(1) + ' ' + Y(0).toFixed(1) + 'Z';
|
||||
}
|
||||
function stackPath(ages, lower, upper, X, Y) {
|
||||
var p = 'M' + X(ages[0]).toFixed(1) + ' ' + Y(upper[0]).toFixed(1);
|
||||
for (var i = 1; i < ages.length; i++) p += 'L' + X(ages[i]).toFixed(1) + ' ' + Y(upper[i]).toFixed(1);
|
||||
for (i = ages.length - 1; i >= 0; i--) p += 'L' + X(ages[i]).toFixed(1) + ' ' + Y(lower[i]).toFixed(1);
|
||||
return p + 'Z';
|
||||
}
|
||||
function axes(ages, ymax, X, Y, opts) {
|
||||
var a0 = ages[0], a1 = ages[ages.length - 1], s = '';
|
||||
var yt = 4, i;
|
||||
for (i = 0; i <= yt; i++) {
|
||||
var v = ymax * i / yt, y = Y(v);
|
||||
s += '<line x1="' + L + '" x2="' + (W - R) + '" y1="' + y.toFixed(1) + '" y2="' + y.toFixed(1) + '" stroke="#eef2f8"/>' +
|
||||
'<text x="' + (L - 6) + '" y="' + (y + 4).toFixed(1) + '" text-anchor="end" font-size="11" fill="#8a97ad">' + (opts && opts.money === false ? v : k(v)) + '</text>';
|
||||
}
|
||||
var step = a1 - a0 > 34 ? 10 : 5;
|
||||
for (var a = Math.ceil(a0 / step) * step; a <= a1; a += step) {
|
||||
var x = X(a);
|
||||
s += '<text x="' + x.toFixed(1) + '" y="' + (H - B + 16) + '" text-anchor="middle" font-size="11" fill="#8a97ad">' + a + '</text>';
|
||||
}
|
||||
s += '<text x="' + (W / 2) + '" y="' + (H - 4) + '" text-anchor="middle" font-size="11" fill="#8a97ad">age</text>';
|
||||
return s;
|
||||
}
|
||||
function svgWrap(inner) {
|
||||
return '<svg viewBox="0 0 ' + W + ' ' + H + '" role="img" xmlns="http://www.w3.org/2000/svg">' + inner + '</svg>';
|
||||
}
|
||||
function legend(items) {
|
||||
return '<div class="legend">' + items.map(function (t) {
|
||||
return '<span><i style="background:' + t[1] + '"></i>' + t[0] + '</span>';
|
||||
}).join('') + '</div>';
|
||||
}
|
||||
|
||||
var COL = { bal: '#2563eb', cpp: '#2563eb', oas: '#dc2626', gis: '#f59e0b', wdr: '#10b981', cash: '#0ea5e9', need: '#dc2626' };
|
||||
|
||||
function g1_balance(proj) {
|
||||
var yrs = proj.years, ages = yrs.map(function (r) { return r.age; });
|
||||
var vals = yrs.map(function (r) { return r.totalBal; });
|
||||
var a0 = ages[0], a1 = ages[ages.length - 1];
|
||||
var X = sc(ages, a0, a1), ymax = Math.max(10000, Math.max.apply(null, vals) * 1.08), Y = sy(ymax);
|
||||
var inner = axes(ages, ymax, X, Y);
|
||||
inner += '<path d="' + areaPath(ages, vals, X, Y) + '" fill="#2563eb22"/>' +
|
||||
'<path d="' + pathX(ages, vals, X, Y) + '" fill="none" stroke="' + COL.bal + '" stroke-width="2.5"/>';
|
||||
if (proj.depletionAge) {
|
||||
var x = X(proj.depletionAge);
|
||||
inner += '<line x1="' + x + '" x2="' + x + '" y1="' + T + '" y2="' + (H - B) + '" stroke="#dc2626" stroke-dasharray="4 4" stroke-width="2"/>' +
|
||||
'<text x="' + (x + 5) + '" y="' + (T + 12) + '" font-size="12" fill="#dc2626" font-weight="700">runs out ≈ ' + proj.depletionAge + '</text>';
|
||||
}
|
||||
var ret = Math.max(a0, inputs().retireAge);
|
||||
inner += '<line x1="' + X(ret) + '" x2="' + X(ret) + '" y1="' + T + '" y2="' + (H - B) + '" stroke="#94a3b8" stroke-dasharray="2 4"/>' +
|
||||
'<text x="' + (X(ret) + 4) + '" y="' + (H - B - 6) + '" font-size="10.5" fill="#94a3b8">retire</text>';
|
||||
$('#g1').innerHTML = svgWrap(inner) + legend([['Total savings (today’s $)', COL.bal]]);
|
||||
}
|
||||
|
||||
function g2_income(proj, p) {
|
||||
var yrs = proj.years.filter(function (r) { return r.phase === 'draw'; });
|
||||
if (!yrs.length) { $('#g2').innerHTML = '<p class="cap">Retirement after your plan horizon.</p>'; return; }
|
||||
var ages = yrs.map(function (r) { return r.age; });
|
||||
var X = sc(ages, ages[0], ages[ages.length - 1]);
|
||||
var series = [
|
||||
['CPP', COL.cpp, yrs.map(function (r) { return r.cpp; })],
|
||||
['OAS (net)', COL.oas, yrs.map(function (r) { return Math.max(0, r.oasGross - r.oasClawback); })],
|
||||
['GIS', COL.gis, yrs.map(function (r) { return r.gis; })],
|
||||
['From savings', COL.wdr, yrs.map(function (r) { return r.rrifWithdrawal + r.nonregW + r.tfsaW; })]
|
||||
];
|
||||
var n = ages.length, tot = [], i, acc;
|
||||
for (i = 0; i < n; i++) tot[i] = series.reduce(function (s, se) { return s + se[2][i]; }, 0);
|
||||
var ymax = Math.max(1000, Math.max.apply(null, tot) * 1.08), Y = sy(ymax);
|
||||
var inner = axes(ages, ymax, X, Y);
|
||||
var lower = new Array(n).fill(0);
|
||||
series.forEach(function (se) {
|
||||
var upper = se[2].map(function (v, j) { return lower[j] + v; });
|
||||
inner += '<path d="' + stackPath(ages, lower, upper, X, Y) + '" fill="' + se[1] + '33" stroke="' + se[1] + '" stroke-width="1"/>';
|
||||
lower = upper;
|
||||
});
|
||||
$('#g2').innerHTML = svgWrap(inner) + legend(series.map(function (s) { return [s[0], s[1]]; }));
|
||||
}
|
||||
|
||||
function g3_cash(proj) {
|
||||
var yrs = proj.years.filter(function (r) { return r.phase === 'draw'; });
|
||||
if (!yrs.length) { $('#g3').innerHTML = ''; return; }
|
||||
var ages = yrs.map(function (r) { return r.age; });
|
||||
var X = sc(ages, ages[0], ages[ages.length - 1]);
|
||||
var inc = yrs.map(function (r) { return r.cash + r.unfunded; });
|
||||
var need = yrs.map(function (r) { return r.need; });
|
||||
var ymax = Math.max(1000, Math.max(Math.max.apply(null, inc), Math.max.apply(null, need)) * 1.12), Y = sy(ymax);
|
||||
var inner = axes(ages, ymax, X, Y);
|
||||
inner += '<path d="' + pathX(ages, inc, X, Y) + '" fill="none" stroke="' + COL.cash + '" stroke-width="2.5"/>';
|
||||
inner += '<path d="' + pathX(ages, need, X, Y) + '" fill="none" stroke="' + COL.need + '" stroke-width="2" stroke-dasharray="6 4"/>';
|
||||
var first = yrs.map(function (r, i) { return r.unfunded > 1 ? ages[i] : null; }).filter(function (x) { return x; })[0];
|
||||
if (first) {
|
||||
inner += '<circle cx="' + X(first) + '" cy="' + Y(need[ages.indexOf(first)]) + '" r="4.5" fill="#dc2626"/>' +
|
||||
'<text x="' + (X(first) + 6) + '" y="' + (Y(need[ages.indexOf(first)]) - 6) + '" font-size="12" fill="#dc2626" font-weight="700">shortfall from ~' + first + '</text>';
|
||||
}
|
||||
$('#g3').innerHTML = svgWrap(inner) + legend([['Cash you’ll have (after tax)', COL.cash], ['Planned spending', COL.need]]);
|
||||
}
|
||||
|
||||
var g4cache = null;
|
||||
function g4_bars(p, fast) {
|
||||
if (g4cache && fast) { drawG4(g4cache); $('#g4-note').textContent = 'Computed from the current answers.'; return; }
|
||||
$('#g4').innerHTML = '<p class="cap">Calculating scenarios…</p>';
|
||||
setTimeout(function () {
|
||||
var scen = [
|
||||
['Current plan', p],
|
||||
['+$200/mo to RRSP', Object.assign({}, p, { monthlyContribution: p.monthlyContribution + 200, employerMatchMonthly: p.employerMatchMonthly })],
|
||||
['Retire 2 years later', Object.assign({}, p, { retireAge: Math.min(p.retireAge + 2, 75) })],
|
||||
['CPP at 70', Object.assign({}, p, { cppStartAge: 70 })]
|
||||
];
|
||||
var res = scen.map(function (s) { return [s[0], E.sustainableSpending(s[1]).annual]; });
|
||||
g4cache = res; drawG4(res);
|
||||
var best = res.slice().sort(function (a, b) { return b[1] - a[1]; })[0];
|
||||
$('#g4-note').textContent = 'Biggest lever here: “' + best[0] + '” → ' + money(best[1]) + '/yr, forever.';
|
||||
}, 30);
|
||||
}
|
||||
function drawG4(res) {
|
||||
var ymax = Math.max(1000, Math.max.apply(null, res.map(function (r) { return r[1]; })) * 1.15);
|
||||
var bw = (W - L - R) / res.length, inner = '';
|
||||
for (var i = 0; i <= 4; i++) {
|
||||
var v = ymax * i / 4, y = sy(ymax)(v);
|
||||
inner += '<line x1="' + L + '" x2="' + (W - R) + '" y1="' + y + '" y2="' + y + '" stroke="#eef2f8"/>' +
|
||||
'<text x="' + (L - 6) + '" y="' + (y + 4) + '" text-anchor="end" font-size="11" fill="#8a97ad">' + k(v) + '</text>';
|
||||
}
|
||||
res.forEach(function (r, i) {
|
||||
var hgt = (H - T - B) * r[1] / ymax, x = L + i * bw + bw * 0.16, y = H - B - hgt;
|
||||
var col = i === 0 ? '#94a3b8' : (r[1] >= res[0][1] ? '#059669' : '#d97706');
|
||||
inner += '<rect x="' + x + '" y="' + y + '" width="' + bw * 0.68 + '" height="' + hgt + '" rx="6" fill="' + col + '"/>' +
|
||||
'<text x="' + (x + bw * 0.34) + '" y="' + (y - 6) + '" text-anchor="middle" font-size="12.5" font-weight="700" fill="#16233b">' + k(r[1]) + '</text>' +
|
||||
'<text x="' + (x + bw * 0.34) + '" y="' + (H - B + 14) + '" text-anchor="middle" font-size="10.5" fill="#8a97ad">' + r[0] + '</text>';
|
||||
});
|
||||
$('#g4').innerHTML = svgWrap(inner);
|
||||
}
|
||||
|
||||
/* ---------- vision ---------- */
|
||||
var visionManifest = null;
|
||||
fetch('vision/manifest.json').then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (m) { visionManifest = m; }).catch(function () { });
|
||||
|
||||
function renderVision(a) {
|
||||
var el = $('#vision-card');
|
||||
var provSlugs = PROV_SLUGS[a.province] || [];
|
||||
var picks = a.interests || [];
|
||||
function score(slug) {
|
||||
var s = 0;
|
||||
if (picks.indexOf(slug) >= 0) s += 3;
|
||||
if (provSlugs.indexOf(slug) >= 0) s += 2;
|
||||
if (slug === 'hero' && s === 0) s += 0.5;
|
||||
return s;
|
||||
}
|
||||
var keys = visionManifest ? Object.keys(visionManifest) : null;
|
||||
if (!keys) { keys = Object.keys(CAPTIONS); }
|
||||
var best = keys.map(function (s) { return [s, score(s)]; }).sort(function (x, y) { return y[1] - x[1]; })[0];
|
||||
if (!best) best = ['hero', 0];
|
||||
var slug = best[1] > 0 ? best[0] : 'hero';
|
||||
var cap = CAPTIONS[slug] || CAPTIONS.hero;
|
||||
var img = visionManifest && visionManifest[slug] ? 'vision/' + visionManifest[slug] : null;
|
||||
if (img) {
|
||||
el.className = 'vision';
|
||||
el.innerHTML = '<img src="' + img + '" alt="Your retirement vision" loading="lazy">' +
|
||||
'<div class="vcap"><b>Retirement Vision — ' + slug.replace(/^\w/, function (c) { return c.toUpperCase(); }) + '</b><br>' + cap +
|
||||
' <span style="opacity:.6;font-size:12px">AI-generated preview (z-image-turbo). Your numbers: below.</span></div>';
|
||||
var im = el.querySelector('img');
|
||||
im.onerror = function () { grad(el, slug, cap); };
|
||||
} else grad(el, slug, cap);
|
||||
}
|
||||
function grad(el, slug, cap) {
|
||||
el.className = 'vision grad';
|
||||
var h = 0; for (var i = 0; i < slug.length; i++) h = (h * 31 + slug.charCodeAt(i)) >>> 0;
|
||||
el.style.background = GRADS[h % GRADS.length];
|
||||
el.innerHTML = '<div class="vcap"><b>Retirement Vision — ' + slug.replace(/^\w/, function (c) { return c.toUpperCase(); }) + '</b><br>' + cap + '</div>';
|
||||
}
|
||||
|
||||
/* ---------- results ---------- */
|
||||
function renderResults(fast) {
|
||||
var p = inputs(), proj = E.project(p), rec;
|
||||
try { rec = E.recommendations(p, proj); } catch (e) { rec = { gapAnnual: 0 }; }
|
||||
var ss = E.sustainableSpending(p).annual;
|
||||
|
||||
var v = $('#verdict'), vh = $('#verdict-h');
|
||||
if (proj.depletionAge) {
|
||||
v.className = 'verdict bad';
|
||||
v.innerHTML = 'On this exact path you run out of money at <b>age ' + proj.depletionAge + '</b>. ' +
|
||||
'You can safely spend <b>' + money(ss) + '/yr</b> to 95 (you’re planning ' + money(p.spendingAnnual) + '). The three cards below close the gap — each one, by itself.';
|
||||
} else {
|
||||
v.className = 'verdict good';
|
||||
v.innerHTML = 'This plan survives to <b>95</b> with ' + k(proj.finalBal) + ' left over — you can sustain about <b>' + money(ss) + '/yr</b>. ' +
|
||||
'That’s ' + money(ss - p.spendingAnnual) + '/yr of headroom above your plan.';
|
||||
}
|
||||
$('#lifeexp-lbl').textContent = p.lifeExp;
|
||||
|
||||
var cards = '';
|
||||
if (rec.gapAnnual > 0) {
|
||||
cards += '<div class="gcard warn"><h4>The gap</h4><div class="big">' + money(rec.gapAnnual) + '/yr</h4><p>between your planned spending and what the plan can safely fund.</p></div>';
|
||||
cards += '<div class="gcard save"><h4>💪 Save more</h4><div class="big">+' + money(rec.extraMonthly) + '/mo</div>' +
|
||||
(rec.extraMonthly > 0 ? '<p>extra RRSP room from today closes the gap entirely. ~' + pct(rec.extraMonthly / Math.max(1, p.income / 12)) + ' of income.</p>' : '<p>Not closable by saving alone — see the other two levers.</p>') + '</div>';
|
||||
cards += '<div class="gcard"><h4>🕰 Work longer</h4><div class="big">' + (rec.retireDelayYears ? '+' + rec.retireDelayYears + (rec.retireDelayYears === 1 ? ' year' : ' years') : '—') + '</div>' +
|
||||
(rec.retireDelayYears ? '<p>retiring at ' + Math.min(p.retireAge + rec.retireDelayYears, 75) + ' instead of ' + p.retireAge + ' closes the gap — and CPP/OAS grow while you do.</p>' : '<p>Within 10 extra years, delaying retirement alone can’t close this gap — combine levers.</p>') + '</div>';
|
||||
cards += '<div class="gcard"><h4>🌿 Trim spending</h4><div class="big">−' + money(rec.spendingReduction) + '/yr</div><p>to ' + money(Math.max(0, p.spendingAnnual - rec.spendingReduction)) + '. The cheapest lever, and the one nobody talks about at 45.</p></div>';
|
||||
} else {
|
||||
cards = '<div class="gcard save"><h4>✅ Status</h4><div class="big">On track</div><p>No gap at ' + money(p.spendingAnnual) + '/yr. Consider testing a bigger number with the what-if sliders.</p></div>' +
|
||||
'<div class="gcard"><h4>Headroom</h4><div class="big">' + money(ss - p.spendingAnnual) + '/yr</div><p>you could add to spending — or save for the cottage.</p></div>' +
|
||||
'<div class="gcard save"><h4>🛡 Stress-tested</h4><div class="big">' + money(ss) + '/yr</div><p>sustainable real spending to ' + p.lifeExp + ' with RRIF minimums and taxes enforced.</p></div>';
|
||||
}
|
||||
$('#gap-cards').innerHTML = cards;
|
||||
|
||||
g1_balance(proj); g2_income(proj, p); g3_cash(proj); g4_bars(p, fast);
|
||||
|
||||
var tbl = '<tr><th>Age</th><th>RRSP</th><th>TFSA</th><th>Non-reg</th><th>Total</th><th>Withdraw</th><th>CPP</th><th>OAS</th><th>GIS</th><th>Tax+claw</th><th>Unfunded</th></tr>';
|
||||
var n = 0;
|
||||
proj.years.forEach(function (r) {
|
||||
if (r.phase !== 'draw' && r.age % 5 !== 0) return; n++;
|
||||
tbl += '<tr class="' + (r.unfunded > 1 ? 'dep' : '') + '"><td>' + r.age + '</td><td>' + k(r.rrspBal) + '</td><td>' + k(r.tfsaBal) +
|
||||
'</td><td>' + k(r.nonregBal) + '</td><td><b>' + k(r.totalBal) + '</b></td><td>' + k(r.rrifWithdrawal + r.nonregW + r.tfsaW) +
|
||||
'</td><td>' + k(r.cpp) + '</td><td>' + k(r.oasGross - r.oasClawback) + '</td><td>' + k(r.gis) + '</td><td>' + k(r.tax + r.oasClawback) +
|
||||
'</td><td>' + (r.unfunded > 1 ? k(r.unfunded) : '—') + '</td></tr>';
|
||||
});
|
||||
$('#yr-table').innerHTML = tbl; $('#yr-n').textContent = n;
|
||||
|
||||
var m2 = function (v) { return '$' + Number(v).toLocaleString('en-CA', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); };
|
||||
$('#stamp').textContent = 'tax rates 2026 · CPP max ' + m2(C.cppMax65Monthly.value) + '/mo · OAS max ' + m2(C.oasMax6574Monthly.value) + '/mo · GIS max ' + m2(C.gisMaxSingleMonthly.value) + '/mo · constants as of ' + C.asOf + ' · RRIF table CRA Reg. 8301(1)';
|
||||
|
||||
var a = state.a;
|
||||
$('#wi-spend').value = (state.wi.spend != null ? state.wi.spend : a.spendingAnnual);
|
||||
$('#wi-ctrb').value = (state.wi.ctrb != null ? state.wi.ctrb : a.monthlyContribution);
|
||||
$('#wi-ret').value = (state.wi.ret != null ? state.wi.ret : a.retireAge);
|
||||
$('#wi-cpp').value = (state.wi.cpp != null ? state.wi.cpp : a.cppStartAge);
|
||||
wiLabels();
|
||||
|
||||
renderVision(a);
|
||||
save();
|
||||
}
|
||||
function wiLabels() {
|
||||
$('#wi-spend-o').textContent = money(+$('#wi-spend').value);
|
||||
$('#wi-ctrb-o').textContent = money(+$('#wi-ctrb').value) + '/mo';
|
||||
$('#wi-ret-o').textContent = $('#wi-ret').value + ' yrs';
|
||||
$('#wi-cpp-o').textContent = 'age ' + $('#wi-cpp').value;
|
||||
}
|
||||
var wiTimer = null;
|
||||
function wiChange(key, el) {
|
||||
state.wi[key] = +el.value; wiLabels();
|
||||
clearTimeout(wiTimer); wiTimer = setTimeout(function () { renderResults(true); }, 250);
|
||||
}
|
||||
$('#wi-spend').addEventListener('input', function () { wiChange('spend', this); });
|
||||
$('#wi-ctrb').addEventListener('input', function () { wiChange('ctrb', this); });
|
||||
$('#wi-ret').addEventListener('input', function () { wiChange('ret', this); });
|
||||
$('#wi-cpp').addEventListener('input', function () { wiChange('cpp', this); });
|
||||
|
||||
/* ---------- wiring ---------- */
|
||||
$('#btn-begin').onclick = function () { showScreen('quiz'); renderStep(); liveUpdate(); };
|
||||
$('#btn-back').onclick = function () { if (state.step > 0) { state.step--; save(); renderStep(); } };
|
||||
btnNext.onclick = function () {
|
||||
var vs = visibleSteps();
|
||||
if (state.step < vs.length - 1) { state.step++; save(); renderStep(); }
|
||||
else { g4cache = null; showScreen('results'); renderResults(false); }
|
||||
};
|
||||
$('#btn-edit').onclick = function () { showScreen('quiz'); renderStep(); liveUpdate(); };
|
||||
$('#btn-restart').onclick = function () {
|
||||
if (!confirm('Start over? Clears saved answers in this browser.')) return;
|
||||
try { localStorage.removeItem(LS); } catch (e) { }
|
||||
history.replaceState(null, '', location.pathname);
|
||||
state.a = defaults(); state.wi = {}; state.step = 0; g4cache = null;
|
||||
showScreen('intro');
|
||||
};
|
||||
$('#btn-print').onclick = function () { window.print(); };
|
||||
$('#btn-share').onclick = function () {
|
||||
var url = location.origin + location.pathname + '#p=' + encState();
|
||||
var done = function () { $('#btn-share').textContent = '✅ Link copied'; setTimeout(function () { $('#btn-share').textContent = '🔗 Copy link with my answers'; }, 1800); };
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(url).then(done, done);
|
||||
else done();
|
||||
};
|
||||
document.addEventListener('click', function (e) {
|
||||
var h = document.querySelector('.panel-h');
|
||||
if (h && h.contains(e.target)) document.getElementById('panel').classList.toggle('open');
|
||||
});
|
||||
var savedScreen = state.screen, savedStep = state.step;
|
||||
showScreen(savedScreen);
|
||||
if (savedScreen === 'quiz') { renderStep(); liveUpdate(); }
|
||||
else if (savedScreen === 'results') { renderResults(false); liveUpdate(); }
|
||||
})();
|
||||
@@ -0,0 +1,527 @@
|
||||
/* AUTO-GENERATED by scripts/build_canada.js from live sources — see PLAN.md Stage 1.
|
||||
* Evidence JSON kept at scripts/constants-evidence.json */
|
||||
(function (root) { root.RetireCanada = {
|
||||
"generated": "2026-10-08",
|
||||
"year": "2026 (live-fetched; plan fallbacks were 2025)",
|
||||
"asOf": "October 2026 (CPP 2026, OAS/GIS Oct-Dec 2026, tax 2026)",
|
||||
"cppMax65Monthly": {
|
||||
"value": 1507.65,
|
||||
"source": "https://www.canada.ca/en/employment-social-development/programs/pensions/pension/statistics/2026-quarterly-april-june.html Table 1, Jan 2026 (incl. CPP enhancement)",
|
||||
"asOf": "2026-04-01"
|
||||
},
|
||||
"ympe": {
|
||||
"value": 74600,
|
||||
"source": "https://www.canada.ca/en/employment-social-development/programs/pensions/pension/statistics/2026-quarterly-april-june.html Table 2 YMPE 2026 (YBE $3,500)",
|
||||
"asOf": "2026-04-01"
|
||||
},
|
||||
"oasMax6574Monthly": {
|
||||
"value": 762.5,
|
||||
"source": "https://www.canada.ca/en/services/benefits/publicpensions/old-age-security/payments.html Oct-Dec 2026",
|
||||
"asOf": "2026-09-29"
|
||||
},
|
||||
"oas75Boost": {
|
||||
"value": 1.1,
|
||||
"source": "https://www.canada.ca/en/services/benefits/publicpensions/old-age-security/payments.html (+10% at 75, permanent since Jul 2022)",
|
||||
"asOf": "2026-09-29"
|
||||
},
|
||||
"oasClawbackThreshold": {
|
||||
"value": 95323,
|
||||
"source": "https://www.canada.ca/en/services/benefits/publicpensions/old-age-security/recovery-tax.html (2026 income year; est. published 2026-09-29)",
|
||||
"asOf": "2026-09-29"
|
||||
},
|
||||
"oasFullRecovery6574": {
|
||||
"value": 155320,
|
||||
"source": "https://www.canada.ca/en/services/benefits/publicpensions/old-age-security/recovery-tax.html",
|
||||
"asOf": "2026-09-29"
|
||||
},
|
||||
"gisMaxSingleMonthly": {
|
||||
"value": 1138.9,
|
||||
"source": "https://www.canada.ca/en/services/benefits/publicpensions/old-age-security/payments.html Oct-Dec 2026",
|
||||
"asOf": "2026-09-29"
|
||||
},
|
||||
"gisSingleThr": {
|
||||
"value": 23112,
|
||||
"source": "https://www.canada.ca/en/services/benefits/publicpensions/old-age-security/payments.html (income ex-OAS threshold, single)",
|
||||
"asOf": "2026-09-29"
|
||||
},
|
||||
"gisMaxCoupleMonthly": {
|
||||
"value": 1371.12,
|
||||
"source": "https://www.canada.ca/en/services/benefits/publicpensions/old-age-security/payments.html (685.56 x2, spouse receives full OAS)",
|
||||
"asOf": "2026-09-29"
|
||||
},
|
||||
"gisCoupleThr": {
|
||||
"value": 30528,
|
||||
"source": "https://www.canada.ca/en/services/benefits/publicpensions/old-age-security/payments.html (combined income ex-OAS, both on OAS)",
|
||||
"asOf": "2026-09-29"
|
||||
},
|
||||
"fedBrackets": {
|
||||
"value": [
|
||||
{
|
||||
"up": 58523,
|
||||
"rate": 0.14
|
||||
},
|
||||
{
|
||||
"up": 117045,
|
||||
"rate": 0.205
|
||||
},
|
||||
{
|
||||
"up": 181440,
|
||||
"rate": 0.26
|
||||
},
|
||||
{
|
||||
"up": 258482,
|
||||
"rate": 0.29
|
||||
},
|
||||
{
|
||||
"up": null,
|
||||
"rate": 0.33
|
||||
}
|
||||
],
|
||||
"source": "https://www.canada.ca/en/revenue-agency/services/tax/individuals/tax-rates-brackets/current-year.html",
|
||||
"asOf": "2026-06-25"
|
||||
},
|
||||
"fedBPA": {
|
||||
"value": 16452,
|
||||
"source": "taxtips FAQ: federal BPA 2026 max $16,452 (min $14,829)",
|
||||
"asOf": "2026-02-22"
|
||||
},
|
||||
"fedAgeAmount": {
|
||||
"value": 8790,
|
||||
"source": "2025 value (plan-stamped; web search unavailable at build time)",
|
||||
"asOf": "2025-01-01"
|
||||
},
|
||||
"fedAgeThr": {
|
||||
"value": 44017,
|
||||
"source": "2025 value (plan-stamped)",
|
||||
"asOf": "2025-01-01"
|
||||
},
|
||||
"piaAmount": {
|
||||
"value": 2000,
|
||||
"source": "plan-stamped 2025 simplification (indexed ~$2.2k in 2026)",
|
||||
"asOf": "2025-01-01"
|
||||
},
|
||||
"cppEarlyAdjMonthly": {
|
||||
"value": 0.006,
|
||||
"source": "plan-stamped / canada.ca CPP (0.6% per month before 65)",
|
||||
"asOf": "2025-01-01"
|
||||
},
|
||||
"cppLateAdjMonthly": {
|
||||
"value": 0.007,
|
||||
"source": "plan-stamped (0.7% per month after 65)",
|
||||
"asOf": "2025-01-01"
|
||||
},
|
||||
"oasDeferralMonthly": {
|
||||
"value": 0.006,
|
||||
"source": "https://www.canada.ca/en/employment-social-development/programs/pensions/pension/statistics/2026-quarterly-april-june.html Table 5 footnote 8 (+0.6%/mo, max 36%)",
|
||||
"asOf": "2026-04-01"
|
||||
},
|
||||
"rrifMinPct": {
|
||||
"value": {
|
||||
"71": 5.28,
|
||||
"72": 5.4,
|
||||
"73": 5.53,
|
||||
"74": 5.67,
|
||||
"75": 5.82,
|
||||
"76": 5.98,
|
||||
"77": 6.15,
|
||||
"78": 6.33,
|
||||
"79": 6.53,
|
||||
"80": 6.74,
|
||||
"81": 6.97,
|
||||
"82": 7.22,
|
||||
"83": 7.48,
|
||||
"84": 7.77,
|
||||
"85": 8.08,
|
||||
"86": 8.41,
|
||||
"87": 8.77,
|
||||
"88": 9.15,
|
||||
"89": 9.56,
|
||||
"90": 10,
|
||||
"91": 10.48,
|
||||
"92": 11,
|
||||
"93": 11.56,
|
||||
"94": 12.18,
|
||||
"95": 20
|
||||
},
|
||||
"source": "CRA RRIF minimum factor table (Reg. 8301(1)) — CRA RRIF page 404 at fetch time, table stamped (plan fallback: 71→5.28 ✓)",
|
||||
"asOf": "2026"
|
||||
},
|
||||
"provBrackets": {
|
||||
"ON": {
|
||||
"name": "Ontario",
|
||||
"brackets": [
|
||||
{
|
||||
"up": 53891,
|
||||
"rate": 0.050499999999999996
|
||||
},
|
||||
{
|
||||
"up": 107785,
|
||||
"rate": 0.0915
|
||||
},
|
||||
{
|
||||
"up": 150000,
|
||||
"rate": 0.1116
|
||||
},
|
||||
{
|
||||
"up": 220000,
|
||||
"rate": 0.1216
|
||||
},
|
||||
{
|
||||
"up": null,
|
||||
"rate": 0.1316
|
||||
}
|
||||
],
|
||||
"bpa": 12989,
|
||||
"source": "taxtips.ca/taxrates/on.htm (2026 table)",
|
||||
"asOf": "2026-02-22",
|
||||
"surtax": [
|
||||
{
|
||||
"from": 6474,
|
||||
"rate": 0.2
|
||||
},
|
||||
{
|
||||
"from": 8102,
|
||||
"rate": 0.36
|
||||
}
|
||||
]
|
||||
},
|
||||
"BC": {
|
||||
"name": "British Columbia",
|
||||
"brackets": [
|
||||
{
|
||||
"up": 50363,
|
||||
"rate": 0.055999999999999994
|
||||
},
|
||||
{
|
||||
"up": 100728,
|
||||
"rate": 0.077
|
||||
},
|
||||
{
|
||||
"up": 115648,
|
||||
"rate": 0.105
|
||||
},
|
||||
{
|
||||
"up": 140430,
|
||||
"rate": 0.1229
|
||||
},
|
||||
{
|
||||
"up": 190405,
|
||||
"rate": 0.147
|
||||
},
|
||||
{
|
||||
"up": 265545,
|
||||
"rate": 0.168
|
||||
},
|
||||
{
|
||||
"up": null,
|
||||
"rate": 0.205
|
||||
}
|
||||
],
|
||||
"bpa": 13216,
|
||||
"source": "taxtips.ca/taxrates/bc.htm (2026 table)",
|
||||
"asOf": "2026-02-22"
|
||||
},
|
||||
"AB": {
|
||||
"name": "Alberta",
|
||||
"brackets": [
|
||||
{
|
||||
"up": 61200,
|
||||
"rate": 0.08
|
||||
},
|
||||
{
|
||||
"up": 154259,
|
||||
"rate": 0.1
|
||||
},
|
||||
{
|
||||
"up": 185111,
|
||||
"rate": 0.12
|
||||
},
|
||||
{
|
||||
"up": 246813,
|
||||
"rate": 0.13
|
||||
},
|
||||
{
|
||||
"up": 370220,
|
||||
"rate": 0.14
|
||||
},
|
||||
{
|
||||
"up": null,
|
||||
"rate": 0.15
|
||||
}
|
||||
],
|
||||
"bpa": 22769,
|
||||
"source": "taxtips.ca/taxrates/ab.htm (2026 table)",
|
||||
"asOf": "2026-02-22"
|
||||
},
|
||||
"MB": {
|
||||
"name": "Manitoba",
|
||||
"brackets": [
|
||||
{
|
||||
"up": 47000,
|
||||
"rate": 0.10800000000000001
|
||||
},
|
||||
{
|
||||
"up": 100000,
|
||||
"rate": 0.1275
|
||||
},
|
||||
{
|
||||
"up": null,
|
||||
"rate": 0.174
|
||||
}
|
||||
],
|
||||
"bpa": 15780,
|
||||
"source": "taxtips.ca/taxrates/mb.htm (2026 table)",
|
||||
"asOf": "2026-02-22"
|
||||
},
|
||||
"SK": {
|
||||
"name": "Saskatchewan",
|
||||
"brackets": [
|
||||
{
|
||||
"up": 54532,
|
||||
"rate": 0.105
|
||||
},
|
||||
{
|
||||
"up": 155805,
|
||||
"rate": 0.125
|
||||
},
|
||||
{
|
||||
"up": null,
|
||||
"rate": 0.145
|
||||
}
|
||||
],
|
||||
"bpa": 20381,
|
||||
"source": "taxtips.ca/taxrates/sk.htm (2026 table)",
|
||||
"asOf": "2026-02-22"
|
||||
},
|
||||
"NS": {
|
||||
"name": "Nova Scotia",
|
||||
"brackets": [
|
||||
{
|
||||
"up": 30995,
|
||||
"rate": 0.08789999999999999
|
||||
},
|
||||
{
|
||||
"up": 61991,
|
||||
"rate": 0.1495
|
||||
},
|
||||
{
|
||||
"up": 97417,
|
||||
"rate": 0.16670000000000001
|
||||
},
|
||||
{
|
||||
"up": 157124,
|
||||
"rate": 0.175
|
||||
},
|
||||
{
|
||||
"up": null,
|
||||
"rate": 0.21
|
||||
}
|
||||
],
|
||||
"bpa": 11932,
|
||||
"source": "taxtips.ca/taxrates/ns.htm (2026 table)",
|
||||
"asOf": "2026-02-22"
|
||||
},
|
||||
"NB": {
|
||||
"name": "New Brunswick",
|
||||
"brackets": [
|
||||
{
|
||||
"up": 52333,
|
||||
"rate": 0.094
|
||||
},
|
||||
{
|
||||
"up": 104666,
|
||||
"rate": 0.14
|
||||
},
|
||||
{
|
||||
"up": 193861,
|
||||
"rate": 0.16
|
||||
},
|
||||
{
|
||||
"up": null,
|
||||
"rate": 0.195
|
||||
}
|
||||
],
|
||||
"bpa": 13664,
|
||||
"source": "taxtips.ca/taxrates/nb.htm (2026 table)",
|
||||
"asOf": "2026-02-22"
|
||||
},
|
||||
"NL": {
|
||||
"name": "Newfoundland & Labrador",
|
||||
"brackets": [
|
||||
{
|
||||
"up": 44678,
|
||||
"rate": 0.087
|
||||
},
|
||||
{
|
||||
"up": 89354,
|
||||
"rate": 0.145
|
||||
},
|
||||
{
|
||||
"up": 159528,
|
||||
"rate": 0.158
|
||||
},
|
||||
{
|
||||
"up": 223340,
|
||||
"rate": 0.17800000000000002
|
||||
},
|
||||
{
|
||||
"up": 285319,
|
||||
"rate": 0.198
|
||||
},
|
||||
{
|
||||
"up": 570638,
|
||||
"rate": 0.20800000000000002
|
||||
},
|
||||
{
|
||||
"up": 1141275,
|
||||
"rate": 0.213
|
||||
},
|
||||
{
|
||||
"up": null,
|
||||
"rate": 0.218
|
||||
}
|
||||
],
|
||||
"bpa": 13094,
|
||||
"source": "taxtips.ca/taxrates/nl.htm (2026 table)",
|
||||
"asOf": "2026-02-22"
|
||||
},
|
||||
"PE": {
|
||||
"name": "Prince Edward Island",
|
||||
"brackets": [
|
||||
{
|
||||
"up": 33928,
|
||||
"rate": 0.095
|
||||
},
|
||||
{
|
||||
"up": 65820,
|
||||
"rate": 0.13470000000000001
|
||||
},
|
||||
{
|
||||
"up": 106890,
|
||||
"rate": 0.166
|
||||
},
|
||||
{
|
||||
"up": 142250,
|
||||
"rate": 0.17620000000000002
|
||||
},
|
||||
{
|
||||
"up": 200000,
|
||||
"rate": 0.19
|
||||
},
|
||||
{
|
||||
"up": null,
|
||||
"rate": 0.2
|
||||
}
|
||||
],
|
||||
"bpa": 15000,
|
||||
"source": "taxtips.ca/taxrates/pe.htm (2026 table)",
|
||||
"asOf": "2026-02-22"
|
||||
},
|
||||
"YT": {
|
||||
"name": "Yukon",
|
||||
"brackets": [
|
||||
{
|
||||
"up": 58523,
|
||||
"rate": 0.064
|
||||
},
|
||||
{
|
||||
"up": 117045,
|
||||
"rate": 0.09
|
||||
},
|
||||
{
|
||||
"up": 181440,
|
||||
"rate": 0.109
|
||||
},
|
||||
{
|
||||
"up": 258482,
|
||||
"rate": 0.1293
|
||||
},
|
||||
{
|
||||
"up": 500000,
|
||||
"rate": 0.128
|
||||
},
|
||||
{
|
||||
"up": null,
|
||||
"rate": 0.15
|
||||
}
|
||||
],
|
||||
"bpa": 16452,
|
||||
"source": "taxtips.ca/taxrates/yt.htm (2026 table)",
|
||||
"asOf": "2026-02-22"
|
||||
},
|
||||
"NT": {
|
||||
"name": "Northwest Territories",
|
||||
"brackets": [
|
||||
{
|
||||
"up": 53003,
|
||||
"rate": 0.059000000000000004
|
||||
},
|
||||
{
|
||||
"up": 106009,
|
||||
"rate": 0.086
|
||||
},
|
||||
{
|
||||
"up": 172346,
|
||||
"rate": 0.122
|
||||
},
|
||||
{
|
||||
"up": null,
|
||||
"rate": 0.1405
|
||||
}
|
||||
],
|
||||
"bpa": 18198,
|
||||
"source": "taxtips.ca/taxrates/nt.htm (2026 table)",
|
||||
"asOf": "2026-02-22"
|
||||
},
|
||||
"NU": {
|
||||
"name": "Nunavut",
|
||||
"brackets": [
|
||||
{
|
||||
"up": 55801,
|
||||
"rate": 0.04
|
||||
},
|
||||
{
|
||||
"up": 111602,
|
||||
"rate": 0.07
|
||||
},
|
||||
{
|
||||
"up": 181439,
|
||||
"rate": 0.09
|
||||
},
|
||||
{
|
||||
"up": null,
|
||||
"rate": 0.115
|
||||
}
|
||||
],
|
||||
"bpa": 19659,
|
||||
"source": "taxtips.ca/taxrates/nu.htm (2026 table)",
|
||||
"asOf": "2026-02-22"
|
||||
},
|
||||
"QC": {
|
||||
"name": "Quebec",
|
||||
"brackets": [
|
||||
{
|
||||
"up": 54345,
|
||||
"rate": 0.14
|
||||
},
|
||||
{
|
||||
"up": 108680,
|
||||
"rate": 0.19
|
||||
},
|
||||
{
|
||||
"up": 132245,
|
||||
"rate": 0.24
|
||||
},
|
||||
{
|
||||
"up": null,
|
||||
"rate": 0.2575
|
||||
}
|
||||
],
|
||||
"bpa": 18952,
|
||||
"source": "taxtips.ca/taxrates/qc.htm (2026 table)",
|
||||
"asOf": "2026-02-22"
|
||||
}
|
||||
}
|
||||
};
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = root.RetireCanada;
|
||||
})(typeof self !== 'undefined' ? self : this);
|
||||
@@ -0,0 +1,421 @@
|
||||
/* retire-planner engine — pure functions, no DOM. UMD so node can unit-test.
|
||||
* All money is in REAL (today's) dollars. Annual units unless noted.
|
||||
* Sources/stamps for every constant live in canada.js (window.RetireCanada). */
|
||||
(function (root, factory) {
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
var C; try { C = require('./canada.js'); } catch (e) { C = {}; }
|
||||
module.exports = factory(C);
|
||||
} else {
|
||||
root.RetireEngine = factory(root.RetireCanada || {});
|
||||
}
|
||||
})(typeof self !== 'undefined' ? self : this, function (C) {
|
||||
'use strict';
|
||||
|
||||
// In-engine fallbacks mirror PLAN.md Stage 1 fallback table (stamped FALLBACK in canada.js).
|
||||
var FALLBACK = {
|
||||
cppMax65Monthly: 1433.00, ympe: 69700, oasMax6574Monthly: 713.34, oas75Boost: 1.10,
|
||||
oasClawbackThreshold: 93454, gisMaxSingleMonthly: 1086.88, gisMaxCoupleMonthly: 1436.68,
|
||||
gisSingleThr: 20000, gisCoupleThr: 26000
|
||||
};
|
||||
function V(key) {
|
||||
var e = C && C[key];
|
||||
if (!e) return FALLBACK[key];
|
||||
if (typeof e === 'number') return e;
|
||||
return (e && typeof e.value === 'number') ? e.value : FALLBACK[key];
|
||||
}
|
||||
function fedBrackets() { return (C.fedBrackets && C.fedBrackets.value) || (C.fedBrackets && C.fedBrackets.brackets) || [
|
||||
{ up: 57375, rate: 0.15 }, { up: 114750, rate: 0.205 }, { up: 177882, rate: 0.26 },
|
||||
{ up: 253414, rate: 0.29 }, { up: null, rate: 0.33 }]; }
|
||||
function fedBPA() { return (C.fedBPA && (C.fedBPA.value !== undefined ? C.fedBPA.value : C.fedBPA)) || 15705; }
|
||||
function fedAgeAmount() { return (C.fedAgeAmount && (C.fedAgeAmount.value !== undefined ? C.fedAgeAmount.value : C.fedAgeAmount)) || 8790; }
|
||||
function provTable(p) { return (C.provBrackets && (C.provBrackets.value || C.provBrackets))[p] || null; }
|
||||
function rrifTable() { return (C.rrifMinPct && (C.rrifMinPct.value || C.rrifMinPct)) || null; }
|
||||
|
||||
var AGE_CLAW_START = 44017; // federal age amount clawback threshold (2025)
|
||||
var BPA_CLAW_FED = 165000; // simplified net-income clawback starts (plan)
|
||||
var BPA_CLAW_PROV = 173000;
|
||||
var PIA = 2000; // pension income amount (fed + prov)
|
||||
|
||||
function bracketTax(income, brackets) {
|
||||
var t = 0, prev = 0;
|
||||
for (var i = 0; i < brackets.length; i++) {
|
||||
var b = brackets[i], up = (b.up === null || b.up === undefined) ? Infinity : b.up;
|
||||
if (income <= prev) break;
|
||||
var seg = Math.min(income, up) - prev;
|
||||
if (seg > 0) t += seg * b.rate;
|
||||
prev = up;
|
||||
}
|
||||
return Math.max(0, t);
|
||||
}
|
||||
|
||||
/* ---- CPP ---- */
|
||||
// 0.25 × min(avgEarnings, YMPE) × min(1, contribYears/40) / 12, capped at max.
|
||||
// contributory period 18→65 (47 yrs), general drop-out ≈17% → /40 effective. ESTIMATE ONLY.
|
||||
function estimateCppMonthlyAt65(income, age) {
|
||||
var contribYears = Math.min(47, Math.max(0, age - 22));
|
||||
var eff = Math.min(1, contribYears / 40);
|
||||
var m = 0.25 * Math.min(income || 0, V('ympe')) * eff / 12;
|
||||
return Math.min(m, V('cppMax65Monthly'));
|
||||
}
|
||||
// Pension STARTS at startAge with actuarial adj (−0.6%/mo before 65, +0.7%/mo after), stays flat (real).
|
||||
function cppMonthlyAt(age, startAge, at65) {
|
||||
if (age < startAge) return 0;
|
||||
var adj;
|
||||
if (startAge < 65) adj = 1 - 0.006 * (65 - startAge) * 12; // 60 → 0.64
|
||||
else if (startAge > 65) adj = 1 + 0.007 * (startAge - 65) * 12; // 70 → 1.42
|
||||
else adj = 1;
|
||||
return at65 * adj;
|
||||
}
|
||||
|
||||
/* ---- OAS ---- */
|
||||
function oasMonthlyAt(age, startAge) {
|
||||
if (age < startAge) return 0;
|
||||
var m = V('oasMax6574Monthly');
|
||||
if (startAge > 65) m *= 1 + 0.006 * (startAge - 65) * 12; // +36% at 70
|
||||
if (age >= 75) m *= V('oas75Boost'); // +10% at 75
|
||||
return m;
|
||||
}
|
||||
|
||||
/* ---- OAS recovery tax (clawback) ---- */
|
||||
function clawback(netIncome) {
|
||||
return 0.15 * Math.max(0, (netIncome || 0) - V('oasClawbackThreshold'));
|
||||
}
|
||||
|
||||
/* ---- GIS (SIMPLIFIED — stamp: simplified) ---- */
|
||||
// Only below ~$20k (single) / ~$26k (couple, both 65+) income ex-OAS.
|
||||
// gis = max(0, gisMax − 0.5 × max(0, incomeExOAS − 5000)) (first $5k employment exemption approximated).
|
||||
// Returns ANNUAL $, non-taxable.
|
||||
function gis(incomeExOas, household) {
|
||||
var single = household !== 'couple';
|
||||
var thr = single ? V('gisSingleThr') : V('gisCoupleThr');
|
||||
var maxMo = single ? V('gisMaxSingleMonthly') : V('gisMaxCoupleMonthly');
|
||||
var i = incomeExOas || 0;
|
||||
if (i >= thr) return 0;
|
||||
// All-annual units: gisMax annual = maxMo*12; reduction 0.5 × (income − 5000).
|
||||
return Math.max(0, maxMo * 12 - 0.5 * Math.max(0, i - 5000));
|
||||
}
|
||||
|
||||
/* ---- Tax (2025 brackets; simplified credits) ---- */
|
||||
function taxProvincial(taxable, prov, age, opts) {
|
||||
var pb = provTable(prov);
|
||||
if (!pb) return Math.max(0, taxable) * 0.05; // flat 5% surrogate — stamped in canada.js
|
||||
var gross = bracketTax(taxable, pb.brackets);
|
||||
var base = pb.brackets[0].rate;
|
||||
var credit = 0;
|
||||
var bpa = Math.max(0, (pb.bpa || 0) - 0.15 * Math.max(0, taxable - BPA_CLAW_PROV));
|
||||
credit += bpa * base;
|
||||
if (age >= 65 && pb.ageAmt) {
|
||||
credit += Math.max(0, pb.ageAmt - 0.25 * Math.max(0, taxable - (pb.ageThr || 35000))) * base;
|
||||
}
|
||||
if (age >= 65 && opts && opts.pensionIncome > 0) credit += Math.min(opts.pensionIncome, PIA) * base;
|
||||
var t = Math.max(0, gross - credit);
|
||||
if (pb.surtax) {
|
||||
for (var i = 0; i < pb.surtax.length; i++) {
|
||||
var s = pb.surtax[i];
|
||||
t += Math.max(0, gross - s.from) * s.rate;
|
||||
}
|
||||
}
|
||||
return t;
|
||||
}
|
||||
// taxableIncome = T1 line-15000-style taxable income. age drives senior credits.
|
||||
// opts: {pensionIncome: eligible pension/RRIF $ for PIA}
|
||||
function tax(taxableIncome, province, age, opts) {
|
||||
opts = opts || {};
|
||||
taxableIncome = Math.max(0, taxableIncome || 0);
|
||||
var ni = taxableIncome;
|
||||
var fgross = bracketTax(taxableIncome, fedBrackets());
|
||||
var fcr = 0;
|
||||
var fbase = fedBrackets()[0].rate; // lowest federal rate (15% 2025, 14% 2026+)
|
||||
var bpa = Math.max(0, fedBPA() - 0.15 * Math.max(0, ni - BPA_CLAW_FED));
|
||||
fcr += bpa * fbase;
|
||||
if (age >= 65) {
|
||||
var ageAmt = Math.max(0, fedAgeAmount() - 0.15 * Math.max(0, ni - AGE_CLAW_START));
|
||||
fcr += ageAmt * fbase;
|
||||
if (opts.pensionIncome > 0) fcr += Math.min(opts.pensionIncome, PIA) * fbase;
|
||||
}
|
||||
var federal = Math.max(0, fgross - fcr);
|
||||
if (province === 'QC') federal *= (1 - 0.165); // Quebec abatement (simplified: no QPP/YAP/RDAP)
|
||||
var provincial = taxProvincial(taxableIncome, province, age, opts);
|
||||
var r2 = function (x) { return Math.round(x * 100) / 100; };
|
||||
return { federal: r2(federal), provincial: r2(provincial), total: r2(federal + provincial) };
|
||||
}
|
||||
|
||||
function combinedTax(taxable, prov, age, opts) { // tax + clawback
|
||||
return tax(taxable, prov, age, opts).total + clawback(taxable);
|
||||
}
|
||||
function marginalRate(taxable, prov, age, opts) {
|
||||
var step = 2000;
|
||||
return (combinedTax(taxable + step, prov, age, opts) - combinedTax(taxable, prov, age, opts)) / step;
|
||||
}
|
||||
|
||||
/* ---- RRIF minimums: (115−age)/(120−age), 20% at 95+ ---- */
|
||||
function rrifMinPct(age) {
|
||||
if (age < 71) return 0;
|
||||
if (age >= 95) return 20.0;
|
||||
var t = rrifTable();
|
||||
if (t && t[age] !== undefined) return t[age];
|
||||
return Math.round((115 - age) / (120 - age) * 10000) / 100;
|
||||
}
|
||||
|
||||
/* ---- Inputs defaults (stamped in UI) ---- */
|
||||
var DEFAULTS = {
|
||||
age: 45, province: 'ON', household: 'single', partner: null,
|
||||
income: 60000, rrsp: 100000, tfsa: 30000, nonreg: 20000,
|
||||
monthlyContribution: 500, employerMatchMonthly: 0,
|
||||
retireAge: 65, spendingAnnual: 50000,
|
||||
cppStartAge: 65, cppOverrideMonthlyAt65: 0, oasStartAge: 65,
|
||||
rrspWithdrawalOverrideAnnual: 0,
|
||||
pensionAnnual: 0, rentalAnnual: 0, otherAnnual: 0,
|
||||
realReturnPre: 0.035, realReturnPost: 0.025, inflation: 0.02, lifeExp: 95
|
||||
};
|
||||
function fillDefaults(inp) {
|
||||
var o = {}, k;
|
||||
for (k in DEFAULTS) o[k] = DEFAULTS[k];
|
||||
for (k in (inp || {})) if (inp[k] !== undefined && inp[k] !== null) o[k] = inp[k];
|
||||
return o;
|
||||
}
|
||||
|
||||
/* ---- Core year-by-year projection (single person) ---- */
|
||||
function projectSingle(p) {
|
||||
var at65 = p.cppOverrideMonthlyAt65 > 0 ? p.cppOverrideMonthlyAt65 : estimateCppMonthlyAt65(p.income, p.age);
|
||||
var rrsp = Math.max(0, p.rrsp), tfsa = Math.max(0, p.tfsa), nonreg = Math.max(0, p.nonreg);
|
||||
var years = [], depletionAge = null, totalTax = 0, totalClawback = 0, nestEgg = null;
|
||||
var end = Math.ceil(p.lifeExp);
|
||||
var other0 = (p.pensionAnnual || 0) + (p.rentalAnnual || 0) + (p.otherAnnual || 0);
|
||||
|
||||
for (var a = p.age; a < end; a++) {
|
||||
var row = { age: a };
|
||||
if (a < p.retireAge) {
|
||||
rrsp = rrsp * (1 + p.realReturnPre) + 12 * ((p.monthlyContribution || 0) + (p.employerMatchMonthly || 0));
|
||||
tfsa = tfsa * (1 + p.realReturnPre);
|
||||
nonreg = nonreg * (1 + p.realReturnPre);
|
||||
row.phase = 'accum'; row.cpp = 0; row.oasGross = 0; row.oasClawback = 0; row.gis = 0; row.other = 0;
|
||||
row.rrifWithdrawal = 0; row.nonregW = 0; row.tfsaW = 0; row.taxable = 0; row.tax = 0;
|
||||
row.cash = 0; row.need = 0; row.unfunded = 0;
|
||||
row.rrspBal = rrsp; row.tfsaBal = tfsa; row.nonregBal = nonreg; row.totalBal = rrsp + tfsa + nonreg;
|
||||
years.push(row);
|
||||
continue;
|
||||
}
|
||||
// post-retirement real growth on opening balances, then withdrawals
|
||||
rrsp *= (1 + p.realReturnPost); tfsa *= (1 + p.realReturnPost); nonreg *= (1 + p.realReturnPost);
|
||||
if (nestEgg === null && a === Math.floor(p.retireAge)) nestEgg = rrsp + tfsa + nonreg;
|
||||
var rrspJan = rrsp;
|
||||
var rrifMin = a >= 71 ? rrifMinPct(a) / 100 * rrspJan : 0;
|
||||
var cpp = cppMonthlyAt(a, p.cppStartAge, at65) * 12;
|
||||
var oasGross = oasMonthlyAt(a, p.oasStartAge) * 12;
|
||||
var pre = cpp + oasGross + other0;
|
||||
var need = p.spendingAnnual;
|
||||
|
||||
// Given RRSP/RRIF withdrawal W: waterfall RRSP → non-reg → TFSA, taxes & clawback, GIS.
|
||||
function solve(W) {
|
||||
W = Math.min(Math.max(W, 0), rrspJan);
|
||||
var taxable1 = pre + W;
|
||||
var opts1 = { pensionIncome: (p.pensionAnnual || 0) + (a >= 65 ? W : 0) };
|
||||
var t1 = tax(taxable1, p.province, a, opts1);
|
||||
var c1 = clawback(taxable1);
|
||||
var g = gis(taxable1 - oasGross, 'single');
|
||||
var cash1 = pre + W + g - t1.total - c1;
|
||||
var n = 0, t = 0, taxable = taxable1, tt = t1, cc = c1, opts = opts1;
|
||||
var rem = need - cash1;
|
||||
if (rem > 0 && nonreg > 0) {
|
||||
var m = marginalRate(taxable1, p.province, a, { pensionIncome: 0 });
|
||||
n = Math.min(nonreg, Math.max(0, rem / Math.max(0.2, 1 - 0.5 * m)));
|
||||
taxable = taxable1 + 0.5 * n;
|
||||
opts = { pensionIncome: opts1.pensionIncome };
|
||||
tt = tax(taxable, p.province, a, opts);
|
||||
cc = clawback(taxable);
|
||||
g = gis(taxable - oasGross, 'single');
|
||||
rem = need - (pre + W + g + n - tt.total - cc);
|
||||
}
|
||||
if (rem > 0 && tfsa > 0) {
|
||||
t = Math.min(tfsa, rem);
|
||||
g = gis(taxable - oasGross, 'single');
|
||||
rem = need - (pre + W + g + n + t - tt.total - cc);
|
||||
}
|
||||
var cash = pre + W + g + n + t - tt.total - cc;
|
||||
return { W: W, n: n, t: t, taxable: taxable, tax: tt, cw: cc, gis: g, cash: cash, rem: rem };
|
||||
}
|
||||
|
||||
var r;
|
||||
if (p.rrspWithdrawalOverrideAnnual > 0) {
|
||||
r = solve(Math.min(Math.max(p.rrspWithdrawalOverrideAnnual, rrifMin), rrspJan));
|
||||
} else {
|
||||
var lo = Math.min(rrifMin, rrspJan), hi = Math.max(rrspJan, lo);
|
||||
var flo = solve(lo), fhi = solve(hi);
|
||||
if (fhi.cash <= need + 0.01) {
|
||||
r = fhi; // everything spent; shortfall covered below
|
||||
} else if (flo.cash >= need - 0.01) {
|
||||
r = flo; // minimum withdrawal already covers need
|
||||
} else {
|
||||
for (var it = 0; it < 25; it++) {
|
||||
var mid = (lo + hi) / 2;
|
||||
var fm = solve(mid);
|
||||
if (fm.cash < need) lo = mid; else hi = mid;
|
||||
}
|
||||
r = solve((lo + hi) / 2);
|
||||
}
|
||||
}
|
||||
|
||||
var surplus = Math.max(0, r.cash - need);
|
||||
var unfunded = Math.max(0, need - r.cash);
|
||||
rrsp = Math.max(0, rrspJan - r.W);
|
||||
nonreg = Math.max(0, nonreg - r.n);
|
||||
tfsa = Math.max(0, tfsa - r.t) + surplus; // surplus saved tax-free (TFSA proxy)
|
||||
var totalBal = rrsp + tfsa + nonreg;
|
||||
if (totalBal <= 0.5 && unfunded > 0.5 && depletionAge === null) depletionAge = a;
|
||||
|
||||
totalTax += r.tax.total; totalClawback += r.cw;
|
||||
row.phase = 'draw';
|
||||
row.cpp = cpp; row.oasGross = oasGross; row.oasClawback = r.cw; row.gis = r.gis; row.other = other0;
|
||||
row.rrifWithdrawal = r.W; row.nonregW = r.n; row.tfsaW = r.t;
|
||||
row.taxable = r.taxable; row.tax = r.tax.total; row.cash = Math.min(r.cash, need);
|
||||
row.need = need; row.unfunded = unfunded;
|
||||
row.rrspBal = rrsp; row.tfsaBal = tfsa; row.nonregBal = nonreg; row.totalBal = totalBal;
|
||||
row.taxFederal = r.tax.federal; row.taxProvincial = r.tax.provincial;
|
||||
years.push(row);
|
||||
}
|
||||
var last = years.length ? years[years.length - 1] : null;
|
||||
return {
|
||||
years: years, nestEgg: nestEgg === null ? (last ? last.totalBal : 0) : nestEgg,
|
||||
depletionAge: depletionAge, totalTax: totalTax, totalClawback: totalClawback,
|
||||
finalBal: last ? last.totalBal : 0
|
||||
};
|
||||
}
|
||||
|
||||
/* ---- Couple: per-person runs, household spending split 50/50, merge ---- */
|
||||
function projectCouple(p) {
|
||||
var me = fillDefaults(Object.assign({}, p, { household: 'single', partner: null, spendingAnnual: p.spendingAnnual * 0.5 }));
|
||||
var pt = p.partner || {};
|
||||
var partnerInp = fillDefaults(Object.assign({}, p, {
|
||||
household: 'single', partner: null,
|
||||
age: pt.age !== undefined ? pt.age : p.age,
|
||||
income: pt.income || 0, rrsp: pt.rrsp || 0, tfsa: pt.tfsa || 0, nonreg: pt.nonreg || 0,
|
||||
monthlyContribution: pt.monthlyContribution || 0, employerMatchMonthly: 0,
|
||||
spendingAnnual: p.spendingAnnual * 0.5
|
||||
}));
|
||||
var A = projectSingle(me), B = projectSingle(partnerInp);
|
||||
function byYear(res) { var m = {}; res.years.forEach(function (y) { m[y.age] = y; }); return m; }
|
||||
var ya = byYear(A), yb = byYear(B);
|
||||
var start = Math.min(p.age, partnerInp.age), end = Math.ceil(p.lifeExp);
|
||||
var merged = [], totalTax = 0, totalClawback = 0, depletionAge = null;
|
||||
for (var yr = start; yr < end; yr++) {
|
||||
var ra = ya[yr], rb = yb[yr];
|
||||
if (!ra && !rb) continue;
|
||||
ra = ra || { cpp: 0, oasGross: 0, gis: 0, other: 0, rrifWithdrawal: 0, nonregW: 0, tfsaW: 0, taxable: 0, tax: 0, oasClawback: 0, cash: 0, need: 0, unfunded: 0, rrspBal: 0, tfsaBal: 0, nonregBal: 0, totalBal: 0, phase: 'none' };
|
||||
rb = rb || { cpp: 0, oasGross: 0, gis: 0, other: 0, rrifWithdrawal: 0, nonregW: 0, tfsaW: 0, taxable: 0, tax: 0, oasClawback: 0, cash: 0, need: 0, unfunded: 0, rrspBal: 0, tfsaBal: 0, nonregBal: 0, totalBal: 0, phase: 'none' };
|
||||
var row = {
|
||||
age: yr, phase: (ra.phase === 'draw' || rb.phase === 'draw') ? 'draw' : 'accum',
|
||||
cpp: ra.cpp + rb.cpp, oasGross: ra.oasGross + rb.oasGross, other: ra.other + rb.other,
|
||||
rrifWithdrawal: ra.rrifWithdrawal + rb.rrifWithdrawal, nonregW: ra.nonregW + rb.nonregW, tfsaW: ra.tfsaW + rb.tfsaW,
|
||||
rrspBal: ra.rrspBal + rb.rrspBal, tfsaBal: ra.tfsaBal + rb.tfsaBal, nonregBal: ra.nonregBal + rb.nonregBal
|
||||
};
|
||||
row.totalBal = row.rrspBal + row.tfsaBal + row.nonregBal;
|
||||
row.need = ra.need + rb.need;
|
||||
var taxSum = ra.tax + rb.tax, clawSum = ra.oasClawback + rb.oasClawback;
|
||||
row.taxable = ra.taxable + rb.taxable;
|
||||
// Pension income splitting (both 65+): split pension+RRIF 50/50, keep the cheaper total.
|
||||
if (ra.phase === 'draw' && rb.phase === 'draw' && yr >= 65 && (ra.age >= 65 && rb.age >= 65)) {
|
||||
var pa = (me.pensionAnnual || 0) + ra.rrifWithdrawal, pb2 = (partnerInp.pensionAnnual || 0) + rb.rrifWithdrawal;
|
||||
var shift = 0.5 * (pa - pb2);
|
||||
if (Math.abs(shift) > 1) {
|
||||
var tA = tax(ra.taxable - shift, p.province, yr, { pensionIncome: pa - shift });
|
||||
var tB = tax(rb.taxable + shift, p.province, yr, { pensionIncome: pb2 + shift });
|
||||
var newTax = tA.total + tB.total, newClaw = clawback(ra.taxable - shift) + clawback(rb.taxable + shift);
|
||||
if (newTax + newClaw < taxSum + clawSum) { taxSum = newTax; clawSum = newClaw; }
|
||||
}
|
||||
}
|
||||
row.tax = taxSum; row.oasClawback = clawSum;
|
||||
// GIS: household rate when both 65+, else sum of singles
|
||||
var incA = ra.taxable - ra.oasGross, incB = rb.taxable - rb.oasGross;
|
||||
var gisSum;
|
||||
if (ra.phase === 'draw' && rb.phase === 'draw' && ra.age >= 65 && rb.age >= 65) {
|
||||
gisSum = gis(incA + incB, 'couple');
|
||||
} else {
|
||||
gisSum = (ra.phase === 'draw' ? gis(incA, 'single') : 0) + (rb.phase === 'draw' ? gis(incB, 'single') : 0);
|
||||
}
|
||||
row.gis = gisSum;
|
||||
var cash = ra.cash + rb.cash + (ra.gis + rb.gis ? gisSum - ra.gis - rb.gis : 0) + (taxSum ? (ra.tax + rb.tax - taxSum) : 0);
|
||||
cash = ra.cash + rb.cash - (ra.gis + rb.gis) + gisSum + (ra.tax + rb.tax) - taxSum;
|
||||
row.cash = Math.min(cash, row.need);
|
||||
row.unfunded = Math.max(0, row.need - cash);
|
||||
if (row.phase === 'draw' && row.totalBal <= 0.5 && row.unfunded > 0.5 && depletionAge === null) depletionAge = yr;
|
||||
totalTax += taxSum; totalClawback += clawSum;
|
||||
merged.push(row);
|
||||
}
|
||||
return {
|
||||
years: merged, nestEgg: A.nestEgg + B.nestEgg, depletionAge: depletionAge,
|
||||
totalTax: totalTax, totalClawback: totalClawback,
|
||||
finalBal: merged.length ? merged[merged.length - 1].totalBal : 0
|
||||
};
|
||||
}
|
||||
|
||||
function project(inputs) {
|
||||
var p = fillDefaults(inputs);
|
||||
if (p.household === 'couple' && p.partner) return projectCouple(p);
|
||||
return projectSingle(p);
|
||||
}
|
||||
|
||||
/* ---- Sustainable spending: bisection on spending that survives to lifeExp ---- */
|
||||
function sustainableSpending(inputs) {
|
||||
var lo = 4000, hi = 400000;
|
||||
var survives = function (s) {
|
||||
return project(Object.assign({}, inputs, { spendingAnnual: s })).depletionAge === null;
|
||||
};
|
||||
if (!survives(lo)) return { annual: 0, monthly: 0 };
|
||||
if (survives(hi)) return { annual: hi, monthly: hi / 12 };
|
||||
for (var i = 0; i < 30; i++) {
|
||||
var mid = (lo + hi) / 2;
|
||||
if (survives(mid)) lo = mid; else hi = mid;
|
||||
}
|
||||
lo = Math.floor(lo / 100) * 100;
|
||||
return { annual: lo, monthly: lo / 12 };
|
||||
}
|
||||
|
||||
/* ---- Three gap closers ---- */
|
||||
function recommendations(inputs, projection) {
|
||||
var p = fillDefaults(inputs);
|
||||
var proj = projection || project(p);
|
||||
var s = sustainableSpending(p).annual;
|
||||
var gap = p.spendingAnnual - s;
|
||||
var out = { extraMonthly: 0, retireDelayYears: 0, spendingReduction: 0, gapAnnual: Math.max(0, Math.round(gap)) };
|
||||
if (gap <= 100) { out.gapAnnual = 0; return out; }
|
||||
out.spendingReduction = Math.round(gap / 100) * 100;
|
||||
// (a) extra monthly contribution that closes the gap
|
||||
var lo = 0, hi = 30000;
|
||||
var closes = function (c) {
|
||||
return sustainableSpending(Object.assign({}, p, {
|
||||
monthlyContribution: (p.monthlyContribution || 0) + c
|
||||
})).annual >= p.spendingAnnual - 100;
|
||||
};
|
||||
if (closes(hi)) {
|
||||
for (var i = 0; i < 20; i++) {
|
||||
var mid = (lo + hi) / 2;
|
||||
if (closes(mid)) hi = mid; else lo = mid;
|
||||
}
|
||||
out.extraMonthly = Math.ceil(hi / 10) * 10;
|
||||
} else out.extraMonthly = -1; // not closable by saving alone below $30k/mo
|
||||
// (b) retirement delay
|
||||
for (var d = 1; d <= 10; d++) {
|
||||
if (sustainableSpending(Object.assign({}, p, { retireAge: Math.min(p.retireAge + d, 75) })).annual >= p.spendingAnnual - 100) {
|
||||
out.retireDelayYears = d; break;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
return {
|
||||
estimateCppMonthlyAt65: estimateCppMonthlyAt65,
|
||||
cppMonthlyAt: cppMonthlyAt,
|
||||
oasMonthlyAt: oasMonthlyAt,
|
||||
tax: tax,
|
||||
clawback: clawback,
|
||||
gis: gis,
|
||||
rrifMinPct: rrifMinPct,
|
||||
marginalRate: marginalRate,
|
||||
project: project,
|
||||
sustainableSpending: sustainableSpending,
|
||||
recommendations: recommendations,
|
||||
defaults: DEFAULTS,
|
||||
constants: C
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<!-- retire-planner v1 -->
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Retire Planner — Canadian retirement, modelled properly</title>
|
||||
<meta name="description" content="A Canadian retirement calculator with real CPP/OAS/GIS/RRIF mechanics and live 2026 tax rates. 100% client-side — answers never leave your browser.">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="hdr">
|
||||
<div class="hdr-in">
|
||||
<span class="logo">🍁 <b>Retire Planner</b></span>
|
||||
<span class="pill" title="No server, no tracking, no accounts">🔒 100% client-side — answers never leave your browser</span>
|
||||
<button id="btn-restart" class="ghost" hidden>Start over</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<!-- INTRO -->
|
||||
<section id="scr-intro" class="scr">
|
||||
<h1>Will you actually run out of money?</h1>
|
||||
<p class="lede">Most Canadian retirement calculators stop at “your RRSP grows at 6%”. This one models <b>real CPP/OAS/GIS/RRIF mechanics</b>, provincial taxes fetched live, and the tax cliff most planners won't show you.</p>
|
||||
<ul class="ticks">
|
||||
<li>CPP early/late (−0.6%/mo before 65, +0.7% after), OAS deferral, clawback & GIS</li>
|
||||
<li>Live 2026 federal + all 13 province/territory tax tables</li>
|
||||
<li>RRIF minimums at 71, pension splitting, tax-smart drawdown</li>
|
||||
<li>Couples: joint taxes, split pensions, household GIS</li>
|
||||
</ul>
|
||||
<p class="privacy">🔒 100% client-side — answers never leave your browser. No account, no tracking, nothing is sent anywhere.</p>
|
||||
<button id="btn-begin" class="cta">Build my retirement plan →</button>
|
||||
<p class="fine">~2 minutes. Works on one screen at a time; you can change anything later.</p>
|
||||
</section>
|
||||
|
||||
<!-- QUESTIONNAIRE -->
|
||||
<section id="scr-quiz" class="scr" hidden>
|
||||
<div class="quiz-wrap">
|
||||
<div class="quiz-main">
|
||||
<div class="prog"><div id="prog-bar"></div></div>
|
||||
<div class="step-nav">
|
||||
<button id="btn-back" class="ghost">← Back</button>
|
||||
<span id="step-count" class="step-count"></span>
|
||||
</div>
|
||||
<div id="step-body"></div>
|
||||
<button id="btn-next" class="cta">Next →</button>
|
||||
</div>
|
||||
<aside class="panel" id="panel">
|
||||
<div class="panel-h">Your plan so far</div>
|
||||
<div id="panel-chips" class="chips"></div>
|
||||
<div id="panel-insight" class="insight"></div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- RESULTS -->
|
||||
<section id="scr-results" class="scr" hidden>
|
||||
<div id="vision-card" class="vision"></div>
|
||||
<h2 id="verdict-h">Your plan, in one line</h2>
|
||||
<p id="verdict" class="verdict"></p>
|
||||
|
||||
<div class="cards" id="gap-cards"></div>
|
||||
|
||||
<h3>Will it last? Projected savings</h3>
|
||||
<p class="cap">Total portfolio (RRSP + TFSA + non-registered), in today's dollars. Shaded: money running out.</p>
|
||||
<div id="g1" class="chartbox"></div>
|
||||
|
||||
<h3>Where retirement income comes from</h3>
|
||||
<p class="cap">CPP, OAS, GIS and what you must withdraw from savings — stacked, after-tax view.</p>
|
||||
<div id="g2" class="chartbox"></div>
|
||||
|
||||
<h3>Spending vs. what you'll actually have</h3>
|
||||
<p class="cap">Annual cash in (income after tax & clawback, plus withdrawals) vs. planned spending.</p>
|
||||
<div id="g3" class="chartbox"></div>
|
||||
|
||||
<h3>Four futures, one number</h3>
|
||||
<p class="cap">Sustainable annual spending that lasts to <span id="lifeexp-lbl">95</span> under each scenario.</p>
|
||||
<div id="g4" class="chartbox"></div>
|
||||
<p id="g4-note" class="cap"></p>
|
||||
|
||||
<details class="whatif" id="whatif">
|
||||
<summary><b>Try a what-if</b> — move a slider, watch the plan update</summary>
|
||||
<div class="wi-grid">
|
||||
<label>Annual spending <output id="wi-spend-o"></output>
|
||||
<input type="range" id="wi-spend" min="20000" max="150000" step="1000"></label>
|
||||
<label>Monthly savings <output id="wi-ctrb-o"></output>
|
||||
<input type="range" id="wi-ctrb" min="0" max="5000" step="50"></label>
|
||||
<label>Retirement age <output id="wi-ret-o"></output>
|
||||
<input type="range" id="wi-ret" min="55" max="72" step="1"></label>
|
||||
<label>CPP starts at <output id="wi-cpp-o"></output>
|
||||
<input type="range" id="wi-cpp" min="60" max="70" step="1"></label>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="yearly">
|
||||
<summary>Year-by-year detail (<span id="yr-n">0</span> years)</summary>
|
||||
<div class="tbl-wrap"><table id="yr-table"></table></div>
|
||||
</details>
|
||||
|
||||
<div class="disclaimer">
|
||||
<p><b>Estimates only — not financial advice.</b> Simplified model: GIS uses a simplified 50% phase-out; Quebec shown with the 16.5% federal abatement (QPP and Quebec payroll taxes not modelled); DB pensions not modelled; returns are constant real rates; Ontario surtax modelled on base tax.</p>
|
||||
<p class="stamp" id="stamp">tax rates 2026</p>
|
||||
<p class="privacy">🔒 100% client-side — answers never leave your browser. Your answers are saved only in this browser (localStorage).</p>
|
||||
<div class="btn-row">
|
||||
<button id="btn-print" class="ghost">🖨 Print / save PDF</button>
|
||||
<button id="btn-share" class="ghost">🔗 Copy link with my answers</button>
|
||||
<button id="btn-edit" class="ghost">✎ Edit answers</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="engine/canada.js"></script>
|
||||
<script src="engine/engine.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,121 @@
|
||||
/* Retire Planner — light, mobile-first, system fonts, no frameworks */
|
||||
:root{
|
||||
--ink:#16233b; --ink2:#4c5b74; --line:#e3e8f0; --bg:#f7f9fc; --card:#ffffff;
|
||||
--blue:#2563eb; --red:#dc2626; --amber:#d97706; --green:#059669; --violet:#7c3aed;
|
||||
--r:14px;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html{scroll-behavior:smooth}
|
||||
body{margin:0;font:16px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;color:var(--ink);background:var(--bg)}
|
||||
main{max-width:1060px;margin:0 auto;padding:12px}
|
||||
h1{font-size:clamp(26px,6vw,40px);line-height:1.15;margin:.4em 0 .3em}
|
||||
h2{font-size:clamp(20px,4.5vw,26px);margin:1.2em 0 .3em}
|
||||
h3{font-size:18px;margin:1.4em 0 .1em}
|
||||
p{color:var(--ink2)}
|
||||
b{color:var(--ink)}
|
||||
|
||||
/* header */
|
||||
.hdr{position:sticky;top:0;z-index:30;background:#ffffffd9;backdrop-filter:blur(8px);border-bottom:1px solid var(--line)}
|
||||
.hdr-in{max-width:1060px;margin:0 auto;display:flex;align-items:center;gap:10px;padding:10px 12px;flex-wrap:wrap}
|
||||
.logo{font-size:17px}
|
||||
.pill{font-size:12px;background:#e8f5ee;color:#0a5c36;border:1px solid #bfe6d0;border-radius:99px;padding:3px 10px;color:#0a5c36}
|
||||
.ghost{background:none;border:1px solid var(--line);border-radius:10px;padding:7px 12px;font-size:14px;color:var(--ink2);cursor:pointer}
|
||||
.ghost:hover{border-color:var(--blue);color:var(--blue)}
|
||||
|
||||
/* screens */
|
||||
.scr{padding:8px 4px 80px}
|
||||
.lede{font-size:18px}
|
||||
.ticks{list-style:none;padding:0;margin:14px 0}
|
||||
.ticks li{padding:7px 0 7px 30px;position:relative;color:var(--ink2)}
|
||||
.ticks li::before{content:"✅";position:absolute;left:0;font-size:14px}
|
||||
.privacy{font-size:14px;color:#0a5c36;background:#e8f5ee;border:1px solid #bfe6d0;border-radius:10px;padding:9px 12px}
|
||||
.fine{font-size:13px}
|
||||
.cta{display:block;width:100%;max-width:420px;margin:18px 0 0;background:var(--blue);color:#fff;border:0;border-radius:12px;padding:15px 20px;font-size:17px;font-weight:600;cursor:pointer}
|
||||
.cta:hover{background:#1d4fd7}
|
||||
.cta:disabled{background:#b6c4dd;cursor:default}
|
||||
|
||||
/* quiz */
|
||||
.quiz-wrap{display:grid;grid-template-columns:1fr 320px;gap:20px;align-items:start}
|
||||
@media(max-width:860px){
|
||||
.quiz-wrap{grid-template-columns:1fr}
|
||||
.panel{position:fixed;left:8px;right:8px;bottom:8px;z-index:40;max-height:44vh;overflow:auto;box-shadow:0 -6px 24px #16233b22;transform:translateY(calc(100% - 58px));transition:transform .25s}
|
||||
.panel.open{transform:none}
|
||||
.panel-h{cursor:pointer;position:sticky;top:0;background:var(--card)}
|
||||
.panel-h::after{content:" ▲";font-size:11px;color:var(--ink2)}
|
||||
.panel.open .panel-h::after{content:" ▼"}
|
||||
}
|
||||
.panel{background:var(--card);border:1px solid var(--line);border-radius:var(--r);padding:14px}
|
||||
.panel-h{font-weight:700;font-size:14px;margin-bottom:8px}
|
||||
.chips{display:grid;grid-template-columns:1fr 1fr;gap:8px}
|
||||
.chip{background:var(--bg);border:1px solid var(--line);border-radius:10px;padding:9px 10px}
|
||||
.chip .k{font-size:11px;color:var(--ink2);text-transform:uppercase;letter-spacing:.04em}
|
||||
.chip .v{font-size:19px;font-weight:700}
|
||||
.chip .v.bad{color:var(--red)} .chip .v.good{color:var(--green)}
|
||||
.insight{margin-top:10px;font-size:13.5px;color:var(--ink2);border-left:3px solid var(--amber);padding:2px 0 2px 10px;min-height:20px}
|
||||
.prog{height:6px;background:var(--line);border-radius:99px;overflow:hidden;margin:6px 0 10px}
|
||||
#prog-bar{height:100%;width:0;background:var(--blue);transition:width .3s}
|
||||
.step-nav{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px}
|
||||
.step-count{font-size:13px;color:var(--ink2)}
|
||||
#step-body{background:var(--card);border:1px solid var(--line);border-radius:var(--r);padding:18px;min-height:320px}
|
||||
#step-body h2{margin-top:.1em}
|
||||
.hint{font-size:13.5px;color:var(--ink2)}
|
||||
.fld{margin:14px 0}
|
||||
.fld label{display:block;font-weight:600;font-size:15px;margin-bottom:5px}
|
||||
.fld .unit{font-size:13px;color:var(--ink2)}
|
||||
input[type=number]{width:100%;font-size:22px;padding:11px 12px;border:2px solid var(--line);border-radius:10px;color:var(--ink);background:#fff}
|
||||
input[type=number]:focus{outline:0;border-color:var(--blue)}
|
||||
.opts{display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:8px;margin-top:6px}
|
||||
.opt{border:2px solid var(--line);background:#fff;border-radius:10px;padding:11px 8px;text-align:center;font-size:15px;cursor:pointer}
|
||||
.opt.sel{border-color:var(--blue);background:#eef4ff;font-weight:700}
|
||||
.opt small{display:block;font-size:11.5px;color:var(--ink2);font-weight:400}
|
||||
.err{color:var(--red);font-size:13px;min-height:18px}
|
||||
.range-row{display:flex;align-items:baseline;gap:10px}
|
||||
input[type=range]{width:100%;accent-color:var(--blue)}
|
||||
.range-val{font-size:21px;font-weight:700;min-width:120px}
|
||||
.chips-pick{display:flex;flex-wrap:wrap;gap:8px}
|
||||
|
||||
/* results */
|
||||
.verdict{font-size:18px}
|
||||
.verdict.bad b{color:var(--red)} .verdict.good b{color:var(--green)}
|
||||
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px;margin:14px 0 6px}
|
||||
.gcard{background:var(--card);border:1px solid var(--line);border-left:4px solid var(--blue);border-radius:var(--r);padding:14px}
|
||||
.gcard h4{margin:0 0 4px;font-size:14px;color:var(--ink2)}
|
||||
.gcard .big{font-size:22px;font-weight:800}
|
||||
.gcard p{font-size:13.5px;margin:6px 0 0}
|
||||
.gcard.warn{border-left-color:var(--amber)} .gcard.save{border-left-color:var(--green)}
|
||||
.cap{font-size:13.5px;margin:.2em 0 .6em}
|
||||
.chartbox{background:var(--card);border:1px solid var(--line);border-radius:var(--r);padding:10px;overflow:hidden}
|
||||
.chartbox svg{display:block;width:100%;height:auto}
|
||||
.legend{display:flex;flex-wrap:wrap;gap:12px;font-size:12.5px;color:var(--ink2);padding:4px 6px 0}
|
||||
.legend i{display:inline-block;width:10px;height:10px;border-radius:3px;margin-right:5px;vertical-align:-1px}
|
||||
|
||||
/* vision */
|
||||
.vision{margin:6px 0 2px;border-radius:var(--r);overflow:hidden;border:1px solid var(--line);background:var(--card)}
|
||||
.vision img{display:block;width:100%;height:250px;object-fit:cover}
|
||||
.vision .vcap{padding:12px 14px;font-size:15px}
|
||||
.vision .vcap b{font-size:16px}
|
||||
.vision.grad{color:#fff}
|
||||
.vision.grad img{display:none}
|
||||
.vision.grad .vcap{color:#fff}
|
||||
.vision.grad .vcap b,.vision.grad .vcap{color:#fff}
|
||||
|
||||
/* what-if, table, disclaimer */
|
||||
.whatif,.yearly{background:var(--card);border:1px solid var(--line);border-radius:var(--r);padding:12px 14px;margin:16px 0}
|
||||
.wi-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:14px;margin-top:10px}
|
||||
.wi-grid label{font-size:14px;font-weight:600}
|
||||
.wi-grid output{color:var(--blue);font-weight:800;float:right}
|
||||
.tbl-wrap{overflow:auto;max-height:340px;border:1px solid var(--line);border-radius:8px;margin-top:8px}
|
||||
table{border-collapse:collapse;font-size:12.5px;width:100%}
|
||||
th,td{padding:5px 8px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap}
|
||||
th{position:sticky;top:0;background:var(--bg);text-align:right}
|
||||
td:first-child,th:first-child{position:sticky;left:0;background:var(--bg)}
|
||||
tr.dep td{color:var(--red)}
|
||||
.disclaimer{font-size:13px;border-top:1px dashed var(--line);margin-top:26px;padding-top:12px}
|
||||
.stamp{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12px}
|
||||
.btn-row{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px}
|
||||
|
||||
@media print{
|
||||
.hdr,.whatif,.btn-row,#scr-quiz{display:none!important}
|
||||
body{background:#fff}
|
||||
.chartbox,.gcard,.vision{break-inside:avoid}
|
||||
}
|
||||
|
After Width: | Height: | Size: 305 KiB |
|
After Width: | Height: | Size: 572 KiB |
|
After Width: | Height: | Size: 271 KiB |
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"dock": "dock.png",
|
||||
"garden": "garden.png",
|
||||
"hero": "hero.png",
|
||||
"maritimes": "maritimes.png",
|
||||
"muskoka": "muskoka.png",
|
||||
"prairies": "prairies.png",
|
||||
"quebec": "quebec.png",
|
||||
"rockies": "rockies.png",
|
||||
"toronto": "toronto.png",
|
||||
"travel": "travel.png",
|
||||
"vancouver": "vancouver.png",
|
||||
"winter": "winter.png",
|
||||
"workshop": "workshop.png"
|
||||
}
|
||||
|
After Width: | Height: | Size: 420 KiB |
|
After Width: | Height: | Size: 583 KiB |
|
After Width: | Height: | Size: 469 KiB |
|
After Width: | Height: | Size: 602 KiB |
|
After Width: | Height: | Size: 460 KiB |
|
After Width: | Height: | Size: 351 KiB |
|
After Width: | Height: | Size: 429 KiB |
|
After Width: | Height: | Size: 391 KiB |
|
After Width: | Height: | Size: 428 KiB |
|
After Width: | Height: | Size: 326 KiB |