# Learning languages — the Academy in 中文 and English

## What it does

The Learning Hub (`/property/academy`) teaches in **two languages**. A member picks **中文 / English**
on the toggle in the hub's masthead (and in every DMAIC Road stop's header); the choice is stored on
their profile, so it follows them to every device and to the member app. Every tab that teaches —
**How to Use PropertyLab**, the **DMAIC Road** (all eight stops, the cards, the diagrams, Takeoff and
Next Round), the **Case Debriefs**, the **Glossary** course and the **learning labs** — renders in the
chosen language. The Area Guide opens in it too (it keeps its own switch); e-Learning courses are
authored in Manage and are shown as written.

An unset choice reads as **Chinese** — the Academy's only language until 2026-09-25 — never as the
site locale, which defaults to English for everybody and would have switched every existing reader to
English the day English shipped.

## How it works

- **Storage.** `user_profiles.learning_locale` (`'zh'` | `'en'` | NULL, constants
  `UserProfile::LEARNING_LOCALE_*`), written by `PUT /property/academy/language`
  (`AcademyLanguageController` → `UserRepository::updateProfile`, which writes only the keys present).
  Shared with every page as **`auth.user.learning_lang`** (`HandleInertiaRequests`, `'zh'` when unset).
  Server code asks **`User::learnsInEnglish()`**.
- **Copy is written as PAIRS, in place.** The teaching copy lives in JS modules (a point is a title, a
  paragraph with Ryan's numbers interpolated, `calc` steps, term keys, a diagram state — the numbers
  READ OFF the maths, never typed). A key-per-sentence catalogue would tear each sentence away from the
  numbers it quotes, so each piece of copy is a pair beside its code:
  - a content module: ``line: bi(`月供 RM ${n(INST)}`, `Monthly instalment RM ${n(INST)}`)``
    (`resources/js/utils/i18n/bilingual.js`);
  - a component: `tx('中文', 'English')` in `<script setup>` and the template
    (`resources/js/composables/useLearningLang.js`).

  One structure, one set of numbers, two wordings — the languages cannot drift in anything but words.
- **Resolved at RENDER time, never at module evaluation.** The server renders pages too (Inertia SSR),
  and a module-level "current language" would be shared by every request that process serves. A page
  or component turns a module's tree into plain strings with `resolve(tree)` from `useLearningLang()`;
  children receive strings exactly as before. An unresolved pair prints its Chinese (`toString`), so a
  missed spot reads as untranslated, never as `[object Object]`.
- **Switching** is a full Inertia visit without preserved state (`setLang`), so every component mounts
  again and copy computed once in a `setup()` is computed again in the new language.
- **Language-aware helpers.** A helper that returns a fragment used inside other sentences takes a
  `lang` (e.g. `roadFormat.js` `monthly(v, lang)` → `RM 2,300 /月` | `RM 2,300 /month`); pass
  `lang.value` from `useLearningLang()`.
- **Chinese that stays Chinese on purpose** — a value sent to the server, a key another file compares,
  a term's `zh` name and `aliases` (how a term is recognised in Chinese text) — is marked
  `// i18n-keep: <why>` (or is a `zh` / `aliases` property) and is never paired.
- **Server-written copy follows the member too**: stage letters and names
  (`JourneyCard::stageLabels()` — `起` shows as **S** in English; the prop key stays `chinese`),
  cycle statuses, card validation (`SaveCardRequest::fieldNames/ruleMessages($english)`), the road's
  flash messages, the five-day training's day titles and lock lines (`config/training.php` `*_en`,
  `TrainingAccess`), the Area Guide's membership lock line.
- **Rendering a fixed language without a page** — the member-app export, a component test —
  `app.provide(LEARNING_LANG_KEY, 'en')`; it outranks the page's prop.
- **The member app** reads the same copy exported as JSON (`scripts/export-learning-content.mjs`):
  `resources/learning-content/{set}.json` (Chinese) and `{set}.en.json` (English) — one build per
  language, every pair resolved at the end; the English build FAILS if any Chinese is left (a term's
  `zh` name excepted). `GET member-api/v1/learning/content/{set}` serves the member's own language
  (`?lang=zh|en` overrides); each file has its own `content_hash`, so a switch is never a stale 304.
  Copy that lives only inside a component is read out of its `tx('中文', 'English')` by `pairIn()`.
  The case diagrams are painted per language too: `node scripts/render-case-diagrams.mjs --lang=en`
  → `public/learning-content/cases/en/*.png` + `resources/learning-content/case-diagrams.en.json`,
  served for the English file by `CaseDiagrams::forLang('en')`.
- **How to Use previews** mount real portal screens with demo data (`utils/portalGuide/demoState.js`,
  whose Ryan copy is pairs): `registry.js` `propsFor(key, overrides, lang)` resolves it to the reader's
  language before the fresh per-mount copy.

## Writing or editing Academy copy

1. Read [content-rules.md](/docs/modules_handbook/main/dmaic-road/content-rules.md) — the first-time
   reader test applies to BOTH languages.
2. Write the Chinese and the English together, as a pair. The English says exactly what the Chinese
   says — same facts, numbers, order and caveats — in plain Malaysian/British English, and never names
   a book, course or author. Use the fixed vocabulary: each term's `en` name in
   `resources/js/utils/road/terms.js`, the stage names in `JourneyCard::STAGES` (`english`), Ryan ·
   Xiao Ming (小明) · Ah Keong (阿强).
3. To pair existing Chinese in bulk: `node scripts/i18n/bilingual-codemod.mjs extract <files>` → write a
   `{ "<file>": { "<chinese>": "<english>" } }` map → `… apply <map.json>` (it keeps every `${…}` and
   refuses an English that drops one) → `… check <files>` must say `"unpaired": 0`.
4. Re-export the app's copy: `node scripts/export-learning-content.mjs`.

## Guards

- `resources/js/utils/i18n/academyEnglishRender.test.js` — every diagram frame the content uses, rendered
  in English, shows no Chinese in its text or its accessible names (aria-label / title / alt).
- `resources/js/utils/i18n/academyBilingual.test.js` — **no unpaired Chinese** in any Academy source
  (the Learning Hub, the Road, `Components/{Academy,Road,PortalGuide}`, the content modules under
  `resources/js/utils/`); **no Chinese left in the English** of any content module; no empty English half.
- `tests/Feature/Main/Portal/Road/AcademyLanguageTest.php` — storage, the shared prop, English stage
  labels, English card validation.
- `scripts/check-learning-content.mjs` — both languages of the app's JSON are in step with the modules.

## Changes that came with it (2026-09-25)

- **How to Use lessons now describe the 1 km ring** (the new-project pricing rule of the same day,
  `PricingComparables`) instead of "the 10 nearest": `ap-card` (BMV), `ap-invest` (the Summary's
  asking median), `ap-amenities`. Their review fingerprints (`reviewedComponents.json`) were re-recorded
  after that review, as were `ap-listing` and `wp-properties`, whose screens' Chinese is byte-identical
  (only a new import, `bilingual.js`, changed their dependency set).
- **Glossary terms and FAQ answers open again** — `TermsPanel.vue` passed a ref's VALUE to its toggle,
  so every click threw; found while translating.

## Not translated (yet)

- **Wealth Planning** and the other portal tools the lessons send a member to — their own UI; a few
  Chinese labels remain there (e.g. the 「示范单位」 badge a plan seeded from the D card carries).
- **Lesson and course titles stored in the database** (a stop's "go deeper" shelf, e-Learning) are
  shown as authored.
- **Four Chinese source slips noted during translation, left for the owner:** Measure's a5 says 「下面六步」
  over a seven-step calc; next.js says 「第 5 站算过」 for a sum done at C (stop 6 since 起); Takeoff's
  action list says the bank ticket is valid only while you have a payslip, which newer copy on the same
  stop contradicts; `SieveVerdict`'s caption puts the developer "outside the last tile" (it is tile 4).
- **Staff notifications** (Telegram) stay Chinese — they are for colleagues, not members.

## Related files

**Backend**
- `database/migrations/2026_09_25_150000_add_learning_locale_to_user_profiles.php`
- `src/People/UserProfile.php` (`LEARNING_LOCALE_*`), `src/People/User.php` (`learnsInEnglish()`),
  `src/People/Repositories/UserRepository.php` (`updateProfile`)
- `app/Http/Controllers/Main/Portal/AcademyLanguageController.php`,
  `app/Http/Requests/Main/Portal/Academy/UpdateLanguageRequest.php`
- `app/Http/Middleware/HandleInertiaRequests.php` (`auth.user.learning_lang`)
- `src/Journey/JourneyCard.php` (`STAGES[*].english/letter_en`, `stageLabels()`),
  `src/Journey/Support/RoadPresenter.php`, `src/Journey/Support/TrainingAccess.php`, `config/training.php`
- `app/Http/Controllers/Main/Portal/RoadController.php`, `app/Http/Requests/Main/Portal/Road/SaveCardRequest.php`,
  `StartCycleRequest.php`, `app/Actions/Journey/EnrolInTraining.php`, `app/Http/Controllers/Main/Portal/CoursesController.php`
- `src/Lms/Services/LearningContent.php` (per-language JSON for the app)

**Frontend**
- `resources/js/utils/i18n/bilingual.js` — `bi`, `resolveLang`, `CJK`, `cjkStrings`
- `resources/js/composables/useLearningLang.js` — `lang`, `tx`, `resolve`, `setLang`, `LEARNING_LANG_KEY`
- `resources/js/Components/LearningLangToggle.vue` — the 中文 / English control
- `resources/js/Pages/Main/Portal/Lms/Index.vue` (masthead + toggle; passes the language to the Area Guide)

**Tools**
- `scripts/i18n/cjk-literals.mjs`, `scripts/i18n/bilingual-codemod.mjs`
- `scripts/export-learning-content.mjs` (`--lang=`), `scripts/check-learning-content.mjs` (both languages),
  `scripts/render-case-diagrams.mjs` (`--lang=en`)

**Routes**
- `PUT /property/academy/language` → `main.portal.academy.language.update` (`routes/main.php`)

## Reference usage

- A content module: `resources/js/utils/road/stages.js` (every descriptive field a `bi()` pair).
- A page resolving content: `resources/js/Pages/Main/Portal/Road/Stage.vue` (`resolve()` before props go down).
- A component with its own copy: `resources/js/Pages/Main/Portal/Road/Partials/Viz/FreedomLedger.vue` (`tx()`).
- Server copy: `RoadController::saveCard()` flash messages (`learnsInEnglish()`).
