/* Retire Planner UI — vanilla, no build, no network except same-origin vision manifest. All computation via window.RetireEngine. Answers persist in localStorage + URL hash. All user-facing strings via i18n.js (loaded first): t() + Intl formatters (fmtMoney/fmtMoneyK/fmtMoney2/fmtInt/fmtPct) so every number follows the active locale. */ 'use strict'; (function () { var E = window.RetireEngine, C = window.RetireCanada, PS = window.RetirePS; var LS = 'retire-planner-v1'; var $ = function (s) { return document.querySelector(s); }; /* locale-aware aliases (i18n.js globals) — chart/table code below stays untouched in shape */ var money = fmtMoney, k = fmtMoneyK, pct = fmtPct; var PROVINCES = [['ON', 'prov.ON'], ['BC', 'prov.BC'], ['AB', 'prov.AB'], ['SK', 'prov.SK'], ['MB', 'prov.MB'], ['QC', 'prov.QC'], ['NS', 'prov.NS'], ['NB', 'prov.NB'], ['NL', 'prov.NL'], ['PE', 'prov.PE'], ['YT', 'prov.YT'], ['NT', 'prov.NT'], ['NU', 'prov.NU']]; var INTERESTS = [ ['rockies', 'int.rockies'], ['maritimes', 'int.maritimes'], ['toronto', 'int.toronto'], ['dock', 'int.dock'], ['muskoka', 'int.muskoka'], ['prairies', 'int.prairies'], ['winter', 'int.winter'], ['workshop', 'int.workshop'], ['garden', 'int.garden'], ['travel', 'int.travel'], ['quebec', 'int.quebec'] ]; var PROV_SLUGS = { ON: ['toronto', 'muskoka', 'garden'], QC: ['quebec'], BC: ['vancouver'], AB: ['rockies', 'prairies'], MB: ['prairies'], SK: ['prairies'], NB: ['maritimes', 'dock'], NS: ['maritimes', 'dock'], PE: ['maritimes'], NL: ['maritimes'], YT: ['winter', 'rockies'], NT: ['winter'], NU: ['winter'] }; /* captions per slug per language (hero … dock) */ var CAPTIONS = { hero: { en: 'The good life — paid for by the plan on this page.', fr: 'La belle vie — payée par le plan sur cette page.' }, muskoka: { en: 'Cottage country, mortgage-free.', fr: 'Le pays des chalets, hypothèque payée.' }, rockies: { en: 'Lake Louise at 68, on your own schedule.', fr: 'Le lac Louise à 68 ans, à votre rythme.' }, maritimes: { en: 'Lobster season, every season.', fr: 'La saison du homard, toutes les saisons.' }, prairies: { en: 'Wide skies and a paid-off acreage.', fr: 'De grands espaces et une terre payée.' }, quebec: { en: 'Terrasses, fêtes et liberté financière.', fr: 'Terrasses, fêtes et liberté financière.' }, vancouver: { en: 'Ocean sunsets from a paid-for deck.', fr: 'Des couchers de soleil sur l’océan depuis une terrasse payée.' }, toronto: { en: 'City life, funded — not scraped.', fr: 'La vie de ville, financée — pas au rabais.' }, garden: { en: 'Mornings that start with tomatoes.', fr: 'Des matins qui commencent par des tomates.' }, travel: { en: 'Long-haul flights, fully deductible… just kidding, paid in cash.', fr: 'Des vols long-courriers, entièrement déductibles… je plaisante, payés comptant.' }, workshop: { en: 'A shop full of projects and zero alarms.', fr: 'Un atelier plein de projets et zéro réveil.' }, winter: { en: 'Fresh tracks on a Tuesday.', fr: 'De la neige fraîche un mardi.' }, dock: { en: 'First coffee on your own dock.', fr: 'Le premier café sur votre propre quai.' } }; var GRADS = ['linear-gradient(135deg,#1e3a8a,#0ea5e9)', 'linear-gradient(135deg,#7c2d12,#f59e0b)', 'linear-gradient(135deg,#14532d,#84cc16)', 'linear-gradient(135deg,#4c1d95,#ec4899)', 'linear-gradient(135deg,#0f172a,#64748b)']; /* ---------- state ---------- */ function defaults() { return { province: null, age: 45, household: 'single', partner: { age: 45, income: 0, rrsp: 0, tfsa: 0, nonreg: 0, monthlyContribution: 0 }, income: 60000, rrsp: 100000, tfsa: 30000, nonreg: 20000, monthlyContribution: 500, employerMatchMonthly: 0, retireAge: 65, spendingAnnual: 50000, cppStartAge: 65, oasStartAge: 65, pensionAnnual: 0, rentalAnnual: 0, otherAnnual: 0, psPension: false, psJoinYear: null, psServiceYears: null, psServiceOverride: null, psAvgSalary: null, psOccupation: null, psItLevel: null, psItStep: 0, realReturnPre: 3.5, realReturnPost: 2.5, inflation: 2.0, lifeExp: 95, interests: [] }; } var state = { screen: 'intro', step: 0, a: defaults(), wi: {} }; (function load() { var raw = null; var m = location.hash.match(/[#&]p=([^&]+)/); if (m) { try { raw = decodeURIComponent(escape(atob(m[1]))); } catch (e) { raw = null; } } if (!raw) { try { raw = localStorage.getItem(LS); } catch (e) { raw = null; } } if (raw) { try { var o = JSON.parse(raw); var b = Object.assign({}, o.a || o); delete b.lang; Object.assign(state.a, b); state.step = o.step | 0; if (o.screen) state.screen = o.screen; else if (m) state.screen = 'results'; } catch (e) { /* fresh */ } } /* Migration (PS-AUTO-SERVICE): legacy saved psServiceYears was always user-typed → treat it as the override so pre-auto-derivation plans compute exactly as before. */ if (state.a.psServiceOverride == null && typeof state.a.psServiceYears === 'number' && isFinite(state.a.psServiceYears)) { state.a.psServiceOverride = state.a.psServiceYears; } })(); function save() { var t2 = { a: state.a, step: state.step, screen: state.screen, lang: getLang() }; try { localStorage.setItem(LS, JSON.stringify(t2)); } catch (e) { } } function encState() { return btoa(unescape(encodeURIComponent(JSON.stringify({ a: state.a, step: 0, lang: getLang() })))); } function inputs() { var a = state.a, w = state.wi; var p = { province: a.province || 'ON', age: +a.age, household: a.household, partner: a.household === 'couple' ? { age: +a.partner.age, income: +a.partner.income || 0, rrsp: +a.partner.rrsp || 0, tfsa: +a.partner.tfsa || 0, nonreg: +a.partner.nonreg || 0, monthlyContribution: +a.partner.monthlyContribution || 0 } : null, income: +a.income || 0, rrsp: +a.rrsp || 0, tfsa: +a.tfsa || 0, nonreg: +a.nonreg || 0, monthlyContribution: +(w.ctrb != null ? w.ctrb : a.monthlyContribution) || 0, employerMatchMonthly: +a.employerMatchMonthly || 0, retireAge: +(w.ret != null ? w.ret : a.retireAge), spendingAnnual: +(w.spend != null ? w.spend : a.spendingAnnual) || 0, cppStartAge: +(w.cpp != null ? w.cpp : a.cppStartAge), oasStartAge: +a.oasStartAge, pensionAnnual: +a.pensionAnnual || 0, rentalAnnual: +a.rentalAnnual || 0, otherAnnual: +a.otherAnnual || 0, realReturnPre: (+a.realReturnPre) / 100, realReturnPost: (+a.realReturnPost) / 100, inflation: (+a.inflation) / 100, lifeExp: +a.lifeExp }; /* Optional federal public service pension (PSSA): when enabled, it * replaces the manual DB-pension input and feeds pensionAnnual / * pensionStartAge / pensionBridgeAnnual into the engine. Recomputed for * the effective retireAge so what-if scenarios stay accurate. */ var ps = psCompute(p.retireAge); if (ps) { p.pensionAnnual = ps.pensionAtRetirement; p.pensionStartAge = ps.startAge; p.pensionBridgeAnnual = ps.bridge; } else { p.pensionStartAge = 0; p.pensionBridgeAnnual = 0; } return p; } function psOn() { return !!state.a.psPension; } /* Auto-derived pensionable service (PS-AUTO-SERVICE): retirement age − age at join, from current age + join year. Null when inputs are missing/invalid. */ var PS_CUR_YEAR = new Date().getFullYear(); function psAutoServiceYears() { if (typeof PS === 'undefined' || !PS.autoServiceYears) return null; return PS.autoServiceYears(state.a.age, state.a.psJoinYear, state.a.retireAge, PS_CUR_YEAR); } /* EFFECTIVE service years — the single contract the engine + summary lines must use: user override when set, else the auto derivation (null = not derivable yet). */ function psServiceEffective() { var o = state.a.psServiceOverride; if (o != null && isFinite(+o)) return +o; return psAutoServiceYears(); } function psOverrideActive() { var o = state.a.psServiceOverride; return o != null && isFinite(+o) && psAutoServiceYears() != null; } function psAugment(p) { /* re-derive PS pension for a scenario's retireAge */ var ps = psCompute(p.retireAge); if (ps) { p.pensionAnnual = ps.pensionAtRetirement; p.pensionStartAge = ps.startAge; p.pensionBridgeAnnual = ps.bridge; } return p; } function psCompute(retireAge) { var a = state.a; if (!a.psPension || typeof PS === 'undefined') return null; var avg = +a.psAvgSalary || 0; if (!avg) return null; return PS.computePSPension({ joinYear: +a.psJoinYear || 0, retireAge: retireAge, /* EFFECTIVE value: override when set, else auto-derivation (never null while the module is computable — PS-AUTO-SERVICE). */ serviceYears: +psServiceEffective() || 0, avgSalary: avg, ampe: (typeof CANADA !== 'undefined' && CANADA.ympe && CANADA.ympe.value) || 74600 }); } /* ---------- formatting (i18n.js: Intl.NumberFormat, locale-aware) ---------- */ function pct2(v) { return fmtPct(v / 100, 2); } /* slider values arrive as plain numbers: 3.5 → 3.5% */ /* ---------- steps ---------- */ function couple() { return state.a.household === 'couple'; } var STEPS = [ { key: 'province', titleK: 'q.province.title', hintK: 'q.province.hint', type: 'grid', opts: PROVINCES, get: function () { return state.a.province; }, set: function (v) { state.a.province = v; } }, { key: 'age', titleK: 'q.age.title', hintK: 'q.age.hint', type: 'number', min: 18, max: 74, get: function () { return state.a.age; }, set: function (v) { state.a.age = +v; } }, { key: 'household', titleK: 'q.household.title', hintK: 'q.household.hint', type: 'opts', opts: [['single', 'q.household.single'], ['couple', 'q.household.couple']], get: function () { return state.a.household; }, set: function (v) { state.a.household = v; } }, { key: 'p_age', show: couple, titleK: 'q.p_age.title', type: 'number', min: 18, max: 90, get: function () { return state.a.partner.age; }, set: function (v) { state.a.partner.age = +v; } }, { key: 'p_income', show: couple, titleK: 'q.p_income.title', hintK: 'q.p_income.hint', type: 'number', min: 0, max: 1e6, money: true, get: function () { return state.a.partner.income; }, set: function (v) { state.a.partner.income = +v; } }, { key: 'p_savings', show: couple, titleK: 'q.p_savings.title', hintK: 'q.p_savings.hint', type: 'fields', fields: [ { labelK: 'q.p_savings.rrsp', key: 'rrsp' }, { labelK: 'q.p_savings.tfsa', key: 'tfsa' }, { labelK: 'q.p_savings.nonreg', key: 'nonreg' } ], get: function (f) { return state.a.partner[f]; }, set: function (f, v) { state.a.partner[f] = +v; } }, { key: 'income', titleK: 'q.income.title', hintK: 'q.income.hint', type: 'number', min: 0, max: 1e6, money: true, get: function () { return state.a.income; }, set: function (v) { state.a.income = +v; } }, { key: 'rrsp', titleK: 'q.rrsp.title', type: 'number', min: 0, max: 2e7, money: true, get: function () { return state.a.rrsp; }, set: function (v) { state.a.rrsp = +v; } }, { key: 'savings2', titleK: 'q.savings2.title', type: 'fields', fields: [ { labelK: 'q.savings2.tfsa', key: 'tfsa' }, { labelK: 'q.savings2.nonreg', key: 'nonreg' } ], get: function (f) { return state.a[f]; }, set: function (f, v) { state.a[f] = +v; } }, { key: 'contrib', titleK: 'q.contrib.title', hintK: 'q.contrib.hint', type: 'fields', fields: [ { labelK: 'q.contrib.you', key: 'monthlyContribution' }, { labelK: 'q.contrib.match', key: 'employerMatchMonthly' } ], get: function (f) { return state.a[f]; }, set: function (f, v) { state.a[f] = +v; } }, { key: 'retire', titleK: 'q.retire.title', hintK: 'q.retire.hint', type: 'slider', min: 55, max: 72, step: 1, fmt: function (v) { return t('q.retire.years', { v: v }); }, get: function () { return state.a.retireAge; }, set: function (v) { state.a.retireAge = +v; } }, { key: 'spend', titleK: 'q.spend.title', hintK: 'q.spend.hint', type: 'number', min: 10000, max: 5e5, money: true, get: function () { return state.a.spendingAnnual; }, set: function (v) { state.a.spendingAnnual = +v; } }, { key: 'cppstart', titleK: 'q.cppstart.title', hint: function () { var m = { 60: 'q.cppstart.m60', 65: 'q.cppstart.m65', 70: 'q.cppstart.m70' }; return t('q.cppstart.hint', { age: state.a.cppStartAge, adj: t(m[state.a.cppStartAge] || 'q.cppstart.mCustom') }); }, type: 'opts', opts: [[60, '60', 'q.cppstart.os60'], [65, '65', 'q.cppstart.os65'], [70, '70', 'q.cppstart.os70']], get: function () { return state.a.cppStartAge; }, set: function (v) { state.a.cppStartAge = +v; } }, { key: 'oasstart', titleK: 'q.oasstart.title', hintK: 'q.oasstart.hint', type: 'opts', opts: [[65, '65', 'q.oasstart.os65'], [70, '70', 'q.oasstart.os70']], get: function () { return state.a.oasStartAge; }, set: function (v) { state.a.oasStartAge = +v; } }, { key: 'ps', titleK: 'q.ps.title', hintK: 'q.ps.hint', type: 'opts', opts: [['yes', 'q.ps.yes', 'q.ps.yesD'], ['no', 'q.ps.no', 'q.ps.noD']], get: function () { return state.a.psPension ? 'yes' : 'no'; }, set: function (v) { state.a.psPension = v === 'yes'; } }, { key: 'ps_join', show: psOn, titleK: 'q.ps_join.title', hintK: 'q.ps_join.hint', type: 'number', min: 1950, max: 2026, get: function () { return state.a.psJoinYear; }, set: function (v) { state.a.psJoinYear = +v; } }, { key: 'ps_service', show: psOn, titleK: 'q.ps_service.title', hint: function () { var auto = psAutoServiceYears(), o = state.a.psServiceOverride; if (o != null && isFinite(+o)) { return t('q.ps_service.override', { years: fmtInt(+o), auto: auto == null ? '—' : fmtInt(auto) }); } if (auto != null) { return t('q.ps_service.auto', { join: state.a.psJoinYear, joinAge: fmtInt(+state.a.age - (PS_CUR_YEAR - +state.a.psJoinYear)), ret: state.a.retireAge, years: fmtInt(auto) }); } return t('q.ps_service.hint'); }, type: 'number', min: 0, max: 60, /* auto-derivation clamps to 60 (engine caps 35) */ get: function () { var e = psServiceEffective(); return e == null ? '' : e; }, set: function (v) { state.a.psServiceOverride = (v === '' || v == null || isNaN(+v)) ? null : +v; } }, { key: 'ps_salary', show: psOn, titleK: 'q.ps_salary.title', hint: function () { var r = state.a.psItLevel && PS && PS.itRange(state.a.psItLevel); if (r) return t('q.ps_salary.hintIt', { lo: fmtMoneyK(r.min), hi: fmtMoneyK(r.top), asof: r.asof }); return t('q.ps_salary.hint'); }, type: 'number', min: 0, max: 1e6, money: true, get: function () { return state.a.psAvgSalary; }, set: function (v) { state.a.psAvgSalary = +v; } }, { key: 'ps_occ', show: psOn, titleK: 'q.ps_occ.title', hintK: 'q.ps_occ.hint', type: 'opts', opts: [['it', 'q.ps_occ.it', 'q.ps_occ.itD'], ['other', 'q.ps_occ.other', 'q.ps_occ.otherD']], get: function () { return state.a.psOccupation; }, set: function (v) { state.a.psOccupation = v; } }, { key: 'ps_it', show: function () { return psOn() && state.a.psOccupation === 'it'; }, titleK: function () { return state.a.psItLevel && state.a.psItLevel !== 'none' ? t('q.ps_it.stepTitle', { level: state.a.psItLevel }) : t('q.ps_it.title'); }, hint: function () { var r = state.a.psItLevel && state.a.psItLevel !== 'none' && PS.itRange(state.a.psItLevel); if (r) return t('q.ps_it.stepHint', { lo: fmtMoneyK(r.min), hi: fmtMoneyK(r.top), asof: r.asof }); return t('q.ps_it.hint', { asof: PS.IT_RATES_ASOF }); }, type: 'opts', opts: function () { if (state.a.psItLevel && state.a.psItLevel !== 'none') { var r = PS.itRange(state.a.psItLevel); return r.steps.map(function (v, i) { return [String(i + 1), fmtMoney(v), i === 0 ? 'q.ps_it.stepMin' : (i === r.steps.length - 1 ? 'q.ps_it.stepTop' : '')]; }); } return PS.IT_LEVELS.map(function (lv) { return [lv.id, lv.id, function () { var r = PS.itRange(lv.id); return fmtMoneyK(r.min) + ' – ' + fmtMoneyK(r.top); }]; }).concat([['none', 'q.ps_it.skip', 'q.ps_it.skipD']]); }, get: function () { if (state.a.psItLevel && state.a.psItLevel !== 'none' && state.a.psItStep) return String(state.a.psItStep); return state.a.psItLevel || ''; }, set: function (v) { if (state.a.psItLevel && state.a.psItLevel !== 'none' && /^\d+$/.test(v)) { var r = PS.itRange(state.a.psItLevel); state.a.psItStep = +v; state.a.psAvgSalary = r.steps[+v - 1]; /* step value = 5-yr average proxy */ } else { state.a.psItLevel = v; state.a.psItStep = 0; } } }, { key: 'extra', titleK: 'q.extra.title', hintK: 'q.extra.hint', type: 'fields', fields: [ { labelK: 'q.extra.pension', key: 'pensionAnnual' }, { labelK: 'q.extra.rental', key: 'rentalAnnual' }, { labelK: 'q.extra.other', key: 'otherAnnual' } ], get: function (f) { return state.a[f]; }, set: function (f, v) { state.a[f] = +v; } }, { key: 'assumptions', titleK: 'q.assumptions.title', hintK: 'q.assumptions.hint', type: 'sliders', sliders: [ { labelK: 'q.assumptions.pre', key: 'realReturnPre', min: 0, max: 8, step: 0.25, fmt: pct2 }, { labelK: 'q.assumptions.post', key: 'realReturnPost', min: 0, max: 6, step: 0.25, fmt: pct2 }, { labelK: 'q.assumptions.inf', key: 'inflation', min: 0, max: 5, step: 0.25, fmt: pct2 }, { labelK: 'q.assumptions.life', key: 'lifeExp', min: 80, max: 100, step: 1, fmt: function (v) { return t('q.assumptions.yrs', { v: v }); } } ], get: function (f) { return state.a[f]; }, set: function (f, v) { state.a[f] = +v; } }, { key: 'vision', titleK: 'q.vision.title', hintK: 'q.vision.hint', type: 'chips', opts: INTERESTS, get: function () { return state.a.interests; }, set: function (v) { state.a.interests = v; } } ]; function visibleSteps() { return STEPS.filter(function (s) { return !s.show || s.show(); }); } /* ---------- quiz rendering ---------- */ var scrQuiz = $('#scr-quiz'), stepBody = $('#step-body'), btnNext = $('#btn-next'); function showScreen(name) { state.screen = name; ['intro', 'quiz', 'results'].forEach(function (s) { $('#scr-' + s).hidden = s !== name; }); $('#btn-restart').hidden = name === 'intro'; syncScrim(); window.scrollTo(0, 0); } function renderStep() { var vs = visibleSteps(); if (state.step >= vs.length) state.step = vs.length - 1; var s = vs[state.step]; $('#prog-bar').style.width = Math.round((state.step / vs.length) * 100) + '%'; $('#step-count').textContent = t('quiz.stepCount', { i: state.step + 1, n: vs.length }); $('#btn-back').style.visibility = state.step === 0 ? 'hidden' : 'visible'; btnNext.textContent = state.step === vs.length - 1 ? t('quiz.seePlan') : t('quiz.next'); var hintTxt = typeof s.hint === 'function' ? s.hint() : (s.hintK ? t(s.hintK) : ''); var hintAttr = s.key === 'ps_service' ? ' id="ps-hint"' : ''; /* PS-AUTO-SERVICE: live-swap hook */ var titleTxt = typeof s.titleK === 'function' ? s.titleK() : t(s.titleK); var optList = typeof s.opts === 'function' ? s.opts() : s.opts; var h = '
' + hintTxt + '
' : ''); if (s.type === 'grid') { h += '' + t('q.extra.psNote', { v: pr ? money(pr.pensionAtRetirement) + t('unit.yr') : t('q.extra.psNotePending') }) + '
' + t('g1.cap') + '
'; }, '#g2': function () { return '' + t('g2.cap') + '
'; }, '#g3': function () { return '' + t('g3.cap') + '
'; }, '#g4': function () { return '' + t('g4.cap', { age: state.a.lifeExp }) + '
'; } }; function chartH(sel) { var hd = typeof CHART_HDR[sel] === 'function' ? CHART_HDR[sel]() : (CHART_HDR[sel] || ''); return '' + t('g2.empty') + '
'); return; } var ages = yrs.map(function (r) { return r.age; }); var X = sc(ages, ages[0], ages[ages.length - 1]); var series = [ [t('g2.cpp'), COL.cpp, yrs.map(function (r) { return r.cpp; })], [t('g2.oas'), COL.oas, yrs.map(function (r) { return Math.max(0, r.oasGross - r.oasClawback); })], [t('g2.gis'), COL.gis, yrs.map(function (r) { return r.gis; })] ]; if (yrs.some(function (r) { return (r.pension || 0) > 0; })) { series.push([t('g2.psPension'), COL.ps, yrs.map(function (r) { return r.pension || 0; })]); } series.push([t('g2.savings'), COL.wdr, yrs.map(function (r) { return r.rrifWithdrawal + r.nonregW + r.tfsaW; })]); var n = ages.length, tot = [], i, acc; for (i = 0; i < n; i++) tot[i] = series.reduce(function (s, se) { return s + se[2][i]; }, 0); var ymax = Math.max(1000, Math.max.apply(null, tot) * 1.08), Y = sy(ymax); var inner = axes(ages, ymax, X, Y); var lower = new Array(n).fill(0); series.forEach(function (se) { var upper = se[2].map(function (v, j) { return lower[j] + v; }); inner += '' + t('g4.calc') + '
'); setTimeout(function () { var scen = [ [t('g4.s0'), p], [t('g4.s1'), Object.assign({}, p, { monthlyContribution: p.monthlyContribution + 200, employerMatchMonthly: p.employerMatchMonthly })], [t('g4.s2'), psAugment(Object.assign({}, p, { retireAge: Math.min(p.retireAge + 2, 75) }))], [t('g4.s3'), Object.assign({}, p, { cppStartAge: 70 })] ]; var res = scen.map(function (s) { return [s[0], E.sustainableSpending(s[1]).annual]; }); g4cache = res; drawG4(res); var best = res.slice().sort(function (a, b) { return b[1] - a[1]; })[0]; $('#g4-note').textContent = t('g4.best', { name: best[0], money: money(best[1]) }); }, 30); } function drawG4(res) { var ymax = Math.max(1000, Math.max.apply(null, res.map(function (r) { return r[1]; })) * 1.15); var bw = (W - L - R) / res.length, inner = ''; for (var i = 0; i <= 4; i++) { var v = ymax * i / 4, y = sy(ymax)(v); inner += '| ' + r[0] + ' | ' + r[1] + ' |
' + notes.join(' ') + '
'; var asOf = (getLang() === 'fr' ? C.asOfFr : C.asOf), dPspc = '2025-08-15'; h += '' + t('ps.card.sources') + ' ' + '' + t('ps.card.srcPspc', { d: dPspc }) + ' · ' + '' + t('ps.card.srcGlance') + ' · ' + '' + t('ps.card.srcGloss') + ' · ' + '' + t('ps.card.srcCra') + ' · ' + '' + t('ps.card.srcYmpe', { v: m(ampe), y: C.ympe.year || 2026 }) + '' + (state.a.psItLevel && state.a.psItLevel !== 'none' ? ' · ' + t('ps.card.srcIt', { d: PS.IT_RATES_ASOF }) + '' : '') + ' · ' + t('ps.card.dataAsOf', { d: asOf }) + '
'; el.innerHTML = h; } /* ---------- results ---------- */ function renderResults(fast) { var p = inputs(), proj = E.project(p), rec; try { rec = E.recommendations(p, proj); } catch (e) { rec = { gapAnnual: 0 }; } var ss = E.sustainableSpending(p).annual; var v = $('#verdict'), vh = $('#verdict-h'); vh.textContent = t('r.verdictH'); if (proj.depletionAge) { v.className = 'verdict bad'; v.innerHTML = t('r.depletion', { age: fmtInt(proj.depletionAge), ss: money(ss), le: p.lifeExp, sp: money(p.spendingAnnual) }); } else { v.className = 'verdict good'; v.innerHTML = t('r.good', { le: p.lifeExp, bal: k(proj.finalBal), ss: money(ss), hr: money(ss - p.spendingAnnual) }); } /* lifeexp-lbl now lives inside the g4 card header (rendered below via chart('#g4')); the header bakes in state.a.lifeExp, so just sync if the node exists (re-renders). */ var le = $('#lifeexp-lbl'); if (le) le.textContent = p.lifeExp; var cards = ''; if (rec.gapAnnual > 0) { cards += '' + t('g.gapP') + '
' + t('g.saveP', { pct: pct(rec.extraMonthly / Math.max(1, p.income / 12)) }) + '
' : '' + t('g.saveP2') + '
') + '' + t('g.workP', { at: Math.min(p.retireAge + rec.retireDelayYears, 75), now: p.retireAge }) + '
' : '' + t('g.workP2') + '
') + '' + t('g.trimP', { to: money(Math.max(0, p.spendingAnnual - rec.spendingReduction)) }) + '
' + t('g.statusP', { sp: money(p.spendingAnnual) }) + '
' + t('g.headP') + '
' + t('g.stressP', { age: p.lifeExp }) + '