648 lines
38 KiB
JavaScript
648 lines
38 KiB
JavaScript
/* 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';
|
||
syncScrim();
|
||
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();
|
||
/* Fix 8: Enter on a number step = press Next (when enabled). */
|
||
if (s.type === 'number') f1.onkeydown = function (e) { if (e.key === 'Enter' && !btnNext.disabled) btnNext.click(); };
|
||
}
|
||
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="13" 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="13" fill="#8a97ad">' + a + '</text>';
|
||
}
|
||
s += '<text x="' + (W / 2) + '" y="' + (H - 4) + '" text-anchor="middle" font-size="12" 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="13" 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="12" 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="13" 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="13" 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="13.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="12" fill="#8a97ad">' + r[0] + '</text>';
|
||
});
|
||
$('#g4').innerHTML = svgWrap(inner);
|
||
}
|
||
|
||
/* ---------- vision ---------- */
|
||
var visionManifest = null;
|
||
/* Fix 6: a fresh/direct load into results can render before the manifest lands.
|
||
When it resolves (or fails, or times out at 2.5s — then we just keep the gradient),
|
||
re-render the vision card if results is on screen, so real images replace the fallback. */
|
||
var visionReady = (function () {
|
||
var p = fetch('vision/manifest.json').then(function (r) { return r.ok ? r.json() : null; });
|
||
var timeout = new Promise(function (res) { setTimeout(function () { res(null); }, 2500); });
|
||
return Promise.race([p, timeout]);
|
||
})();
|
||
visionReady
|
||
.then(function (m) { visionManifest = m || null; })
|
||
.catch(function () { visionManifest = visionManifest || null; })
|
||
.then(function () { if (state.screen === 'results') renderVision(state.a); });
|
||
|
||
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 restore = function () { $('#btn-share').textContent = '🔗 Copy link with my answers'; };
|
||
var done = function () { $('#btn-share').textContent = '✅ Link copied'; setTimeout(restore, 1800); };
|
||
/* Fix 7: never claim "copied" when the clipboard API is missing or rejected. */
|
||
var fail = function () {
|
||
window.prompt('Copy your link:', url);
|
||
$('#btn-share').textContent = '🔗 Link shown — copy it';
|
||
setTimeout(restore, 1800);
|
||
};
|
||
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(url).then(done, fail);
|
||
else fail();
|
||
};
|
||
document.addEventListener('click', function (e) {
|
||
var h = document.querySelector('.panel-h');
|
||
if (h && h.contains(e.target)) {
|
||
document.getElementById('panel').classList.toggle('open');
|
||
syncScrim();
|
||
}
|
||
});
|
||
/* Fix 3: scrim behind the open sheet on mobile; tapping it closes the sheet. */
|
||
function syncScrim() {
|
||
var scrim = document.getElementById('scrim');
|
||
if (!scrim) return;
|
||
var open = document.getElementById('panel').classList.contains('open');
|
||
scrim.hidden = !open;
|
||
scrim.classList.toggle('on', open);
|
||
}
|
||
var scrimEl = document.getElementById('scrim');
|
||
if (scrimEl) scrimEl.addEventListener('click', function () {
|
||
document.getElementById('panel').classList.remove('open');
|
||
syncScrim();
|
||
});
|
||
var savedScreen = state.screen, savedStep = state.step;
|
||
showScreen(savedScreen);
|
||
if (savedScreen === 'quiz') { renderStep(); liveUpdate(); }
|
||
else if (savedScreen === 'results') { renderResults(false); liveUpdate(); }
|
||
})();
|