Files

167 lines
14 KiB
Markdown
Raw Permalink 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.
# FEATURE: FULL BILINGUAL (EN/FR) — retire.krisforbes.ca
**Plan v1 — written 2026-10-09 by Hermes (GLM). Execution: DSH (local 27B), all stages, zero user interaction.**
User request (verbatim): *"I also want this application to be available in english and french fully bilingual."*
This plan is stage A of a two-stage feature (stage B = public service pension module, see PS-PENSION-PLAN.md, runs after this one).
## Goal
Make **every user-facing string** in the app available in English and French, with a visible
language toggle, persisted per visitor, included in shared links, with locale-correct number
formatting. The engine (`site/engine/*.js`) is presentation-neutral and must NOT change.
English behavior must remain functionally identical to today (mobile pass round 1+2 state is the baseline).
## Current state (verified 2026-10-09, after mobile rounds 1+2)
- Live: https://retire.krisforbes.ca (Unraid `retireapp` nginx :8103, webroot `/mnt/better/appdata/retireapp`, Caddy + CF grey-cloud). Deploy: `bash scripts/deploy.sh` → `DEPLOY-OK`.
- Repo: `gitea.krisforbes.ca/krisf/retire-planner`, main @ `65863c0`. Tests: engine 36/36, UI smoke OK.
- Files: `site/index.html`, `site/app.js` (questionnaire STEPS, panel, charts G0–G3, results, table, vision card), `site/style.css`, `site/engine/{engine,canada}.js`, `site/vision/manifest.json` + images, `tests/{engine.test.js,ui.smoke.js}`, `scripts/deploy.sh`.
- localStorage key `retire-planner-v1` holds `{a: <answers>, step, screen}`. Share links use `#p=<payload>` encoding the answers.
- Header (global, sticky): logo, privacy pill, spacer, `Start over` / `Print` / `Copy link` ghost buttons.
- 14 questionnaire steps (single) / 17 (couple), conditional; persistent "Your plan so far" sheet with 4 stat chips + insight line; results with verdict, gap cards, vision card, 4 SVG charts, year-by-year table (11 cols), what-if scenarios, disclaimers, privacy box, footer.
- Vision card: `CAPTIONS` object in app.js (14 slugs incl. `hero`) + `vision/manifest.json`.
## Architecture (implement exactly this shape)
1. **New file `site/i18n.js`** (dependency-free, loads before `app.js`):
- `var I18N = { en: {…}, fr: {…} }` — nested objects, dot-path keys (e.g. `panel.title`, `q.step1.title`).
- `function t(key, vars)` — resolves dot path in current language; falls back to `en`, then to the key itself. Interpolates `{name}` placeholders from `vars`.
- `function getLang()` / `function setLang(l)` — `'en' | 'fr'`; persists to state; calls `applyI18n()` + re-renders current screen.
- `function detectLang()` — `navigator.language` starts with `fr` → `'fr'`, else `'en'`. Used only when no saved state exists.
- Locale-aware formatters (use `Intl.NumberFormat`):
- `fmtMoney(n)` → en-CA `$959,835` / fr-CA `959 835 $` (0 decimals, CAD)
- `fmtMoneyK(n)` → en-CA `$523K`, `$1.05M` / fr-CA `523 k$`, `1,05 M$` (compact, ≤3 significant digits)
- `fmtInt(n)` → `959,835` / `959 835`
- `fmtPct(x)` → `5%` / `5 %` (fr: space before %)
- All chart axis/legend/annotation text and table cells must go through these (today they hand-roll `$523k`).
- `function applyI18n()` — walks `[data-i18n="key"]` (sets textContent), `[data-i18n-html="key"]` (innerHTML, for strings with markup), `[data-i18n-attr="placeholder:key,aria-label:key2"]`; sets `document.documentElement.lang` and `document.title` per language.
2. **Language state**: add `lang` to the persisted state object (`{a, step, screen, lang}`). On load: payload lang > saved state lang > `detectLang()`. **Share payload**: include `lang` in the `#p=` payload; old payloads without it → `en` (backward compatible).
3. **Header toggle**: a compact two-button pill group `EN | FR` in the header (all screens), active button styled, `aria-pressed`. Placed after the logo / before the spacer. On mobile it must fit the single-row header (round-2 layout: ≤52px, hide pill ≤700px, hide header Print ≤860px).
4. **index.html**: add `data-i18n` attributes to every static text node (intro, privacy box, footer, disclaimers, panel header label, button labels, section headings, table headers are dynamic — see app.js). Add `<script src="i18n.js"></script>` before `app.js`. Keep `<!-- retire-planner v1 -->` as first line in `<head>`.
5. **app.js**: replace every user-facing literal with `t()` / formatters:
- `STEPS` definitions: title/sub/options/hints/placeholders → keys.
- Province names → keys (`prov.yt` … `prov.nu`).
- Panel: chips labels/values, insight line, "Your plan so far".
- Insights (all templates), gap cards, verdict text, what-if scenario names + descriptions.
- Chart titles/subtitles/axis labels/legends/annotations (G0–G3), incl. the `retire` marker, `runs out ≈`, `shortfall from ~`, G4 bar names + values.
- Year table column headers; the collapsed `<summary>` label.
- Vision card: `CAPTIONS` becomes `{ slug: {en, fr} }`; `renderVision` picks by current lang.
- Validation/error messages ("Enter a value between {min} and {max}").
- Buttons: Next/Back/Start over/Print/Copy link/Edit answers/Start/Continue/Yes/No/optional tags.
- Share success/failure strings; print header if any.
6. **style.css**: minor additions only if needed for the toggle + FR (e.g., `.lang-toggle` styles; allow `hyphens:auto` on FR paragraphs/cards if overflow appears — see risk section). No redesign.
## Canadian French glossary (use these; official terminology where one exists)
| EN | FR |
|---|---|
| Retire Planner (title) | Planificateur de retraite |
| Your plan so far | Votre plan jusqu'ici |
| Savings at {age} (chip) | Épargne à {age} ans |
| Monthly need (chip) | Besoin mensuel |
| Shortfall (chip) | Manque à gagner |
| Surplus (chip) | Excédent |
| RRSP | REER (régime enregistré d'épargne-retraite) |
| TFSA | CELI (compte d'épargne libre d'impôt) |
| RRIF | FERR (fonds enregistré de revenu de retraite) |
| CPP | RPC (Régime de pensions du Canada) |
| OAS | SV (Sécurité de la vieillesse) |
| GIS | SRG (Supplément de revenu garanti) |
| YMPE | MGA (maximum des gains admissibles) |
| Age amount (credit) | Crédit pour âge |
| Pension income splitting | Fractionnement du revenu de pension |
| Pension income amount ($2,000) | Montant pour revenu de pension |
| Province names | Colombie-Britannique, Alberta, Saskatchewan, Manitoba, Ontario, Québec, Nouveau-Brunswick, Nouvelle-Écosse, Île-du-Prince-Édouard, Terre-Neuve-et-Labrador, Yukon, Territoires du Nord-Ouest, Nunavut |
| Single | Célibataire |
| Couple / common-law | Couple / conjoint de fait |
| Current age | Âge actuel |
| Annual income | Revenu annuel |
| Household | Ménage |
| Retirement age | Âge de la retraite |
| Spending goal | Objectif de dépenses |
| Monthly contribution | Cotisation mensuelle |
| Employer match | Contrepartie de l'employeur |
| Real return | Rendement réel |
| Inflation | Inflation |
| Life expectancy | Espérance de vie |
| Pension / rental / other income | Revenu de pension / locatif / autre |
| RRSP balance | Solde du REER |
| TFSA balance | Solde du CELI |
| Non-registered | Non enregistré |
| CPP start age | Âge de début de la RPC |
| OAS start age | Âge de début de la SV |
| Next / Back | Suivant / Précédent |
| Start over | Recommencer |
| Print / save PDF | Imprimer / enregistrer en PDF |
| Copy link | Copier le lien |
| Edit answers | Modifier les réponses |
| Start / Continue | Commencer / Continuer |
| Optional | Facultatif |
| Your savings journey (chart) | L'évolution de votre épargne |
| Retirement income by source (chart) | Revenu de retraite par source |
| Cash vs. planned spending (chart) | Encaisse vs dépenses prévues |
| Four futures (chart) | Quatre scénarios |
| Current plan (scenario) | Plan actuel |
| +$200/mo to RRSP (scenario) | +200 $/mois au REER |
| Retire at 63 (scenario) | Retraite à 63 ans |
| Spend $5k more/yr (scenario) | Dépenser 5 000 $ de plus par an |
| Year-by-year detail | Détail année par année |
| Try a what-if | Essayer un scénario |
| What-if scenarios | Scénarios « et si » |
| Age (table col) | Âge |
| Withdraw (col) | Retrait |
| Total (col) | Total |
| Non-reg (col) | Non enreg. |
| Tax + clawback (col) | Impôt + récup. |
| Unfunded (col) | Non financé |
| Estimates only — not financial advice | Estimations seulement — il ne s'agit pas de conseils financiers |
| 100% client-side — answers never leave your browser | 100 % côté client — vos réponses ne quittent jamais votre navigateur |
| Interests: dock/travel/garden/family/volunteering/learning/snowbird/hobbies/cottage/city/pets/grandkids/health/legacy | quai / voyages / jardinage / famille / bénévolat / apprentissage / snowbird (courant au Québec) / passe-temps / chalet / ville / animaux / petits-enfants / santé / héritage |
| Sources | Sources |
| (public service pension terms — stage B will need these too) | pension de la fonction publique, service ouvrant droit à pension, pension viagère, prestation de raccordement, allocation annuelle, pension différée, pension non réduite, réduction pour retraite anticipée, conjoint survivant, indexée à l'IPC, salaire moyen des cinq années consécutives les mieux payées, classification TI, groupe 1 / groupe 2, MMGP (moyenne des maximums des gains admissibles) |
Number formatting rules (fr-CA): thousands = narrow no-break space, decimal = comma,
percent has a space before `%`, currency symbol trails (`959 835 $`, `523 k$`).
In SVG text the same formatters apply (`1,05 M$` etc.).
## Known risk: French is ~20–30% longer than English
Mitigate and verify:
- Chart SVG labels: keep FR labels concise; after implementing, assert programmatically that
every `<text>` in each chart SVG has `getBBox().width` within the 640-wide viewBox (allow the
horizontally-scrollable `.chartbox` on mobile, but nothing may overflow the viewBox itself).
- Mobile: re-run the mobile audit in FR at 320/360/390 (script: adapt
`/home/ubuntu/.hermes/cache/scratch/mobile_audit.py` to set `lang:'fr'` in localStorage before load).
The `--sheet-h` live-sync from mobile round 2 handles panel height automatically. Check:
no horizontal document overflow, chips readable, table scrolls, sticky CTA works, buttons fit.
- Desktop FR screenshot at 1280px.
- If specific FR strings overflow, shorten the FR wording (never the EN) or allow wrapping —
fix in CSS, not by truncating text.
## Stages (execute in order; zero questions; every stage pre-approved)
0. **Preflight**: confirm working tree clean at `65863c0`; run `node tests/engine.test.js` (36/36) and `node tests/ui.smoke.js` (SMOKE-OK); capture EN baseline screenshots at 390/320 (reuse `mobile_audit.py` → copy to `audit/bilingual-en-baseline/`).
1. **i18n scaffold**: create `site/i18n.js` (dictionary with **all** keys for both languages — build it by walking index.html + app.js and extracting every string), formatters, `t()`, lang state, `applyI18n()`, share-payload lang, header toggle in index.html + style.css, script tag.
2. **Wrap static HTML**: `data-i18n` attributes across index.html.
3. **Wrap app.js**: all dynamic strings via `t()`/formatters (steps, panel, insights, charts, table, vision captions, scenarios, errors, share/print strings). Vision `CAPTIONS` → `{slug:{en,fr}}`.
4. **Tests**: new `tests/i18n.test.js` — (a) deep key-set parity en↔fr; (b) no empty values; (c) `{placeholder}` sets match across languages for every key; (d) formatter exact-output checks (`$959,835` / `959 835 $` / `$1.05M` / `1,05 M$` / `5%` / `5 %`); (e) FR render assertions: with lang=fr, DOM shows French for ~15 critical strings (title, panel label, a step title, a chip label, results heading, a chart title, a table header, the privacy line, the disclaimer). Extend `tests/ui.smoke.js` to run its full assertion set in **both** languages (parameterize expected strings by lang; keep output ending `SMOKE-OK`). Engine tests untouched (36/36).
5. **Deploy + verify**: `bash scripts/deploy.sh` → `DEPLOY-OK`; origin grep marker = 1. EN regression: fresh EN screenshots at 390/320 — functionally identical to baseline (spot-check via vision). FR: mobile audit variant at 320/360/390 + desktop 1280; programmatic checks: no horizontal overflow (`document.documentElement.scrollWidth <= innerWidth + 1`), chart text fits viewBox (getBBox assertion), sheet/chips/CTA checks pass. Off-LAN paramiko check (Midhurst troublebox `trouble@100.96.20.61:2222`, password candidates in `~/projects/*.py` lines containing `b1tvvt`): site 200 + marker. Save FR screenshots to `audit/bilingual-fr/`.
6. **Report + commit + push**: `BILINGUAL-REPORT.md` (what changed, glossary notes, test evidence, FR screenshots, known FR layout notes) + `audit/` evidence; commit + push to `krisf/retire-planner` main.
## Hard constraints
- **Do not modify** `site/engine/engine.js` or `site/engine/canada.js` (presentation-only change).
- Keep `<!-- retire-planner v1 -->` first line in `<head>`; keep the privacy statement's meaning intact in both languages.
- No frameworks, no build step, no CDN. `deploy.sh` rsyncs `site/` — `i18n.js` ships automatically.
- Desktop (≥861px) EN layout unchanged; FR may wrap slightly longer text (acceptable if nothing overflows horizontally).
- If a stage fails twice, stop, keep the last good deploy live, and report the blocker.
## Definition of done
- Toggle EN|FR in header on every screen; persists across reload; survives in share links; `navigator.language=fr*` defaults to FR for new visitors.
- Every visible string renders in the selected language (EN and FR verified by screenshots + tests).
- `tests/i18n.test.js` green (parity/placeholders/formatters/FR render), `tests/ui.smoke.js` green in both languages, engine 36/36 untouched.
- FR mobile verified at 320/360/390 + desktop; no horizontal overflow; charts fit viewBox.
- Deployed, off-LAN 200, committed + pushed with `BILINGUAL-REPORT.md` + audit evidence.
- **Your FINAL output line must be exactly: `BILINGUAL_DONE`**