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

14 KiB

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

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:

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

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:

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.

// 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.

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:

{
  "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

node scripts/generate-pseudo-locales.cjs

4. Validate

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:

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:

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 messagestoast.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 (useXOptions() / useFormatX()) instead. Applies to inline const fooOptions = [{ value, label: 'Xyz' }, ...] arrays inside components as well.