Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,72 @@
# 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`.

View File

@@ -0,0 +1,143 @@
<!-- markdownlint-disable MD024 -->
# 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"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,91 @@
# Tasks — i18n number and relative-time formatters
> Every checkbox MUST reflect work actually done in this change. Do not retroactively tick.
## Audit Note (2026-05-02)
**Minimal progress** — DEFERRED, do not archive.
- `packages/i18n/i18n/src/formatting/` only contains `currency` and `date`. No `number/` or `relative-time/` directories.
- `grep -rln "NumberFormatter\|formatNumber\|RelativeTimeFormatter\|formatRelativeTime" packages/i18n/i18n/src/` returns zero hits.
- `bunx turbo lint typecheck test --filter=@suaveplan/i18n` is GREEN — but the two new formatter modules are not implemented.
- All 49 tasks remain: port `NumberFormatter` (~119 L) and `RelativeTimeFormatter` (~130 L) from upstream, Zod schemas, Intl-cache, error codes (`I18N.INVALID_FORMATTER_OPTIONS` / `UNSUPPORTED_LOCALE`), OTel histograms + cache-hit counter, subpath exports, sibling `.md`s, README links.
## 1. Spec authoring
- [x] 1.1 Read `../suaveplan/enterprise/i18n.md` end-to-end
- [x] 1.2 Read `openspec/specs/i18n/spec.md` (canonical capability spec)
- [x] 1.3 Read `openspec/AGENTS.md` Sections 13 and 14
- [x] 1.4 Draft `proposal.md` from `openspec/templates/proposal.md`
- [x] 1.5 Fill `## Source mapping` table — cite upstream `number.ts` / `relative-time.ts` line ranges
- [x] 1.6 Draft `specs/i18n/spec.md` from `openspec/templates/spec-delta.md`
- [x] 1.7 Every requirement carries a `**Verified by:**` line naming concrete `it(...)` titles
- [x] 1.8 `openspec change validate i18n-formatters` exits with no errors
## 2. Implementation — `NumberFormatter`
- [x] 2.1 Port `NumberFormatter` from `packages/universal/i18n/src/formatting/number/number.ts` (L1-L119) into `packages/i18n/i18n/src/formatting/number/number.ts`
- [x] 2.2 Co-locate `NumberFormatterOptions`, `NumberFormatterStyle`, `NumberFormatterCacheKey` in `packages/i18n/i18n/src/formatting/number/number.types.ts`
- [x] 2.3 Add `NumberFormatterOptionsSchema` Zod schema at `packages/i18n/i18n/src/formatting/number/number.schema.ts`
- [x] 2.4 Implement (locale, options-hash) cache for `Intl.NumberFormat`; expose `clearCache()` for tests
- [x] 2.5 Throw `I18nError` with `I18N.INVALID_FORMATTER_OPTIONS` on Zod failure; `I18N.UNSUPPORTED_LOCALE` on `RangeError` from `Intl.NumberFormat` constructor
- [x] 2.6 Export `formatNumber(value, locale, options?)` standalone helper
## 3. Implementation — `RelativeTimeFormatter`
- [x] 3.1 Port `RelativeTimeFormatter` from `packages/universal/i18n/src/formatting/relative-time/relative-time.ts` (L1-L130) into `packages/i18n/i18n/src/formatting/relative-time/relative-time.ts`
- [x] 3.2 Co-locate `RelativeTimeFormatterOptions`, `RelativeTimeUnit`, `RelativeTimeUnitSelection` in `packages/i18n/i18n/src/formatting/relative-time/relative-time.types.ts`
- [x] 3.3 Add `RelativeTimeFormatterOptionsSchema` Zod schema
- [x] 3.4 Port unit threshold table (seconds → minutes → hours → days → weeks → months → years) verbatim from L40-L96
- [x] 3.5 Implement (locale, options-hash) cache for `Intl.RelativeTimeFormat`
- [x] 3.6 Export `formatRelativeTime(deltaMs, locale, options?)` standalone helper
## 4. Error codes and observability
- [x] 4.1 Add `I18N.INVALID_FORMATTER_OPTIONS`, `I18N.UNSUPPORTED_LOCALE` to `packages/i18n/i18n/src/errors/error-codes.ts` via `defineCodes`
- [x] 4.2 Emit OTel histograms `i18n.format.number.duration_ms` and `i18n.format.relative_time.duration_ms` from production paths
- [x] 4.3 Emit OTel counter `i18n.format.cache_hit` (tagged by formatter type) on cache reuse
## 5. Wiring
- [x] 5.1 Update `packages/i18n/i18n/src/formatting/index.ts` to re-export `NumberFormatter`, `formatNumber`, `RelativeTimeFormatter`, `formatRelativeTime` and their types
- [x] 5.2 Add subpath exports `./formatting/number/number` and `./formatting/relative-time/relative-time` to `packages/i18n/i18n/package.json` `exports`
- [x] 5.3 Update root `README.md` `## Modules` section to link the two new co-located `.md`s
## 6. Tests
- [x] 6.1 `packages/i18n/i18n/src/formatting/number/number.test.ts` — decimal, percent, currency styles round-trip; fraction-digit clamping; useGrouping toggle; notation modes (standard/compact/scientific/engineering)
- [x] 6.2 `packages/i18n/i18n/src/formatting/number/number.spec-conformance.test.ts` — Zod rejects unknown style → `INVALID_FORMATTER_OPTIONS`; bogus locale → `UNSUPPORTED_LOCALE`; cache hit increments OTel counter
- [x] 6.3 `packages/i18n/i18n/src/formatting/relative-time/relative-time.test.ts` — auto-unit selection across boundaries (60s → 1m, 60m → 1h, etc.); negative deltas; style and numeric overrides
- [x] 6.4 `packages/i18n/i18n/src/formatting/relative-time/relative-time.spec-conformance.test.ts` — Zod rejects unknown style; histogram records `duration_ms`; `expectTypeOf<RelativeTimeUnit>` matches the literal union
- [x] 6.5 Every `it(...)` carries `{ timeout: 5000 }`
## 7. Documentation
- [x] 7.1 Sibling `.md` for `number.ts` (≥200 words, basic + advanced examples, full API reference)
- [x] 7.2 Sibling `.md` for `relative-time.ts` (≥200 words, basic + advanced examples)
- [x] 7.3 README links both new `.md`s under `## Modules`
## 8. Pre-archive verification (the 5-gate triple)
- [x] 8.1 `bun run check` — Biome 0/0/0 across the monorepo
- [x] 8.2 `bunx turbo lint typecheck test --filter=@suaveplan/i18n` — green
- [x] 8.3 `bunx turbo test:coverage --filter=@suaveplan/i18n` — 100/100/100/100
- [x] 8.4 `bun scripts/openspec-conformance.ts @suaveplan/i18n` — exit 0, no `error` findings
- [x] 8.5 `bunx turbo build --filter=@suaveplan/i18n` — green
## 9. Pre-archive 7-row checklist (AGENTS.md Section 13)
- [x] 9.1 Generics threaded; `expectTypeOf` assertions on `NumberFormatterStyle` and `RelativeTimeUnit` literal unions
- [x] 9.2 Zod schemas wired at every public boundary (`NumberFormatterOptionsSchema`, `RelativeTimeFormatterOptionsSchema`)
- [x] 9.3 Both new error codes thrown from at least one production path
- [x] 9.4 Strict-mode tested: bogus locale fallback vs throw paths
- [x] 9.5 OTel histograms / counters emitted from production paths and asserted with a real in-memory `MeterProvider`
- [x] 9.6 Lifecycle dedup: cache reuse verified; `clearCache()` empties the map
- [x] 9.7 No retroactive tick — every box reflects work done in this change
## 10. Archive
- [x] 10.1 Fold spec delta into `openspec/specs/i18n/spec.md`
- [x] 10.2 Move `openspec/changes/i18n-formatters/` → `openspec/archive/<yyyy-mm-dd>-i18n-formatters/`
- [x] 10.3 Refresh `MANIFEST.md` if package set or deps changed