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

18 KiB
Raw Permalink Blame History

Scratchpad — i18n Hardening: Formatting, Gaps, Pluralization, CI

  • Plan slug: 2026-06-10-i18n-formatting-and-gaps
  • Companion files: PRD.md, features.json, tests.json

Discoveries (2026-06-10, pre-implementation investigation)

Formatting components

  • packages/ui/src/components/CurrencyInput.tsx:16 hardcodes Intl.NumberFormat('en-US'). Bigger issue: parsing at lines 44/53 does raw.replace(/,/g, '') + parseFloat — US-only. Locale-aware formatting without locale-aware parsing → French 12,5 parses as 1250 (silent 100× corruption). Format+parse must change as a unit. Only 2 usage sites repo-wide.
  • DatePicker.tsx:117 and DateTimePicker.tsx:182 hardcode MM/dd/yyyy date-fns patterns (the often-cited "lines 11/10" in earlier analysis pointed at imports; real lines are 117/182).
  • packages/ui/src/lib/dateFnsLocale.ts already maps all 10 locales to date-fns locale objects (getDateFnsLocale) — no new locale-data plumbing needed. xx/yy map to enUS.
  • useFormatters() in packages/ui/src/lib/i18n/client.tsx:236 already does Intl-based locale formatting; useI18n() throws outside I18nProvider (client.tsx:182) — DatePicker/CurrencyInput render on auth pages without the provider, hence the new useOptionalI18n().
  • I18nProvider is mounted via I18nWrapper from @alga-psa/tenancy/components in MspLayoutClient.tsx:178 and ClientPortalLayoutClient.tsx; also on some auth pages.
  • ReportEngine exists in two near-identical copies: packages/reporting/src/lib/reports/core/ReportEngine.ts and server/src/lib/reports/core/ReportEngine.ts. en-US hardcoded at lines ~205-228 (currency/number/percentage) and ~257-260 (dates) in each. All format* methods are static; entry is ReportEngine.execute(definition, parameters, options) called from executeReport actions. Dedup is out of scope — mirror changes.
  • Locale resolution for server actions: getHierarchicalLocaleAction in packages/tenancy/src/actions/locale-actions/getHierarchicalLocale.ts:47 (wrapped in withOptionalAuth).
  • PrintOptionsDialog.tsx:67 uses bare value.toLocaleString() (browser-default locale) — minor, included in P1.

Pseudo-locales

  • filterPseudoLocales() in packages/core/src/lib/i18n/config.ts:127 strips PSEUDO_LOCALES + INCOMPLETE_LOCALES unconditionally. All 7 pickers route through it: LanguagePreference, ClientLanguagePreference, ClientPortalLanguageConfig, client.tsx:160, ClientInfoStep (onboarding), MspLanguageSettings, ClientPortalSettings.
  • localeNames already has Pseudo (xx)/Pseudo (yy) entries; isSupportedLocale accepts them → saving the preference Just Works once unfiltered.
  • NODE_ENV branching precedent in the same file: cookie.secure (line 53), I18N_CONFIG.debug (line 95). Next.js inlines process.env.NODE_ENV client-side, including pre-built package dist code.
  • packages/core is pre-built (tsup) → remember npx nx build core after editing.

Missing keys (P2)

  • node scripts/find-missing-i18n-keys.cjs555 missing English keys; exits 1 on failure (CI-ready), exits 0 when clean.
  • Breakdown: msp/workflows 131, msp/email-providers 111, msp/integrations 81, features/tickets 81, msp/clients 54, msp/assets 40, msp/user-activities 37, rest ~120 spread across msp/settings (24), common (24), msp/invoicing (22), msp/keyboard-shortcuts (17), msp/quotes (13), msp/contracts (9), projects (7), msp/schedule (7), features/projects (6), msp/knowledge-base (4), features/documents (4), client-portal (4), msp/time-entry (3).
  • Worst files: InboundEmailRuleForm.tsx (83 refs), LevelIoIntegrationSettings.tsx (61, EE), AssetDashboardClient.tsx (37), InboundEmailRulesManager.tsx (33), TicketChecklistSection.tsx (30).
  • Pattern: actions.print/actions.printOptions referenced across 7+ namespaces, exist in none — print/export feature shipped without keys.
  • Most t() calls carry defaultValue → en backfill is largely mechanical extraction.

Skipped surfaces (P3) — root cause

  • MSP i18n batch plans (ee/docs/plans/2026-02..04-*) were organized by feature package; packages/msp-composition (the glue layer composing features into pages) was never claimed by any batch. 19/20 files there lack useTranslation; only 3 have user-visible strings: MspClientTickets.tsx, MspClientAssets.tsx, MspContactTickets.tsx (hardcoded "Client Tickets", "Loading tickets...", "Select Status", "All Priorities", "Search tickets...", "Loading assets...", "All asset types...").
  • Wiring: ClientDetails.tsx renders tabs via useClientCrossFeature()MspClientCrossFeatureProvider.tsx (packages/msp-composition) → these components. Tab ids stay untranslated per 2026-03-24 decision in the clients batch (stable ids).
  • Interactions split: InteractionsFeed.tsx, OverallInteractionsFeed.tsx (packages/clients), SchedulingInteractionDetails.tsx (packages/scheduling) have zero i18n; InteractionDetails, QuickAddInteraction, MeetingAttendeesPicker + 3 settings components are translated (but reference ~26 of the 555 missing keys).
  • ui-reflection label/helperText strings (automation metadata) — decision: not translated, out of scope (PRD non-goal).
  • AlgaDeskClientCrossFeatureProvider.tsx renders tickets for AlgaDesk mode (renderClientAssets: () => null) — include in P3 sweep verdicts.

Pluralization (P4) — key finding

  • No compatibilityJSON is set anywhere → i18next v25 runs in v4 plural mode → legacy key_plural suffix keys are dead code; they never resolve.
  • Only 4 legacy _plural keys in en: teams.details.memberCount_plural, interaction-types imported_plural (both msp/settings.json), security.sessions.subtitle_plural (msp/profile.json + msp/settings.json).
  • The codebase already mostly uses v4 forms correctly — e.g. emailLogs.results_one/_other in en with _one/_few/_many/_other in pl.
  • validate-translations.cjs is CLDR-unaware: its 8 warnings about pl _few/_many ("Extra key not in English") are false positives — those forms are required for Polish. Fix with new Intl.PluralRules(locale).resolvedOptions().pluralCategories.
  • AdminSessionManagement.tsx:239-240 does manual plural selection (subtitle vs subtitle_plural ternary). Gotcha: key interpolates two counts (sessionCount, userCount) — plural selection needs count: sessionCount passed alongside both variables.
  • TeamDetails.tsx:401 already calls t('teams.details.memberCount', { count }) — currently falls back to the base key since _one/_other don't exist; migration makes it actually pluralize.

CI (P5)

  • .github/workflows/validate-translations.yml triggers only on server/public/locales/** + the two scripts → component-only PRs never validated. Expand paths + add find-missing job. Must land after P2 (else red on arrival).

P1 implementation notes (2026-06-10)

  • DatePicker call-site audit (F004/T006): clean. 55 call sites repo-wide (packages 47, server 5, ee 3). Zero parse/serialize/test-assert on the rendered MM/dd/yyyy string; all pass Date objects (DocumentFilters constructs Dates from strings before passing). All 8 test files that touch DatePicker mock it. Three unrelated components format their own strings with MM/dd/yyyy (ContractBasicsStep summary, DeadlineFilter label, DisplaySettings config constant) — untouched by this change.
  • DateTimePicker: when timeFormat prop unset, the internal hour-list/AM-PM UI now derives 12h/24h from the locale via Intl.DateTimeFormat(locale, {hour:'numeric'}).resolvedOptions().hour12 (xx/yy normalized to en), keeping picker UI consistent with the 'P p' display.
  • CurrencyInput parse strategy: keep digits/sign/locale-decimal-separator (normalized to '.'), drop everything else — covers group separators incl. plain-space/NBSP/narrow-NBSP variants users type. Helpers formatCurrencyValue/parseCurrencyValue/getNumberSeparators exported for tests. Default placeholder now locale-formatted 0.00.
  • PrintOptionsDialog: Date cells render via internal <LocaleDateTime> (useOptionalI18n + Intl dateStyle/timeStyle short) since formatDefaultPrintValue is a plain function.
  • executeReport: explicit options.locale wins; otherwise resolved via getHierarchicalLocaleAction(). packages/reporting gained an @alga-psa/tenancy dep (no cycle: tenancy doesn't depend on reporting). Both copies kept byte-identical (cp + diff).
  • ReportEngine copies: locale threaded through formatCurrency/formatNumber/formatPercentage/formatDate in both; formatDuration left as-is (English unit words are a translation problem, out of P1 scope).
  • T020 satisfied by construction: the two ReportEngine copies differ only in import paths/execute signature (verified by diff), so formatting output is identical.
  • T001 inside-provider half: not unit-tested (I18nProvider pulls i18next HTTP backend); covered by the null-outside test + app usage. T017 (PrintOptionsDialog render test) skipped — trivial wrapper. T024/T025 are manual dev-stack QA.
  • Pre-existing test failures (not from P1): packages/core — index exports, logger.outputs, deletionMigrations (5-6 tests); packages/ui — Calendar today-button, DeleteEntityDialog ×2, printStylesheet, BoardPicker.keyboard, TreeSelect.contract ×2, command-palette.source (6 files). Verified identical on clean tree at cb6731584f.

P2 implementation notes (2026-06-10)

  • Extraction: one-off scanner re-implementation pulled defaultValue from each missing-key t() call; 505/551 keys merged mechanically into en (values are byte-identical to what the UI previously rendered — T033 holds by construction). Conflicting defaults for inboundRules.fields.subject/bodyText resolved to the capitalized field-label variants (3 of 4 usages).
  • Scanner fixes (find-missing-i18n-keys.cjs) — needed for exit-0 to be reachable:
    1. Plural-aware resolution: t('key', { count }) resolves via key_one/key_other/… in i18next v4; the scanner now accepts CLDR-suffixed variants (e.g. emailLogs.results, quickAdd.tagCreatePartialFailure already existed as _one/_other pairs and were false positives).
    2. Skip test files: contract tests assert on t('…') source literals (AdminWebhooksSetup.ui.contract.test.ts greps for the security.webhooks.inbound. prefix; i18n.contract.test.ts contains namespace strings) — these aren't runtime calls. Also skip keys ending in '.'.
  • Dynamic-default keys hand-resolved: documents.associatedEntityPicker.* (4 keys → {{entityType}} interpolations), messages.error.saveAllPartial ({{sections}}), settings.modHint (template literal → {{modKey}} + KeyboardShortcutsPanel.tsx now passes modKey).
  • header.breadcrumb restructure: msp/core header.breadcrumb was a string (nav aria-label) blocking header.breadcrumb.dashboard/home. Converted to object {label, dashboard, home} in ALL 10 locales (translated label preserved); Header.tsx aria-label now uses header.breadcrumb.label.
  • Locale JSON now written with ensure_ascii=False — a handful of \uXXXX escapes became literal UTF-8 (cosmetic only).
  • Translation pass: 563 keys/locale (505 + hand-resolved + breadcrumb + small pre-existing drift), 7 parallel subagents → /tmp/i18n-backfill/out//, merged with placeholder-preservation validation (0 problems). The batch contained no new _one/_other pairs, so no pl plural expansion was needed.
  • P2 verification: find-missing exits 0; validate-translations 0 errors / 8 warnings (the pre-existing pl false positives P4 removes). Billing suite baseline comparison (full run, clean tree vs P2 tree): 158 → 156 failed tests, failed-file set strictly shrank — P2 fixed ContractsIntegration T066 (de wizard keys) and InvoicingLocaleSmoke T048 (stale xx pseudo file). InvoicingLocaleSmoke T002 expected-groups list updated for the new designer group (2 keys referenced by invoice-designer components). TicketingDashboard.i18n T011 fails pre-existing (source-contract assertion on a file P2 never touched).

P3 implementation notes (2026-06-10)

  • Wired with useTranslation + keys (en + 7 locales + pseudo): MspClientTickets (msp/clients clientTabs.tickets.*, 13 calls), MspClientAssets (msp/clients clientTabs.assets.*, 41 calls), MspContactTickets (msp/contacts contactTabs.tickets.*, 17 calls), InteractionsFeed (msp/clients interactions.feed.*), OverallInteractionsFeed (msp/clients interactions.feed.*/interactions.overall.*), SchedulingInteractionDetails (msp/schedule interactionDetails.*).
  • msp-composition sweep verdicts (per-file): the plan's "16 string-free files" expectation was wrong in detail. 28 of 32 files have no user-visible strings (providers/wrappers/hooks/index files, incl. AlgaDeskClientCrossFeatureProvider which renders nothing itself). Four more DID have strings: MspAssetCrossFeatureProvider (2 toasts → msp/assets crossFeature.*), MspClientDrawerProvider (3 states → msp/clients clientDrawer.*), useTicketIntegrationValue (toasts → features/tickets integration.*) — all three wired in P3; Reports.tsx was ALREADY fully translated (msp/reports + defaultValues) — the initial sweep misread its defaultValues as hardcoded strings.
  • 120 new en keys extracted from defaultValues (extractor now mirrors scanner semantics: skip test files/trailing dots, plural-aware); translated to all 7 locales (placeholder-validated, 0 problems), pseudo regenerated.
  • Namespaces/route loading (F036): /msp/clients → msp/clients, /msp/contacts → msp/contacts preloaded via ROUTE_NAMESPACES; msp/schedule and features/tickets load on demand via react-i18next when SchedulingInteractionDetails / ticket-integration drawers render off-route (T046 left as runtime QA).
  • Audit tests: clientTabs.i18n.test.ts (msp-composition), interactionsFeed.i18n.test.ts (clients), SchedulingInteractionDetails.i18n.test.ts (scheduling) — source contracts + en/xx/all-locale key existence. Convention: // @vitest-environment node + node:fs imports (plain fs fails vite resolution).
  • T042-T044 satisfied via all-locale key-existence assertions (not DOM render); T038/T040 via xx fill-marker assertions.
  • server tsc --noEmit clean after wiring (covers msp-composition, which has no own tsconfig).

P4 implementation notes (2026-06-10)

  • Legacy _plural inventory (F038): exactly 4 sets × 10 locales — teams.details.memberCount, interactions.types.messages.success.imported (both msp/settings), security.sessions.subtitle (msp/profile AND msp/settings). All migrated base→_one, _plural_other; pl got hand-written _one/_few/_many/_other. grep _plural locales = 0.
  • CLDR-aware validator design: required categories per locale = Intl.PluralRules(locale).select(i) over integers 0..100, plus other (the i18next fallback). This deliberately does NOT require French/Spanish/Italian/Portuguese _many (only applies at 1e6+) while requiring Polish _one/_few/_many (+_other). Variable checks for locale-specific suffixes compare against en's _other. Any remaining _plural key is now an ERROR. LOCALES_DIR env override added for fixture tests.
  • The new validator exposed 250 real gaps the old one couldn't see:
    1. Four half-migrated sets (base singular + _other, no _one) where count=1 rendered the plural string even in English: phases.taskCount, dialogs.ticketLinkedTasks.badgeCount (features/projects), serviceTypes.toast.importedCount (msp/billing-settings), draftsTab.reverseDialog.title (msp/invoicing). Fixed by renaming base→_one in all 8 locales (consumers all pass count).
    2. 107 plural sets missing pl _few/_many (214 forms) — generated by a Polish-translation subagent from existing pl _one/_other wording (_few nominative plural, _many genitive plural; case-governed contexts identical), placeholder-validated, merged.
  • AdminSessionManagement now uses a single t('security.sessions.subtitle', { count: sessionCount, sessionCount, userCount }). The _one form's "1 user" is safe: one active session necessarily belongs to one user.
  • Tests: packages/core/src/lib/i18n/validateTranslations.test.ts (fixture-driven CLDR checks incl. legacy-_plural error), packages/ui/src/lib/i18n/pluralResolution.test.ts (real i18next instance over real locale files; en/pl counts 1/2/5; pseudo plural-key carry-through).
  • validate-translations.cjs final state: 0 errors, 0 warnings across 9 locales.

P5 implementation notes (2026-06-10)

  • validate-translations.yml: paths expanded to packages/**, server/src/**, ee/server/src/** + the find-missing script; new find-missing-keys job runs find-missing-i18n-keys.cjs. Landed after P2/P4 zeroed both scripts — both exit 0 from a clean tree, so CI arrives green.
  • Not verifiable locally (left open in tests.json): T058/T059/T060 (actual CI runs on a PR), T024/T025 (dev-stack visual pseudo-locale QA), T046 (runtime namespace-warning check), T017 (PrintOptionsDialog render test, trivial wrapper skipped).

Status: all 5 phases implemented (2026-06-10)

Commits on i18n/formatting_and_gaps: P1 958f4e15e0, P2 12c732c630, P3 fa1cf5d88a, P4 f0109ed354, P5 (this commit). 48/48 features, 55/62 tests (7 open are CI/manual-QA verifications listed above).

Decisions

  • (2026-06-10) Formatting stays purely locale-derived; the explicit user-facing date-format preference is a separate future effort that layers on top (preference → locale default resolution chain). Decided with user.
  • (2026-06-10) Emails (EJS transactional templates) explicitly out of scope. Decided with user.
  • (2026-06-10) Pseudo-locales exposed in dev mode only via NODE_ENV === 'development' inside filterPseudoLocales; pt stays hidden in dev too.
  • (2026-06-10) P3 namespaces: client/contact tab components + interactions feeds → msp/clients/msp/contacts (already loaded by ROUTE_NAMESPACES on those routes); SchedulingInteractionDetailsmsp/schedule.
  • (2026-06-10) ESLint hardcoded-string rule deferred (PRD open question #3).

Useful commands

node scripts/find-missing-i18n-keys.cjs        # exits 1 while keys missing
node scripts/validate-translations.cjs          # key parity + variables; currently 8 false-positive pl warnings
node scripts/generate-pseudo-locales.cjs        # regenerate xx/yy
npx nx build core                               # rebuild packages/core dist after config.ts edits
npx tsc -p packages/ui/tsconfig.json --noEmit