# Capability Spec Delta: i18n (i18n-formatters) **Package**: `@suaveplan/i18n` **Status**: PROPOSED > Every requirement below carries a `**Verified by:**` line at authoring time. (See `openspec/AGENTS.md` Section 13.) ## ADDED Requirements ### Requirement: Number formatting with style and currency support The package SHALL expose a `NumberFormatter` class and a `formatNumber(value, locale, options?)` standalone helper that wrap `Intl.NumberFormat`. The formatter SHALL support `style: 'decimal' | 'percent' | 'currency'`, `currency` (ISO 4217 code), `currencyDisplay` (`'symbol' | 'code' | 'name' | 'narrowSymbol'`), `minimumFractionDigits`, `maximumFractionDigits`, `useGrouping`, `notation` (`'standard' | 'scientific' | 'engineering' | 'compact'`), and `signDisplay`. Options SHALL be validated through `NumberFormatterOptionsSchema` at construction. #### Scenario: decimal style produces a grouped number - **WHEN** `formatNumber(1234567.89, 'en-US', { style: 'decimal', maximumFractionDigits: 2 })` is called - **THEN** the result equals `"1,234,567.89"` #### Scenario: currency style honours `currencyDisplay` - **WHEN** `formatNumber(42, 'en-GB', { style: 'currency', currency: 'GBP', currencyDisplay: 'code' })` is called - **THEN** the result contains the substring `"GBP"` (not the `£` glyph) #### Scenario: percent style multiplies by 100 - **WHEN** `formatNumber(0.255, 'en-US', { style: 'percent', maximumFractionDigits: 1 })` is called - **THEN** the result equals `"25.5%"` **Verified by:** `packages/i18n/i18n/src/formatting/number/number.test.ts::"formats decimals with grouping in en-US"` AND `packages/i18n/i18n/src/formatting/number/number.test.ts::"formats currency with currencyDisplay code"` AND `packages/i18n/i18n/src/formatting/number/number.test.ts::"formats percent by multiplying by 100"` --- ### Requirement: NumberFormatter validates options via Zod The package SHALL validate every `NumberFormatterOptions` object through `NumberFormatterOptionsSchema` (Zod). Unknown styles, negative fraction digits, fraction-digit inversions (`min > max`), and unsupported locale strings SHALL be rejected with `I18nError`. #### Scenario: unknown style rejected - **WHEN** `new NumberFormatter({ style: 'magic' } as never)` is constructed - **THEN** it throws `I18nError` with code `I18N.INVALID_FORMATTER_OPTIONS` and `context.field === "style"` #### Scenario: unsupported locale surfaces structured error - **WHEN** `formatNumber(1, 'xx-INVALID-LOCALE-1234', {})` is called - **THEN** it throws `I18nError` with code `I18N.UNSUPPORTED_LOCALE` (the underlying `Intl.NumberFormat` `RangeError` is mapped, not propagated) #### Scenario: fraction-digit inversion rejected - **WHEN** options carry `{ minimumFractionDigits: 4, maximumFractionDigits: 2 }` - **THEN** Zod rejection produces `I18N.INVALID_FORMATTER_OPTIONS` with `context.field === "fractionDigits"` **Verified by:** `packages/i18n/i18n/src/formatting/number/number.spec-conformance.test.ts::"rejects unknown style with INVALID_FORMATTER_OPTIONS"` AND `packages/i18n/i18n/src/formatting/number/number.spec-conformance.test.ts::"maps Intl RangeError to UNSUPPORTED_LOCALE"` AND `packages/i18n/i18n/src/formatting/number/number.spec-conformance.test.ts::"rejects fraction-digit inversion"` --- ### Requirement: NumberFormatter caches `Intl.NumberFormat` instances The package SHALL cache the underlying `Intl.NumberFormat` keyed by (locale, canonicalised options hash). Repeat calls with the same key SHALL reuse the cached instance and emit the OTel counter `i18n.format.cache_hit` with attribute `formatter="number"`. #### Scenario: cache reused on identical key - **WHEN** `formatNumber(1, 'en-US', { style: 'decimal' })` is called twice - **THEN** the second call increments `i18n.format.cache_hit` by exactly 1 #### Scenario: distinct options produce distinct cache entries - **WHEN** the same locale is called with `{ style: 'decimal' }` then `{ style: 'percent' }` - **THEN** both calls miss the cache; `i18n.format.cache_hit` does not increment **Verified by:** `packages/i18n/i18n/src/formatting/number/number.spec-conformance.test.ts::"reuses cached Intl.NumberFormat on identical key"` AND `packages/i18n/i18n/src/formatting/number/number.spec-conformance.test.ts::"does not reuse cache across distinct options"` --- ### Requirement: Relative-time formatting with auto-unit selection The package SHALL expose a `RelativeTimeFormatter` class and a `formatRelativeTime(deltaMs, locale, options?)` helper that wrap `Intl.RelativeTimeFormat`. When `options.unit` is omitted, the formatter SHALL auto-select the largest unit whose absolute value is ≥ 1 from the ordered set `seconds`, `minutes`, `hours`, `days`, `weeks`, `months`, `years`. Threshold boundaries match upstream `relative-time.ts` L40-L96 (60s, 60m, 24h, 7d, 4.345w, 12mo). #### Scenario: 30 seconds selects "seconds" unit - **WHEN** `formatRelativeTime(30_000, 'en-US')` is called - **THEN** the result contains `"second"` (e.g. `"in 30 seconds"`) AND not `"minute"` #### Scenario: 90 seconds rolls over to "minutes" unit - **WHEN** `formatRelativeTime(90_000, 'en-US')` is called - **THEN** the selected unit is `"minutes"` and the value is `2` (rounded) #### Scenario: negative delta produces past-tense phrasing - **WHEN** `formatRelativeTime(-3_600_000, 'en-US', { numeric: 'auto' })` is called - **THEN** the result reads `"1 hour ago"` (past), not `"in 1 hour"` (future) #### Scenario: explicit unit override skips auto-selection - **WHEN** `formatRelativeTime(90_000, 'en-US', { unit: 'seconds' })` is called - **THEN** the formatter renders the value in seconds (`"in 90 seconds"`), not minutes **Verified by:** `packages/i18n/i18n/src/formatting/relative-time/relative-time.test.ts::"auto-selects seconds for sub-minute deltas"` AND `packages/i18n/i18n/src/formatting/relative-time/relative-time.test.ts::"rolls over to minutes at the 60s boundary"` AND `packages/i18n/i18n/src/formatting/relative-time/relative-time.test.ts::"renders past-tense phrasing for negative deltas"` AND `packages/i18n/i18n/src/formatting/relative-time/relative-time.test.ts::"honours explicit unit override"` --- ### Requirement: RelativeTimeFormatter style and numeric overrides The package SHALL accept `style: 'long' | 'short' | 'narrow'` (default `'long'`) and `numeric: 'always' | 'auto'` (default `'always'`) on `RelativeTimeFormatter`. Both fields SHALL be validated through `RelativeTimeFormatterOptionsSchema`. Unknown values SHALL throw `I18nError` with code `I18N.INVALID_FORMATTER_OPTIONS`. #### Scenario: short style produces abbreviated unit - **WHEN** `formatRelativeTime(86_400_000, 'en-US', { style: 'short' })` is called - **THEN** the result contains `"day"` or `"days"` rendered in short form (e.g. `"in 1 day"` vs the long-style `"in 1 day"`; the test asserts the `Intl.RelativeTimeFormat({ style: 'short' })` output exactly) #### Scenario: numeric `'auto'` produces phrases like "yesterday" - **WHEN** `formatRelativeTime(-86_400_000, 'en-US', { numeric: 'auto' })` is called - **THEN** the result equals `"yesterday"` (the platform-defined phrase for –1 day) #### Scenario: invalid style rejected - **WHEN** `new RelativeTimeFormatter({ style: 'rad' } as never)` is constructed - **THEN** it throws `I18nError` with code `I18N.INVALID_FORMATTER_OPTIONS` and `context.field === "style"` **Verified by:** `packages/i18n/i18n/src/formatting/relative-time/relative-time.test.ts::"renders short style"` AND `packages/i18n/i18n/src/formatting/relative-time/relative-time.test.ts::"renders 'yesterday' under numeric: auto"` AND `packages/i18n/i18n/src/formatting/relative-time/relative-time.spec-conformance.test.ts::"rejects unknown style with INVALID_FORMATTER_OPTIONS"` --- ### Requirement: Both formatters emit OTel duration histograms The package SHALL emit `i18n.format.number.duration_ms` and `i18n.format.relative_time.duration_ms` histograms recording the wall-clock duration of each `format()` call. Histograms SHALL be tagged with `locale` and `style` attributes. #### Scenario: histogram observes a sample per format call - **WHEN** `formatNumber(1, 'en-US', { style: 'decimal' })` is called once - **THEN** the in-memory `MeterProvider` records exactly one sample under `i18n.format.number.duration_ms` with attributes `{ locale: 'en-US', style: 'decimal' }` #### Scenario: relative-time histogram tags include selected unit - **WHEN** `formatRelativeTime(90_000, 'en-US')` is called - **THEN** the histogram sample carries attribute `unit: 'minutes'` (the auto-selected unit) **Verified by:** `packages/i18n/i18n/src/formatting/number/number.spec-conformance.test.ts::"records a duration_ms sample per call"` AND `packages/i18n/i18n/src/formatting/relative-time/relative-time.spec-conformance.test.ts::"tags histogram with auto-selected unit"`