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:
1 parent
65863c0302
commit
5f01dd7ca6
60 files changed
+2788
-241
No files matched your search
+164
-142
@@ -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 & 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(); }
|
||||
|
||||
Reference in new issue
Block a user