Files
retire-planner/site/app.js
T

866 lines
49 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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(); 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 '<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();
})();