Hermes 284313f908
Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Initial import of AlgaPSA codebase from PSA server
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz

Source: /opt/alga-psa on psa.joliet.tech
2026-06-22 16:12:17 -05:00

332 lines
14 KiB
Markdown

# Internationalization (i18n) Architecture
Alga PSA uses **i18next** with **react-i18next** for internationalization. Both the MSP portal and Client Portal are fully internationalized with feature-based namespace splitting and lazy loading.
## Quick Reference
| Item | Value |
|------|-------|
| Languages | en, fr, es, de, nl, it, pl (+ xx, yy pseudo-locales for QA) |
| Total keys per language | ~9,959 |
| Namespace files per language | 27 |
| Translation files | `server/public/locales/{lang}/{namespace}.json` |
| Central config | `packages/core/src/lib/i18n/config.ts` |
| Feature flag (MSP) | `msp-i18n-enabled` |
| Validation | `node scripts/validate-translations.cjs` |
| Pseudo-locale generation | `node scripts/generate-pseudo-locales.cjs` |
## Key Files
| Purpose | Path |
|---------|------|
| Core config (LOCALE_CONFIG, ROUTE_NAMESPACES, types) | `packages/core/src/lib/i18n/config.ts` |
| Re-exports from core (convenience) | `packages/ui/src/lib/i18n/config.ts` |
| I18nProvider, useTranslation, useFormatters | `packages/ui/src/lib/i18n/client.tsx` |
| I18nWrapper (route-aware, portal-aware) | `packages/tenancy/src/components/i18n/I18nWrapper.tsx` |
| Core formatters (server-side) | `packages/core/src/lib/formatters.ts` |
| Email locale resolver | `packages/notifications/src/notifications/emailLocaleResolver.ts` |
| MSP layout integration | `server/src/app/msp/MspLayoutClient.tsx` |
| MSP server layout (flag check + locale) | `server/src/app/msp/layout.tsx` |
| i18n middleware (edge-safe) | `server/src/middleware/i18n.ts` |
| MSP language settings UI | `server/src/components/settings/general/MspLanguageSettings.tsx` |
| Translation validation CI | `.github/workflows/validate-translations.yml` |
## Namespace Structure
```
server/public/locales/{lang}/
├── common.json (646 keys) — shared across entire app
├── client-portal.json (365 keys) — client portal UI chrome
├── features/ — shared between both portals
│ ├── tickets.json (892 keys)
│ ├── projects.json (1,174 keys)
│ ├── documents.json (172 keys)
│ ├── appointments.json (106 keys)
│ └── billing.json (82 keys)
└── msp/ — MSP portal only
├── core.json (152 keys) — loads on every MSP route
├── settings.json (769 keys)
├── clients.json (953 keys)
├── assets.json (782 keys)
├── onboarding.json (462 keys)
├── contacts.json (395 keys)
├── schedule.json (293 keys)
├── email-providers.json (285 keys)
├── time-entry.json (259 keys)
├── surveys.json (259 keys)
├── knowledge-base.json (163 keys)
├── admin.json (137 keys)
├── profile.json (117 keys)
├── licensing.json (108 keys)
├── reports.json (99 keys)
├── dispatch.json (98 keys)
├── dashboard.json (89 keys)
├── jobs.json (47 keys)
└── extensions.json (44 keys)
```
### Which namespace to use
| String location | Namespace |
|----------------|-----------|
| Generic button/label used everywhere (Save, Cancel, Delete) | `common` |
| Client portal nav, auth, dashboard, profile | `client-portal` |
| Ticket/project/billing/document UI used by both portals | `features/*` |
| MSP nav, sidebar, header | `msp/core` |
| MSP feature page (time entry, dispatch, assets, etc.) | `msp/<feature>` |
### Route-to-namespace mapping
Namespaces are lazy-loaded per route. The mapping is defined in `ROUTE_NAMESPACES` in `packages/core/src/lib/i18n/config.ts`. When adding new routes, register them there so the correct namespaces are preloaded.
## How to Use Translations
### In React components
```tsx
import { useTranslation } from '@alga-psa/ui/lib/i18n/client';
export function MyComponent() {
const { t } = useTranslation('msp/time-entry');
return (
<div>
<h1>{t('page.title')}</h1>
<button>{t('actions.save')}</button>
<p>{t('messages.success.created', { name: itemName })}</p>
</div>
);
}
```
### Locale-aware formatting
Use `useFormatters()` instead of hardcoded date/number/currency formatting:
```tsx
import { useFormatters } from '@alga-psa/ui/lib/i18n/client';
export function InvoiceRow({ invoice }) {
const { formatDate, formatCurrency, formatNumber, formatRelativeTime } = useFormatters();
return (
<tr>
<td>{formatDate(invoice.date, { month: 'short', day: 'numeric' })}</td>
<td>{formatCurrency(invoice.amount, 'USD')}</td>
<td>{formatNumber(invoice.tax, { style: 'percent' })}</td>
<td>{formatRelativeTime(invoice.created_at)}</td>
</tr>
);
}
```
**Migration from hardcoded patterns:**
| Old pattern | New pattern |
|------------|------------|
| `new Date(x).toLocaleDateString('en-US', opts)` | `formatDate(x, opts)` |
| `` `$${amount.toFixed(2)}` `` | `formatCurrency(amount, 'USD')` |
| `amount.toFixed(2)` (for display) | `formatNumber(amount, { minimumFractionDigits: 2 })` |
| Hardcoded `"Today"`, `"Yesterday"` | `formatRelativeTime(date)` |
### Server-side formatting
```typescript
import { formatCurrency, formatDate } from '@alga-psa/core';
formatCurrency(100.50, 'fr', 'EUR'); // "100,50 EUR"
formatDate(new Date(), 'de'); // "18.02.2026"
```
### Error and toast messages
Server actions return English error strings. Components map them to translation keys:
```tsx
const { t } = useTranslation('msp/settings');
const handleSave = async () => {
const result = await updateSettings(data);
if (result.success) {
toast.success(t('messages.success.updated'));
} else {
const errorMap: Record<string, string> = {
'Permission denied': t('errors.permissionDenied'),
'Invalid configuration': t('errors.invalidConfig'),
};
toast.error(errorMap[result.error!] || t('errors.saveFailed'));
}
};
```
### Enum labels from shared constants
Shared enum labels (e.g. `BILLING_FREQUENCY` → Monthly / Quarterly / Annually, `CONTRACT_LINE_TYPE` → Fixed / Hourly / Usage Based, status badges, Yes/No) must not ship as hardcoded English strings inside `*_DISPLAY` / `*_OPTIONS` constant files. The labels are baked in at module load, bypass `t()`, and are invisible to both the audit test suite and `validate-translations.cjs` — only pseudo-locale visual QA catches them, and only on screens someone remembers to visit.
The project uses a **localized option-hook pattern**: the constant file owns the values and the TypeScript types, a colocated hook produces the localized option list, and every consumer imports the hook instead of the constant.
```ts
// Source of truth: values only
export const BILLING_FREQUENCY_VALUES = ['monthly', 'quarterly', 'annually'] as const;
export type BillingFrequency = typeof BILLING_FREQUENCY_VALUES[number];
// Published hook: returns localized options
export function useBillingFrequencyOptions() {
const { t } = useTranslation('features/billing');
return BILLING_FREQUENCY_VALUES.map((value) => ({
value,
label: t(`enums.billingFrequency.${value}`),
}));
}
// Companion formatter for table/cell renderers
export function useFormatBillingFrequency() {
const { t } = useTranslation('features/billing');
return (value: string) => t(`enums.billingFrequency.${value}`, { defaultValue: value });
}
```
Consumers call the hook at the top of the component body and pass the result into `<CustomSelect>`, column `render` callbacks, badges, etc. The route that renders any consumer must have the chosen namespace listed in `ROUTE_NAMESPACES` or the hook returns the raw key.
**Where to put the keys:**
| Blast radius | Namespace |
|---|---|
| Single feature area | `msp/<feature>.json` or `features/<feature>.json` under `enums.<enumName>.*` |
| One domain, many features | `features/<domain>.json` under `enums.<enumName>.*` |
| App-wide (Yes/No, status, priority) | `common.json` under `enums.*` |
Inline hardcoded option arrays inside components (`const userTypeOptions = [{ value, label: 'Technician' }, ...]`) are the same anti-pattern with a smaller blast radius and should be migrated the same way — or deleted if they are dead code.
Full recipe, reviewer checklist, audit greps, and the current migration backlog live in [`.ai/translation/enum-labels-pattern.md`](../../.ai/translation/enum-labels-pattern.md).
## How to Add Translation Keys
### 1. Add keys to the English JSON file
Add your keys to `server/public/locales/en/<namespace>.json` following the naming convention:
```json
{
"page": { "title": "...", "description": "..." },
"sections": { "sectionName": { "title": "...", "empty": "..." } },
"fields": { "fieldName": { "label": "...", "placeholder": "...", "help": "..." } },
"actions": { "create": "...", "edit": "...", "delete": "..." },
"table": { "columns": { "name": "...", "status": "..." }, "empty": "..." },
"dialogs": { "confirmDelete": { "title": "...", "message": "...", "confirm": "...", "cancel": "..." } },
"errors": { "loadFailed": "...", "saveFailed": "...", "permissionDenied": "..." },
"validation": { "nameRequired": "...", "emailInvalid": "..." },
"messages": { "success": { "created": "...", "updated": "..." }, "error": { "createFailed": "..." } }
}
```
### 2. Add translations for all other languages
Add the same keys with translated values to:
- `server/public/locales/fr/<namespace>.json`
- `server/public/locales/es/<namespace>.json`
- `server/public/locales/de/<namespace>.json`
- `server/public/locales/nl/<namespace>.json`
- `server/public/locales/it/<namespace>.json`
- `server/public/locales/pl/<namespace>.json`
### 3. Regenerate pseudo-locales
```bash
node scripts/generate-pseudo-locales.cjs
```
### 4. Validate
```bash
node scripts/validate-translations.cjs
```
This checks: all keys present in all languages, no extra keys, no broken `{{variables}}`, valid JSON.
### 5. Register routes (if new namespace)
If you created a new namespace file, add route entries to `ROUTE_NAMESPACES` in `packages/core/src/lib/i18n/config.ts`:
```typescript
export const ROUTE_NAMESPACES = {
// ...
'/msp/my-feature': ['common', 'msp/core', 'msp/my-feature'],
};
```
## How Locale Resolution Works
### MSP Portal (internal users)
1. User language preference (`user_preferences` table)
2. MSP org default locale (`tenant_settings.settings.mspPortal.defaultLocale`)
3. System default (`'en'`)
Gated by the `msp-i18n-enabled` feature flag. When the flag is off, the MSP portal forces English. The `I18nWrapper` always renders regardless of the flag state — it just forces `initialLocale='en'` when off.
### Client Portal
1. User language preference
2. Client default locale (`clients.properties.defaultLocale`)
3. Tenant client portal default (`tenant_settings.settings.clientPortal.defaultLocale`)
4. Cookie / browser language
5. System default (`'en'`)
### Emails
Currently locale-resolved for **client portal users only**. MSP internal users receive English emails regardless of preference.
Resolution hierarchy (client users): User preference > Client preference > Tenant client portal default > Tenant default > English. Falls back to English if no template exists for the resolved locale.
**Translation coverage:** en, fr, es, de, nl have full coverage. Italian and Polish are partial — missing project, SLA, time entry, and billing-misc email templates. See `docs/email/email-i18n-implementation-summary.md` for details.
## Pseudo-Locale Visual QA
Two pseudo-locales exist for visual testing:
- `xx` — all values = `'11111'`
- `yy` — all values = `'55555'`
**QA process:**
1. Enable the `msp-i18n-enabled` flag locally
2. Switch to locale `xx` in the language picker
3. Navigate the feature area
4. Every user-visible string should show `11111`
5. Any string still showing English was not extracted — fix it
Dates, numbers, and currency will NOT show `11111` — they come from `useFormatters()`.
## CustomTabs and Translation
`CustomTabs` uses a required `id` field for matching (URL sync, state, Radix values). The `label` property is display-only and safe to translate:
```tsx
const tabs = [
{ id: 'general', label: t('tabs.general'), content: <GeneralTab /> },
{ id: 'billing', label: t('tabs.billing'), content: <BillingTab /> },
];
```
Always store/compare active tab state using `id`, never `label`.
## Adding a New Language
1. Add the locale code to `packages/core/src/lib/i18n/config.ts` in `supportedLocales` and `localeNames`
2. Copy `server/public/locales/en/` to `server/public/locales/<code>/` and translate all files
3. Create email template migrations (see `server/migrations/20251228123000_add_polish_email_templates.cjs` for reference)
4. Create internal notification template migrations (see `server/migrations/20251228120000_add_polish_internal_notification_templates.cjs`)
5. Add language to `languageNames` mapping in `server/src/components/settings/notifications/EmailTemplates.tsx` (3 locations)
6. Run `node scripts/generate-pseudo-locales.cjs` and `node scripts/validate-translations.cjs`
7. Test in browser with both portals
## Common Pitfalls
1. **Wrong import path** — use `@alga-psa/ui/lib/i18n/client`, not a server path
2. **Hardcoded locale in formatting** — use `useFormatters()`, not `toLocaleDateString('en-US')`
3. **Missing route in ROUTE_NAMESPACES** — if `t('key')` returns the key itself, the namespace likely isn't loaded for that route
4. **German/Dutch text overflow** — these translations are 30-50% longer than English; test layouts
5. **Forgetting toast/error messages** — `toast.success()`, `toast.error()`, `setError()` calls are easy to miss
6. **Missing aria-labels and tooltips** — accessibility text must also be translated
7. **Currency symbol placement** — use `formatCurrency()` which handles per-locale placement
8. **Enum labels from `*_DISPLAY` / `*_OPTIONS` constants** — labels imported from a constant file never reach `t()` and never appear in the audit suite. Use the [enum-labels option-hook pattern](../../.ai/translation/enum-labels-pattern.md) (`useXOptions()` / `useFormatX()`) instead. Applies to inline `const fooOptions = [{ value, label: 'Xyz' }, ...]` arrays inside components as well.