866 lines
49 KiB
JavaScript
866 lines
49 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.
|
||
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 = '<h2>' + titleTxt + '</h2>' + (hintTxt ? '<p class="hint">' + hintTxt + '</p>' : '');
|
||
|
||
if (s.type === 'grid') {
|
||
h += '<div class="opts">' + optList.map(function (o) {
|
||
return '<button class="opt' + (s.get() === o[0] ? ' sel' : '') + '" data-v="' + o[0] + '">' + t(o[1]) + '</button>';
|
||
}).join('') + '</div>';
|
||
} else if (s.type === 'opts') {
|
||
h += '<div class="opts">' + optList.map(function (o) {
|
||
var d = o[2] ? (typeof o[2] === 'function' ? o[2]() : t(o[2])) : '';
|
||
return '<button class="opt' + (String(s.get()) === String(o[0]) ? ' sel' : '') + '" data-v="' + o[0] + '">' + t(o[1]) + (d ? '<small>' + d + '</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] + '">' + t(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="' + fmtInt(s.max) + '"') + '></div>';
|
||
} else if (s.type === 'fields') {
|
||
h += s.fields.map(function (f, i) {
|
||
if (f.key === 'pensionAnnual' && psOn()) {
|
||
var pr = psCompute(+state.a.retireAge);
|
||
return '<div class="fld"><label>' + t(f.labelK) + '</label><p class="hint" style="margin:4px 0 0">' +
|
||
t('q.extra.psNote', { v: pr ? money(pr.pensionAtRetirement) + t('unit.yr') : t('q.extra.psNotePending') }) + '</p></div>';
|
||
}
|
||
return '<div class="fld"><label>' + t(f.labelK) + ' <span class="unit">' + t('fields.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>' + t(sl.labelK) + ' <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 = 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 '<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 = 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 += '<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 ? fmtInt(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">' + fmtInt(a) + '</text>';
|
||
}
|
||
s += '<text x="' + (W / 2) + '" y="' + (H - 4) + '" text-anchor="middle" font-size="12" fill="#8a97ad">' + t('chart.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 (x) {
|
||
return '<span><i style="background:' + x[1] + '"></i>' + x[0] + '</span>';
|
||
}).join('') + '</div>';
|
||
}
|
||
|
||
/* 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 '<span class="swipe">' + t('chart.swipe') + '</span>'; }
|
||
var CHART_HDR = {
|
||
'#g1': function () { return '<h3>' + t('g1.h') + '</h3><p class="cap">' + t('g1.cap') + '</p>'; },
|
||
'#g2': function () { return '<h3>' + t('g2.h') + '</h3><p class="cap">' + t('g2.cap') + '</p>'; },
|
||
'#g3': function () { return '<h3>' + t('g3.h') + '</h3><p class="cap">' + t('g3.cap') + '</p>'; },
|
||
'#g4': function () { return '<h3>' + t('g4.h') + '</h3><p class="cap">' + t('g4.cap', { age: state.a.lifeExp }) + '</p>'; }
|
||
};
|
||
function chartH(sel) {
|
||
var hd = typeof CHART_HDR[sel] === 'function' ? CHART_HDR[sel]() : (CHART_HDR[sel] || '');
|
||
return '<div class="chart-h"><div>' + hd + '</div>' + chartSwipe() + '</div>';
|
||
}
|
||
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 += '<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">' + t('chart.runsOut', { age: fmtInt(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" text-anchor="end">' + t('chart.retire') + '</text>';
|
||
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', '<p class="cap">' + t('g2.empty') + '</p>'); 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 += '<path d="' + stackPath(ages, lower, upper, X, Y) + '" fill="' + se[1] + '33" stroke="' + se[1] + '" stroke-width="1"/>';
|
||
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 += '<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">' + t('g3.shortfall', { age: fmtInt(first) }) + '</text>';
|
||
}
|
||
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', '<p class="cap">' + t('g4.calc') + '</p>');
|
||
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 += '<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>';
|
||
});
|
||
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 = '<img src="' + img + '" alt="' + t('vision.alt') + '" loading="lazy">' +
|
||
'<div class="vcap"><b>' + label + '</b><br>' + cap +
|
||
' <span style="opacity:.6;font-size:12px">' + t('vision.ai') + '</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>' + t('vision.label', { slug: slug.replace(/^\w/, function (c) { return c.toUpperCase(); }) }) + '</b><br>' + cap + '</div>';
|
||
}
|
||
|
||
/* ---------- 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(['<b>' + t('ps.card.atRet', { age: fmtInt(ps.startAge) }) + '</b>', '<b>' + m(ps.pensionAtRetirement) + t('unit.yr') + ' · ' + m(ps.pensionAtRetirement / 12) + t('unit.mo') + '</b>']);
|
||
rows.push(['<b>' + t('ps.card.from65') + '</b>', '<b>' + m(ps.pensionFrom65) + t('unit.yr') + ' · ' + m(ps.pensionFrom65 / 12) + t('unit.mo') + '</b>']);
|
||
var h = '<div class="chart-h"><b>' + t('ps.card.title') + '</b></div><div class="tbl-wrap"><table class="cards">' +
|
||
rows.map(function (r) { return '<tr><td>' + r[0] + '</td><td>' + r[1] + '</td></tr>'; }).join('') + '</table></div>';
|
||
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 += '<p class="cap">' + notes.join(' ') + '</p>';
|
||
var asOf = C.asOf, dPspc = '2025-08-15';
|
||
h += '<p class="cap">' + t('ps.card.sources') + ' ' +
|
||
'<a href="' + (ps.group === 1 ? S.g1En : S.g2En) + '" rel="noopener" target="_blank">' + t('ps.card.srcPspc', { d: dPspc }) + '</a> · ' +
|
||
'<a href="' + S.atAGlanceEn + '" rel="noopener" target="_blank">' + t('ps.card.srcGlance') + '</a> · ' +
|
||
'<a href="' + S.glossaryEn + '" rel="noopener" target="_blank">' + t('ps.card.srcGloss') + '</a> · ' +
|
||
'<a href="' + S.splittingCra + '" rel="noopener" target="_blank">' + t('ps.card.srcCra') + '</a> · ' +
|
||
'<a href="' + S.ympeCra + '" rel="noopener" target="_blank">' + t('ps.card.srcYmpe', { v: m(ampe), y: C.ympe.year || 2026 }) + '</a>' +
|
||
(state.a.psItLevel && state.a.psItLevel !== 'none' ? ' · <a href="' + S.itAgreement + '" rel="noopener" target="_blank">' + t('ps.card.srcIt', { d: PS.IT_RATES_ASOF }) + '</a>' : '') +
|
||
' · <span>' + t('ps.card.dataAsOf', { d: asOf }) + '</span></p>';
|
||
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 += '<div class="gcard warn"><h4>' + t('g.gapH') + '</h4><div class="big">' + money(rec.gapAnnual) + t('unit.yr') + '</h4><p>' + t('g.gapP') + '</p></div>';
|
||
cards += '<div class="gcard save"><h4>' + t('g.saveH') + '</h4><div class="big">+' + money(rec.extraMonthly) + t('unit.mo') + '</div>' +
|
||
(rec.extraMonthly > 0 ? '<p>' + t('g.saveP', { pct: pct(rec.extraMonthly / Math.max(1, p.income / 12)) }) + '</p>' : '<p>' + t('g.saveP2') + '</p>') + '</div>';
|
||
cards += '<div class="gcard"><h4>' + t('g.workH') + '</h4><div class="big">' + (rec.retireDelayYears ? '+' + rec.retireDelayYears + ' ' + t(rec.retireDelayYears === 1 ? 'g.workY1' : 'g.workYN', { n: rec.retireDelayYears }) : '—') + '</div>' +
|
||
(rec.retireDelayYears ? '<p>' + t('g.workP', { at: Math.min(p.retireAge + rec.retireDelayYears, 75), now: p.retireAge }) + '</p>' : '<p>' + t('g.workP2') + '</p>') + '</div>';
|
||
cards += '<div class="gcard"><h4>' + t('g.trimH') + '</h4><div class="big">−' + money(rec.spendingReduction) + t('unit.yr') + '</div><p>' + t('g.trimP', { to: money(Math.max(0, p.spendingAnnual - rec.spendingReduction)) }) + '</p></div>';
|
||
} else {
|
||
cards = '<div class="gcard save"><h4>' + t('g.statusH') + '</h4><div class="big">' + t('g.onTrack') + '</div><p>' + t('g.statusP', { sp: money(p.spendingAnnual) }) + '</p></div>' +
|
||
'<div class="gcard"><h4>' + t('g.headH') + '</h4><div class="big">' + money(ss - p.spendingAnnual) + t('unit.yr') + '</div><p>' + t('g.headP') + '</p></div>' +
|
||
'<div class="gcard save"><h4>' + t('g.stressH') + '</h4><div class="big">' + money(ss) + t('unit.yr') + '</div><p>' + t('g.stressP', { age: p.lifeExp }) + '</p></div>';
|
||
}
|
||
$('#gap-cards').innerHTML = cards;
|
||
|
||
g1_balance(proj); g2_income(proj, p); g3_cash(proj); g4_bars(p, fast);
|
||
renderPsCard(p);
|
||
|
||
var tbl = '<tr><th>' + t('t.age') + '</th><th>' + t('t.rrsp') + '</th><th>' + t('t.tfsa') + '</th><th>' + t('t.nonreg') + '</th><th>' + t('t.total') + '</th><th>' + t('t.wdr') + '</th><th>' + t('t.cpp') + '</th><th>' + t('t.oas') + '</th><th>' + t('t.gis') + '</th><th>' + t('t.tax') + '</th><th>' + t('t.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-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();
|
||
})();
|