Bilingual EN/FR: i18n.js dict+t(), lang state+payload, header toggle, FR copy, Intl formatters; i18n.test.js + bilingual smoke; FR mobile audit evidence + BILINGUAL-REPORT.md

This commit is contained in:
krisf committed 2026-10-09 10:06:55 -04:00
1 parent 1b194b6f92
commit ea953ec23c
60 files changed
+2788 -241

No files matched your search

+164 -142
View File
@@ -1,29 +1,41 @@
/* 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 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;
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', 'Ontario'], ['BC', 'British Columbia'], ['AB', 'Alberta'], ['SK', 'Saskatchewan'],
['MB', 'Manitoba'], ['QC', 'Quebec'], ['NS', 'Nova Scotia'], ['NB', 'New Brunswick'],
['NL', 'Newfoundland & Labrador'], ['PE', 'PEI'], ['YT', 'Yukon'], ['NT', 'Northwest Territories'], ['NU', 'Nunavut']];
var 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', '🏔 Mountains'], ['maritimes', '🌊 Ocean'], ['toronto', '🏙 Big city'],
['dock', '🛶 Lake / dock'], ['muskoka', '🌲 Cottage country'], ['prairies', '🌾 Open sky'],
['winter', '⛷ Snow / ski'], ['workshop', '🪵 Workshop'], ['garden', '🌺 Garden'],
['travel', '✈️ Travel'], ['quebec', '🇫🇷 Québec']
['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: 'The good life — paid for by the plan on this page.', muskoka: 'Cottage country, mortgage-free.',
rockies: 'Lake Louise at 68, on your own schedule.', maritimes: 'Lobster season, every season.',
prairies: 'Wide skies and a paid-off acreage.', quebec: 'Terrasses, fêtes et liberté financière.',
vancouver: 'Ocean sunsets from a paid-for deck.', toronto: 'City life, funded — not scraped.',
garden: 'Mornings that start with tomatoes.', travel: 'Long-haul flights, fully deductible… just kidding, paid in cash.',
workshop: 'A shop full of projects and zero alarms.', winter: 'Fresh tracks on a Tuesday.', dock: 'First coffee on your own dock.'
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)',
@@ -50,13 +62,13 @@
var m = location.hash.match(/[#&]p=([^&]+)/);
if (m) { try { raw = decodeURIComponent(escape(atob(m[1]))); } catch (e) { raw = null; } }
if (!raw) { try { raw = localStorage.getItem(LS); } catch (e) { raw = null; } }
if (raw) { try { var o = JSON.parse(raw); Object.assign(state.a, o.a || o); state.step = o.step | 0; if (o.screen) state.screen = o.screen; else if (m) state.screen = 'results'; } catch (e) { /* fresh */ } }
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 t = { a: state.a, step: state.step, screen: state.screen };
try { localStorage.setItem(LS, JSON.stringify(t)); } catch (e) { }
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 })))); }
function encState() { return btoa(unescape(encodeURIComponent(JSON.stringify({ a: state.a, step: 0, lang: getLang() })))); }
function inputs() {
var a = state.a, w = state.wi;
@@ -80,117 +92,109 @@
return p;
}
/* ---------- formatting ---------- */
function money(v) { v = Math.round(v); return (v < 0 ? '−$' : '$') + Math.abs(v).toLocaleString('en-CA'); }
function k(v) {
var s = v < 0 ? '−' : ''; v = Math.abs(v);
if (v >= 1e6) return s + '$' + (v / 1e6).toFixed(v >= 1e7 ? 1 : 2) + 'M';
if (v >= 1000) return s + '$' + Math.round(v / 1000) + 'k';
return s + '$' + Math.round(v);
}
function pct(v) { return (v * 100).toFixed(0) + '%'; }
/* ---------- 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', title: 'Where do you live?', hint: 'Taxes, and your retirement view.',
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', title: 'Your age', hint: 'Years.', type: 'number', min: 18, max: 74,
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', title: 'Household', hint: 'Couple means joint taxes, pension splitting and household GIS.',
type: 'opts', opts: [['single', 'Single'], ['couple', 'Couple']],
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, title: 'Partner’s age', type: 'number', min: 18, max: 90,
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, title: 'Partner’s annual employment income', hint: '$/yr before tax. 0 if not working.',
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, title: 'Partner’s savings', hint: 'Current balances, today’s dollars.',
key: 'p_savings', show: couple, titleK: 'q.p_savings.title', hintK: 'q.p_savings.hint',
type: 'fields', fields: [
{ label: 'RRSP / RRIF', key: 'rrsp' }, { label: 'TFSA', key: 'tfsa' }, { label: 'Non-registered', key: 'nonreg' }
{ 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', title: 'Your annual employment income', hint: '$/yr. Used to estimate your CPP history.',
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', title: 'RRSP / RRIF balance', type: 'number', min: 0, max: 2e7, money: true,
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', title: 'TFSA and non-registered savings', type: 'fields', fields: [
{ label: 'TFSA', key: 'tfsa' }, { label: 'Non-registered (cash, stocks, mutual funds)', key: 'nonreg' }
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', title: 'Monthly savings into RRSP', hint: 'Your contribution + any employer match.', type: 'fields', fields: [
{ label: 'You contribute $/mo', key: 'monthlyContribution' }, { label: 'Employer match $/mo', key: 'employerMatchMonthly' }
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', title: 'Retirement age', hint: 'When you stop earning employment income.', type: 'slider',
min: 55, max: 72, step: 1, fmt: function (v) { return v + ' years'; },
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', title: 'Planned annual spending in retirement', hint: 'Today’s dollars, after tax. Be honest — this is the number that decides everything.',
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', title: 'Start CPP at…', hint: function () {
var m = { 60: '−36% for life', 65: 'Full rate', 70: '+42% for life' };
return 'Age ' + state.a.cppStartAge + ': ' + (m[state.a.cppStartAge] || 'custom adjustment');
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', '−36% for life'], [65, '65', 'full rate'], [70, '70', '+42% for life']],
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', title: 'Start OAS at…', hint: 'Deferring to 70 is +36% for life, plus a bigger GIS window.',
type: 'opts', opts: [[65, '65', 'on eligibility'], [70, '70', '+36% for life']],
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: 'extra', title: 'Other income', hint: 'DB pension, rental, royalties… leave 0 if none.', type: 'fields', fields: [
{ label: 'Pension $/yr', key: 'pensionAnnual' }, { label: 'Rental $/yr', key: 'rentalAnnual' }, { label: 'Other $/yr', key: 'otherAnnual' }
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', title: 'Assumptions', hint: 'Real (above-inflation) returns. Defaults match a balanced portfolio.', type: 'sliders', sliders: [
{ label: 'Return before retirement', key: 'realReturnPre', min: 0, max: 8, step: 0.25, fmt: pct2 },
{ label: 'Return in retirement', key: 'realReturnPost', min: 0, max: 6, step: 0.25, fmt: pct2 },
{ label: 'Inflation', key: 'inflation', min: 0, max: 5, step: 0.25, fmt: pct2 },
{ label: 'Plan to age', key: 'lifeExp', min: 80, max: 100, step: 1, fmt: function (v) { return v + ' yrs'; } }
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', title: 'What are you retiring toward?', hint: 'Pick a few. We’ll paint your plan with it.',
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 pct2(v) { return v.toFixed(2).replace(/0$/, '') + '%'; }
function visibleSteps() { return STEPS.filter(function (s) { return !s.show || s.show(); }); }
@@ -210,37 +214,37 @@
if (state.step >= vs.length) state.step = vs.length - 1;
var s = vs[state.step];
$('#prog-bar').style.width = Math.round((state.step / vs.length) * 100) + '%';
$('#step-count').textContent = (state.step + 1) + ' / ' + vs.length;
$('#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 ? 'See my plan →' : 'Next →';
btnNext.textContent = state.step === vs.length - 1 ? t('quiz.seePlan') : t('quiz.next');
var hintTxt = typeof s.hint === 'function' ? s.hint() : (s.hint || '');
var h = '<h2>' + s.title + '</h2>' + (hintTxt ? '<p class="hint">' + hintTxt + '</p>' : '');
var hintTxt = typeof s.hint === 'function' ? s.hint() : (s.hintK ? t(s.hintK) : '');
var h = '<h2>' + t(s.titleK) + '</h2>' + (hintTxt ? '<p class="hint">' + hintTxt + '</p>' : '');
if (s.type === 'grid') {
h += '<div class="opts">' + s.opts.map(function (o) {
return '<button class="opt' + (s.get() === o[0] ? ' sel' : '') + '" data-v="' + o[0] + '">' + o[1] + '</button>';
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">' + s.opts.map(function (o) {
return '<button class="opt' + (String(s.get()) === String(o[0]) ? ' sel' : '') + '" data-v="' + o[0] + '">' + o[1] + (o[2] ? '<small>' + o[2] + '</small>' : '') + '</button>';
return '<button class="opt' + (String(s.get()) === String(o[0]) ? ' sel' : '') + '" data-v="' + o[0] + '">' + t(o[1]) + (o[2] ? '<small>' + t(o[2]) + '</small>' : '') + '</button>';
}).join('') + '</div>';
} else if (s.type === 'chips') {
var cur = s.get() || [];
h += '<div class="chips-pick">' + s.opts.map(function (o) {
return '<button class="opt' + (cur.indexOf(o[0]) >= 0 ? ' sel' : '') + '" data-v="' + o[0] + '">' + o[1] + '</button>';
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="' + s.max + '"') + '></div>';
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) {
return '<div class="fld"><label>' + f.label + ' <span class="unit">($)</span></label><input type="number" id="f' + (i + 1) + '" data-k="' + f.key + '" value="' + (s.get(f.key) == null ? '' : s.get(f.key)) + '" min="0" inputmode="numeric" placeholder="0"></div>';
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>' + sl.label + ' <span class="unit" style="float:right;font-weight:700;color:var(--blue)">' + sl.fmt(+s.get(sl.key)) + '</span></label><input type="range" data-k="' + sl.key + '" min="' + sl.min + '" max="' + sl.max + '" step="' + sl.step + '" value="' + s.get(sl.key) + '"></div>';
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;
@@ -285,9 +289,9 @@
if (s.type === 'number') {
var v = +s.get();
if (s.get() === null || s.get() === '' || isNaN(v) || v < s.min || v > s.max) {
ok = false; msg = 'Enter a number between ' + s.min + ' and ' + s.max + '.';
ok = false; msg = t('v.number', { min: fmtInt(s.min), max: fmtInt(s.max) });
}
} else if (s.type === 'grid' && !s.get()) { ok = false; msg = 'Pick a province to continue.'; }
} 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); }
@@ -304,23 +308,23 @@
var atRet = proj.years.filter(function (r) { return r.age === Math.floor(p.retireAge); })[0] || draw[0];
var g67 = proj.years.filter(function (r) { return r.age === 67; })[0] || { cpp: 0, oasGross: 0 };
var chips = [
['Savings at retirement', money(atRet ? atRet.totalBal : 0), ''],
['CPP + OAS at 67', money(g67.cpp + g67.oasGross) + '/yr', ''],
['Money lasts to', proj.depletionAge ? 'runs out ~' + proj.depletionAge : (p.lifeExp + '+'), proj.depletionAge ? 'bad' : 'good'],
['Lifetime tax', k(proj.totalTax + proj.totalClawback), '']
[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 = '⚠️ On this path you run out of money around age <b>' + proj.depletionAge + '</b>. Keep answering — the plan ends with three concrete ways to close the gap.';
else if (p.cppStartAge < 65) ins = 'CPP at ' + p.cppStartAge + ' is permanently reduced — deferring to 70 is a +42% inflation-proof raise. Move the slider later and watch the panel.';
else if (p.oasStartAge >= 70) ins = 'OAS at ' + p.oasStartAge + ': +36% for life, plus a bigger GIS window in between. Good trade for most people.';
else if (p.province === 'QC') ins = 'ℹ️ Québec: QPP is <b>not</b> modelled — add your QPP estimate as “Pension $/yr”. QPP typically replaces ~35% of income.';
else if (p.tfsa < 15000 && p.rrsp > 200000) ins = 'You’ll hit the RRIF-minimum tax cliff at 71. TFSA room now softens it later — check the gap cards.';
else if (p.household === 'couple') ins = 'With pension splitting + household GIS, your joint taxes drop — the panel shows the joint total.';
else ins = 'Live numbers — change any answer and this panel moves.';
var ins;
if (proj.depletionAge) ins = t('i.depletion', { age: proj.depletionAge });
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) */
}
@@ -349,39 +353,40 @@
for (i = 0; i <= yt; i++) {
var v = ymax * i / yt, y = Y(v);
s += '<line x1="' + L + '" x2="' + (W - R) + '" y1="' + y.toFixed(1) + '" y2="' + y.toFixed(1) + '" stroke="#eef2f8"/>' +
'<text x="' + (L - 6) + '" y="' + (y + 4).toFixed(1) + '" text-anchor="end" font-size="13" fill="#8a97ad">' + (opts && opts.money === false ? v : k(v)) + '</text>';
'<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">' + a + '</text>';
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">age</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 (t) {
return '<span><i style="background:' + t[1] + '"></i>' + t[0] + '</span>';
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 moved here from index.html so every render keeps the header. */
var CHART_SWIPE = '<span class="swipe">swipe →</span>';
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': '<h3>Will it last? Projected savings</h3><p class="cap">Total portfolio (RRSP + TFSA + non-registered), in today\'s dollars. Shaded: money running out.</p>',
'#g2': '<h3>Where retirement income comes from</h3><p class="cap">CPP, OAS, GIS and what you must withdraw from savings — stacked, after-tax view.</p>',
'#g3': '<h3>Spending vs. what you\'ll actually have</h3><p class="cap">Annual cash in (income after tax &amp; clawback, plus withdrawals) vs. planned spending.</p>',
'#g4': function () { return '<h3>Four futures, one number</h3><p class="cap">Sustainable annual spending that lasts to <span id="lifeexp-lbl">' + state.a.lifeExp + '</span> under each scenario.</p>'; }
'#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>' + CHART_SWIPE + '</div>';
return '<div class="chart-h"><div>' + hd + '</div>' + chartSwipe() + '</div>';
}
function chart(sel, inner, legendItems) {
$(sel).innerHTML = chartH(sel) + (inner || '') + (legendItems ? legend(legendItems) : '');
@@ -400,24 +405,24 @@
if (proj.depletionAge) {
var x = X(proj.depletionAge);
inner += '<line x1="' + x + '" x2="' + x + '" y1="' + T + '" y2="' + (H - B) + '" stroke="#dc2626" stroke-dasharray="4 4" stroke-width="2"/>' +
'<text x="' + (x + 5) + '" y="' + (T + 12) + '" font-size="13" fill="#dc2626" font-weight="700">runs out ≈ ' + proj.depletionAge + '</text>';
'<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">retire</text>';
chart('#g1', svgWrap(inner), [['Total savings (today’s $)', COL.bal]]);
'<text x="' + (X(ret) + 4) + '" y="' + (H - B - 6) + '" font-size="12" fill="#94a3b8">' + 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">Retirement after your plan horizon.</p>'); return; }
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 = [
['CPP', COL.cpp, yrs.map(function (r) { return r.cpp; })],
['OAS (net)', COL.oas, yrs.map(function (r) { return Math.max(0, r.oasGross - r.oasClawback); })],
['GIS', COL.gis, yrs.map(function (r) { return r.gis; })],
['From savings', COL.wdr, yrs.map(function (r) { return r.rrifWithdrawal + r.nonregW + r.tfsaW; })]
[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; })],
[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);
@@ -446,26 +451,26 @@
var first = yrs.map(function (r, i) { return r.unfunded > 1 ? ages[i] : null; }).filter(function (x) { return x; })[0];
if (first) {
inner += '<circle cx="' + X(first) + '" cy="' + Y(need[ages.indexOf(first)]) + '" r="4.5" fill="#dc2626"/>' +
'<text x="' + (X(first) + 6) + '" y="' + (Y(need[ages.indexOf(first)]) - 6) + '" font-size="13" fill="#dc2626" font-weight="700">shortfall from ~' + first + '</text>';
'<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), [['Cash you’ll have (after tax)', COL.cash], ['Planned spending', COL.need]]);
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 = 'Computed from the current answers.'; return; }
chart('#g4', '<p class="cap">Calculating scenarios…</p>');
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 = [
['Current plan', p],
['+$200/mo to RRSP', Object.assign({}, p, { monthlyContribution: p.monthlyContribution + 200, employerMatchMonthly: p.employerMatchMonthly })],
['Retire 2 years later', Object.assign({}, p, { retireAge: Math.min(p.retireAge + 2, 75) })],
['CPP at 70', Object.assign({}, p, { cppStartAge: 70 })]
[t('g4.s0'), p],
[t('g4.s1'), Object.assign({}, p, { monthlyContribution: p.monthlyContribution + 200, employerMatchMonthly: p.employerMatchMonthly })],
[t('g4.s2'), 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 = 'Biggest lever here: “' + best[0] + '” → ' + money(best[1]) + '/yr, forever.';
$('#g4-note').textContent = t('g4.best', { name: best[0], money: money(best[1]) });
}, 30);
}
function drawG4(res) {
@@ -501,6 +506,10 @@
.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] || [];
@@ -517,13 +526,14 @@
var best = keys.map(function (s) { return [s, score(s)]; }).sort(function (x, y) { return y[1] - x[1]; })[0];
if (!best) best = ['hero', 0];
var slug = best[1] > 0 ? best[0] : 'hero';
var cap = CAPTIONS[slug] || CAPTIONS.hero;
var 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="Your retirement vision" loading="lazy">' +
'<div class="vcap"><b>Retirement Vision — ' + slug.replace(/^\w/, function (c) { return c.toUpperCase(); }) + '</b><br>' + cap +
' <span style="opacity:.6;font-size:12px">AI-generated preview (z-image-turbo). Your numbers: below.</span></div>';
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);
@@ -532,7 +542,7 @@
el.className = 'vision grad';
var h = 0; for (var i = 0; i < slug.length; i++) h = (h * 31 + slug.charCodeAt(i)) >>> 0;
el.style.background = GRADS[h % GRADS.length];
el.innerHTML = '<div class="vcap"><b>Retirement Vision — ' + slug.replace(/^\w/, function (c) { return c.toUpperCase(); }) + '</b><br>' + cap + '</div>';
el.innerHTML = '<div class="vcap"><b>' + t('vision.label', { slug: slug.replace(/^\w/, function (c) { return c.toUpperCase(); }) }) + '</b><br>' + cap + '</div>';
}
/* ---------- results ---------- */
@@ -542,14 +552,13 @@
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 = 'On this exact path you run out of money at <b>age ' + proj.depletionAge + '</b>. ' +
'You can safely spend <b>' + money(ss) + '/yr</b> to 95 (you’re planning ' + money(p.spendingAnnual) + '). The three cards below close the gap — each one, by itself.';
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 = 'This plan survives to <b>95</b> with ' + k(proj.finalBal) + ' left over — you can sustain about <b>' + money(ss) + '/yr</b>. ' +
'That’s ' + money(ss - p.spendingAnnual) + '/yr of headroom above your plan.';
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). */
@@ -557,22 +566,22 @@
var cards = '';
if (rec.gapAnnual > 0) {
cards += '<div class="gcard warn"><h4>The gap</h4><div class="big">' + money(rec.gapAnnual) + '/yr</h4><p>between your planned spending and what the plan can safely fund.</p></div>';
cards += '<div class="gcard save"><h4>💪 Save more</h4><div class="big">+' + money(rec.extraMonthly) + '/mo</div>' +
(rec.extraMonthly > 0 ? '<p>extra RRSP room from today closes the gap entirely. ~' + pct(rec.extraMonthly / Math.max(1, p.income / 12)) + ' of income.</p>' : '<p>Not closable by saving alone — see the other two levers.</p>') + '</div>';
cards += '<div class="gcard"><h4>🕰 Work longer</h4><div class="big">' + (rec.retireDelayYears ? '+' + rec.retireDelayYears + (rec.retireDelayYears === 1 ? ' year' : ' years') : '—') + '</div>' +
(rec.retireDelayYears ? '<p>retiring at ' + Math.min(p.retireAge + rec.retireDelayYears, 75) + ' instead of ' + p.retireAge + ' closes the gap — and CPP/OAS grow while you do.</p>' : '<p>Within 10 extra years, delaying retirement alone can’t close this gap — combine levers.</p>') + '</div>';
cards += '<div class="gcard"><h4>🌿 Trim spending</h4><div class="big">−' + money(rec.spendingReduction) + '/yr</div><p>to ' + money(Math.max(0, p.spendingAnnual - rec.spendingReduction)) + '. The cheapest lever, and the one nobody talks about at 45.</p></div>';
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>✅ Status</h4><div class="big">On track</div><p>No gap at ' + money(p.spendingAnnual) + '/yr. Consider testing a bigger number with the what-if sliders.</p></div>' +
'<div class="gcard"><h4>Headroom</h4><div class="big">' + money(ss - p.spendingAnnual) + '/yr</div><p>you could add to spending — or save for the cottage.</p></div>' +
'<div class="gcard save"><h4>🛡 Stress-tested</h4><div class="big">' + money(ss) + '/yr</div><p>sustainable real spending to ' + p.lifeExp + ' with RRIF minimums and taxes enforced.</p></div>';
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);
var tbl = '<tr><th>Age</th><th>RRSP</th><th>TFSA</th><th>Non-reg</th><th>Total</th><th>Withdraw</th><th>CPP</th><th>OAS</th><th>GIS</th><th>Tax+claw</th><th>Unfunded</th></tr>';
var 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++;
@@ -581,10 +590,13 @@
'</td><td>' + k(r.cpp) + '</td><td>' + k(r.oasGross - r.oasClawback) + '</td><td>' + k(r.gis) + '</td><td>' + k(r.tax + r.oasClawback) +
'</td><td>' + (r.unfunded > 1 ? k(r.unfunded) : '—') + '</td></tr>';
});
$('#yr-table').innerHTML = tbl; $('#yr-n').textContent = n;
$('#yr-table').innerHTML = tbl; $('#yr-summary').innerHTML = t('r.yearlySummary', { n: n });
var m2 = function (v) { return '$' + Number(v).toLocaleString('en-CA', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); };
$('#stamp').textContent = 'tax rates 2026 · CPP max ' + m2(C.cppMax65Monthly.value) + '/mo · OAS max ' + m2(C.oasMax6574Monthly.value) + '/mo · GIS max ' + m2(C.gisMaxSingleMonthly.value) + '/mo · constants as of ' + C.asOf + ' · RRIF table CRA Reg. 8301(1)';
var 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);
@@ -598,9 +610,9 @@
}
function wiLabels() {
$('#wi-spend-o').textContent = money(+$('#wi-spend').value);
$('#wi-ctrb-o').textContent = money(+$('#wi-ctrb').value) + '/mo';
$('#wi-ret-o').textContent = $('#wi-ret').value + ' yrs';
$('#wi-cpp-o').textContent = 'age ' + $('#wi-cpp').value;
$('#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) {
@@ -622,7 +634,7 @@
};
$('#btn-edit').onclick = function () { showScreen('quiz'); renderStep(); liveUpdate(); };
$('#btn-restart').onclick = function () {
if (!confirm('Start over? Clears saved answers in this browser.')) return;
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;
@@ -631,12 +643,12 @@
$('#btn-print').onclick = function () { window.print(); };
$('#btn-share').onclick = function () {
var url = location.origin + location.pathname + '#p=' + encState();
var restore = function () { $('#btn-share').textContent = '🔗 Copy link with my answers'; };
var done = function () { $('#btn-share').textContent = '✅ Link copied'; setTimeout(restore, 1800); };
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('Copy your link:', url);
$('#btn-share').textContent = '🔗 Link shown — copy it';
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);
@@ -673,6 +685,16 @@
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(); }
+558
View File
@@ -0,0 +1,558 @@
/* Retire Planner i18n — dependency-free, loads before app.js.
EN/FR dictionary (dot-path keys), t() with {placeholder} interpolation, lang state
(payload > saved state > navigator), Intl formatters, applyI18n(), header EN|FR toggle.
Exposes globals: I18N, t, getLang, setLang, detectLang, applyI18n,
fmtMoney, fmtMoneyK, fmtMoney2, fmtInt, fmtPct. */
(function () {
'use strict';
var LS_KEY = 'retire-planner-v1';
var MINUS = '\u2212'; /* − */
var NB = '\u00a0'; /* no-break space (French punctuation) */
var NNB = '\u202f'; /* narrow no-break space (French units, matches Intl fr-CA) */
/* ---------- dictionary ---------- */
var EN = {
doc: {
title: 'Retire Planner — Canadian retirement, modelled properly',
desc: 'A Canadian retirement calculator with real CPP/OAS/GIS/RRIF mechanics and live 2026 tax rates. 100% client-side — answers never leave your browser.'
},
brand: 'Retire Planner',
hdr: {
pill: '🔒 100% client-side — answers never leave your browser',
pillTitle: 'No server, no tracking, no accounts',
startOver: 'Start over'
},
intro: {
h1: 'Will you actually run out of money?',
lede: 'Most Canadian retirement calculators stop at “your RRSP grows at 6%”. This one models <b>real CPP/OAS/GIS/RRIF mechanics</b>, provincial taxes fetched live, and the tax cliff most planners won\'t show you.',
tick1: 'CPP early/late (−0.6%/mo before 65, +0.7% after), OAS deferral, clawback & GIS',
tick2: 'Live 2026 federal + all 13 province/territory tax tables',
tick3: 'RRIF minimums at 71, pension splitting, tax-smart drawdown',
tick4: 'Couples: joint taxes, split pensions, household GIS',
privacy: '🔒 100% client-side — answers never leave your browser. No account, no tracking, nothing is sent anywhere.',
cta: 'Build my retirement plan →',
fine: '~2 minutes. Works on one screen at a time; you can change anything later.'
},
quiz: {
back: '← Back',
next: 'Next →',
seePlan: 'See my plan →',
stepCount: '{i} / {n}'
},
panel: {
title: 'Your plan so far',
savingsAt: 'Savings at retirement',
cppOas67: 'CPP + OAS at 67',
lastsTo: 'Money lasts to',
runsOut: 'runs out ~{age}',
lifetimeTax: 'Lifetime tax'
},
unit: { yr: '/yr', mo: '/mo' },
fields: { unit: '($)' },
i: {
depletion: '⚠️ On this path you run out of money around age <b>{age}</b>. Keep answering — the plan ends with three concrete ways to close the gap.',
cppEarly: 'CPP at {age} is permanently reduced — deferring to 70 is a +42% inflation-proof raise. Move the slider later and watch the panel.',
oasLate: 'OAS at {age}: +36% for life, plus a bigger GIS window in between. Good trade for most people.',
qc: 'ℹ️ Québec: QPP is <b>not</b> modelled — add your QPP estimate as “Pension $/yr”. QPP typically replaces ~35% of income.',
cliff: 'You’ll hit the RRIF-minimum tax cliff at 71. TFSA room now softens it later — check the gap cards.',
couple: 'With pension splitting + household GIS, your joint taxes drop — the panel shows the joint total.',
live: 'Live numbers — change any answer and this panel moves.'
},
prov: {
ON: 'Ontario', BC: 'British Columbia', AB: 'Alberta', SK: 'Saskatchewan',
MB: 'Manitoba', QC: 'Quebec', NS: 'Nova Scotia', NB: 'New Brunswick',
NL: 'Newfoundland & Labrador', PE: 'PEI', YT: 'Yukon', NT: 'Northwest Territories', NU: 'Nunavut'
},
int: {
rockies: '🏔 Mountains', maritimes: '🌊 Ocean', toronto: '🏙 Big city',
dock: '🛶 Lake / dock', muskoka: '🌲 Cottage country', prairies: '🌾 Open sky',
winter: '⛷ Snow / ski', workshop: '🪵 Workshop', garden: '🌺 Garden',
travel: '✈️ Travel', quebec: '🇫🇷 Québec'
},
q: {
province: { title: 'Where do you live?', hint: 'Taxes, and your retirement view.' },
age: { title: 'Your age', hint: 'Years.' },
household: {
title: 'Household', hint: 'Couple means joint taxes, pension splitting and household GIS.',
single: 'Single', couple: 'Couple'
},
p_age: { title: 'Partner’s age' },
p_income: { title: 'Partner’s annual employment income', hint: '$/yr before tax. 0 if not working.' },
p_savings: {
title: 'Partner’s savings', hint: 'Current balances, today’s dollars.',
rrsp: 'RRSP / RRIF', tfsa: 'TFSA', nonreg: 'Non-registered'
},
income: { title: 'Your annual employment income', hint: '$/yr. Used to estimate your CPP history.' },
rrsp: { title: 'RRSP / RRIF balance' },
savings2: {
title: 'TFSA and non-registered savings',
tfsa: 'TFSA', nonreg: 'Non-registered (cash, stocks, mutual funds)'
},
contrib: {
title: 'Monthly savings into RRSP', hint: 'Your contribution + any employer match.',
you: 'You contribute $/mo', match: 'Employer match $/mo'
},
retire: { title: 'Retirement age', hint: 'When you stop earning employment income.', years: '{v} years' },
spend: { title: 'Planned annual spending in retirement', hint: 'Today’s dollars, after tax. Be honest — this is the number that decides everything.' },
cppstart: {
title: 'Start CPP at…', hint: 'Age {age}: {adj}',
m60: '−36% for life', m65: 'Full rate', m70: '+42% for life', mCustom: 'custom adjustment',
os60: '−36% for life', os65: 'full rate', os70: '+42% for life'
},
oasstart: {
title: 'Start OAS at…', hint: 'Deferring to 70 is +36% for life, plus a bigger GIS window.',
os65: 'on eligibility', os70: '+36% for life'
},
extra: {
title: 'Other income', hint: 'DB pension, rental, royalties… leave 0 if none.',
pension: 'Pension $/yr', rental: 'Rental $/yr', other: 'Other $/yr'
},
assumptions: {
title: 'Assumptions', hint: 'Real (above-inflation) returns. Defaults match a balanced portfolio.',
pre: 'Return before retirement', post: 'Return in retirement', inf: 'Inflation',
life: 'Plan to age', yrs: '{v} yrs'
},
vision: { title: 'What are you retiring toward?', hint: 'Pick a few. We’ll paint your plan with it.' }
},
v: {
number: 'Enter a number between {min} and {max}.',
province: 'Pick a province to continue.'
},
chart: {
age: 'age', swipe: 'swipe →',
runsOut: 'runs out ≈ {age}', retire: 'retire'
},
g1: {
h: 'Will it last? Projected savings',
cap: 'Total portfolio (RRSP + TFSA + non-registered), in today\'s dollars. Shaded: money running out.',
legend: 'Total savings (today’s $)'
},
g2: {
h: 'Where retirement income comes from',
cap: 'CPP, OAS, GIS and what you must withdraw from savings — stacked, after-tax view.',
empty: 'Retirement after your plan horizon.',
cpp: 'CPP', oas: 'OAS (net)', gis: 'GIS', savings: 'From savings'
},
g3: {
h: 'Spending vs. what you\'ll actually have',
cap: 'Annual cash in (income after tax & clawback, plus withdrawals) vs. planned spending.',
shortfall: 'shortfall from ~{age}',
cash: 'Cash you’ll have (after tax)', need: 'Planned spending'
},
g4: {
h: 'Four futures, one number',
cap: 'Sustainable annual spending that lasts to <span id="lifeexp-lbl">{age}</span> under each scenario.',
calc: 'Calculating scenarios…',
computed: 'Computed from the current answers.',
best: 'Biggest lever here: “{name}” → {money}/yr, forever.',
s0: 'Current plan', s1: '+$200/mo to RRSP', s2: 'Retire 2 years later', s3: 'CPP at 70'
},
r: {
verdictH: 'Your plan, in one line',
depletion: 'On this exact path you run out of money at <b>age {age}</b>. ' +
'You can safely spend <b>{ss}/yr</b> to {le} (you’re planning {sp}). The three cards below close the gap — each one, by itself.',
good: 'This plan survives to <b>{le}</b> with {bal} left over — you can sustain about <b>{ss}/yr</b>. ' +
'That’s {hr}/yr of headroom above your plan.',
yearlySummary: 'Year-by-year detail ({n} years)',
whatif: '<b>Try a what-if</b> — move a slider, watch the plan update',
stamp: 'tax rates 2026 · CPP max {cpp}/mo · OAS max {oas}/mo · GIS max {gis}/mo · constants as of {asOf} · RRIF table CRA Reg. 8301(1)',
wiSpend: 'Annual spending', wiCtrb: 'Monthly savings', wiRet: 'Retirement age', wiCpp: 'CPP starts at',
wiYears: '{v} yrs', wiAge: 'age {v}'
},
g: {
gapH: 'The gap', gapP: 'between your planned spending and what the plan can safely fund.',
saveH: '💪 Save more',
saveP: 'extra RRSP room from today closes the gap entirely. ~{pct} of income.',
saveP2: 'Not closable by saving alone — see the other two levers.',
workH: '🕰 Work longer', workY1: '{n} year', workYN: '{n} years',
workP: 'retiring at {at} instead of {now} closes the gap — and CPP/OAS grow while you do.',
workP2: 'Within 10 extra years, delaying retirement alone can’t close this gap — combine levers.',
trimH: '🌿 Trim spending', trimP: 'to {to}. The cheapest lever, and the one nobody talks about at 45.',
statusH: '✅ Status', onTrack: 'On track',
statusP: 'No gap at {sp}/yr. Consider testing a bigger number with the what-if sliders.',
headH: 'Headroom', headP: 'you could add to spending — or save for the cottage.',
stressH: '🛡 Stress-tested', stressP: 'sustainable real spending to {age} with RRIF minimums and taxes enforced.'
},
t: {
age: 'Age', rrsp: 'RRSP', tfsa: 'TFSA', nonreg: 'Non-reg', total: 'Total', wdr: 'Withdraw',
cpp: 'CPP', oas: 'OAS', gis: 'GIS', tax: 'Tax+claw', unfunded: 'Unfunded'
},
d: {
main: '<b>Estimates only — not financial advice.</b> Simplified model: GIS uses a simplified 50% phase-out; Quebec shown with the 16.5% federal abatement (QPP and Quebec payroll taxes not modelled); DB pensions not modelled; returns are constant real rates; Ontario surtax modelled on base tax.',
privacy: '🔒 100% client-side — answers never leave your browser. Your answers are saved only in this browser (localStorage).'
},
res: {
print: '🖨 Print / save PDF',
share: '🔗 Copy link with my answers',
edit: '✎ Edit answers'
},
share: {
copied: '✅ Link copied',
shown: '🔗 Link shown — copy it',
prompt: 'Copy your link:'
},
restart: { confirm: 'Start over? Clears saved answers in this browser.' },
vision: {
alt: 'Your retirement vision',
label: 'Retirement Vision — {slug}',
ai: 'AI-generated preview (z-image-turbo). Your numbers: below.'
}
};
var FR = {
doc: {
title: 'Planificateur de retraite — la retraite canadienne, modélisée comme il faut',
desc: 'Une calculatrice de retraite canadienne avec les vraies mécaniques RPC/SV/SRG/FERR et les taux d\'impôt 2026 en direct. 100 % côté client — vos réponses ne quittent jamais votre navigateur.'
},
brand: 'Planificateur de retraite',
hdr: {
pill: '🔒 100 % côté client — vos réponses ne quittent jamais votre navigateur',
pillTitle: 'Aucun serveur, aucun suivi, aucun compte',
startOver: 'Recommencer'
},
intro: {
h1: 'Allez-vous vraiment manquer d\'argent ?',
lede: 'La plupart des calculateurs de retraite canadiens s\'arrêtent à « votre REER croît de 6 % ». Celui-ci modélise <b>les vraies mécaniques RPC/SV/SRG/FERR</b>, les impôts provinciaux obtenus en direct et le palier fiscal que la plupart des planificateurs ne vous montrent pas.',
tick1: 'RPC hâtive ou tardive (−0,6 %/mois avant 65 ans, +0,7 % après), report de la SV, récupération et SRG',
tick2: 'Barèmes fédéraux 2026 en direct + les 13 tables fiscales provinciales et territoriales',
tick3: 'Minimums de FERR à 71 ans, fractionnement du revenu de pension, retraits fiscalement intelligents',
tick4: 'Couples : impôts conjoints, pensions fractionnées, SRG du ménage',
privacy: '🔒 100 % côté client — vos réponses ne quittent jamais votre navigateur. Aucun compte, aucun suivi, rien n\'est envoyé nulle part.',
cta: 'Élaborer mon plan de retraite →',
fine: 'Environ 2 minutes. Un écran à la fois; vous pourrez tout modifier plus tard.'
},
quiz: {
back: '← Précédent',
next: 'Suivant →',
seePlan: 'Voir mon plan →',
stepCount: '{i} / {n}'
},
panel: {
title: 'Votre plan jusqu\'ici',
savingsAt: 'Épargne à la retraite',
cppOas67: 'RPC + SV à 67',
lastsTo: 'L\'argent dure jusqu\'à',
runsOut: 'épuisé ~{age}',
lifetimeTax: 'Impôt à vie'
},
unit: { yr: '/an', mo: '/mois' },
fields: { unit: '($)' },
i: {
depletion: '⚠️ Sur ce parcours, vous manquez d\'argent vers <b>{age} ans</b>. Continuez — le plan se termine par trois façons concrètes de combler l\'écart.',
cppEarly: 'La RPC à {age} ans est réduite de façon permanente — reporter à 70 ans équivaut à +42 % à l\'abri de l\'inflation. Bougez le curseur et regardez le panneau.',
oasLate: 'SV à {age} ans : +36 % pour la vie, et une plus grande fenêtre de SRG entre-temps. Un bon compromis pour la plupart des gens.',
qc: 'ℹ️ Québec : le RRQ <b>n\'est pas</b> modélisé — ajoutez votre estimation du RRQ sous « Pension ($/an) ». Le RRQ remplace habituellement ~35 % du revenu.',
cliff: 'Vous heurterez le palier fiscal du minimum de FERR à 71 ans. Du CELI aujourd\'hui en atténue l\'effet plus tard — voyez les cartes d\'écart.',
couple: 'Avec le fractionnement du revenu de pension et la SRG du ménage, vos impôts conjoints baissent — le panneau affiche le total conjoint.',
live: 'Chiffres en direct — changez une réponse et ce panneau bouge.'
},
prov: {
ON: 'Ontario', BC: 'Colombie-Britannique', AB: 'Alberta', SK: 'Saskatchewan',
MB: 'Manitoba', QC: 'Québec', NS: 'Nouvelle-Écosse', NB: 'Nouveau-Brunswick',
NL: 'Terre-Neuve-et-Labrador', PE: 'Île-du-Prince-Édouard', YT: 'Yukon', NT: 'Territoires du Nord-Ouest', NU: 'Nunavut'
},
int: {
rockies: '🏔 Montagnes', maritimes: '🌊 Océan', toronto: '🏙 Grande ville',
dock: '🛶 Lac / quai', muskoka: '🌲 Pays des chalets', prairies: '🌾 Grands espaces',
winter: '⛷ Neige / ski', workshop: '🪵 Atelier', garden: '🌺 Jardin',
travel: '✈️ Voyages', quebec: '🇫🇷 Québec'
},
q: {
province: { title: 'Où habitez-vous ?', hint: 'Vos impôts — et votre décor de retraite.' },
age: { title: 'Votre âge', hint: 'Années.' },
household: {
title: 'Ménage', hint: 'Un couple : impôts conjoints, fractionnement du revenu de pension et SRG du ménage.',
single: 'Célibataire', couple: 'Couple'
},
p_age: { title: 'Âge du conjoint' },
p_income: { title: 'Revenu d\'emploi annuel du conjoint', hint: '$/an avant impôt. 0 si sans emploi.' },
p_savings: {
title: 'Épargne du conjoint', hint: 'Soldes actuels, en dollars d\'aujourd\'hui.',
rrsp: 'REER / FERR', tfsa: 'CELI', nonreg: 'Non enregistré'
},
income: { title: 'Votre revenu d\'emploi annuel', hint: '$/an. Sert à estimer votre historique RPC.' },
rrsp: { title: 'Solde du REER / FERR' },
savings2: {
title: 'Épargne CELI et non enregistrée',
tfsa: 'CELI', nonreg: 'Non enregistré (comptant, actions, fonds communs)'
},
contrib: {
title: 'Épargne mensuelle au REER', hint: 'Votre cotisation + la contrepartie de l\'employeur.',
you: 'Vous cotisez', match: 'Contrepartie de l\'employeur'
},
retire: { title: 'Âge de la retraite', hint: 'Quand vous cessez de gagner un revenu d\'emploi.', years: '{v} ans' },
spend: { title: 'Dépenses annuelles prévues à la retraite', hint: 'En dollars d\'aujourd\'hui, après impôt. Soyez honnête — c\'est le chiffre qui décide de tout.' },
cppstart: {
title: 'Débuter la RPC à…', hint: 'À {age} ans : {adj}',
m60: '−36 % pour la vie', m65: 'Taux plein', m70: '+42 % pour la vie', mCustom: 'ajustement personnalisé',
os60: '−36 % pour la vie', os65: 'taux plein', os70: '+42 % pour la vie'
},
oasstart: {
title: 'Débuter la SV à…', hint: 'Reporter à 70 ans : +36 % pour la vie, et une plus grande fenêtre de SRG.',
os65: 'dès l\'admissibilité', os70: '+36 % pour la vie'
},
extra: {
title: 'Autres revenus', hint: 'Pension à prestations, location, redevances… laissez 0 sinon.',
pension: 'Pension par année', rental: 'Location par année', other: 'Autre par année'
},
assumptions: {
title: 'Hypothèses', hint: 'Rendements réels (supérieurs à l\'inflation). Les valeurs par défaut correspondent à un portefeuille équilibré.',
pre: 'Rendement avant la retraite', post: 'Rendement à la retraite', inf: 'Inflation',
life: 'Planifier jusqu\'à', yrs: '{v} ans'
},
vision: { title: 'Vers quoi prenez-vous votre retraite ?', hint: 'Choisissez-en quelques-uns. Nous en imprégnerons votre plan.' }
},
v: {
number: 'Entrez un nombre entre {min} et {max}.',
province: 'Choisissez une province pour continuer.'
},
chart: {
age: 'âge', swipe: 'glissez →',
runsOut: 'épuisé ≈ {age}', retire: 'retraite'
},
g1: {
h: 'L\'épargne tiendra-t-elle ?',
cap: 'Portefeuille complet (REER + CELI + non enregistré), en dollars d\'aujourd\'hui. Zone ombrée : épargne épuisée.',
legend: 'Épargne totale ($ d\'aujourd\'hui)'
},
g2: {
h: 'D\'où vient le revenu de retraite',
cap: 'RPC, SV, SRG et ce que vous retirez de l\'épargne — empilé, après impôt.',
empty: 'Retraite après l\'horizon du plan.',
cpp: 'RPC', oas: 'SV (net)', gis: 'SRG', savings: 'Épargne'
},
g3: {
h: 'Dépenses vs ce que vous aurez',
cap: 'Encaisse annuelle (revenu après impôt et récupération, plus retraits) vs dépenses prévues.',
shortfall: 'déficit dès ~{age}',
cash: 'Encaisse disponible (après impôt)', need: 'Dépenses prévues'
},
g4: {
h: 'Quatre scénarios, un chiffre',
cap: 'Dépenses annuelles durables jusqu\'à <span id="lifeexp-lbl">{age}</span> ans, par scénario.',
calc: 'Calcul des scénarios…',
computed: 'Calculé à partir des réponses actuelles.',
best: 'Le levier le plus puissant ici : « {name} » → {money}/an, pour toujours.',
s0: 'Plan actuel', s1: '+200 $/mois au REER', s2: 'Retard de 2 ans', s3: 'RPC à 70'
},
r: {
verdictH: 'Votre plan, en une phrase',
depletion: 'Sur ce parcours exact, vous manquez d\'argent à <b>{age} ans</b>. ' +
'Vous pouvez dépenser en toute sûreté <b>{ss}/an</b> jusqu\'à {le} ans (vous prévoyez {sp}). Les trois cartes ci-dessous comblent l\'écart — chacune, à elle seule.',
good: 'Ce plan tient jusqu\'à <b>{le} ans</b> avec {bal} restants — vous pouvez soutenir environ <b>{ss}/an</b>. ' +
'Cela fait {hr}/an de marge au-dessus de votre plan.',
yearlySummary: 'Détail année par année ({n} ans)',
whatif: '<b>Essayer un scénario</b> — déplacez un curseur, regardez le plan se mettre à jour',
stamp: 'barèmes 2026 · max. RPC {cpp}/mois · max. SV {oas}/mois · max. SRG {gis}/mois · constantes au {asOf} · table FERR Règl. 8301(1) ARC',
wiSpend: 'Dépenses annuelles', wiCtrb: 'Épargne mensuelle', wiRet: 'Âge de la retraite', wiCpp: 'La RPC commence à',
wiYears: '{v} ans', wiAge: 'à {v} ans'
},
g: {
gapH: 'L\'écart', gapP: 'entre vos dépenses prévues et ce que le plan peut financer de façon sûre.',
saveH: '💪 Épargner plus',
saveP: 'de REER en plus dès aujourd\'hui comble l\'écart à lui seul. ~{pct} du revenu.',
saveP2: 'Impossible à combler par l\'épargne seule — voyez les deux autres leviers.',
workH: '🕰 Travailler plus', workY1: '{n} an', workYN: '{n} ans',
workP: 'partir à {at} ans au lieu de {now} comble l\'écart — et RPC/SV grossissent pendant ce temps.',
workP2: 'En 10 ans de plus, retarder seul la retraite ne comble pas cet écart — combinez les leviers.',
trimH: '🌿 Réduire les dépenses', trimP: 'à {to}. Le levier le moins coûteux, et celui dont personne ne parle à 45 ans.',
statusH: '✅ Statut', onTrack: 'Sur la bonne voie',
statusP: 'Aucun écart à {sp}/an. Essayez un chiffre plus élevé avec les curseurs « et si ».',
headH: 'Marge', headP: 'que vous pourriez ajouter aux dépenses — ou épargner pour le chalet.',
stressH: '🛡 Test de résistance', stressP: 'dépenses réelles durables jusqu\'à {age} ans, minimums FERR et impôts respectés.'
},
t: {
age: 'Âge', rrsp: 'REER', tfsa: 'CELI', nonreg: 'Non enreg.', total: 'Total', wdr: 'Retrait',
cpp: 'RPC', oas: 'SV', gis: 'SRG', tax: 'Impôt + récup.', unfunded: 'Non financé'
},
d: {
main: '<b>Estimations seulement — il ne s\'agit pas de conseils financiers.</b> Modèle simplifié : retrait progressif simplifié de 50 % pour la SRG; Québec présenté avec le dégagement fédéral de 16,5 % (RRQ et cotisations sociales québécoises non modélisés); pensions à prestations non modélisées; rendements à taux réels constants; surtaxe de l\'Ontario calculée sur l\'impôt de base.',
privacy: '🔒 100 % côté client — vos réponses ne quittent jamais votre navigateur. Vos réponses ne sont enregistrées que dans ce navigateur (localStorage).'
},
res: {
print: '🖨 Imprimer / enregistrer en PDF',
share: '🔗 Copier le lien avec mes réponses',
edit: '✎ Modifier les réponses'
},
share: {
copied: '✅ Lien copié',
shown: '🔗 Lien affiché — copiez-le',
prompt: 'Copiez votre lien :'
},
restart: { confirm: 'Recommencer ? Les réponses enregistrées dans ce navigateur seront effacées.' },
vision: {
alt: 'Votre vision de retraite',
label: 'Vision de retraite — {slug}',
ai: 'Aperçu généré par IA (z-image-turbo). Vos chiffres : ci-dessous.'
}
};
/* French typography pass: ' between letters, no-break space before : ; ! ? %
and inside « » guillemets. Applied to every fr leaf at load. */
function frNorm(s) {
return s
.replace(/(\p{L})'(\p{L})/gu, '$1\u2019$2')
.replace(/« /g, '\u00ab' + NB)
.replace(/ »/g, NB + '\u00bb')
.replace(/ ([:;!?%])/g, NB + '$1');
}
(function walkFr(o) {
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) {
if (typeof o[k] === 'string') o[k] = frNorm(o[k]);
else if (o[k] && typeof o[k] === 'object') walkFr(o[k]);
}
})(FR);
var I18N = { en: EN, fr: FR };
/* ---------- language state ---------- */
var lang = 'en';
function detectLang() {
var n = (typeof navigator !== 'undefined' && navigator.language) || 'en';
return String(n).toLowerCase().indexOf('fr') === 0 ? 'fr' : 'en';
}
/* payload lang > saved state lang > detectLang(); old payloads (no lang) → en */
function initialLang() {
var m = typeof location !== 'undefined' && location.hash ? location.hash.match(/[#&]p=([^&]+)/) : null;
if (m) {
var o = null;
try { o = JSON.parse(decodeURIComponent(escape(atob(m[1])))); } catch (e) { o = null; }
if (o && typeof o === 'object') return o.lang === 'fr' ? 'fr' : 'en';
}
try {
var raw = localStorage.getItem(LS_KEY);
if (raw) {
var s = JSON.parse(raw);
if (s && (s.lang === 'en' || s.lang === 'fr')) return s.lang;
}
} catch (e) { /* fresh */ }
return detectLang();
}
function getLang() { return lang; }
/* merge lang into the persisted state object, keeping {a, step, screen, lang} shape */
function persistLang() {
try {
var o = {};
var raw = localStorage.getItem(LS_KEY);
if (raw) { var p = JSON.parse(raw); if (p && typeof p === 'object') o = p; }
o.lang = lang;
localStorage.setItem(LS_KEY, JSON.stringify(o));
} catch (e) { /* private mode etc. — language just won't persist */ }
}
function setLang(l) {
l = (l === 'fr') ? 'fr' : 'en';
if (l === lang) return;
lang = l;
persistLang();
applyI18n();
if (typeof window !== 'undefined' && typeof window.__onLangChange === 'function') {
try { window.__onLangChange(); } catch (e) { /* re-render hook must not break toggle */ }
}
}
/* ---------- t() ---------- */
function lookup(obj, key) {
var p = key.split('.'), o = obj;
for (var i = 0; i < p.length; i++) {
if (o === null || typeof o !== 'object') return null;
o = o[p[i]];
}
return (typeof o === 'string') ? o : null;
}
function t(key, vars) {
var s = lookup(I18N[lang], key);
if (s === null) s = lookup(I18N.en, key);
if (s === null) return key;
if (vars) s = s.replace(/\{(\w+)\}/g, function (m0, k) { return vars[k] != null ? String(vars[k]) : m0; });
return s;
}
/* ---------- locale-aware formatters (Intl.NumberFormat) ---------- */
var nfCache = {};
function nf(kind, opts) {
var key = kind + '|' + lang;
if (!nfCache[key]) nfCache[key] = new Intl.NumberFormat(lang === 'fr' ? 'fr-CA' : 'en-CA', opts);
return nfCache[key];
}
function fmtInt(n) { return nf('int', { maximumFractionDigits: 0 }).format(n); }
function fmtMoney(n) {
n = Math.round(n);
return (n < 0 ? MINUS : '') + nf('c0', { style: 'currency', currency: 'CAD', maximumFractionDigits: 0 }).format(Math.abs(n));
}
function fmtMoney2(n) {
n = +n;
return (n < 0 ? MINUS : '') + nf('c2', { style: 'currency', currency: 'CAD', minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(Math.abs(n));
}
function fmtPct(x, digits) {
var d = digits == null ? 0 : digits;
return nf('p' + d, { style: 'percent', maximumFractionDigits: d }).format(x);
}
/* compact currency, ≤3 significant digits: $523K / $1.05M — 523 k$ / 1,05 M$ */
function fmtMoneyK(n) {
n = Math.round(n);
var s = n < 0 ? MINUS : '';
n = Math.abs(n);
var num, unit;
if (n >= 1e6) { num = sig3(n / 1e6); unit = 'M'; }
else if (n >= 1000) { num = String(Math.round(n / 1000)); unit = 'K'; }
else { num = String(n); unit = ''; }
if (lang === 'fr') {
num = nf('kd', { maximumFractionDigits: 2 }).format(Number(num)); /* 1.05 → 1,05 */
unit = unit === 'K' ? 'k' : unit;
return s + num + NNB + (unit || '') + '$';
}
return s + '$' + num + unit;
}
function sig3(x) {
var d = x >= 100 ? 0 : (x >= 10 ? 1 : 2);
var s = x.toFixed(d);
if (s.indexOf('.') >= 0) s = s.replace(/0+$/, '').replace(/\.$/, '');
return s;
}
/* ---------- static DOM ---------- */
function forEachEl(sel, fn) {
if (!document.querySelectorAll) return;
var nodes = document.querySelectorAll(sel);
for (var i = 0; i < nodes.length; i++) fn(nodes[i]);
}
function applyI18n() {
var de = document.documentElement;
if (de) { if (de.setAttribute) de.setAttribute('lang', lang); de.lang = lang; }
document.title = t('doc.title');
forEachEl('[data-i18n]', function (el) { el.textContent = t(el.getAttribute('data-i18n')); });
forEachEl('[data-i18n-html]', function (el) { el.innerHTML = t(el.getAttribute('data-i18n-html')); });
forEachEl('[data-i18n-attr]', function (el) {
var spec = el.getAttribute('data-i18n-attr') || '';
spec.split(',').forEach(function (pair) {
var i = pair.indexOf(':');
if (i < 0) return;
el.setAttribute(pair.slice(0, i).trim(), t(pair.slice(i + 1).trim()));
});
});
var be = document.getElementById('lang-en'), bf = document.getElementById('lang-fr');
if (be) be.setAttribute('aria-pressed', lang === 'en' ? 'true' : 'false');
if (bf) bf.setAttribute('aria-pressed', lang === 'fr' ? 'true' : 'false');
}
function wireToggle() {
var be = document.getElementById('lang-en'), bf = document.getElementById('lang-fr');
if (be) be.onclick = function () { setLang('en'); };
if (bf) bf.onclick = function () { setLang('fr'); };
}
/* ---------- expose (browser window / Node globalThis in the test shims) ---------- */
var exp = {
I18N: I18N, t: t, getLang: getLang, setLang: setLang, detectLang: detectLang,
applyI18n: applyI18n, fmtMoney: fmtMoney, fmtMoneyK: fmtMoneyK, fmtMoney2: fmtMoney2,
fmtInt: fmtInt, fmtPct: fmtPct
};
lang = initialLang();
try { if (typeof window !== 'undefined') for (var k in exp) window[k] = exp[k]; } catch (e) { }
try { for (var k2 in exp) globalThis[k2] = exp[k2]; } catch (e) { }
if (typeof document !== 'undefined' && document.querySelectorAll) {
applyI18n();
wireToggle();
}
})();
+32 -28
View File
@@ -6,32 +6,35 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#f7f9fc">
<title>Retire Planner — Canadian retirement, modelled properly</title>
<meta name="description" content="A Canadian retirement calculator with real CPP/OAS/GIS/RRIF mechanics and live 2026 tax rates. 100% client-side — answers never leave your browser.">
<meta name="description" data-i18n-attr="content:doc.desc" content="A Canadian retirement calculator with real CPP/OAS/GIS/RRIF mechanics and live 2026 tax rates. 100% client-side — answers never leave your browser.">
<link rel="stylesheet" href="style.css">
</head>
<body>
<header class="hdr">
<div class="hdr-in">
<span class="logo">🍁 <b>Retire Planner</b></span>
<span class="pill" title="No server, no tracking, no accounts">🔒 100% client-side — answers never leave your browser</span>
<button id="btn-restart" class="ghost" hidden>Start over</button>
<span class="logo">🍁 <b data-i18n="brand">Retire Planner</b></span>
<span class="lang-toggle" role="group" aria-label="Language / Langue">
<button id="lang-en" class="lang-btn" aria-pressed="true">EN</button><button id="lang-fr" class="lang-btn" aria-pressed="false">FR</button>
</span>
<span class="pill" data-i18n="hdr.pill" data-i18n-attr="title:hdr.pillTitle" title="No server, no tracking, no accounts">🔒 100% client-side — answers never leave your browser</span>
<button id="btn-restart" class="ghost" data-i18n="hdr.startOver" hidden>Start over</button>
</div>
</header>
<main>
<!-- INTRO -->
<section id="scr-intro" class="scr">
<h1>Will you actually run out of money?</h1>
<p class="lede">Most Canadian retirement calculators stop at “your RRSP grows at 6%”. This one models <b>real CPP/OAS/GIS/RRIF mechanics</b>, provincial taxes fetched live, and the tax cliff most planners won't show you.</p>
<h1 data-i18n="intro.h1">Will you actually run out of money?</h1>
<p class="lede" data-i18n-html="intro.lede">Most Canadian retirement calculators stop at “your RRSP grows at 6%”. This one models <b>real CPP/OAS/GIS/RRIF mechanics</b>, provincial taxes fetched live, and the tax cliff most planners won't show you.</p>
<ul class="ticks">
<li>CPP early/late (−0.6%/mo before 65, +0.7% after), OAS deferral, clawback &amp; GIS</li>
<li>Live 2026 federal + all 13 province/territory tax tables</li>
<li>RRIF minimums at 71, pension splitting, tax-smart drawdown</li>
<li>Couples: joint taxes, split pensions, household GIS</li>
<li data-i18n="intro.tick1">CPP early/late (−0.6%/mo before 65, +0.7% after), OAS deferral, clawback &amp; GIS</li>
<li data-i18n="intro.tick2">Live 2026 federal + all 13 province/territory tax tables</li>
<li data-i18n="intro.tick3">RRIF minimums at 71, pension splitting, tax-smart drawdown</li>
<li data-i18n="intro.tick4">Couples: joint taxes, split pensions, household GIS</li>
</ul>
<p class="privacy">🔒 100% client-side — answers never leave your browser. No account, no tracking, nothing is sent anywhere.</p>
<button id="btn-begin" class="cta">Build my retirement plan →</button>
<p class="fine">~2 minutes. Works on one screen at a time; you can change anything later.</p>
<p class="privacy" data-i18n="intro.privacy">🔒 100% client-side — answers never leave your browser. No account, no tracking, nothing is sent anywhere.</p>
<button id="btn-begin" class="cta" data-i18n="intro.cta">Build my retirement plan →</button>
<p class="fine" data-i18n="intro.fine">~2 minutes. Works on one screen at a time; you can change anything later.</p>
</section>
<!-- QUESTIONNAIRE -->
@@ -40,14 +43,14 @@
<div class="quiz-main">
<div class="prog"><div id="prog-bar"></div></div>
<div class="step-nav">
<button id="btn-back" class="ghost">← Back</button>
<button id="btn-back" class="ghost" data-i18n="quiz.back">← Back</button>
<span id="step-count" class="step-count"></span>
</div>
<div id="step-body"></div>
<button id="btn-next" class="cta">Next →</button>
<button id="btn-next" class="cta" data-i18n="quiz.next">Next →</button>
</div>
<aside class="panel" id="panel">
<div class="panel-h">Your plan so far</div>
<div class="panel-h" data-i18n="panel.title">Your plan so far</div>
<div id="panel-chips" class="chips"></div>
<div id="panel-insight" class="insight"></div>
</aside>
@@ -59,7 +62,7 @@
<!-- RESULTS -->
<section id="scr-results" class="scr" hidden>
<div id="vision-card" class="vision"></div>
<h2 id="verdict-h">Your plan, in one line</h2>
<h2 id="verdict-h" data-i18n="r.verdictH">Your plan, in one line</h2>
<p id="verdict" class="verdict"></p>
<div class="cards" id="gap-cards"></div>
@@ -72,32 +75,32 @@
<p id="g4-note" class="cap"></p>
<details class="whatif" id="whatif">
<summary><b>Try a what-if</b> — move a slider, watch the plan update</summary>
<summary data-i18n-html="r.whatif"><b>Try a what-if</b> — move a slider, watch the plan update</summary>
<div class="wi-grid">
<label>Annual spending <output id="wi-spend-o"></output>
<label><span data-i18n="r.wiSpend">Annual spending</span> <output id="wi-spend-o"></output>
<input type="range" id="wi-spend" min="20000" max="150000" step="1000"></label>
<label>Monthly savings <output id="wi-ctrb-o"></output>
<label><span data-i18n="r.wiCtrb">Monthly savings</span> <output id="wi-ctrb-o"></output>
<input type="range" id="wi-ctrb" min="0" max="5000" step="50"></label>
<label>Retirement age <output id="wi-ret-o"></output>
<label><span data-i18n="r.wiRet">Retirement age</span> <output id="wi-ret-o"></output>
<input type="range" id="wi-ret" min="55" max="72" step="1"></label>
<label>CPP starts at <output id="wi-cpp-o"></output>
<label><span data-i18n="r.wiCpp">CPP starts at</span> <output id="wi-cpp-o"></output>
<input type="range" id="wi-cpp" min="60" max="70" step="1"></label>
</div>
</details>
<details class="yearly">
<summary>Year-by-year detail (<span id="yr-n">0</span> years)</summary>
<summary id="yr-summary">Year-by-year detail (<span id="yr-n">0</span> years)</summary>
<div class="tbl-wrap"><table id="yr-table"></table></div>
</details>
<div class="disclaimer">
<p><b>Estimates only — not financial advice.</b> Simplified model: GIS uses a simplified 50% phase-out; Quebec shown with the 16.5% federal abatement (QPP and Quebec payroll taxes not modelled); DB pensions not modelled; returns are constant real rates; Ontario surtax modelled on base tax.</p>
<p data-i18n-html="d.main"><b>Estimates only — not financial advice.</b> Simplified model: GIS uses a simplified 50% phase-out; Quebec shown with the 16.5% federal abatement (QPP and Quebec payroll taxes not modelled); DB pensions not modelled; returns are constant real rates; Ontario surtax modelled on base tax.</p>
<p class="stamp" id="stamp">tax rates 2026</p>
<p class="privacy">🔒 100% client-side — answers never leave your browser. Your answers are saved only in this browser (localStorage).</p>
<p class="privacy" data-i18n="d.privacy">🔒 100% client-side — answers never leave your browser. Your answers are saved only in this browser (localStorage).</p>
<div class="btn-row">
<button id="btn-print" class="ghost">🖨 Print / save PDF</button>
<button id="btn-share" class="ghost">🔗 Copy link with my answers</button>
<button id="btn-edit" class="ghost">✎ Edit answers</button>
<button id="btn-print" class="ghost" data-i18n="res.print">🖨 Print / save PDF</button>
<button id="btn-share" class="ghost" data-i18n="res.share">🔗 Copy link with my answers</button>
<button id="btn-edit" class="ghost" data-i18n="res.edit">✎ Edit answers</button>
</div>
</div>
</section>
@@ -105,6 +108,7 @@
<script src="engine/canada.js"></script>
<script src="engine/engine.js"></script>
<script src="i18n.js"></script>
<script src="app.js"></script>
</body>
</html>
+5
View File
@@ -23,6 +23,11 @@ b{color:var(--ink)}
.hdr-in{max-width:1060px;margin:0 auto;display:flex;align-items:center;gap:10px;padding:10px 12px;flex-wrap:wrap}
.logo{font-size:17px}
.pill{font-size:12px;background:#e8f5ee;color:#0a5c36;border:1px solid #bfe6d0;border-radius:99px;padding:3px 10px;color:#0a5c36}
/* language toggle: compact EN|FR pill group; 44px touch targets (mobile pass rules) */
.lang-toggle{display:inline-flex;border:1px solid var(--line);border-radius:99px;overflow:hidden;flex:none;background:var(--card)}
.lang-btn{padding:4px 10px;border:0;background:transparent;font-size:13px;font-weight:700;color:var(--ink2);cursor:pointer;min-height:44px;line-height:1.2}
.lang-btn[aria-pressed="true"]{background:var(--blue);color:#fff}
.logo{font-size:17px;min-width:0;flex:0 1 auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ghost{background:none;border:1px solid var(--line);border-radius:10px;padding:7px 12px;font-size:14px;color:var(--ink2);cursor:pointer;min-height:44px}
button,input{touch-action:manipulation}
.ghost:hover{border-color:var(--blue);color:var(--blue)}