Retire Planner v1: engine (live 2026 CPP/OAS/GIS/RRIF/tax), 17-step UI, 4 SVG charts, vision gallery, tests

This commit is contained in:
krisf committed 2026-10-08 23:00:18 -04:00
commit e25dd4e0a1
29 files changed
+3143

No files matched your search

+611
View File
@@ -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(); }
})();