# ADR-089: Bilingual EN/HI Platform Architecture — DB-Backed Language Preference, t() Helper, and Adoption Checklist

## Status

Accepted, 2026-06-27.

## Status History

```yaml
status_history:
  - date: 2026-06-27
    status: Proposed
    changed_by: hkl
    reason: Capturing bilingual architecture that multiple PWAs implement (Recce v15a, Vehicle, Learning Hub). DevGuide exists but lacks formal ADR context; new developers may miss it.
    changed_via: adr-kit (360lm)
  - date: 2026-06-27
    status: Accepted
    changed_by: hkl
    reason: Architecture proven in production (Recce v15a, Vehicle, Learn); ready to mandate across all field-facing PWAs
    changed_via: adr-kit (360lm)
```

## Context

The 360lm platform operates across two user cohorts with different language preferences:
- **Field staff (Android):** Hindi-first, require bilingual UI for error messages and workflow instructions
- **Supervisors/management (desktop):** English-first, benefit from bilingual option

Multiple PWAs now implement bilingual UI: Learning Hub (ADR-064, ADR-065), Recce (v15a), Vehicle. Each implementation uses the same architecture pattern (DB-backed language preference, `t()` helper, top-bar toggle), but this pattern is documented only in `/var/www/360lm/docs/bilingual_devguide.md`. New PWA developers may not discover this guide, leading to:
1. Reimplementation from scratch (wasted effort, inconsistency)
2. Omission of bilingual support in field-facing PWAs (UX regression)
3. Inconsistent adoption timelines across PWAs

**Affected PWAs:**
- Mandatory bilingual: Recce, Vehicle, Activity, Learn (all field-facing)
- Optional bilingual: Finance, Admin, HR, Sales (back-office; implement if requested)

**Evidence:**
- Recce v15a (2026-06-05): full implementation with `t()` helper, `applyI18n()`, language toggle, TTS
- Learning Hub (ADR-064, ADR-065): video tutorials and scene guides use bilingual content objects `{en, hi}`
- Indian number formatting (ADR-071): uses similar copy-verbatim pattern for utility functions
- hub session (ADR-012): employee language preference stored in `employee.lang` DB column

**Constraints:**
- Per ADR-013 (Single HTML File, No Framework): no build pipeline, no import from `/shared/`. Bilingual helper functions must be copy-verbatim into each PWA's index.html.
- Per ADR-012 (Hub as SSO Gateway): language preference is stored in employee profile DB table (persists across devices), not localStorage.
- Field staff use Android; must support browser-native Text-to-Speech (window.speechSynthesis API) for audio guidance.

## Decision

**All field-facing PWAs implement bilingual EN/HI support using a standardised architecture: DB-backed language preference per employee, stateless `t()` helper function, top-bar language toggle button, and optional browser TTS.**

### Language Preference Storage

- **Primary:** stored in employee profile DB column `lang CHAR(2) DEFAULT 'en'`
  - Persists across devices and logins
  - Synced from hub session after employee login
- **Fallback (before login):** `localStorage.getItem('lang') || 'en'`
  - Allows unauthenticated users (e.g., client portal) to select language
  - Ignored once employee is logged in (DB preference takes priority)

### The `t()` Helper Function (Copy-Verbatim)

Single stateless function resolves bilingual string objects:

```javascript
// Global variable (set during boot after fetching lang preference)
let _lang = 'en';

// Stateless helper — copy this function verbatim into every bilingual PWA
const t = (obj) => {
  if (typeof obj === 'string') return obj;  // Fallback for non-bilingual strings
  return obj[_lang] || obj.en || '';        // Prefer current lang, then English, then empty
};
```

**Usage:**
- In HTML built in JS (template literals): `` `<button>${t({en: 'Submit', hi: 'जमा करें'})}</button>` ``
- In toast/alert calls: `toast(t({en: 'Saved', hi: 'सहेजा गया'}))`
- Do NOT wrap element text content in `t()`; use `data-i18n="key"` with `applyI18n()` instead

### All User-Facing Strings Use Bilingual Objects

- **Pattern:** `{en: '...', hi: '...'}`
- **Where they live:**
  - JS-built HTML (templates): inline `${t(...)}`
  - Static HTML: `data-i18n="key"` attribute (see `applyI18n()`)
  - Toast messages: `toast(t({...}))`
  - Ask modal titles/subtitles: `ask({title: t({...}), ...})`
- **What to NOT translate:**
  - User-entered data (store names, addresses, employee names)
  - Brand codes (HP, LENOVO)
  - Numbers (always English digits regardless of `_lang`)
  - Internal status codes, URL strings

### Top-Bar Language Toggle Button

```html
<!-- Place in header/top-bar of PWA -->
<button id="lang-toggle" class="lang-toggle">
  <span id="lang-label"></span>
</button>

<script>
const langToggle = document.getElementById('lang-toggle');
const langLabel = document.getElementById('lang-label');

function updateLangLabel() {
  langLabel.textContent = _lang === 'en' ? 'हिं' : 'EN';
}

langToggle.addEventListener('click', async () => {
  _lang = _lang === 'en' ? 'hi' : 'en';
  localStorage.setItem('lang', _lang);
  
  // Persist to DB (fire-and-forget RPC)
  if (window._emp_id) {  // Only if logged in
    try {
      await fetch('/rpc/set_language', {
        method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({lang: _lang})
      });
    } catch (e) {
      console.error('Failed to save language preference:', e);
    }
  }
  
  // Re-render entire page (simplest approach)
  applyI18n();
  updateLangLabel();
  
  // Optional: update page title, re-fetch bilingual data if needed
});

updateLangLabel();
</script>
```

**Behaviour:**
- Button label shows the NEXT language (English UI shows हिं, Hindi UI shows EN)
- On click: toggle `_lang`, save to localStorage, RPC to DB, call `applyI18n()` for page re-render, update button label
- Full page re-render is intentionally simple — partial updates risk missing some strings

### HTML Elements Using `data-i18n` Attribute

For static HTML text (not built in JS):

```html
<h1 data-i18n="home_title">Home</h1>
<input type="text" data-i18n-attr="placeholder:tf_amount_placeholder,title:tf_amount_title">
```

Define translations in JS:
```javascript
const I18N = {
  en: {
    home_title: 'Home',
    tf_amount_placeholder: 'Enter amount',
    tf_amount_title: 'Amount in INR'
  },
  hi: {
    home_title: 'होम',
    tf_amount_placeholder: 'राशि दर्ज करें',
    tf_amount_title: 'INR में राशि'
  }
};

function applyI18n(rootEl = document) {
  if (_lang === 'en') return;  // English is fallback, no translation needed
  
  rootEl.querySelectorAll('[data-i18n]').forEach(el => {
    const key = el.getAttribute('data-i18n');
    const text = I18N[_lang]?.[key] || I18N.en[key] || '';
    el.textContent = text;
  });
  
  rootEl.querySelectorAll('[data-i18n-attr]').forEach(el => {
    const pairs = el.getAttribute('data-i18n-attr').split(',');
    pairs.forEach(pair => {
      const [attrName, key] = pair.split(':');
      const text = I18N[_lang]?.[key] || I18N.en[key] || '';
      el.setAttribute(attrName.trim(), text);
    });
  });
  
  document.documentElement.setAttribute('lang', _lang);
}
```

**Call `applyI18n(rootEl)` after any `el.innerHTML = ...` that injects new DOM elements.**

### Browser Text-to-Speech (Optional)

Use when app benefits from audio guidance (Learning Hub scene guides, supervisor instructions):

```javascript
function ttsRead(text) {
  if (!window.speechSynthesis) return;
  
  const utterance = new SpeechSynthesisUtterance(text);
  utterance.lang = _lang === 'hi' ? 'hi-IN' : 'en-IN';
  utterance.rate = 1.0;
  
  window.speechSynthesis.cancel();  // Cancel any prior speech
  window.speechSynthesis.speak(utterance);
}

// Optional: speaker icon button next to field
function ttsIcon(text) {
  const btn = document.createElement('button');
  btn.className = 'tts-btn';
  btn.textContent = '🔊';
  btn.type = 'button';
  btn.onclick = () => ttsRead(text);
  return btn;
}
```

**Rules:**
- Never autoplay TTS; user must tap speaker icon or explicitly trigger it
- Good spots: workflow notes, error toasts, free-text supervisor instructions
- Bad spots: every label (Hindi readers don't need audio for "Settings" → "सेटिंग्स")

### DB Schema for Bilingual Data

When PWA has structured bilingual data (e.g., scene guide titles, tutorial narration):

**Option A: Separate columns (if searchable)**
```sql
CREATE TABLE scenes (
  id TEXT,
  title_en TEXT,
  title_hi TEXT,
  narration_en TEXT,
  narration_hi TEXT
);
```

**Option B: JSONB object (if display-only)**
```sql
CREATE TABLE scenes (
  id TEXT,
  title JSONB DEFAULT '{"en":"","hi":""}' ::jsonb,
  narration JSONB DEFAULT '{"en":"","hi":""}' ::jsonb
);

-- Retrieve: SELECT (title ->> _lang) as title FROM scenes;
```

Read as:
```javascript
const text = row.title_en;  // Option A
const text = row.title[_lang] || row.title.en;  // Option B
```

### RPC for Saving Language Preference

Create RPC in PWA schema (or use shared hub RPC):

```sql
CREATE FUNCTION [pwa].set_language(p_lang CHAR(2)) RETURNS BOOLEAN AS $$
BEGIN
  UPDATE hub.employees SET lang = p_lang WHERE id = auth.uid();
  RETURN TRUE;
EXCEPTION WHEN OTHERS THEN
  RAISE WARNING 'set_language failed: %', SQLERRM;
  RETURN FALSE;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

GRANT EXECUTE ON FUNCTION [pwa].set_language(CHAR(2)) TO web_anon;
```

### Adoption Checklist for New Bilingual PWA

1. **Copy helpers to `<script>` block:**
   - `_lang = 'en'` global variable
   - `t()` function (copy-verbatim)
   - `I18N` dict with en/hi pairs
   - `applyI18n(rootEl)` function
   - Optional: `ttsRead()`, `ttsIcon()`, `_initTts()`

2. **Add top-bar language toggle button**
   - EN/हिं button that cycles `_lang` and calls `applyI18n()`
   - Save to localStorage and RPC to DB

3. **Boot sequence (after employee session loaded):**
   ```javascript
   async function init() {
     // ... session load ...
     _lang = (await rpc('get_language')) || localStorage.getItem('lang') || 'en';
     applyI18n();
     // ... rest of init ...
   }
   ```

4. **Replace all hardcoded strings with bilingual objects**
   - JS-built HTML: `${t({en: '...', hi: '...'})}`
   - Static HTML: `data-i18n="key"` + extend I18N dict
   - Toasts: `toast(t({...}))`
   - Ask modals: `ask({title: t({...}), ...})`

5. **Add `applyI18nIn(el)` hook after HTML injection**
   - After any `el.innerHTML = ...`, call `applyI18n(el)` to translate new elements

6. **Create RPC `set_language(lang)` in schema** (or delegate to hub)

7. **Test in both languages:**
   - Open in English mode, verify all visible text matches I18N.en
   - Toggle to हिंदी, verify all visible text matches I18N.hi
   - Toggle back to EN, verify strings swap back correctly
   - Test TTS in both languages (if implemented)
   - Refresh page, verify language persists (DB round-trip)
   - Log in as different employee on same device, verify their language preference loads, not the previous user's

### Translation Naming Conventions

- **Keys:** snake_case, namespaced by feature: `home_title`, `delete_modal_subtitle`, `form_step_1_instruction`
- **Verbs:** present-tense imperative: `submit`, `delete`, `approve` (not `submitted`, `deleting`)
- **Keep short:** long sentences are hard to translate consistently; break into labels + instructions

## Which PWAs Require Bilingual (Mandatory)

- **Recce** — field staff capture; Hindi-first user base
- **Vehicle** — field staff driver checklist; Hindi-first user base
- **Activity** — field staff time tracking; Hindi-first user base
- **Learn** — field staff training; bilingual content mandatory
- **Dispatch** (future) — field staff routing; bilingual if field-facing

All others (Finance, Admin, HR, Sales, Custodian) are optional unless explicitly requested.

## Implementation Notes

**Files and references:**
- **Bilingual DevGuide:** `/var/www/360lm/docs/bilingual_devguide.md` — full implementation reference with Recce v15a as example
- **Reference implementation:** `/var/www/360lm/recce/index.html` (v15a, lines ~100–500 for i18n infrastructure)
- **DB schema:** Recce user_prefs table (EAV pattern) or hub.employees.lang column
- **Related ADRs:** ADR-064 (video tutorials use bilingual content), ADR-065 (scene guides use bilingual objects)

**Key identifiers:**
- Global: `_lang` (current language, 'en' or 'hi')
- Helper: `t(obj)` — resolves `{en, hi}` object to current language
- HTML hook: `data-i18n="key"` + `data-i18n-attr="placeholder:key,title:key"`
- RPC: `set_language(lang)` to persist to DB
- TTS: `window.speechSynthesis` API, voices: `en-IN-PrabhatNeural`, `hi-IN-MadhurNeural` (from edge-tts)

**Find existing implementations:**
```bash
grep -r "const t = " /var/www/360lm --include="*.html" | head -5
grep -r "data-i18n=" /var/www/360lm --include="*.html" | head -10
grep -r "set_language" /var/www/360lm --include="*.html" --include="*.sql"
```

**Critical gotchas:**

1. **Language preference sources:** DB (logged-in) > localStorage (before login). Never read localStorage after login; employee session takes priority.

2. **All strings must be objects:** `{en: '...', hi: '...'}` in JS-built HTML. Plain strings in variables will not translate. The `t()` function handles both, but discipline is required.

3. **Data-i18n on HTML-injected elements:** After `el.innerHTML = \`...\``, immediately call `applyI18n(el)` or it will render in English only (data-i18n attributes are not processed until applyI18n walks the DOM).

4. **Cursor position in formatted fields:** If PWA uses Indian number formatting (ADR-071), the `fmtAmountInput()` function must NOT be called on a field that's being translated; format in the change handler, not on the HTML template string.

5. **Missing translation fallback:** If `I18N[_lang][key]` is missing, `applyI18n()` silently falls back to `.en` or shows the key itself. Watch the browser console for `[i18n] missing key:` warnings in dev mode.

6. **TTS voice availability:** Browser populates voice list asynchronously. Query `window.speechSynthesis.getVoices()` after `voiceschanged` event if initializing TTS on page load; provide a fallback voice if hi-IN/en-IN not available.

## Alternatives Considered

- **Centralized translation service (e.g., Crowdin, Lokalise):** Rejected. Overkill for current scale (20 PWAs, ~500 keys per PWA). Manual bilingual objects in code are sufficient. If translating 50+ PWAs in the future, revisit.

- **LocalStorage only (no DB persistence):** Rejected. User logs in on device A (sets lang=hi in localStorage), logs in on device B (gets device B's localStorage, lang=en). Database persistence ensures preference follows the employee, not the device.

- **Single global i18n.js file from `/shared/`:** Rejected. Violates ADR-013 (single-file, no framework, no imports from `/shared/`). Each PWA must copy helpers verbatim.

- **Pre-rendered all static strings in both languages (dual spans per text node):** Rejected. Approach used in Recce v9 (`.en-txt`/`.hi-txt` with CSS hide). Problem: dropdown `<option>` text cannot be hidden reliably; placeholder/title attributes cannot be wrapped in spans; native confirm/prompt cannot be translated. Current `data-i18n` + `applyI18n()` approach is simpler and more complete.

- **Use a translation library (i18next, gettext):** Rejected. Single HTML file constraint (ADR-013) precludes npm imports. Hand-coded objects are minimal and sufficient.

- **Separate HTML files for each language (en/index.html, hi/index.html):** Rejected. Doubles maintenance burden; language toggle would require full page reload; code duplication. Bilingual objects with `applyI18n()` reuse the same HTML and JS.

- **Machine translation (Google Translate API):** Rejected. Field staff require human-quality Hindi translations for safety-critical instructions (approval flows, error messages). No budget for API per-character cost.

## Consequences

**Positive:**
- **Single code path for all languages:** one PWA, one set of JS/HTML, language toggled at runtime. No build step needed.
- **Consistency across PWAs:** all field-facing PWAs use the same architecture; new developers know exactly what to do.
- **Device-independent preference:** DB persistence means language preference follows the employee across devices (Android, desktop, etc.).
- **Immediate re-render:** `applyI18n()` processes all HTML in one pass; no per-element observer needed.
- **Optional TTS:** can add audio guidance for high-value spots (error messages, supervisor notes) without over-engineering.
- **No external service:** free, self-hosted; no translation API costs or vendor lock-in.

**Negative / Trade-offs:**
- **Copy-paste duplication:** helpers and I18N dicts are copied into each PWA instead of imported. Intentional per ADR-013; unavoidable with single-file architecture.
- **Manual string tagging:** developers must tag every visible string with `data-i18n="key"` or wrap in `${t(...)}`; no linter to catch missed strings. Mitigation: QA checklist mandatory; toggle to हिंदी and verify every screen.
- **TTS quality varies by browser:** some browsers synthesize poor Hindi; no control over accent or pace. Mitigation: test in target browsers (Chrome, Firefox, Safari); offer pre-rendered audio for critical tutorials (future enhancement).
- **Cursor position in `data-i18n-attr` fields:** the `applyI18n()` function does not preserve cursor position when updating placeholder/title. If user is editing a field and language toggle fires, focus and selection are lost. Mitigation: language toggle requires explicit button click (not auto-detection), so UX impact is minimal.

**Risks and mitigations:**

| Risk | Mitigation |
|------|-----------|
| Developer forgets to extend I18N dict when adding new `data-i18n` keys | Checklist in VCC pre-build: every `data-i18n="key"` must have a corresponding entry in I18N.en and I18N.hi. Code review catches this. |
| Missing Hindi translations in I18N dict cause app to show English-only text to Hindi users | Same as above; QA mandatory: toggle to हिंदी and verify every visible string changes. Watch console for `missing key:` warnings. |
| Employee logs in, but language preference RPC fails; localStorage fallback is used instead | Acceptable; user experience is unchanged (still shows their language preference). Log the error but don't block page load. |
| `applyI18n()` called before `I18N` dict is declared (TDZ error) | Boot sequence must initialize I18N dict before calling `applyI18n()`. Current pattern (helpers in `<script>`, I18N dict defined, `init()` async later) is safe. |
| Language toggle is slow; full page re-render takes >500ms | Acceptable for current PWA sizes (<3000 lines). If re-renders become slow, extract `applyI18n()` logic for data-heavy sections and re-render only those sections. |
| Third-party library (Leaflet, SortableJS) renders text in English; toggle doesn't translate it | Out of scope for this ADR; library text is not part of the PWA's UI text. If library needs translation, patch the instance after init (e.g., `map.editControl.setTitle(t({en: '...', hi: '...'}))`) or use library's own i18n hook. |

## Related Decisions

- **ADR-012:** Hub as SSO Gateway — employee language preference synced from hub session
- **ADR-013:** Single HTML File, No Framework — why helpers are copied, not imported
- **ADR-064:** Video Tutorial Production Pipeline — screencasts use bilingual content objects (`{en, hi}`)
- **ADR-065:** Scene Guide Format — scene guides use bilingual JSON objects for title, text, tips
- **ADR-071:** Indian Number Formatting — similar copy-verbatim pattern for utility functions
- **ADR-001:** Hub ?next= Redirect on Login — PWAs redirect to hub without session

## References

- **Bilingual DevGuide (full reference):** `/var/www/360lm/docs/bilingual_devguide.md`
- **Reference implementation:** `/var/www/360lm/recce/index.html` (v15a, 2026-06-05)
- **DB schema (Recce example):** `/var/www/360lm/recce/migrate_recce_v15.sql` — user_prefs EAV table
- **Related docs:** 
  - Input and image standards (section 1): `/var/www/360lm/docs/input_and_image_standards.md` — Indian number formatting (copy-verbatim pattern, like this ADR)
  - Learning Hub architecture: `/var/www/360lm/learn/index.html` — language preference stored in employee profile
  - Video tutorial style: `/var/www/360lm/video_tutorial_style.md` — edge-tts voices (hi-IN-MadhurNeural, en-IN-PrabhatNeural)

---

**Changed via:** adr-kit (360lm)  
**Last updated:** 2026-06-27
