ps: auto-derive years of service from join year + retirement age

This commit is contained in:
krisf committed 2026-10-09 15:41:58 -04:00
1 parent a0fbb390b5
commit 97cecb91a7
17 files changed
+667 -18

No files matched your search

+66 -6
View File
@@ -51,7 +51,7 @@
retireAge: 65, spendingAnnual: 50000,
cppStartAge: 65, oasStartAge: 65,
pensionAnnual: 0, rentalAnnual: 0, otherAnnual: 0,
psPension: false, psJoinYear: null, psServiceYears: null, psAvgSalary: null,
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: []
@@ -65,6 +65,11 @@
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() };
@@ -106,6 +111,24 @@
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; }
@@ -118,7 +141,9 @@
if (!avg) return null;
return PS.computePSPension({
joinYear: +a.psJoinYear || 0, retireAge: retireAge,
serviceYears: +a.psServiceYears || 0, avgSalary: avg,
/* 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
});
}
@@ -215,8 +240,24 @@
get: function () { return state.a.psJoinYear; }, set: function (v) { state.a.psJoinYear = +v; }
},
{
key: 'ps_service', show: psOn, titleK: 'q.ps_service.title', hintK: 'q.ps_service.hint', type: 'number', min: 0, max: 45,
get: function () { return state.a.psServiceYears; }, set: function (v) { state.a.psServiceYears = +v; }
key: 'ps_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',
@@ -314,9 +355,10 @@
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 = '<h2>' + titleTxt + '</h2>' + (hintTxt ? '<p class="hint">' + hintTxt + '</p>' : '');
var h = '<h2>' + titleTxt + '</h2>' + (hintTxt ? '<p class="hint"' + hintAttr + '>' + hintTxt + '</p>' : '');
if (s.type === 'grid') {
h += '<div class="opts">' + optList.map(function (o) {
@@ -334,6 +376,12 @@
}).join('') + '</div>';
} else if (s.type === 'number') {
h += '<div class="fld"><input type="number" id="f1" value="' + (s.get() == null ? '' : s.get()) + '" min="' + (s.min != null ? s.min : 0) + '" max="' + (s.max != null ? s.max : 1e9) + '" inputmode="numeric" ' + (s.money ? 'placeholder="0"' : 'placeholder="' + fmtInt(s.max) + '"') + '></div>';
/* PS-AUTO-SERVICE: when the user has overridden the auto-derived service, offer the
CPP-"Use maximum"-style reset back to the calculated value. Always in the DOM so
oninput can reveal/hide it live (real browsers only re-render on step change). */
if (s.key === 'ps_service') {
h += '<p class="hint" id="ps-svc-reset" style="margin:8px 0 0' + (psOverrideActive() ? '' : ';display:none') + '"><button type="button" class="link" id="ps-usecalc">' + t('q.ps_service.useCalc') + '</button></p>';
}
} else if (s.type === 'fields') {
h += s.fields.map(function (f, i) {
if (f.key === 'pensionAnnual' && psOn()) {
@@ -369,11 +417,23 @@
});
var f1 = $('#f1');
if (f1) {
f1.oninput = function () { s.set(f1.value); save(); liveUpdate(); validate(s); if (s.type === 'slider') $('#sv').textContent = s.fmt(+f1.value); };
f1.oninput = function () {
s.set(f1.value); save(); liveUpdate(); validate(s);
if (s.type === 'slider') $('#sv').textContent = s.fmt(+f1.value);
/* PS-AUTO-SERVICE: real browsers keep the rendered hint on typing (no re-render until
step change) — swap the derivation hint + reveal the reset button live. */
if (s.key === 'ps_service') {
var he = $('#ps-hint'); if (he) he.textContent = s.hint();
var rb = $('#ps-svc-reset'); if (rb) rb.style.display = psOverrideActive() ? '' : 'none';
}
};
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(); };
}
/* PS-AUTO-SERVICE: "Use calculated" clears the override → auto value flows to engine + summary. */
var uc = $('#ps-usecalc');
if (uc) uc.onclick = function () { state.a.psServiceOverride = null; save(); liveUpdate(); renderStep(); };
stepBody.querySelectorAll('input[type=number][data-k]').forEach(function (inp) {
inp.oninput = function () { s.set(inp.dataset.k, inp.value); save(); liveUpdate(); validate(s); };
});
+20 -1
View File
@@ -87,6 +87,25 @@
return y <= GROUP1_CUTOFF_YEAR ? 1 : 2;
}
/* Auto-derive years of pensionable service (PS-AUTO-SERVICE):
* yearsWorkedSoFar = currentYear − joinYear
* ageAtJoin = age − yearsWorkedSoFar
* serviceYears = retireAge − ageAtJoin, clamped to [0, 60] (whole years;
* the pension formula applies its own 35-yr MAX_SERVICE cap).
* currentYear defaults to the calendar year. Returns null when age, joinYear or
* retireAge is missing/invalid — the UI then falls back to manual entry. */
function autoServiceYears(age, joinYear, retireAge, currentYear) {
var y = +currentYear;
if (!(y > 0)) y = new Date().getFullYear();
var a = +age, j = +joinYear, r = +retireAge;
if (!(a > 0) || !(r > 0) || !(y > 0)) return null;
if (!(j >= 1900 && j <= 2999)) return null;
if (!isFinite(a) || !isFinite(j) || !isFinite(r) || !isFinite(y)) return null;
var joinAge = a - (y - j);
var s = r - joinAge;
return Math.round(Math.max(0, Math.min(60, s)));
}
function itLevel(id) {
for (var i = 0; i < IT_LEVELS.length; i++) if (IT_LEVELS[i].id === id) return IT_LEVELS[i];
return null;
@@ -194,7 +213,7 @@
SOURCES: SOURCES,
psGroup: psGroup, psLifetimePension: psLifetimePension,
psBridgeBenefit: psBridgeBenefit, psEarlyReduction: psEarlyReduction,
computePSPension: computePSPension,
computePSPension: computePSPension, autoServiceYears: autoServiceYears,
itLevel: itLevel, itRange: itRange
};
});
+8 -2
View File
@@ -122,7 +122,10 @@
},
ps_service: {
title: 'Years of pensionable service',
hint: 'Recognized by PSPC (the calculation caps it at 35 years).'
hint: 'Recognized by PSPC (the calculation caps it at 35 years).',
auto: 'Calculated: joined {join} at age {joinAge}, retiring at {ret} → {years} years of service. Adjust only if you bought past service.',
override: 'Using your value ({years} years). The calculation from your join year gives {auto} years.',
useCalc: 'Use calculated'
},
ps_salary: {
title: 'Average salary — your 5 consecutive highest-paid years',
@@ -379,7 +382,10 @@
},
ps_service: {
title: 'Années de service ouvrant droit à pension',
hint: 'Reconnues par la PSCC (le calcul est plafonné à 35 années).'
hint: 'Reconnues par la PSCC (le calcul est plafonné à 35 années).',
auto: 'Calculé : entrée en poste en {join} à {joinAge} ans, départ à {ret} ans → {years} années de service. À ajuster seulement si vous avez acheté des services antérieurs.',
override: 'Votre valeur ({years} années) est utilisée. Le calcul à partir de votre année d\'entrée donne {auto} ans.',
useCalc: 'Utiliser le calcul'
},
ps_salary: {
title: 'Salaire moyen — vos 5 années consécutives les mieux rémunérées',
+1
View File
@@ -104,6 +104,7 @@ button,input{touch-action:manipulation}
#step-body{background:var(--card);border:1px solid var(--line);border-radius:var(--r);padding:18px;min-height:320px}
#step-body h2{margin-top:.1em}
.hint{font-size:13.5px;color:var(--ink2)}
.link{background:none;border:0;padding:0;font:inherit;font-weight:700;color:var(--blue);cursor:pointer;text-decoration:underline;text-underline-offset:2px} /* text-button (e.g. "Use calculated" reset on the auto-derived service step) */
.fld{margin:14px 0}
.fld label{display:block;font-weight:600;font-size:15px;margin-bottom:5px}
.fld .unit{font-size:13px;color:var(--ink2)}