';
} else if (s.type === 'number') {
h += '';
} else if (s.type === 'fields') {
h += s.fields.map(function (f, i) {
return '';
}).join('');
} else if (s.type === 'slider') {
h += '
' + s.fmt(s.get()) + '
';
} else if (s.type === 'sliders') {
h = s.sliders.map(function (sl) {
return '';
}).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 '
' + c[0] + '
' + c[1] + '
';
}).join('');
var ins = '';
if (proj.depletionAge) ins = '⚠️ On this path you run out of money around age ' + proj.depletionAge + '. 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 not 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 += '' +
'' + (opts && opts.money === false ? v : k(v)) + '';
}
var step = a1 - a0 > 34 ? 10 : 5;
for (var a = Math.ceil(a0 / step) * step; a <= a1; a += step) {
var x = X(a);
s += '' + a + '';
}
s += 'age';
return s;
}
function svgWrap(inner) {
return '';
}
function legend(items) {
return '
';
}
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 += '' +
'';
if (proj.depletionAge) {
var x = X(proj.depletionAge);
inner += '' +
'runs out ≈ ' + proj.depletionAge + '';
}
var ret = Math.max(a0, inputs().retireAge);
inner += '' +
'retire';
$('#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 = '
Retirement after your plan horizon.
'; 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 += '';
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 += '';
inner += '';
var first = yrs.map(function (r, i) { return r.unfunded > 1 ? ages[i] : null; }).filter(function (x) { return x; })[0];
if (first) {
inner += '' +
'shortfall from ~' + first + '';
}
$('#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 = '
Calculating scenarios…
';
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 += '' +
'' + k(v) + '';
}
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 += '' +
'' + k(r[1]) + '' +
'' + r[0] + '';
});
$('#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 = '' +
'
Retirement Vision — ' + slug.replace(/^\w/, function (c) { return c.toUpperCase(); }) + ' ' + cap +
' AI-generated preview (z-image-turbo). Your numbers: below.
';
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 = '
Retirement Vision — ' + slug.replace(/^\w/, function (c) { return c.toUpperCase(); }) + ' ' + cap + '
';
}
/* ---------- 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 age ' + proj.depletionAge + '. ' +
'You can safely spend ' + money(ss) + '/yr 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 95 with ' + k(proj.finalBal) + ' left over — you can sustain about ' + money(ss) + '/yr. ' +
'That’s ' + money(ss - p.spendingAnnual) + '/yr of headroom above your plan.';
}
$('#lifeexp-lbl').textContent = p.lifeExp;
var cards = '';
if (rec.gapAnnual > 0) {
cards += '
The gap
' + money(rec.gapAnnual) + '/yr
between your planned spending and what the plan can safely fund.
';
cards += '
💪 Save more
+' + money(rec.extraMonthly) + '/mo
' +
(rec.extraMonthly > 0 ? '
extra RRSP room from today closes the gap entirely. ~' + pct(rec.extraMonthly / Math.max(1, p.income / 12)) + ' of income.
' : '
Not closable by saving alone — see the other two levers.