Files
2026-05-27 23:29:53 +01:00

6.3 KiB
Raw Permalink Blame History

i18n number and relative-time formatters

Package: @suaveplan/i18n Status: PROPOSED Type: feature + spec-conformance

Why

@suaveplan/i18n currently exports only the currency and date modules from src/formatting/index.ts (packages/i18n/i18n/src/formatting/index.ts L1-L2). The upstream package ships two additional formatters — NumberFormatter and RelativeTimeFormatter — that wrap Intl.NumberFormat / Intl.RelativeTimeFormat with consistent option handling, locale validation, and structured error reporting. Without them, downstream consumers (drive UI, network admin pages, suppression dashboards) re-implement the same Intl wiring with subtle differences in fraction-digit clamping, currency-display defaults, and unit selection. Both legacy modules already exist at upstream parity (packages/universal/i18n/src/formatting/number/number.ts 119 L; packages/universal/i18n/src/formatting/relative-time/relative-time.ts 130 L) and pass the genesis quality bar — porting them closes the parity gap before consumers fork.

What changes

@suaveplan/i18n

  • ADDED: NumberFormatter class — format(value, locale, options?) with style: 'decimal' | 'percent' | 'currency', currency, currencyDisplay, minimumFractionDigits, maximumFractionDigits, useGrouping, notation, signDisplay. Validates options through a Zod schema; throws I18nError with code I18N.INVALID_FORMATTER_OPTIONS on rejection. Caches the underlying Intl.NumberFormat per (locale, options-hash) pair to amortise construction cost.
  • ADDED: formatNumber(value, locale, options?) standalone helper — pure-function wrapper around NumberFormatter for one-off calls.
  • ADDED: RelativeTimeFormatter class — format(deltaMs, locale, options?) with auto-unit selection (seconds → minutes → hours → days → weeks → months → years), numeric: 'always' | 'auto', style: 'long' | 'short' | 'narrow'. Auto-selects the largest unit whose absolute value ≥ 1; threshold table mirrors upstream relative-time.ts L40-L96.
  • ADDED: formatRelativeTime(deltaMs, locale, options?) standalone helper.
  • ADDED: NumberFormatterOptions, NumberFormatterStyle, NumberFormatterCacheKey, RelativeTimeFormatterOptions, RelativeTimeUnit, RelativeTimeUnitSelection types.
  • ADDED: NumberFormatterOptionsSchema and RelativeTimeFormatterOptionsSchema Zod schemas.
  • ADDED: I18N.INVALID_FORMATTER_OPTIONS and I18N.UNSUPPORTED_LOCALE error codes registered via defineCodes.
  • MODIFIED: src/formatting/index.ts — re-exports the two new modules alongside currency and date.

Out of scope

  • List formatting (Intl.ListFormat) — separate change once number and relative-time ship and prove the formatter-cache pattern.
  • Display-name formatting (Intl.DisplayNames) — separate change; depends on locale-data plumbing not yet wired in genesis.
  • Plural-rule integration into RelativeTimeFormatter — Intl.RelativeTimeFormat already pluralises internally; manual integration with @suaveplan/i18n plural rules is unnecessary and adds risk of double-pluralisation.

Source mapping

This change derives from ../suaveplan/enterprise/i18n.md and the upstream package source.

Requirement Source file Source lines Notes
NumberFormatter class packages/universal/i18n/src/formatting/number/number.ts L1-L119 Verbatim port; thread I18nError through
formatNumber helper packages/universal/i18n/src/formatting/number/number.ts L101-L119 Verbatim
NumberFormatterOptions, NumberFormatterStyle types packages/universal/i18n/src/formatting/number/number.types.ts L1-L17 Verbatim
RelativeTimeFormatter class packages/universal/i18n/src/formatting/relative-time/relative-time.ts L1-L130 Verbatim port; thresholds at L40-L96
formatRelativeTime helper packages/universal/i18n/src/formatting/relative-time/relative-time.ts L110-L130 Verbatim
RelativeTimeFormatterOptions, RelativeTimeUnit types packages/universal/i18n/src/formatting/relative-time/relative-time.types.ts L1-L23 Verbatim

Requirements with no source line

Requirement Justification
NumberFormatterOptionsSchema / RelativeTimeFormatterOptionsSchema Zod schemas AGENTS.md §13 row 2 ("Zod schemas wired at every declared boundary"). Upstream uses runtime typeof guards.
I18N.INVALID_FORMATTER_OPTIONS / I18N.UNSUPPORTED_LOCALE error codes AGENTS.md §13 row 3 ("every error code thrown by at least one production path"). Upstream throws plain Error.
Per-(locale, options) Intl.NumberFormat / Intl.RelativeTimeFormat cache Performance. Constructing Intl.* is ~50× slower than calling format(); without a cache the pattern goes quadratic on dashboards rendering hundreds of values. Not in upstream.

Verification approach

Each requirement carries a **Verified by:** line in the spec delta.

  • packages/i18n/i18n/src/formatting/number/number.test.ts — covers Decimal/percent/currency formatting, Fraction-digit clamping, Cache reuse
  • packages/i18n/i18n/src/formatting/number/number.spec-conformance.test.ts — pins Zod schema rejection, error-code emission, and expectTypeOf assertions on NumberFormatterStyle
  • packages/i18n/i18n/src/formatting/relative-time/relative-time.test.ts — covers Auto-unit selection, Style and numeric overrides, Negative deltas
  • packages/i18n/i18n/src/formatting/relative-time/relative-time.spec-conformance.test.ts — pins Zod schema rejection, unsupported-locale fallback, and OTel histogram emission

Impact

Affected dependents

Run bunx turbo ls --filter=...@suaveplan/i18n to enumerate. Genesis grep result:

  • No dependents (verified by grep -rl "@suaveplan/i18n" /home/ubuntu/web/genesis/packages/ --include="package.json" → only packages/i18n/i18n/package.json itself).

Pure additive change — existing formatting/currency and formatting/date re-exports unchanged.

Migration impact

None — additive only. Callers that already import formatCurrency / formatDate from @suaveplan/i18n/formatting are unaffected. New consumers can import formatNumber / formatRelativeTime from the same root or from the per-feature subpaths @suaveplan/i18n/formatting/number/number and @suaveplan/i18n/formatting/relative-time/relative-time.