8.7 KiB
Capability Spec Delta: i18n (i18n-formatters)
Package: @suaveplan/i18n
Status: PROPOSED
Every requirement below carries a
**Verified by:**line at authoring time. (Seeopenspec/AGENTS.mdSection 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
I18nErrorwith codeI18N.INVALID_FORMATTER_OPTIONSandcontext.field === "style"
Scenario: unsupported locale surfaces structured error
- WHEN
formatNumber(1, 'xx-INVALID-LOCALE-1234', {})is called - THEN it throws
I18nErrorwith codeI18N.UNSUPPORTED_LOCALE(the underlyingIntl.NumberFormatRangeErroris mapped, not propagated)
Scenario: fraction-digit inversion rejected
- WHEN options carry
{ minimumFractionDigits: 4, maximumFractionDigits: 2 } - THEN Zod rejection produces
I18N.INVALID_FORMATTER_OPTIONSwithcontext.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_hitby 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_hitdoes 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 is2(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 theIntl.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
I18nErrorwith codeI18N.INVALID_FORMATTER_OPTIONSandcontext.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
MeterProviderrecords exactly one sample underi18n.format.number.duration_mswith 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"