Files
retire-planner/site/app.js
T
krisf 1b194b6f92 Round 2 mobile pass: CTA pinned above bottom sheet, swipe hint in chart header, anchor scroll-margin
- Issue 1: --sheet-h CSS var (live sheet height via syncSheetH: load/toggle/resize/
  panel-content), .scr clearance and sticky #btn-next/.step-nav above the sheet;
  income short step pixel-identical to round 1 (CTA top 485).
- Issue 2: chart() helper renders .chart-h (h3+p + .swipe span) into #g1-#g4;
  .chartbox::before overlay removed; hint is flex sibling in header, sticky left.
- Issue 3: [id]{scroll-margin-top:70px} so #whatif anchor lands below sticky hdr.
- G2 tick clip and Four-futures scroll documented as non-issues in report.
- verify2.py: 90/90 PASS live; before/after evidence in audit/mobile-{before,after}2/.

Spec: MOBILE-FIX-2.md. Report: MOBILE-FIX-2-REPORT.md.
2026-10-09 06:44:08 -04:00

682 lines
40 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Retire Planner UI — vanilla, no build, no network except same-origin vision manifest.
All computation via window.RetireEngine. Answers persist in localStorage + URL hash. */
'use strict';
(function () {
var E = window.RetireEngine, C = window.RetireCanada;
var LS = 'retire-planner-v1';
var $ = function (s) { return document.querySelector(s); };
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 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']
];
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'] };
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.'
};
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,
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); 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 */ } }
})();
function save() {
var t = { a: state.a, step: state.step, screen: state.screen };
try { localStorage.setItem(LS, JSON.stringify(t)); } catch (e) { }
}
function encState() { return btoa(unescape(encodeURIComponent(JSON.stringify({ a: state.a, step: 0 })))); }
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
};
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) + '%'; }
/* ---------- steps ---------- */
function couple() { return state.a.household === 'couple'; }
var STEPS = [
{
key: 'province', title: 'Where do you live?', hint: 'Taxes, and your retirement view.',
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,
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']],
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,
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.',
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.',
type: 'fields', fields: [
{ label: 'RRSP / RRIF', key: 'rrsp' }, { label: 'TFSA', key: 'tfsa' }, { label: 'Non-registered', 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.',
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,
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' }
],
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' }
],
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'; },
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.',
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');
},
type: 'opts', opts: [[60, '60', '−36% for life'], [65, '65', 'full rate'], [70, '70', '+42% for life']],
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']],
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' }
],
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'; } }
],
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.',
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(); }); }
/* ---------- 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 = (state.step + 1) + ' / ' + vs.length;
$('#btn-back').style.visibility = state.step === 0 ? 'hidden' : 'visible';
btnNext.textContent = state.step === vs.length - 1 ? 'See my plan →' : 'Next →';
var hintTxt = typeof s.hint === 'function' ? s.hint() : (s.hint || '');
var h = '<h2>' + s.title + '</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>';
}).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>';
}).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>';
}).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>';
} 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>';
}).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>';
}).join('');
}
stepBody.innerHTML = h;
stepBody.querySelectorAll('.opt').forEach(function (b) {
b.onclick = function () {
if (s.type === 'chips') {
var arr = (s.get() || []).slice();
var i = arr.indexOf(b.dataset.v);
if (i >= 0) arr.splice(i, 1); else arr.push(b.dataset.v);
s.set(arr); b.classList.toggle('sel');
} else {
s.set(b.dataset.v);
stepBody.querySelectorAll('.opt').forEach(function (x) { x.classList.remove('sel'); });
b.classList.add('sel');
}
save(); liveUpdate(); renderStep();
};
});
var f1 = $('#f1');
if (f1) {
f1.oninput = function () { s.set(f1.value); save(); liveUpdate(); if (s.type === 'slider') $('#sv').textContent = s.fmt(+f1.value); };
if (s.type === 'slider') f1.oninput();
/* Fix 8: Enter on a number step = press Next (when enabled). */
if (s.type === 'number') f1.onkeydown = function (e) { if (e.key === 'Enter' && !btnNext.disabled) btnNext.click(); };
}
stepBody.querySelectorAll('input[type=number][data-k]').forEach(function (inp) {
inp.oninput = function () { s.set(inp.dataset.k, inp.value); save(); liveUpdate(); };
});
stepBody.querySelectorAll('input[type=range][data-k]').forEach(function (inp) {
inp.oninput = function () {
s.set(inp.dataset.k, inp.value); save(); liveUpdate();
var sl = s.sliders.filter(function (x) { return x.key === inp.dataset.k; })[0];
inp.previousElementSibling.lastElementChild.textContent = sl.fmt(+inp.value);
};
});
validate(s);
}
function validate(s) {
var ok = true, msg = '';
if (s.type === 'number') {
var v = +s.get();
if (s.get() === null || s.get() === '' || isNaN(v) || v < s.min || v > s.max) {
ok = false; msg = 'Enter a number between ' + s.min + ' and ' + s.max + '.';
}
} else if (s.type === 'grid' && !s.get()) { ok = false; msg = 'Pick a province to continue.'; }
btnNext.disabled = !ok;
var old = stepBody.querySelector('.err'); if (old) old.remove();
if (!ok && msg) { var d = document.createElement('div'); d.className = 'err'; d.textContent = msg; stepBody.appendChild(d); }
}
/* ---------- live panel ---------- */
var liveTimer = null;
function liveUpdate() { clearTimeout(liveTimer); liveTimer = setTimeout(updatePanel, 120); }
function updatePanel() {
var p = inputs(), proj;
try { proj = E.project(p); } catch (e) { return; }
var draw = proj.years.filter(function (r) { return r.phase === 'draw'; });
var atRet = proj.years.filter(function (r) { return r.age === Math.floor(p.retireAge); })[0] || draw[0];
var g67 = proj.years.filter(function (r) { return r.age === 67; })[0] || { cpp: 0, oasGross: 0 };
var chips = [
['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), '']
];
$('#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.';
$('#panel-insight').innerHTML = ins;
syncSheetH(); /* panel content just changed height — keep --sheet-h live (round 2 Fix 1) */
}
/* ---------- charts (hand-rolled inline SVG) ---------- */
var W = 640, H = 270, L = 52, R = 12, T = 14, B = 30;
function sc(ages, a0, a1) { return function (a) { return L + (a - a0) / (a1 - a0) * (W - L - R); }; }
function sy(ymax) { return function (v) { return H - B - Math.max(0, Math.min(1, v / ymax)) * (H - T - B); }; }
function pathX(ages, ys, X, Y) {
return ys.map(function (v, i) { return (i ? 'L' : 'M') + X(ages[i]).toFixed(1) + ' ' + Y(v).toFixed(1); }).join('');
}
function areaPath(ages, vals, X, Y) {
var p = 'M' + X(ages[0]).toFixed(1) + ' ' + Y(0).toFixed(1);
ages.forEach(function (a, i) { p += 'L' + X(a).toFixed(1) + ' ' + Y(vals[i]).toFixed(1); });
return p + 'L' + X(ages[ages.length - 1]).toFixed(1) + ' ' + Y(0).toFixed(1) + 'Z';
}
function stackPath(ages, lower, upper, X, Y) {
var p = 'M' + X(ages[0]).toFixed(1) + ' ' + Y(upper[0]).toFixed(1);
for (var i = 1; i < ages.length; i++) p += 'L' + X(ages[i]).toFixed(1) + ' ' + Y(upper[i]).toFixed(1);
for (i = ages.length - 1; i >= 0; i--) p += 'L' + X(ages[i]).toFixed(1) + ' ' + Y(lower[i]).toFixed(1);
return p + 'Z';
}
function axes(ages, ymax, X, Y, opts) {
var a0 = ages[0], a1 = ages[ages.length - 1], s = '';
var yt = 4, i;
for (i = 0; i <= yt; i++) {
var v = ymax * i / yt, y = Y(v);
s += '<line x1="' + L + '" x2="' + (W - R) + '" y1="' + y.toFixed(1) + '" y2="' + y.toFixed(1) + '" stroke="#eef2f8"/>' +
'<text x="' + (L - 6) + '" y="' + (y + 4).toFixed(1) + '" text-anchor="end" font-size="13" fill="#8a97ad">' + (opts && opts.money === false ? 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="' + (W / 2) + '" y="' + (H - 4) + '" text-anchor="middle" font-size="12" fill="#8a97ad">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>';
}).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>';
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>'; }
};
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>';
}
function chart(sel, inner, legendItems) {
$(sel).innerHTML = chartH(sel) + (inner || '') + (legendItems ? legend(legendItems) : '');
}
var COL = { bal: '#2563eb', cpp: '#2563eb', oas: '#dc2626', gis: '#f59e0b', wdr: '#10b981', cash: '#0ea5e9', need: '#dc2626' };
function g1_balance(proj) {
var yrs = proj.years, ages = yrs.map(function (r) { return r.age; });
var vals = yrs.map(function (r) { return r.totalBal; });
var a0 = ages[0], a1 = ages[ages.length - 1];
var X = sc(ages, a0, a1), ymax = Math.max(10000, Math.max.apply(null, vals) * 1.08), Y = sy(ymax);
var inner = axes(ages, ymax, X, Y);
inner += '<path d="' + areaPath(ages, vals, X, Y) + '" fill="#2563eb22"/>' +
'<path d="' + pathX(ages, vals, X, Y) + '" fill="none" stroke="' + COL.bal + '" stroke-width="2.5"/>';
if (proj.depletionAge) {
var x = X(proj.depletionAge);
inner += '<line x1="' + x + '" x2="' + x + '" y1="' + T + '" y2="' + (H - B) + '" stroke="#dc2626" stroke-dasharray="4 4" stroke-width="2"/>' +
'<text x="' + (x + 5) + '" y="' + (T + 12) + '" font-size="13" fill="#dc2626" font-weight="700">runs out ≈ ' + 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]]);
}
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; }
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; })]
];
var n = ages.length, tot = [], i, acc;
for (i = 0; i < n; i++) tot[i] = series.reduce(function (s, se) { return s + se[2][i]; }, 0);
var ymax = Math.max(1000, Math.max.apply(null, tot) * 1.08), Y = sy(ymax);
var inner = axes(ages, ymax, X, Y);
var lower = new Array(n).fill(0);
series.forEach(function (se) {
var upper = se[2].map(function (v, j) { return lower[j] + v; });
inner += '<path d="' + stackPath(ages, lower, upper, X, Y) + '" fill="' + se[1] + '33" stroke="' + se[1] + '" stroke-width="1"/>';
lower = upper;
});
chart('#g2', svgWrap(inner), series.map(function (s) { return [s[0], s[1]]; }));
}
function g3_cash(proj) {
var yrs = proj.years.filter(function (r) { return r.phase === 'draw'; });
if (!yrs.length) { chart('#g3', ''); return; }
var ages = yrs.map(function (r) { return r.age; });
var X = sc(ages, ages[0], ages[ages.length - 1]);
var inc = yrs.map(function (r) { return r.cash + r.unfunded; });
var need = yrs.map(function (r) { return r.need; });
var ymax = Math.max(1000, Math.max(Math.max.apply(null, inc), Math.max.apply(null, need)) * 1.12), Y = sy(ymax);
var inner = axes(ages, ymax, X, Y);
inner += '<path d="' + pathX(ages, inc, X, Y) + '" fill="none" stroke="' + COL.cash + '" stroke-width="2.5"/>';
inner += '<path d="' + pathX(ages, need, X, Y) + '" fill="none" stroke="' + COL.need + '" stroke-width="2" stroke-dasharray="6 4"/>';
var first = yrs.map(function (r, i) { return r.unfunded > 1 ? ages[i] : null; }).filter(function (x) { return x; })[0];
if (first) {
inner += '<circle cx="' + X(first) + '" cy="' + Y(need[ages.indexOf(first)]) + '" r="4.5" fill="#dc2626"/>' +
'<text x="' + (X(first) + 6) + '" y="' + (Y(need[ages.indexOf(first)]) - 6) + '" font-size="13" fill="#dc2626" font-weight="700">shortfall from ~' + first + '</text>';
}
chart('#g3', svgWrap(inner), [['Cash you’ll have (after tax)', COL.cash], ['Planned spending', 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>');
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 })]
];
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.';
}, 30);
}
function drawG4(res) {
var ymax = Math.max(1000, Math.max.apply(null, res.map(function (r) { return r[1]; })) * 1.15);
var bw = (W - L - R) / res.length, inner = '';
for (var i = 0; i <= 4; i++) {
var v = ymax * i / 4, y = sy(ymax)(v);
inner += '<line x1="' + L + '" x2="' + (W - R) + '" y1="' + y + '" y2="' + y + '" stroke="#eef2f8"/>' +
'<text x="' + (L - 6) + '" y="' + (y + 4) + '" text-anchor="end" font-size="13" fill="#8a97ad">' + k(v) + '</text>';
}
res.forEach(function (r, i) {
var hgt = (H - T - B) * r[1] / ymax, x = L + i * bw + bw * 0.16, y = H - B - hgt;
var col = i === 0 ? '#94a3b8' : (r[1] >= res[0][1] ? '#059669' : '#d97706');
inner += '<rect x="' + x + '" y="' + y + '" width="' + bw * 0.68 + '" height="' + hgt + '" rx="6" fill="' + col + '"/>' +
'<text x="' + (x + bw * 0.34) + '" y="' + (y - 6) + '" text-anchor="middle" font-size="13.5" font-weight="700" fill="#16233b">' + k(r[1]) + '</text>' +
'<text x="' + (x + bw * 0.34) + '" y="' + (H - B + 14) + '" text-anchor="middle" font-size="12" fill="#8a97ad">' + r[0] + '</text>';
});
chart('#g4', svgWrap(inner));
}
/* ---------- vision ---------- */
var visionManifest = null;
/* Fix 6: a fresh/direct load into results can render before the manifest lands.
When it resolves (or fails, or times out at 2.5s — then we just keep the gradient),
re-render the vision card if results is on screen, so real images replace the fallback. */
var visionReady = (function () {
var p = fetch('vision/manifest.json').then(function (r) { return r.ok ? r.json() : null; });
var timeout = new Promise(function (res) { setTimeout(function () { res(null); }, 2500); });
return Promise.race([p, timeout]);
})();
visionReady
.then(function (m) { visionManifest = m || null; })
.catch(function () { visionManifest = visionManifest || null; })
.then(function () { if (state.screen === 'results') renderVision(state.a); });
function renderVision(a) {
var el = $('#vision-card');
var provSlugs = PROV_SLUGS[a.province] || [];
var picks = a.interests || [];
function score(slug) {
var s = 0;
if (picks.indexOf(slug) >= 0) s += 3;
if (provSlugs.indexOf(slug) >= 0) s += 2;
if (slug === 'hero' && s === 0) s += 0.5;
return s;
}
var keys = visionManifest ? Object.keys(visionManifest) : null;
if (!keys) { keys = Object.keys(CAPTIONS); }
var best = keys.map(function (s) { return [s, score(s)]; }).sort(function (x, y) { return y[1] - x[1]; })[0];
if (!best) best = ['hero', 0];
var slug = best[1] > 0 ? best[0] : 'hero';
var cap = CAPTIONS[slug] || CAPTIONS.hero;
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>';
var im = el.querySelector('img');
im.onerror = function () { grad(el, slug, cap); };
} else grad(el, slug, cap);
}
function grad(el, slug, cap) {
el.className = 'vision grad';
var h = 0; for (var i = 0; i < slug.length; i++) h = (h * 31 + slug.charCodeAt(i)) >>> 0;
el.style.background = GRADS[h % GRADS.length];
el.innerHTML = '<div class="vcap"><b>Retirement Vision — ' + slug.replace(/^\w/, function (c) { return c.toUpperCase(); }) + '</b><br>' + cap + '</div>';
}
/* ---------- 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');
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.';
} 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.';
}
/* lifeexp-lbl now lives inside the g4 card header (rendered below via chart('#g4'));
the header bakes in state.a.lifeExp, so just sync if the node exists (re-renders). */
var le = $('#lifeexp-lbl'); if (le) le.textContent = p.lifeExp;
var cards = '';
if (rec.gapAnnual > 0) {
cards += '<div class="gcard warn"><h4>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>';
} 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>';
}
$('#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 n = 0;
proj.years.forEach(function (r) {
if (r.phase !== 'draw' && r.age % 5 !== 0) return; n++;
tbl += '<tr class="' + (r.unfunded > 1 ? 'dep' : '') + '"><td>' + r.age + '</td><td>' + k(r.rrspBal) + '</td><td>' + k(r.tfsaBal) +
'</td><td>' + k(r.nonregBal) + '</td><td><b>' + k(r.totalBal) + '</b></td><td>' + k(r.rrifWithdrawal + r.nonregW + r.tfsaW) +
'</td><td>' + k(r.cpp) + '</td><td>' + k(r.oasGross - r.oasClawback) + '</td><td>' + k(r.gis) + '</td><td>' + k(r.tax + r.oasClawback) +
'</td><td>' + (r.unfunded > 1 ? k(r.unfunded) : '—') + '</td></tr>';
});
$('#yr-table').innerHTML = tbl; $('#yr-n').textContent = 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 a = state.a;
$('#wi-spend').value = (state.wi.spend != null ? state.wi.spend : a.spendingAnnual);
$('#wi-ctrb').value = (state.wi.ctrb != null ? state.wi.ctrb : a.monthlyContribution);
$('#wi-ret').value = (state.wi.ret != null ? state.wi.ret : a.retireAge);
$('#wi-cpp').value = (state.wi.cpp != null ? state.wi.cpp : a.cppStartAge);
wiLabels();
renderVision(a);
save();
}
function wiLabels() {
$('#wi-spend-o').textContent = money(+$('#wi-spend').value);
$('#wi-ctrb-o').textContent = money(+$('#wi-ctrb').value) + '/mo';
$('#wi-ret-o').textContent = $('#wi-ret').value + ' yrs';
$('#wi-cpp-o').textContent = 'age ' + $('#wi-cpp').value;
}
var wiTimer = null;
function wiChange(key, el) {
state.wi[key] = +el.value; wiLabels();
clearTimeout(wiTimer); wiTimer = setTimeout(function () { renderResults(true); }, 250);
}
$('#wi-spend').addEventListener('input', function () { wiChange('spend', this); });
$('#wi-ctrb').addEventListener('input', function () { wiChange('ctrb', this); });
$('#wi-ret').addEventListener('input', function () { wiChange('ret', this); });
$('#wi-cpp').addEventListener('input', function () { wiChange('cpp', this); });
/* ---------- wiring ---------- */
$('#btn-begin').onclick = function () { showScreen('quiz'); renderStep(); liveUpdate(); };
$('#btn-back').onclick = function () { if (state.step > 0) { state.step--; save(); renderStep(); } };
btnNext.onclick = function () {
var vs = visibleSteps();
if (state.step < vs.length - 1) { state.step++; save(); renderStep(); }
else { g4cache = null; showScreen('results'); renderResults(false); }
};
$('#btn-edit').onclick = function () { showScreen('quiz'); renderStep(); liveUpdate(); };
$('#btn-restart').onclick = function () {
if (!confirm('Start over? Clears saved answers in this browser.')) return;
try { localStorage.removeItem(LS); } catch (e) { }
history.replaceState(null, '', location.pathname);
state.a = defaults(); state.wi = {}; state.step = 0; g4cache = null;
showScreen('intro');
};
$('#btn-print').onclick = function () { window.print(); };
$('#btn-share').onclick = function () {
var url = location.origin + location.pathname + '#p=' + encState();
var restore = function () { $('#btn-share').textContent = '🔗 Copy link with my answers'; };
var done = function () { $('#btn-share').textContent = '✅ Link 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';
setTimeout(restore, 1800);
};
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(url).then(done, fail);
else fail();
};
document.addEventListener('click', function (e) {
var h = document.querySelector('.panel-h');
if (h && h.contains(e.target)) {
document.getElementById('panel').classList.toggle('open');
syncScrim();
syncSheetH();
}
});
/* Fix 3: scrim behind the open sheet on mobile; tapping it closes the sheet. */
function syncScrim() {
var scrim = document.getElementById('scrim');
if (!scrim) return;
var open = document.getElementById('panel').classList.contains('open');
scrim.hidden = !open;
scrim.classList.toggle('on', open);
}
/* Round 2 Fix 1: publish the live sheet height as --sheet-h so .scr padding and the sticky
step-nav always clear the sheet (default 218px on :root keeps the first paint right).
Shim-safe: smoke-test stubs have no offsetHeight — the guard skips them. */
function syncSheetH() {
var panel = document.getElementById('panel');
var h = panel && panel.offsetHeight;
if (h) document.documentElement.style.setProperty('--sheet-h', h + 'px');
}
if (window.addEventListener) window.addEventListener('resize', syncSheetH);
var scrimEl = document.getElementById('scrim');
if (scrimEl) scrimEl.addEventListener('click', function () {
document.getElementById('panel').classList.remove('open');
syncScrim();
syncSheetH();
});
var savedScreen = state.screen, savedStep = state.step;
showScreen(savedScreen);
if (savedScreen === 'quiz') { renderStep(); liveUpdate(); }
else if (savedScreen === 'results') { renderResults(false); liveUpdate(); }
syncSheetH();
})();