Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz Source: /opt/alga-psa on psa.joliet.tech
12 KiB
PRD — i18n Hardening: Locale-Aware Formatting, Gap Backfill, Pluralization, CI Enforcement
- Slug:
2026-06-10-i18n-formatting-and-gaps - Date:
2026-06-10 - Status: Draft
Summary
Close the remaining systemic i18n gaps that sit around string translation rather than in it. Five phases, each independently shippable as a PR:
| Phase | Area | Size |
|---|---|---|
| P1 | Locale-aware formatting components + pseudo-locales in dev mode | ~8 files |
| P2 | Backfill 555 missing English locale keys (+ 7 locale translations) | ~20 namespaces |
| P3 | Translate skipped surfaces: msp-composition client/contact tabs + interactions feeds |
6 components |
| P4 | Pluralization: migrate dead legacy _plural keys, fix CLDR-unaware validator |
4 keys + validator |
| P5 | CI enforcement: find-missing-i18n-keys.cjs in CI with full path triggers |
1 workflow |
Problem
- Formatting ignores locale.
CurrencyInput,DatePicker,DateTimePicker,Calendar, andReportEnginehardcodeen-US/MM/dd/yyyy, so even fully translated screens render US formats for all users. - 555 keys referenced in code don't exist in English locale files (
find-missing-i18n-keys.cjs), so users see default-value fallbacks or raw keys. Concentrated in workflows (131), email-providers (111), integrations (81), features/tickets (81), clients (54), assets (40), user-activities (37). packages/msp-compositionwas never claimed by any translation batch. The MSP i18n plans were organized by feature package; the composition layer that renders the Tickets/Assets tabs on client and contact pages (MspClientTickets,MspClientAssets,MspContactTickets) is fully hardcoded. The interactions feeds (InteractionsFeed,OverallInteractionsFeed,SchedulingInteractionDetails) were similarly skipped.- Legacy
key_pluralkeys are dead. i18next v25 runs in v4 plural mode (nocompatibilityJSONis set anywhere), so the 4 legacy_pluralkeys inen/msp/settings.jsonanden/msp/profile.jsonnever resolve. Worse,validate-translations.cjsis CLDR-unaware: it falsely warns on Polish_few/_manyforms (which are required for Polish) while not flagging genuinely missing plural forms. - Pseudo-locales (
xx/yy) were meant to be selectable in dev mode butfilterPseudoLocales()strips them unconditionally — the original intent slipped through the cracks. - No CI guard against the "missing key" class of regression.
find-missing-i18n-keys.cjsexits 1 on failure but is manual-only, and thevalidate-translations.ymlworkflow only triggers on locale-file paths, so component changes never run it.
Goals
- All shared formatting components and ReportEngine derive formats from the active locale (purely locale-derived; no new preference UI).
- Pseudo-locales appear in all language pickers when
NODE_ENV === 'development'. find-missing-i18n-keys.cjsreports 0 missing keys; backfilled keys translated into all 7 non-English production locales + regenerated pseudo-locales.- Client-page Tickets/Assets/Interactions tabs (and contact-page tickets) are fully translated.
- All plural keys use i18next v4 count-based forms (
_one/_other/_few/_manyper CLDR); validator understands CLDR plural categories per locale. - Both i18n scripts run in CI on every PR that touches components or locale files.
Non-goals
- Transactional EJS emails (verify/reset/welcome) — explicitly deferred.
- Invoice PDF / AssemblyScript-WASM renderer localization.
- Page metadata (
generateMetadata) localization. - RTL support.
- Explicit user-facing date-format preference (separate future effort; this plan stays locale-derived so that effort layers on top).
- ESLint rule against hardcoded JSX strings (deferred; revisit after P5 proves out).
- Completing the
ptlocale (stays inINCOMPLETE_LOCALES). - Translating ui-reflection
label/helperTextautomation metadata (not user-visible UI).
Requirements
P1a — Locale-aware formatting components
Foundation: add useOptionalI18n() to packages/ui/src/lib/i18n/client.tsx — same as useI18n() but returns null outside I18nProvider (DatePicker/CurrencyInput render on auth pages without the provider; they must not crash). Locale falls back to LOCALE_CONFIG.defaultLocale. packages/ui/src/lib/dateFnsLocale.ts already maps all 10 locales to date-fns locale objects.
| Component | Change |
|---|---|
DatePicker.tsx:117 |
format(value, 'P', { locale: getDateFnsLocale(locale) }); optional displayFormat prop escape hatch |
DateTimePicker.tsx:182 |
Explicit timeFormat prop wins ('P hh:mm a' / 'P HH:mm'); unset → 'P p' |
Calendar.tsx |
Pass date-fns locale to DayPicker; localize MonthYearSelect caption format() calls |
CurrencyInput.tsx |
Locale-aware format and parse (see risk below) |
PrintOptionsDialog.tsx:67 |
formatDefaultPrintValue uses active locale instead of bare toLocaleString() |
ReportEngine.ts (both copies) |
locale?: string on ReportExecutionOptions, threaded to all 5 format* call sites; default 'en-US' preserves behavior for untouched callers. executeReport action resolves locale via getHierarchicalLocaleAction from @alga-psa/tenancy |
Critical risk — CurrencyInput parsing: current code does raw.replace(/,/g, '') + parseFloat. If formatting becomes French (1 234,56) but parsing stays US, typing 12,5 yields 1250 — silent 100× data corruption. Formatting and parsing must change as a unit: derive group/decimal separators from Intl.NumberFormat(locale).formatToParts(), strip group separators, normalize the decimal separator to . before parseFloat. Round-trip tests for en, de, fr, pl mandatory. Only 2 usage sites, limiting blast radius.
Audit step: review DatePicker's 37+ call sites for any that rely on MM/dd/yyyy output for parsing/serialization rather than pure display.
P1b — Pseudo-locales in dev mode
Single change point: filterPseudoLocales() in packages/core/src/lib/i18n/config.ts:127 keeps xx/yy when process.env.NODE_ENV === 'development'. All 7 pickers already route through it. INCOMPLETE_LOCALES (pt) stays filtered even in dev. Precedent for NODE_ENV branching exists in the same file (cookie.secure, i18next debug); Next.js inlines it client-side. Rebuild packages/core dist (npx nx build core).
P2 — Missing English keys backfill (555)
Backfill en first (defaults are mostly recoverable from defaultValue in the t() calls), then translate to fr/es/de/nl/it/pl/pt, then regenerate pseudo-locales. Namespace order by user impact:
| Namespace | Keys | Primary files |
|---|---|---|
| msp/workflows | 131 | WorkflowRunList, WorkflowDesigner, RunStudioShell (EE) |
| msp/email-providers | 111 | InboundEmailRuleForm (83 refs) |
| msp/integrations | 81 | LevelIoIntegrationSettings (61), InboundEmailRulesManager |
| features/tickets | 81 | TicketChecklistSection (30), TicketingDashboard (23) |
| msp/clients | 54 | Clients.tsx, MeetingAttendeesPicker, ClientDetails |
| msp/assets | 40 | AssetDashboardClient (37) |
| msp/user-activities | 37 | ActivitiesTableFilters, AdHocDetailPanel |
| ~13 others | ~120 | incl. cross-namespace actions.print/actions.printOptions cluster (print/export feature shipped without any keys) |
Exit criterion: node scripts/find-missing-i18n-keys.cjs exits 0.
P3 — Skipped surfaces
| Component | Location | Namespace |
|---|---|---|
MspClientTickets.tsx |
packages/msp-composition/src/clients/ |
msp/clients |
MspClientAssets.tsx |
packages/msp-composition/src/clients/ |
msp/clients |
MspContactTickets.tsx |
packages/msp-composition/src/clients/ |
msp/contacts |
InteractionsFeed.tsx |
packages/clients/src/components/interactions/ |
msp/clients |
OverallInteractionsFeed.tsx |
packages/clients/src/components/interactions/ |
msp/clients |
SchedulingInteractionDetails.tsx |
packages/scheduling/src/components/shared/ |
msp/schedule |
Wire useTranslation(), add keys to en + 7 locales + pseudo-locales. Verify ROUTE_NAMESPACES already loads the chosen namespaces on /msp/clients and /msp/contacts (it does today). Sweep the other 16 msp-composition files and document that they have no user-visible strings (providers/wrappers); record per-file in SCRATCHPAD.
P4 — Pluralization
Inventory (confirmed): 4 legacy _plural keys across en/msp/settings.json + en/msp/profile.json (teams.details.memberCount_plural, interaction-types imported_plural, security.sessions.subtitle_plural ×2) plus counterparts in 7 locales.
- Migrate to i18next v4 forms:
_one/_otherfor English; correct CLDR set per locale (Polish:_one/_few/_many/_other). - Fix consumers doing manual plural selection —
AdminSessionManagement.tsx:239-240pickssubtitlevssubtitle_pluralin code; replace with a singlet()call usingcount. Note:subtitleinterpolates two counts (sessionCount,userCount); passcount: sessionCountfor plural selection while keeping both interpolation variables. - Make
validate-translations.cjsCLDR-aware viaIntl.PluralRules(locale).resolvedOptions().pluralCategories: stop false-warning on Polish_few/_many; flag locales missing required plural categories for a key that has plural forms in English. - Verify
generate-pseudo-locales.cjscarries plural-suffixed keys through correctly.
Exit criterion: validate-translations.cjs passes with 0 warnings.
P5 — CI enforcement
- Add a
find-missing-i18n-keysjob to.github/workflows/validate-translations.yml(script already exits 1 on failure). - Expand workflow
pathstriggers to include component sources:packages/**,server/src/**,ee/server/src/**,scripts/find-missing-i18n-keys.cjs(currently locale files only — component changes never trigger validation). - Must land after P2 (otherwise CI is red on arrival).
Rollout
Five PRs, one per phase, in order P1 → P2 → P3 → P4 → P5 (P3/P4 may proceed in parallel after P2; P5 strictly after P2). Each phase leaves main green: validation scripts pass, existing .i18n.test.* audit suites pass.
Risks
| Risk | Mitigation |
|---|---|
| CurrencyInput locale parsing corrupts amounts (100× errors) | Format+parse change as a unit; round-trip tests across 4 locales; only 2 usage sites |
DatePicker call sites depending on fixed MM/dd/yyyy output |
Pre-change audit of all 37+ call sites; displayFormat escape hatch |
| 555-key backfill drifts from on-screen defaults | Source keys from defaultValue in code; pseudo-locale visual QA (unlocked by P1b) |
| Machine-translated backfill quality in 7 locales | Same translation process as prior MSP batches; pt already flagged incomplete |
| Plural migration changes rendered strings | Per-key before/after snapshot in tests; only 4 keys |
| CI job surfaces pre-existing failures on unrelated PRs | Land P5 only after P2 zeroes the count |
Open Questions
- Namespace for interactions feed keys: reuse
msp/clientsinteractions.*prefix (whereInteractionDetails/QuickAddInteractionkeys already live) — assumed yes. - Should
DateTimePicker'stimeFormatdefault flip to locale-derived everywhere, or keep12hdefault where currently explicit? Assumed: explicit prop wins, unset becomes locale-derived. - ESLint hardcoded-string rule — deferred to a follow-up after P5; revisit then.
Acceptance Criteria / Definition of Done
- A user with
frlocale seesdd/MM/yyyy-style dates in DatePicker/DateTimePicker/Calendar, French month/weekday names, and1 234,56-style numbers in CurrencyInput — and typed12,5parses as 12.5. - Reports render dates/numbers/currency in the viewer's hierarchical locale; callers passing no locale get unchanged
en-USoutput. - In dev mode, all language pickers list
Pseudo (xx)andPseudo (yy); in production builds they don't.pthidden in both. node scripts/find-missing-i18n-keys.cjsexits 0.- Client page Tickets/Assets/Interactions tabs and contact page Tickets tab render fully in the active locale (verified via pseudo-locale).
- No
_plural-suffixed keys remain in any locale file;validate-translations.cjspasses with 0 errors and 0 warnings; Polish plural forms render correctly for counts 1, 2, 5. - Both scripts run in CI on PRs touching
packages/**,server/src/**,ee/server/src/**, or locale files.