Files

14 KiB
Raw Permalink Blame History

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)

  1. 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/).
  2. 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.
  3. Wrap static HTML: data-i18n attributes across index.html.
  4. 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}}.
  5. 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).
  6. 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/.
  7. 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