Files
suaveplan/openspec/changes/archive/2026-05-04-i18n-formatters/specs/i18n/spec.md
2026-05-27 23:29:53 +01:00

8.7 KiB
Raw Blame History

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"