# Bilingual EN/HI — DevGuide for 360LM PWAs

**Status:** Live in Recce (v15a). Reusable pattern for other 360LM PWAs.
**Languages:** English (default) + Hindi (Devanagari). Devanagari script for text; English (Arabic) numerals throughout (matches everyday Indian usage).
**Persistence:** Per-user, DB-backed (so the preference follows the user across devices).

---

## When to adopt this in another PWA

Adopt when **any** of these apply:
- Your PWA's user base includes field staff whose primary reading language is Hindi
- The app surfaces error messages or workflow instructions field users must understand without help
- The app handles actions where a misread English label could destroy data (delete / submit / approve)

If the PWA is internal-tooling for power users only (Hub admin, Settings consoles), it's lower priority.

---

## Architecture — one system, not two

Three string surfaces exist in every PWA. ALL go through the same `t()` helper:

| Surface | Pattern |
|---|---|
| **Static HTML** | `<span data-i18n="key">English fallback</span>` — `applyI18n()` swaps `textContent` |
| **HTML attributes** (placeholder, title, aria-label) | `<input data-i18n-attr="placeholder:address_placeholder,title:address_title">` |
| **HTML built in JS** (`innerHTML = ...`) | Inline `${t('key')}` at template build time — `applyI18n` runs after the write |
| **Toasts / messages** | `toast(t('save_failed'), 'error')` |
| **Native confirm/prompt** | Replaced by `ask({title,subtitle,okDanger?})` and `askText({title,subtitle,placeholder,defaultValue})` — both return Promises |
| **Numbers** | `tNum(123)` — passes through English digits |
| **Dates** | `tDate(d)` / `tDateTime(d)` — Hindi month names, English digits |

The dual-span approach (v9 of Recce, `.en-txt`/`.hi-txt` + CSS hide) is **not used** because:
- Dropdown `<option>` text can't be hidden reliably via CSS
- HTML attribute values (placeholder, title) can't be wrapped in spans
- Browser-native `confirm()`/`prompt()` can't be translated at all
- Dual spans double the HTML size for marginal benefit since `t()` is needed for the above anyway

---

## DB schema (Recce-scoped today; portable)

```sql
-- One per (employee, pref_key) — EAV pattern, lets future prefs land alongside lang
CREATE TABLE recce.user_prefs (
  employee_id  TEXT NOT NULL,
  pref_key     TEXT NOT NULL,
  pref_value   TEXT,
  updated_at   TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  PRIMARY KEY (employee_id, pref_key)
);

-- RPC pair
CREATE FUNCTION recce.get_pref(p_emp_id TEXT, p_key TEXT) RETURNS TEXT ...
CREATE FUNCTION recce.set_pref(p_emp_id TEXT, p_key TEXT, p_value TEXT) RETURNS BOOLEAN ...

-- Grants
GRANT EXECUTE ON FUNCTION recce.get_pref(TEXT, TEXT) TO web_anon;
GRANT EXECUTE ON FUNCTION recce.set_pref(TEXT, TEXT, TEXT) TO web_anon;
GRANT SELECT, INSERT, UPDATE ON recce.user_prefs TO web_anon;
```

**Adoption tip:** when 2+ PWAs need user prefs, migrate this to a shared `hub.employee_prefs` table with the same shape. The application layer stays unchanged — just `Accept-Profile: hub` instead of `recce`.

---

## Adoption checklist (per PWA)

1. **DB**: copy `migrate_recce_v15.sql` → adapt schema name → apply
2. **CSS**:
   - `.ask-modal-bg` + `.ask-modal` + `.ask-row` + `.ask-btn-*` (modal styling)
   - `.lang-toggle` (Settings UI)
   - `.tts-btn` (speaker icon)
3. **HTML**: one ask-modal at the bottom of `<body>`. Settings card with the lang-toggle buttons.
4. **JS**: copy the v15a infrastructure block (`I18N` dict, `t()`, `tNum()`, `tDate()`, `tDateTime()`, `applyI18n()`, `_askResolve`, `_askKind`, `ask()`, `askText()`, `_fetchLangPref()`, `_saveLangPref()`, `setLang()`, `_initTts()`, `ttsRead()`)
5. **Boot**: at end of init, `_initTts(); _lang = await _fetchLangPref(); applyI18n();`
6. **Sweep**: replace every `confirm()` and `prompt()` call with `ask()` and `askText()` (await both)
7. **Tag**: add `data-i18n="key"` to every static visible string
8. **Tag attributes**: add `data-i18n-attr="placeholder:key,title:key"` to inputs / icons
9. **JS-built HTML**: inline `t('key')` at every template-string interpolation
10. **Re-render hook**: after any `el.innerHTML = ...`, call `applyI18n(el)` so newly-injected text is translated
11. **Dict**: extend `I18N.en` and `I18N.hi` with every key you tagged
12. **Verify**: open Settings → toggle to हिंदी → every visible element should change

---

## Naming conventions

### Translation keys
- snake_case, namespaced by feature: `delete_modal_title`, `home_no_submissions`, `form_step_1`
- For verb actions: present tense imperative (`submit`, `delete`, `approve`)
- Keep dictionary entries SHORT — long sentences are hard to translate consistently

### TTS strings
- High-value spots only: errors, supervisor instructions (free text), recce details
- Don't auto-play; user must tap 🔊
- Indian-locale voice preferred (`hi-IN` or `en-IN`)

### Things that should NOT be translated
- User-entered data (store names, addresses, mobile numbers, GPS coordinates, comments)
- Brand codes (HP, LENOVO — these are identifiers not words)
- Employee names (data, not chrome)
- URL strings, sub_ids, internal status codes
- Numbers — English digits regardless of `_lang`

---

## The `ask()` / `askText()` modal pair

Replaces native `confirm()` and `prompt()`. **Use these even when not translating yet** — they enable consistent styling, bilingual support, and async control flow.

```js
// Replacement for: if (!confirm('Delete all?')) return;
if (!await ask({
  title: 'Delete all submissions?',
  subtitle: 'Cannot be undone.',
  okDanger: true,                  // colours the OK button red
})) return;

// Replacement for: const x = prompt('Note?', '');
const note = await askText({
  title: 'Add a note',
  subtitle: 'Sent to your supervisor',
  placeholder: 'e.g. wrong photo',
  defaultValue: '',
});
if (note === null) return;     // user cancelled
```

Both return Promises. `ask` resolves `true`/`false`; `askText` resolves `string`/`null`.

---

## Translation review process

After v15b/c/d tag everything, run this checklist:

- [ ] Open each screen in EN, then HI — every visible word changes
- [ ] Test ask/askText modals in both languages
- [ ] Confirm tDate renders Hindi months (जन, फर, मार्च...) in HI mode but English digits everywhere
- [ ] Inspect all dropdowns: option text changes language
- [ ] Inspect placeholders: change language
- [ ] Tap 🔊 speaker icons: voice plays in correct locale
- [ ] Refresh the page: language persists (DB round-trip)
- [ ] Log in as a different user on the same device: their preference applies, not the previous user's

---

## Maintenance

- New strings → add to BOTH `I18N.en` and `I18N.hi` in the same commit. CI lint could enforce this.
- Missing key falls back to English fallback then to the raw key. Watch the console for `[i18n] missing key:` warnings (consider adding in dev mode).
- For multi-line translations with bold/links, use `data-i18n-html` instead of `data-i18n`.

---

## Recce-specific implementation notes (v15a, 2026-06-05)

- Default = `en`. Boot calls `recce.get_pref(empId, 'lang')`. If RPC fails or no row exists, stays English.
- `setLang('hi')` triggers `recce.set_pref(empId, 'lang', 'hi')` plus `applyI18n()`.
- TTS voices: lazy-loaded after page load (browsers populate the voice list asynchronously). The `_initTts()` helper handles both sync and async cases.
- The `body[lang='hi']` attribute is set on every `applyI18n()` call so future CSS rules can target Hindi specifically (e.g., line-height tweaks for Devanagari).

---

**Source-of-truth file:** `/var/www/360lm/recce/index.html` (v15a/b/c/d blocks).
**This guide:** `/var/www/360lm/docs/bilingual_devguide.md`.
**DB migration:** `/var/www/360lm/recce/migrate_recce_v15.sql`.

---

## Pitfalls encountered during the Recce rollout (read before adopting)

### 1. Substring-match sweeps can corrupt the dict itself

When bulk-replacing English literals with `t('key')` calls via a Python or sed script, **anchor the pattern carefully**. Example bug from v15c:

```
# pattern we wanted to replace at askText call sites:
title: 'Request edit for this Recce',  →  title: t('tst_request_edit_title'),

# BUT it also matched inside the I18N dict on this line:
tst_request_edit_title: 'Request edit for this Recce',
# becoming:
tst_request_edit_title: t('tst_request_edit_title'),  // ← self-reference, ReferenceError at boot
```

**Mitigation:** sweep with longer context (include the leading `tst_*: ` or `title: ` is enough only if you're sure the matched substring NEVER appears inside the dict). Re-run a smoke test after every batch.

### 2. Single-quoted strings ≠ template literals

```js
// Bug — quotes are single, so ${...} is literal text not interpolation:
list.innerHTML = '<div class="empty">${t("sc_no_subs")}</div>';

// Fix — backticks for template literals:
list.innerHTML = `<div class="empty">${t('sc_no_subs')}</div>`;
```

A bulk script that adds `${t(...)}` to existing strings MUST verify each call site uses backticks. The browser surfaces this as `Unexpected identifier` at parse time.

### 3. Functions defined above `const I18N` are fine; calls TO them at module load are not

`function foo()` is hoisted; calling `t()` inside its body is safe because the body runs later. But **module-level code that runs at load time** (e.g. a top-level `applyI18n()` or a function call outside any function) will TDZ-error if `I18N` hasn't been initialised yet.

**Mitigation:** boot the i18n init AFTER the I18N const declaration (already done — `init()` is async and `applyI18n()` runs after the first `await`). Don't add top-level `t()` calls.

---

## v15d additions — auto-hook + TTS

### `applyI18nIn(rootEl)` — post-render hook

After any `el.innerHTML = \`...\``  that inlines `data-i18n` attributes, call `applyI18nIn(el)` to translate the newly-injected nodes. Cheap (DOM walk over just the new subtree). Idempotent (running twice is fine). Skips work when `_lang === 'en'` since data-i18n fallbacks already match.

### `ttsIcon(text)` — speaker badge

Use sparingly. Returns an inline `<button class="tts-btn">🔊</button>` bound to `ttsRead(text)`. Drop it next to high-value strings field users may need to listen to:

```js
`<div class="ewf-comment">
   <b>${t('ewf_supervisor_note')}:</b> ${editComment} ${ttsIcon(editComment)}
 </div>`
```

Good spots:
- Workflow notes (supervisor → creator instructions)
- Error toasts the user must act on
- Recce store details (name, address) on the admin viewer

Do NOT spam every label. Devanagari readers don't need a speaker for "Settings" → "सेटिंग्स"; they need it for **free-text content** they didn't write.

### Auto-speaker on error toasts (v15d)

The `toast()` helper now appends a 🔊 button when `type === 'error'`. No per-call change needed — every error toast gets the icon for free.
