# PS Auto-Service — Years of Pensionable Service, Derived Automatically **Date:** 2026-10-09 · **Plan:** `.hermes/plans/2026-10-09_ps-service-auto.md` · **Commit:** `807a966` (`ps: auto-derive years of service from join year + retirement age`, pushed to `origin/main`) The "Years of pensionable service" quiz step is now **prefilled by calculation** from the join year, current age, and retirement age. The user never has to type it; typing becomes an explicit, visible **override** with a one-click "Use calculated" reset — mirroring the CPP "Use maximum" pattern. ## Derivation ``` yearsWorkedSoFar = CURRENT_YEAR − psJoinYear ageAtJoin = age − yearsWorkedSoFar serviceYears = retireAge − ageAtJoin (rounded, clamped to 0…60) ``` - `CURRENT_YEAR = new Date().getFullYear()` (2026), injectable for tests. - Example: age 45, joined 2005, retiring at 65 → joined at 24 → **41 years**. - Engine contract unchanged: `psCompute` feeds the **effective** value (override when set, auto otherwise) to `computePSPension`, which applies its own 35-year cap → 41 → `serviceCapped` 35. - Invalid/partial inputs (no join year, retireAge ≤ age-at-join, junk) → `null` → step falls back to the plain manual entry, Next disabled until a valid number exists. ## Changes | File | Change | |---|---| | `site/engine/pspension.js` | New exported `autoServiceYears(age, joinYear, retireAge, currentYear)` — guards (`1900…2999`, positive age/retire), rounds, clamps 0…60. | | `site/app.js` | `state.a.psServiceOverride` (new; `psServiceYears` kept as legacy). `load()` migration: legacy saved `psServiceYears` → override (old plans keep their exact numbers). Helpers `psAutoServiceYears` / `psServiceEffective` / `psOverrideActive`. `psCompute` uses the **effective** value (the integration trap the plan flagged — engine reads `state.a.psServiceYears` directly via `psCompute`). ps_service step: prefills from effective value, `set()` writes only the override, min 0 / max 60, derivation hint EN+FR. Real-browser live swap on typing: `#ps-hint` text + `#ps-svc-reset` wrapper (always in DOM, `display` toggled) so the override hint and "Use calculated" button react without a re-render; clicking it clears the override and restores the auto prefill. | | `site/i18n.js` | `q.ps_service.auto` / `.override` / `.useCalc` in EN and FR (parity + placeholder checks pass; FR goes through `frNorm`). | | `site/style.css` | `.link` button style (bold blue underlined text button). | | `tests/ps-auto-service.test.js` | **New**, 21 checks: derivation cases, future join → 0, retire ≤ join age → 0, invalid → null, clamp 60, string inputs, `currentYear` default, engine contract 41→35 and the 49-year plan example. | | `tests/ui.smoke.js` | `runPsQuiz` rewritten: untouched auto prefill (`value="41"`, derivation hint EN+FR, Next enabled, reset hidden) → results card 35 years / 59 508,05 → **real route** (results → Edit answers → 6× Back) → type 30 → override hint + visible reset → results 30 years / 51 006,90 → "Use calculated" → auto restored → results 35 / 59 508,05; legacy-migration check in `runPs` (saved `psServiceYears: 30` → `psServiceOverride: 30`). Shim extensions: `#f1.value` synced from the rendered `value="…"` attribute (real DOM behaviour; a stale shim value let a slider's init wipe `retireAge` to 0), `.opt` registry. | | `audit/ps_auto_service.py`, `audit/ps_auto_offlan.py`, `audit/ps-auto-service/`, `audit/ps-auto-offlan-check.json` | New live audits + evidence. | ## Test results (all green) | Suite | Result | |---|---| | `tests/engine.test.js` | 36 pass, 0 fail | | `tests/i18n.test.js` | 48 checks, I18N-OK | | `tests/ps-pension.test.js` | 70 pass, 0 fail | | `tests/ps-auto-service.test.js` | **21 pass, 0 fail (new)** | | `tests/ui.smoke.js` | **128 checks, SMOKE-OK** (was 92; +36) | ## Live Playwright audit (local server, 390×844, real Chromium) `audit/ps_auto_service.py` → **34 checks, 0 failed, PS-AUTO-AUDIT-OK**; screenshots + JSON in `audit/ps-auto-service/`. Verified end-to-end: prefill 41 untouched, derivation hints EN+FR, real typing of 30 **live-swaps** the hint and reveals "Use calculated"/"Utiliser le calcul", use-calc restores 41 + clears the override in localStorage, results show 30 years → 51,006.90 and auto → 35 years → 59,508.05 (FR: 30 années / 51 006,90 $, 35 années / 59 508,05 $), module OFF parity (15 steps, PS card hidden, no PS quiz text), no horizontal overflow and all controls + ps-card inside the 390px shell on every audited screen. Two real-browser bugs this audit caught that the shim could not: 1. Typing did not update the hint (no re-render in a browser) → live swap added in `oninput`. 2. The reset button is now always in the DOM, hidden via inline `display` — visibility is the tested contract, not presence. ## Deploy - `scripts/deploy.sh` → **DEPLOY-OK** (marker `retire-planner v1` served). - LAN `http://192.168.0.123:8103/`: marker present; `app.js`, `i18n.js`, `engine/pspension.js`, `style.css` all **byte-identical** to the workspace (sha256). - Public `https://retire.krisforbes.ca/` from the Midhurst vantage (paramiko, `audit/ps_auto_offlan.py`): 200 + marker + `psServiceOverride`/`ps-usecalc` in app.js + FR auto hint in i18n.js + `autoServiceYears` in engine → **OFFLAN-OK** (`audit/ps-auto-offlan-check.json`, 2026-10-09T19:41Z). ## Compatibility notes - Old saved plans (pre-change `psServiceYears: 30`) migrate to `psServiceOverride: 30` on load → identical numbers to before this change. - Engine 35-year cap, G1/G2 tables, bridge benefit, IT grid: untouched. - Module OFF path (15 steps) untouched. PS_AUTO_SERVICE_DONE