/* Retire Planner UI — vanilla, no build, no network except same-origin vision manifest. All computation via window.RetireEngine. Answers persist in localStorage + URL hash. All user-facing strings via i18n.js (loaded first): t() + Intl formatters (fmtMoney/fmtMoneyK/fmtMoney2/fmtInt/fmtPct) so every number follows the active locale. */ 'use strict'; (function () { var E = window.RetireEngine, C = window.RetireCanada, PS = window.RetirePS; var LS = 'retire-planner-v1'; var $ = function (s) { return document.querySelector(s); }; /* locale-aware aliases (i18n.js globals) — chart/table code below stays untouched in shape */ var money = fmtMoney, k = fmtMoneyK, pct = fmtPct; var PROVINCES = [['ON', 'prov.ON'], ['BC', 'prov.BC'], ['AB', 'prov.AB'], ['SK', 'prov.SK'], ['MB', 'prov.MB'], ['QC', 'prov.QC'], ['NS', 'prov.NS'], ['NB', 'prov.NB'], ['NL', 'prov.NL'], ['PE', 'prov.PE'], ['YT', 'prov.YT'], ['NT', 'prov.NT'], ['NU', 'prov.NU']]; var INTERESTS = [ ['rockies', 'int.rockies'], ['maritimes', 'int.maritimes'], ['toronto', 'int.toronto'], ['dock', 'int.dock'], ['muskoka', 'int.muskoka'], ['prairies', 'int.prairies'], ['winter', 'int.winter'], ['workshop', 'int.workshop'], ['garden', 'int.garden'], ['travel', 'int.travel'], ['quebec', 'int.quebec'] ]; 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'] }; /* captions per slug per language (hero … dock) */ var CAPTIONS = { hero: { en: 'The good life — paid for by the plan on this page.', fr: 'La belle vie — payée par le plan sur cette page.' }, muskoka: { en: 'Cottage country, mortgage-free.', fr: 'Le pays des chalets, hypothèque payée.' }, rockies: { en: 'Lake Louise at 68, on your own schedule.', fr: 'Le lac Louise à 68 ans, à votre rythme.' }, maritimes: { en: 'Lobster season, every season.', fr: 'La saison du homard, toutes les saisons.' }, prairies: { en: 'Wide skies and a paid-off acreage.', fr: 'De grands espaces et une terre payée.' }, quebec: { en: 'Terrasses, fêtes et liberté financière.', fr: 'Terrasses, fêtes et liberté financière.' }, vancouver: { en: 'Ocean sunsets from a paid-for deck.', fr: 'Des couchers de soleil sur l’océan depuis une terrasse payée.' }, toronto: { en: 'City life, funded — not scraped.', fr: 'La vie de ville, financée — pas au rabais.' }, garden: { en: 'Mornings that start with tomatoes.', fr: 'Des matins qui commencent par des tomates.' }, travel: { en: 'Long-haul flights, fully deductible… just kidding, paid in cash.', fr: 'Des vols long-courriers, entièrement déductibles… je plaisante, payés comptant.' }, workshop: { en: 'A shop full of projects and zero alarms.', fr: 'Un atelier plein de projets et zéro réveil.' }, winter: { en: 'Fresh tracks on a Tuesday.', fr: 'De la neige fraîche un mardi.' }, dock: { en: 'First coffee on your own dock.', fr: 'Le premier café sur votre propre quai.' } }; 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, psPension: false, psJoinYear: null, psServiceYears: null, psAvgSalary: null, psOccupation: null, psItLevel: null, psItStep: 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); var b = Object.assign({}, o.a || o); delete b.lang; Object.assign(state.a, b); state.step = o.step | 0; if (o.screen) state.screen = o.screen; else if (m) state.screen = 'results'; } catch (e) { /* fresh */ } } })(); function save() { var t2 = { a: state.a, step: state.step, screen: state.screen, lang: getLang() }; try { localStorage.setItem(LS, JSON.stringify(t2)); } catch (e) { } } function encState() { return btoa(unescape(encodeURIComponent(JSON.stringify({ a: state.a, step: 0, lang: getLang() })))); } 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 }; /* Optional federal public service pension (PSSA): when enabled, it * replaces the manual DB-pension input and feeds pensionAnnual / * pensionStartAge / pensionBridgeAnnual into the engine. Recomputed for * the effective retireAge so what-if scenarios stay accurate. */ var ps = psCompute(p.retireAge); if (ps) { p.pensionAnnual = ps.pensionAtRetirement; p.pensionStartAge = ps.startAge; p.pensionBridgeAnnual = ps.bridge; } else { p.pensionStartAge = 0; p.pensionBridgeAnnual = 0; } return p; } function psOn() { return !!state.a.psPension; } function psAugment(p) { /* re-derive PS pension for a scenario's retireAge */ var ps = psCompute(p.retireAge); if (ps) { p.pensionAnnual = ps.pensionAtRetirement; p.pensionStartAge = ps.startAge; p.pensionBridgeAnnual = ps.bridge; } return p; } function psCompute(retireAge) { var a = state.a; if (!a.psPension || typeof PS === 'undefined') return null; var avg = +a.psAvgSalary || 0; if (!avg) return null; return PS.computePSPension({ joinYear: +a.psJoinYear || 0, retireAge: retireAge, serviceYears: +a.psServiceYears || 0, avgSalary: avg, ampe: (typeof CANADA !== 'undefined' && CANADA.ympe && CANADA.ympe.value) || 74600 }); } /* ---------- formatting (i18n.js: Intl.NumberFormat, locale-aware) ---------- */ function pct2(v) { return fmtPct(v / 100, 2); } /* slider values arrive as plain numbers: 3.5 → 3.5% */ /* ---------- steps ---------- */ function couple() { return state.a.household === 'couple'; } var STEPS = [ { key: 'province', titleK: 'q.province.title', hintK: 'q.province.hint', type: 'grid', opts: PROVINCES, get: function () { return state.a.province; }, set: function (v) { state.a.province = v; } }, { key: 'age', titleK: 'q.age.title', hintK: 'q.age.hint', type: 'number', min: 18, max: 74, get: function () { return state.a.age; }, set: function (v) { state.a.age = +v; } }, { key: 'household', titleK: 'q.household.title', hintK: 'q.household.hint', type: 'opts', opts: [['single', 'q.household.single'], ['couple', 'q.household.couple']], get: function () { return state.a.household; }, set: function (v) { state.a.household = v; } }, { key: 'p_age', show: couple, titleK: 'q.p_age.title', 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, titleK: 'q.p_income.title', hintK: 'q.p_income.hint', 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, titleK: 'q.p_savings.title', hintK: 'q.p_savings.hint', type: 'fields', fields: [ { labelK: 'q.p_savings.rrsp', key: 'rrsp' }, { labelK: 'q.p_savings.tfsa', key: 'tfsa' }, { labelK: 'q.p_savings.nonreg', key: 'nonreg' } ], get: function (f) { return state.a.partner[f]; }, set: function (f, v) { state.a.partner[f] = +v; } }, { key: 'income', titleK: 'q.income.title', hintK: 'q.income.hint', type: 'number', min: 0, max: 1e6, money: true, get: function () { return state.a.income; }, set: function (v) { state.a.income = +v; } }, { key: 'rrsp', titleK: 'q.rrsp.title', type: 'number', min: 0, max: 2e7, money: true, get: function () { return state.a.rrsp; }, set: function (v) { state.a.rrsp = +v; } }, { key: 'savings2', titleK: 'q.savings2.title', type: 'fields', fields: [ { labelK: 'q.savings2.tfsa', key: 'tfsa' }, { labelK: 'q.savings2.nonreg', key: 'nonreg' } ], get: function (f) { return state.a[f]; }, set: function (f, v) { state.a[f] = +v; } }, { key: 'contrib', titleK: 'q.contrib.title', hintK: 'q.contrib.hint', type: 'fields', fields: [ { labelK: 'q.contrib.you', key: 'monthlyContribution' }, { labelK: 'q.contrib.match', key: 'employerMatchMonthly' } ], get: function (f) { return state.a[f]; }, set: function (f, v) { state.a[f] = +v; } }, { key: 'retire', titleK: 'q.retire.title', hintK: 'q.retire.hint', type: 'slider', min: 55, max: 72, step: 1, fmt: function (v) { return t('q.retire.years', { v: v }); }, get: function () { return state.a.retireAge; }, set: function (v) { state.a.retireAge = +v; } }, { key: 'spend', titleK: 'q.spend.title', hintK: 'q.spend.hint', type: 'number', min: 10000, max: 5e5, money: true, get: function () { return state.a.spendingAnnual; }, set: function (v) { state.a.spendingAnnual = +v; } }, { key: 'cppstart', titleK: 'q.cppstart.title', hint: function () { var m = { 60: 'q.cppstart.m60', 65: 'q.cppstart.m65', 70: 'q.cppstart.m70' }; return t('q.cppstart.hint', { age: state.a.cppStartAge, adj: t(m[state.a.cppStartAge] || 'q.cppstart.mCustom') }); }, type: 'opts', opts: [[60, '60', 'q.cppstart.os60'], [65, '65', 'q.cppstart.os65'], [70, '70', 'q.cppstart.os70']], get: function () { return state.a.cppStartAge; }, set: function (v) { state.a.cppStartAge = +v; } }, { key: 'oasstart', titleK: 'q.oasstart.title', hintK: 'q.oasstart.hint', type: 'opts', opts: [[65, '65', 'q.oasstart.os65'], [70, '70', 'q.oasstart.os70']], get: function () { return state.a.oasStartAge; }, set: function (v) { state.a.oasStartAge = +v; } }, { key: 'ps', titleK: 'q.ps.title', hintK: 'q.ps.hint', type: 'opts', opts: [['yes', 'q.ps.yes', 'q.ps.yesD'], ['no', 'q.ps.no', 'q.ps.noD']], get: function () { return state.a.psPension ? 'yes' : 'no'; }, set: function (v) { state.a.psPension = v === 'yes'; } }, { key: 'ps_join', show: psOn, titleK: 'q.ps_join.title', hintK: 'q.ps_join.hint', type: 'number', min: 1950, max: 2026, get: function () { return state.a.psJoinYear; }, set: function (v) { state.a.psJoinYear = +v; } }, { key: 'ps_service', show: psOn, titleK: 'q.ps_service.title', hintK: 'q.ps_service.hint', type: 'number', min: 0, max: 45, get: function () { return state.a.psServiceYears; }, set: function (v) { state.a.psServiceYears = +v; } }, { key: 'ps_salary', show: psOn, titleK: 'q.ps_salary.title', hint: function () { var r = state.a.psItLevel && PS && PS.itRange(state.a.psItLevel); if (r) return t('q.ps_salary.hintIt', { lo: fmtMoneyK(r.min), hi: fmtMoneyK(r.top), asof: r.asof }); return t('q.ps_salary.hint'); }, type: 'number', min: 0, max: 1e6, money: true, get: function () { return state.a.psAvgSalary; }, set: function (v) { state.a.psAvgSalary = +v; } }, { key: 'ps_occ', show: psOn, titleK: 'q.ps_occ.title', hintK: 'q.ps_occ.hint', type: 'opts', opts: [['it', 'q.ps_occ.it', 'q.ps_occ.itD'], ['other', 'q.ps_occ.other', 'q.ps_occ.otherD']], get: function () { return state.a.psOccupation; }, set: function (v) { state.a.psOccupation = v; } }, { key: 'ps_it', show: function () { return psOn() && state.a.psOccupation === 'it'; }, titleK: function () { return state.a.psItLevel && state.a.psItLevel !== 'none' ? t('q.ps_it.stepTitle', { level: state.a.psItLevel }) : t('q.ps_it.title'); }, hint: function () { var r = state.a.psItLevel && state.a.psItLevel !== 'none' && PS.itRange(state.a.psItLevel); if (r) return t('q.ps_it.stepHint', { lo: fmtMoneyK(r.min), hi: fmtMoneyK(r.top), asof: r.asof }); return t('q.ps_it.hint', { asof: PS.IT_RATES_ASOF }); }, type: 'opts', opts: function () { if (state.a.psItLevel && state.a.psItLevel !== 'none') { var r = PS.itRange(state.a.psItLevel); return r.steps.map(function (v, i) { return [String(i + 1), fmtMoney(v), i === 0 ? 'q.ps_it.stepMin' : (i === r.steps.length - 1 ? 'q.ps_it.stepTop' : '')]; }); } return PS.IT_LEVELS.map(function (lv) { return [lv.id, lv.id, function () { var r = PS.itRange(lv.id); return fmtMoneyK(r.min) + ' – ' + fmtMoneyK(r.top); }]; }).concat([['none', 'q.ps_it.skip', 'q.ps_it.skipD']]); }, get: function () { if (state.a.psItLevel && state.a.psItLevel !== 'none' && state.a.psItStep) return String(state.a.psItStep); return state.a.psItLevel || ''; }, set: function (v) { if (state.a.psItLevel && state.a.psItLevel !== 'none' && /^\d+$/.test(v)) { var r = PS.itRange(state.a.psItLevel); state.a.psItStep = +v; state.a.psAvgSalary = r.steps[+v - 1]; /* step value = 5-yr average proxy */ } else { state.a.psItLevel = v; state.a.psItStep = 0; } } }, { key: 'extra', titleK: 'q.extra.title', hintK: 'q.extra.hint', type: 'fields', fields: [ { labelK: 'q.extra.pension', key: 'pensionAnnual' }, { labelK: 'q.extra.rental', key: 'rentalAnnual' }, { labelK: 'q.extra.other', key: 'otherAnnual' } ], get: function (f) { return state.a[f]; }, set: function (f, v) { state.a[f] = +v; } }, { key: 'assumptions', titleK: 'q.assumptions.title', hintK: 'q.assumptions.hint', type: 'sliders', sliders: [ { labelK: 'q.assumptions.pre', key: 'realReturnPre', min: 0, max: 8, step: 0.25, fmt: pct2 }, { labelK: 'q.assumptions.post', key: 'realReturnPost', min: 0, max: 6, step: 0.25, fmt: pct2 }, { labelK: 'q.assumptions.inf', key: 'inflation', min: 0, max: 5, step: 0.25, fmt: pct2 }, { labelK: 'q.assumptions.life', key: 'lifeExp', min: 80, max: 100, step: 1, fmt: function (v) { return t('q.assumptions.yrs', { v: v }); } } ], get: function (f) { return state.a[f]; }, set: function (f, v) { state.a[f] = +v; } }, { key: 'vision', titleK: 'q.vision.title', hintK: 'q.vision.hint', type: 'chips', opts: INTERESTS, get: function () { return state.a.interests; }, set: function (v) { state.a.interests = v; } } ]; 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 = t('quiz.stepCount', { i: state.step + 1, n: vs.length }); $('#btn-back').style.visibility = state.step === 0 ? 'hidden' : 'visible'; btnNext.textContent = state.step === vs.length - 1 ? t('quiz.seePlan') : t('quiz.next'); var hintTxt = typeof s.hint === 'function' ? s.hint() : (s.hintK ? t(s.hintK) : ''); var titleTxt = typeof s.titleK === 'function' ? s.titleK() : t(s.titleK); var optList = typeof s.opts === 'function' ? s.opts() : s.opts; var h = '

' + titleTxt + '

' + (hintTxt ? '

' + hintTxt + '

' : ''); if (s.type === 'grid') { h += '
' + optList.map(function (o) { return ''; }).join('') + '
'; } else if (s.type === 'opts') { h += '
' + optList.map(function (o) { var d = o[2] ? (typeof o[2] === 'function' ? o[2]() : t(o[2])) : ''; 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) { if (f.key === 'pensionAnnual' && psOn()) { var pr = psCompute(+state.a.retireAge); return '

' + t('q.extra.psNote', { v: pr ? money(pr.pensionAtRetirement) + t('unit.yr') : t('q.extra.psNotePending') }) + '

'; } 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(); validate(s); 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(); validate(s); }; }); 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 = t('v.number', { min: fmtInt(s.min), max: fmtInt(s.max) }); } } else if (s.type === 'grid' && !s.get()) { ok = false; msg = t('v.province'); } 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 = [ [t('panel.savingsAt'), money(atRet ? atRet.totalBal : 0), ''], [t('panel.cppOas67'), money(g67.cpp + g67.oasGross) + t('unit.yr'), ''], [t('panel.lastsTo'), proj.depletionAge ? t('panel.runsOut', { age: proj.depletionAge }) : (p.lifeExp + '+'), proj.depletionAge ? 'bad' : 'good'], [t('panel.lifetimeTax'), k(proj.totalTax + proj.totalClawback), ''] ]; $('#panel-chips').innerHTML = chips.map(function (c) { return '
' + c[0] + '
' + c[1] + '
'; }).join(''); var ins; if (proj.depletionAge) ins = t('i.depletion', { age: proj.depletionAge }); else if (psCoverageInsight(p, proj)) ins = psCoverageInsight(p, proj); else if (p.cppStartAge < 65) ins = t('i.cppEarly', { age: p.cppStartAge }); else if (p.oasStartAge >= 70) ins = t('i.oasLate', { age: p.oasStartAge }); else if (p.province === 'QC') ins = t('i.qc'); else if (p.tfsa < 15000 && p.rrsp > 200000) ins = t('i.cliff'); else if (p.household === 'couple') ins = t('i.couple'); else ins = t('i.live'); $('#panel-insight').innerHTML = ins; syncSheetH(); /* panel content just changed height — keep --sheet-h live (round 2 Fix 1) */ } /* ---------- 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 ? fmtInt(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 += '' + fmtInt(a) + ''; } s += '' + t('chart.age') + ''; return s; } function svgWrap(inner) { return '' + inner + ''; } function legend(items) { return '
' + items.map(function (x) { return '' + x[0] + ''; }).join('') + '
'; } /* Round 2 Fix 2: one chart() helper renders each card = header (.chart-h: h3 + caption) + SVG (+ legend). The "swipe →" hint is appended inside .chart-h — in the header row it can never overlap SVG content (the old .chartbox::before overlay sat on the top y-tick). Card titles/captions live here (i18n keys) so every render keeps the header in the active language. */ function chartSwipe() { return '' + t('chart.swipe') + ''; } var CHART_HDR = { '#g1': function () { return '

' + t('g1.h') + '

' + t('g1.cap') + '

'; }, '#g2': function () { return '

' + t('g2.h') + '

' + t('g2.cap') + '

'; }, '#g3': function () { return '

' + t('g3.h') + '

' + t('g3.cap') + '

'; }, '#g4': function () { return '

' + t('g4.h') + '

' + t('g4.cap', { age: state.a.lifeExp }) + '

'; } }; function chartH(sel) { var hd = typeof CHART_HDR[sel] === 'function' ? CHART_HDR[sel]() : (CHART_HDR[sel] || ''); return '
' + hd + '
' + chartSwipe() + '
'; } function chart(sel, inner, legendItems) { $(sel).innerHTML = chartH(sel) + (inner || '') + (legendItems ? legend(legendItems) : ''); } var COL = { bal: '#2563eb', cpp: '#2563eb', oas: '#dc2626', gis: '#f59e0b', wdr: '#10b981', cash: '#0ea5e9', need: '#dc2626', ps: '#7c3aed' }; 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 += '' + '' + t('chart.runsOut', { age: fmtInt(proj.depletionAge) }) + ''; } var ret = Math.max(a0, inputs().retireAge); inner += '' + '' + t('chart.retire') + ''; chart('#g1', svgWrap(inner), [[t('g1.legend'), COL.bal]]); } function g2_income(proj, p) { var yrs = proj.years.filter(function (r) { return r.phase === 'draw'; }); if (!yrs.length) { chart('#g2', '

' + t('g2.empty') + '

'); return; } var ages = yrs.map(function (r) { return r.age; }); var X = sc(ages, ages[0], ages[ages.length - 1]); var series = [ [t('g2.cpp'), COL.cpp, yrs.map(function (r) { return r.cpp; })], [t('g2.oas'), COL.oas, yrs.map(function (r) { return Math.max(0, r.oasGross - r.oasClawback); })], [t('g2.gis'), COL.gis, yrs.map(function (r) { return r.gis; })] ]; if (yrs.some(function (r) { return (r.pension || 0) > 0; })) { series.push([t('g2.psPension'), COL.ps, yrs.map(function (r) { return r.pension || 0; })]); } series.push([t('g2.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; }); chart('#g2', svgWrap(inner), 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) { chart('#g3', ''); 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 += '' + '' + t('g3.shortfall', { age: fmtInt(first) }) + ''; } chart('#g3', svgWrap(inner), [[t('g3.cash'), COL.cash], [t('g3.need'), COL.need]]); } var g4cache = null; function g4_bars(p, fast) { if (g4cache && fast) { drawG4(g4cache); $('#g4-note').textContent = t('g4.computed'); return; } chart('#g4', '

' + t('g4.calc') + '

'); setTimeout(function () { var scen = [ [t('g4.s0'), p], [t('g4.s1'), Object.assign({}, p, { monthlyContribution: p.monthlyContribution + 200, employerMatchMonthly: p.employerMatchMonthly })], [t('g4.s2'), psAugment(Object.assign({}, p, { retireAge: Math.min(p.retireAge + 2, 75) }))], [t('g4.s3'), 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 = t('g4.best', { name: best[0], money: money(best[1]) }); }, 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] + ''; }); chart('#g4', 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 capText(slug) { var cap = CAPTIONS[slug] || CAPTIONS.hero; return cap[getLang()] || cap.en; } 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 = capText(slug); var label = t('vision.label', { slug: slug.replace(/^\w/, function (c) { return c.toUpperCase(); }) }); var img = visionManifest && visionManifest[slug] ? 'vision/' + visionManifest[slug] : null; if (img) { el.className = 'vision'; el.innerHTML = '' + t('vision.alt') + '' + '
' + label + '
' + cap + ' ' + t('vision.ai') + '
'; 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 = '
' + t('vision.label', { slug: slug.replace(/^\w/, function (c) { return c.toUpperCase(); }) }) + '
' + cap + '
'; } /* ---------- public service pension: insight + results card ---------- */ function psCoverageInsight(p) { if (!psOn()) return null; var ps = psCompute(p.retireAge); if (!ps || !(p.spendingAnnual > 0)) return null; var pct = Math.round(100 * ps.pensionAtRetirement / p.spendingAnnual); if (pct < 10 || pct > 250) return null; return t('i.psCover', { pct: fmtInt(pct) }); } function renderPsCard(p) { var el = $('#ps-card'); if (!el) return; var ps = psCompute(p.retireAge); if (!ps) { el.hidden = true; el.innerHTML = ''; return; } el.hidden = false; var m = fmtMoney2, S = PS.SOURCES, ampe = C.ympe.value; var rows = [ [t('ps.card.group', { n: ps.group }), ''], [t('ps.card.service', { y: fmtInt(ps.serviceCapped) }), ''], [t('ps.card.avg'), m(+state.a.psAvgSalary || 0) + t('unit.yr')], [t('ps.card.ampe', { v: m(ampe), y: C.ympe.year || 2026 }), ''], [t('ps.card.lifetime'), m(ps.lifetime) + t('unit.yr')] ]; if (ps.bridge > 0) rows.push([t('ps.card.bridge'), m(ps.bridge) + t('unit.yr')]); rows.push([t('ps.card.reduction', { pct: fmtPct(ps.reductionPct, 1), f: ps.reductionPct === 0 ? t('ps.card.none') : t(ps.formula === 'F1' ? 'ps.card.fF1' : ps.formula === 'F2' ? 'ps.card.fF2' : 'ps.card.fMin') }), '']); rows.push(['' + t('ps.card.atRet', { age: fmtInt(ps.startAge) }) + '', '' + m(ps.pensionAtRetirement) + t('unit.yr') + ' · ' + m(ps.pensionAtRetirement / 12) + t('unit.mo') + '']); rows.push(['' + t('ps.card.from65') + '', '' + m(ps.pensionFrom65) + t('unit.yr') + ' · ' + m(ps.pensionFrom65 / 12) + t('unit.mo') + '']); var h = '
' + t('ps.card.title') + '
' + rows.map(function (r) { return ''; }).join('') + '
' + r[0] + '' + r[1] + '
'; var notes = []; if (ps.notes.indexOf('lt2') >= 0) notes.push(t('ps.card.lt2')); if (ps.earlyOnlyNote === 'deferredG2') notes.push(t('ps.card.deferred')); if (ps.notes.indexOf('f1Under50') >= 0) notes.push(t('ps.card.under50')); if (ps.notes.indexOf('f1Under55') >= 0) notes.push(t('ps.card.under55')); if (ps.startAge < 65 && ps.bridge > 0) notes.push(t('ps.card.bridgeEnds')); notes.push(t('ps.card.nSurvivor')); notes.push(t('ps.card.nIndex')); notes.push(t('ps.card.nPartTime')); notes.push(t('ps.card.nSplit')); h += '

' + notes.join(' ') + '

'; var asOf = C.asOf, dPspc = '2025-08-15'; h += '

' + t('ps.card.sources') + ' ' + '' + t('ps.card.srcPspc', { d: dPspc }) + ' · ' + '' + t('ps.card.srcGlance') + ' · ' + '' + t('ps.card.srcGloss') + ' · ' + '' + t('ps.card.srcCra') + ' · ' + '' + t('ps.card.srcYmpe', { v: m(ampe), y: C.ympe.year || 2026 }) + '' + (state.a.psItLevel && state.a.psItLevel !== 'none' ? ' · ' + t('ps.card.srcIt', { d: PS.IT_RATES_ASOF }) + '' : '') + ' · ' + t('ps.card.dataAsOf', { d: asOf }) + '

'; el.innerHTML = h; } /* ---------- 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'); vh.textContent = t('r.verdictH'); if (proj.depletionAge) { v.className = 'verdict bad'; v.innerHTML = t('r.depletion', { age: fmtInt(proj.depletionAge), ss: money(ss), le: p.lifeExp, sp: money(p.spendingAnnual) }); } else { v.className = 'verdict good'; v.innerHTML = t('r.good', { le: p.lifeExp, bal: k(proj.finalBal), ss: money(ss), hr: money(ss - p.spendingAnnual) }); } /* lifeexp-lbl now lives inside the g4 card header (rendered below via chart('#g4')); the header bakes in state.a.lifeExp, so just sync if the node exists (re-renders). */ var le = $('#lifeexp-lbl'); if (le) le.textContent = p.lifeExp; var cards = ''; if (rec.gapAnnual > 0) { cards += '

' + t('g.gapH') + '

' + money(rec.gapAnnual) + t('unit.yr') + '

' + t('g.gapP') + '

'; cards += '

' + t('g.saveH') + '

+' + money(rec.extraMonthly) + t('unit.mo') + '
' + (rec.extraMonthly > 0 ? '

' + t('g.saveP', { pct: pct(rec.extraMonthly / Math.max(1, p.income / 12)) }) + '

' : '

' + t('g.saveP2') + '

') + '
'; cards += '

' + t('g.workH') + '

' + (rec.retireDelayYears ? '+' + rec.retireDelayYears + ' ' + t(rec.retireDelayYears === 1 ? 'g.workY1' : 'g.workYN', { n: rec.retireDelayYears }) : '—') + '
' + (rec.retireDelayYears ? '

' + t('g.workP', { at: Math.min(p.retireAge + rec.retireDelayYears, 75), now: p.retireAge }) + '

' : '

' + t('g.workP2') + '

') + '
'; cards += '

' + t('g.trimH') + '

−' + money(rec.spendingReduction) + t('unit.yr') + '

' + t('g.trimP', { to: money(Math.max(0, p.spendingAnnual - rec.spendingReduction)) }) + '

'; } else { cards = '

' + t('g.statusH') + '

' + t('g.onTrack') + '

' + t('g.statusP', { sp: money(p.spendingAnnual) }) + '

' + '

' + t('g.headH') + '

' + money(ss - p.spendingAnnual) + t('unit.yr') + '

' + t('g.headP') + '

' + '

' + t('g.stressH') + '

' + money(ss) + t('unit.yr') + '

' + t('g.stressP', { age: p.lifeExp }) + '

'; } $('#gap-cards').innerHTML = cards; g1_balance(proj); g2_income(proj, p); g3_cash(proj); g4_bars(p, fast); renderPsCard(p); var tbl = '' + t('t.age') + '' + t('t.rrsp') + '' + t('t.tfsa') + '' + t('t.nonreg') + '' + t('t.total') + '' + t('t.wdr') + '' + t('t.cpp') + '' + t('t.oas') + '' + t('t.gis') + '' + t('t.tax') + '' + t('t.unfunded') + ''; 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-summary').innerHTML = t('r.yearlySummary', { n: n }); var m2 = fmtMoney2; $('#stamp').textContent = t('r.stamp', { cpp: m2(C.cppMax65Monthly.value), oas: m2(C.oasMax6574Monthly.value), gis: m2(C.gisMaxSingleMonthly.value), asOf: C.asOf }); 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) + t('unit.mo'); $('#wi-ret-o').textContent = t('r.wiYears', { v: $('#wi-ret').value }); $('#wi-cpp-o').textContent = t('r.wiAge', { v: $('#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(t('restart.confirm'))) 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 = t('res.share'); }; var done = function () { $('#btn-share').textContent = t('share.copied'); setTimeout(restore, 1800); }; /* Fix 7: never claim "copied" when the clipboard API is missing or rejected. */ var fail = function () { window.prompt(t('share.prompt'), url); $('#btn-share').textContent = t('share.shown'); 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(); syncSheetH(); } }); /* 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); } /* Round 2 Fix 1: publish the live sheet height as --sheet-h so .scr padding and the sticky step-nav always clear the sheet (default 218px on :root keeps the first paint right). Shim-safe: smoke-test stubs have no offsetHeight — the guard skips them. */ function syncSheetH() { var panel = document.getElementById('panel'); var h = panel && panel.offsetHeight; if (h) document.documentElement.style.setProperty('--sheet-h', h + 'px'); } if (window.addEventListener) window.addEventListener('resize', syncSheetH); var scrimEl = document.getElementById('scrim'); if (scrimEl) scrimEl.addEventListener('click', function () { document.getElementById('panel').classList.remove('open'); syncScrim(); syncSheetH(); }); /* Language toggle (i18n.js setLang → applyI18n → this): rebuild the active screen in the new language. Static DOM is already handled by applyI18n; charts/cards/vision need a re-render. */ window.__onLangChange = function () { g4cache = null; if (state.screen === 'quiz') { renderStep(); updatePanel(); } else if (state.screen === 'results') { renderResults(false); } save(); }; var savedScreen = state.screen, savedStep = state.step; showScreen(savedScreen); if (savedScreen === 'quiz') { renderStep(); liveUpdate(); } else if (savedScreen === 'results') { renderResults(false); liveUpdate(); } syncSheetH(); })();