/* 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 = '

' + s.title + '

' + (hintTxt ? '

' + hintTxt + '

' : ''); if (s.type === 'grid') { h += '
' + s.opts.map(function (o) { return ''; }).join('') + '
'; } else if (s.type === 'opts') { h += '
' + s.opts.map(function (o) { return ''; }).join('') + '
'; } else if (s.type === 'chips') { var cur = s.get() || []; h += '
' + s.opts.map(function (o) { return ''; }).join('') + '
'; } 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(); /* 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 '
' + 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 '' + inner + ''; } function legend(items) { return '
' + items.map(function (t) { return '' + t[0] + ''; }).join('') + '
'; } 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; /* 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 = 'Your retirement vision' + '
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.

') + '
'; cards += '

🕰 Work longer

' + (rec.retireDelayYears ? '+' + rec.retireDelayYears + (rec.retireDelayYears === 1 ? ' year' : ' years') : '—') + '
' + (rec.retireDelayYears ? '

retiring at ' + Math.min(p.retireAge + rec.retireDelayYears, 75) + ' instead of ' + p.retireAge + ' closes the gap — and CPP/OAS grow while you do.

' : '

Within 10 extra years, delaying retirement alone can’t close this gap — combine levers.

') + '
'; cards += '

🌿 Trim spending

−' + money(rec.spendingReduction) + '/yr

to ' + money(Math.max(0, p.spendingAnnual - rec.spendingReduction)) + '. The cheapest lever, and the one nobody talks about at 45.

'; } else { cards = '

✅ Status

On track

No gap at ' + money(p.spendingAnnual) + '/yr. Consider testing a bigger number with the what-if sliders.

' + '

Headroom

' + money(ss - p.spendingAnnual) + '/yr

you could add to spending — or save for the cottage.

' + '

🛡 Stress-tested

' + money(ss) + '/yr

sustainable real spending to ' + p.lifeExp + ' with RRIF minimums and taxes enforced.

'; } $('#gap-cards').innerHTML = cards; g1_balance(proj); g2_income(proj, p); g3_cash(proj); g4_bars(p, fast); var tbl = 'AgeRRSPTFSANon-regTotalWithdrawCPPOASGISTax+clawUnfunded'; var n = 0; proj.years.forEach(function (r) { if (r.phase !== 'draw' && r.age % 5 !== 0) return; n++; tbl += '' + r.age + '' + k(r.rrspBal) + '' + k(r.tfsaBal) + '' + k(r.nonregBal) + '' + k(r.totalBal) + '' + k(r.rrifWithdrawal + r.nonregW + r.tfsaW) + '' + k(r.cpp) + '' + k(r.oasGross - r.oasClawback) + '' + k(r.gis) + '' + k(r.tax + r.oasClawback) + '' + (r.unfunded > 1 ? k(r.unfunded) : '—') + ''; }); $('#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(); } })();