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,98 @@
# `@suaveplan/i18n-formatter-relative-time` — `Intl.RelativeTimeFormat` wrapper
**Package**: `@suaveplan/i18n-formatter-relative-time` (NEW)
**Status**: PROPOSED
**Type**: feature (new universal-tier package; verbatim port of `formatting/relative-time/`)
**Date**: 2026-05-05
**Wave**: 3 (depends on Wave 1 foundation; required by Wave 6 core slim)
**Design doc**: `openspec/cross-cutting-designs/2026-05-05-i18n-modular-split.md` (especially §5 formatter package shape, §5b FormatBridge, §7.1)
**Path**: `packages/i18n/i18n-formatter-relative-time/`
**Tier**: universal
## Why
The 14-package i18n modular split lifts each `Intl.*` wrapper out of the monolith into its own sibling package so consumers can pull only the formatters they actually use. `@suaveplan/i18n-formatter-relative-time` is the second Wave-3 package, following the canonical formatter shape (per-(locale, options-hash) cache, Zod-validated options, OTel histogram + cache-hit counter, helper + class twin surface).
Today `formatting/relative-time/` ships:
- `RelativeTimeFormatter` class wrapping `Intl.RelativeTimeFormat` (`packages/i18n/i18n/src/formatting/relative-time/relative-time.ts` L157–L168)
- `formatRelativeTime` standalone helper with auto-unit-selection over a 7-tier threshold table (`relative-time.ts` L122–L151, L21–L78)
- Per-(locale, style, numeric) cache (`relative-time.ts` L14, L109–L120)
- `INVALID_FORMATTER_OPTIONS` on Zod failure (`relative-time.ts` L80–L89)
- `UNSUPPORTED_LOCALE` on `Intl.RelativeTimeFormat` `RangeError` (`relative-time.ts` L91–L107)
- OTel histogram `i18n.format.relative_time.duration_ms` and cache-hit counter via `observability/format-metrics.ts`
The auto-unit-selection logic (`autoSelectUnit` at `relative-time.ts` L54–L78) is what makes this package non-trivial: it consults a 7-tier threshold table (`THRESHOLDS_MS`, L21–L28) to pick the human-friendly unit (e.g. `36 h` rolls over to `2 days`).
## What changes
### New package: `@suaveplan/i18n-formatter-relative-time`
- ADDED: `packages/i18n/i18n-formatter-relative-time/` package scaffold (`package.json`, `tsconfig.json`, `vite.config.ts`, `README.md`).
- ADDED: `src/formatter.ts` — `RelativeTimeFormatter` class + `formatRelativeTime` standalone + `clearRelativeTimeFormatterCache` test-only helper. Verbatim port of monolith `relative-time.ts` (L1–L173) with import rewires.
- ADDED: `src/formatter.types.ts` — `RelativeTimeUnit`, `RelativeTimeStyle`, `RelativeTimeNumeric`, `RelativeTimeFormatterOptions`, `RelativeTimeUnitSelection`. Verbatim port of monolith `relative-time.types.ts` (L1–L26).
- ADDED: `src/formatter.schemas.ts` — `RelativeTimeFormatterOptionsSchema` Zod schema. Verbatim port of monolith `relative-time.schemas.ts` (L1–L22).
- ADDED: `src/helpers.ts` — `formatTimeAgo` (`formatRelativeTime(deltaMs, locale, { style: "short" })` shortcut), `formatTimeIn` (positive-delta variant of `formatTimeAgo`), and `selectRelativeUnit` (exposes the auto-unit-selection logic standalone for callers who want the chosen unit only). New ergonomic surface introduced by the split (the monolith only ships the class + standalone).
- ADDED: `src/observability.ts` — local OTel histogram `i18n.format.relative_time.duration_ms` + counter `i18n.format.cache_hit{formatter="relative_time"}`.
- ADDED: `src/formatter.test.ts` — verbatim port of monolith `relative-time.test.ts` (115 lines).
- ADDED: `src/formatter.spec-conformance.test.ts` — verbatim port of monolith `relative-time.spec-conformance.test.ts` (121 lines).
- ADDED: `src/helpers.test.ts` — covers `formatTimeAgo`, `formatTimeIn`, `selectRelativeUnit`.
- ADDED: `src/index.ts` — explicit named re-exports.
- ADDED: `src/formatter.md`, `src/helpers.md`, `src/observability.md` — sibling docs.
### Subpath exports
- `.` — root barrel
- `./formatter` — `RelativeTimeFormatter` class + `formatRelativeTime` + types + schema only
- `./helpers` — `formatTimeAgo`, `formatTimeIn`, `selectRelativeUnit` only
### Dependencies
- `@suaveplan/i18n-shared workspace:^` — `I18N_CODES`, `I18nError`
- `@suaveplan/types workspace:^`
- `@suaveplan/error workspace:^`
- `@opentelemetry/api catalog:observability`
- `tslib catalog:build`
- `zod catalog:forms`
- **No** dep on any other formatter package, on `@suaveplan/i18n` core, or on any backend package.
## Out of scope
- **Removing `formatting/relative-time/` from `packages/i18n/i18n/src/`** — happens in Wave 6.
- **Custom threshold tables** (e.g. business-day-aware rollover) — follow-up; the monolith ships fixed thresholds and so does this port.
- **`formatRange` / `formatRangeToParts` for relative-time intervals** — not part of `Intl.RelativeTimeFormat`; out of scope.
## Source mapping
| Requirement | Source file | Source lines | Notes |
|---|---|---|---|
| `RelativeTimeFormatter` class | `packages/i18n/i18n/src/formatting/relative-time/relative-time.ts` | L157–L168 | Verbatim port; rewire imports |
| `formatRelativeTime` standalone | `packages/i18n/i18n/src/formatting/relative-time/relative-time.ts` | L122–L151 | Verbatim port |
| `clearRelativeTimeFormatterCache` | `packages/i18n/i18n/src/formatting/relative-time/relative-time.ts` | L170–L173 | Verbatim port |
| Auto-unit-selection (`autoSelectUnit`, `THRESHOLDS_MS`) | `packages/i18n/i18n/src/formatting/relative-time/relative-time.ts` | L21–L78 | Verbatim port |
| Per-(locale, style, numeric) cache | `packages/i18n/i18n/src/formatting/relative-time/relative-time.ts` | L14, L109–L120 | Verbatim port |
| `INVALID_FORMATTER_OPTIONS` mapping | `packages/i18n/i18n/src/formatting/relative-time/relative-time.ts` | L80–L89 | Verbatim port |
| `UNSUPPORTED_LOCALE` mapping | `packages/i18n/i18n/src/formatting/relative-time/relative-time.ts` | L91–L107 | Verbatim port |
| Type definitions | `packages/i18n/i18n/src/formatting/relative-time/relative-time.types.ts` | L1–L26 | Verbatim port |
| Zod schema | `packages/i18n/i18n/src/formatting/relative-time/relative-time.schemas.ts` | L1–L22 | Verbatim port |
### Requirements with no source line
| Requirement | Justification |
|---|---|
| Helpers `formatTimeAgo`, `formatTimeIn`, `selectRelativeUnit` | New ergonomic surface introduced by the split (monolith only ships class + standalone). Symmetric with how the number formatter ships `formatPercent` / `formatCompact` / `formatUnit` helpers alongside the standalone. |
| Subpath exports `./formatter`, `./helpers` | Tree-shaking — slim-core `FormatBridge` wiring imports only the standalone `formatRelativeTime` |
## Verification approach
- `packages/i18n/i18n-formatter-relative-time/src/formatter.test.ts` — auto-unit-selection at every threshold boundary (60s, 60m, 24h, 7d, 30.4d, 365.25d); explicit-unit override; class twins helper; Zod failure → `INVALID_FORMATTER_OPTIONS`; bogus locale → `UNSUPPORTED_LOCALE`
- `packages/i18n/i18n-formatter-relative-time/src/formatter.spec-conformance.test.ts` — verbatim port of monolith spec-conformance suite
- `packages/i18n/i18n-formatter-relative-time/src/helpers.test.ts` — `formatTimeAgo` returns `"5 sec. ago"`-style output; `formatTimeIn` returns `"in 5 sec."`-style output; `selectRelativeUnit` returns the chosen unit + value pair
- `packages/i18n/i18n-formatter-relative-time/src/observability.test.ts` — histogram named `i18n.format.relative_time.duration_ms`; counter `i18n.format.cache_hit{formatter="relative_time"}` increments only on second call with same options
- `packages/i18n/i18n-formatter-relative-time/src/index.test.ts` — every documented public export resolves; subpaths resolve
## Impact
- New universal-tier package — joins `@suaveplan/i18n-shared`, `@suaveplan/i18n-icu-parser`, and `@suaveplan/i18n-formatter-number` under `packages/i18n/`.
- Indirect consumer in Wave 6: `@suaveplan/i18n` slim core wires `formatRelativeTime` into the default `FormatBridge` for ICU `{delta, relativeTime}` placeholder rendering.
- `MANIFEST.md` and `CLAUDE.md` updated post-merge.

View File

@@ -0,0 +1,162 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec: i18n-formatter-relative-time (i18n-formatter-relative-time)
**Package**: `@suaveplan/i18n-formatter-relative-time`
**Status**: PROPOSED — first delta; becomes canonical spec on archive.
> Every requirement below carries a `**Verified by:**` line at authoring time. (See `openspec/AGENTS.md` Section 13.)
## ADDED Requirements
### Requirement: RelativeTimeFormatter class wrapping `Intl.RelativeTimeFormat`
The package SHALL export a `RelativeTimeFormatter` class that wraps `Intl.RelativeTimeFormat` with construction-time Zod validation of options and a `format(deltaMs: number, locale: string): string` method. Construction with malformed options SHALL throw `I18nError(I18N.INVALID_FORMATTER_OPTIONS)`.
#### Scenario: format with default options
- **GIVEN** `const f = new RelativeTimeFormatter()`
- **WHEN** `f.format(-3600_000, "en-GB")` is called
- **THEN** the result equals `"1 hour ago"`
#### Scenario: invalid options throw INVALID_FORMATTER_OPTIONS
- **WHEN** `new RelativeTimeFormatter({ style: "invalid" as never })` is called
- **THEN** the call throws `I18nError` with code `"I18N.INVALID_FORMATTER_OPTIONS"`
**Verified by:** `packages/i18n/i18n-formatter-relative-time/src/formatter.test.ts::"RelativeTimeFormatter formats with default options"` AND `packages/i18n/i18n-formatter-relative-time/src/formatter.test.ts::"RelativeTimeFormatter rejects invalid options with INVALID_FORMATTER_OPTIONS"`
---
### Requirement: `formatRelativeTime` standalone helper with auto-unit-selection
The package SHALL export a `formatRelativeTime(deltaMs, locale, options?)` standalone helper that auto-selects the human-friendly unit when `options.unit` is absent. The selection SHALL roll over at: 60 s → minutes, 60 min → hours, 24 h → days, 7 d → weeks, 30.4375 d → months, 365.25 d → years. When `options.unit` is supplied, the explicit unit SHALL be used instead.
#### Scenario: auto-selects seconds for sub-minute deltas
- **WHEN** `formatRelativeTime(-30_000, "en-GB")` is called
- **THEN** the unit chosen is `"seconds"` AND the result reads as a 30-second-ago phrase
#### Scenario: auto-selects hours for 36-hour delta
- **WHEN** `formatRelativeTime(-36 * 3_600_000, "en-GB")` is called
- **THEN** the unit chosen rolls over to `"days"` (auto-selection picks 1.5 → rounds to 2 days) and the result is a days-ago phrase
#### Scenario: explicit unit override skips auto-selection
- **WHEN** `formatRelativeTime(-3600_000, "en-GB", { unit: "seconds" })` is called
- **THEN** the result is a 3600-seconds-ago phrase (no rollover)
**Verified by:** `packages/i18n/i18n-formatter-relative-time/src/formatter.test.ts::"auto-selects seconds for sub-minute deltas"` AND `packages/i18n/i18n-formatter-relative-time/src/formatter.test.ts::"auto-selects days for 36-hour delta"` AND `packages/i18n/i18n-formatter-relative-time/src/formatter.test.ts::"explicit unit override skips auto-selection"`
---
### Requirement: `Intl.RelativeTimeFormat` `RangeError` maps to `UNSUPPORTED_LOCALE`
The package SHALL catch every `RangeError` thrown by the `Intl.RelativeTimeFormat` constructor and re-throw it as `I18nError(I18N.UNSUPPORTED_LOCALE)` with the original error attached as `cause` and the locale string in `context`.
#### Scenario: bogus locale throws UNSUPPORTED_LOCALE
- **WHEN** `formatRelativeTime(-1000, "xx-bogus-tag-!!!")` is called
- **THEN** the call throws `I18nError` with code `"I18N.UNSUPPORTED_LOCALE"` AND the error's `cause` is the original `RangeError`
**Verified by:** `packages/i18n/i18n-formatter-relative-time/src/formatter.test.ts::"unsupported locale throws UNSUPPORTED_LOCALE with cause"`
---
### Requirement: Per-(locale, style, numeric) cache
The package SHALL maintain an internal `Map<string, Intl.RelativeTimeFormat>` keyed by `${locale}|${style}|${numeric}`. Repeated calls with identical `(locale, style, numeric)` SHALL reuse the same `Intl.RelativeTimeFormat` instance. The package SHALL export a `clearRelativeTimeFormatterCache()` test-only helper.
#### Scenario: identical key reuses the same Intl.RelativeTimeFormat
- **GIVEN** the cache is empty
- **WHEN** `formatRelativeTime(-1000, "en-GB")` is called twice in sequence
- **THEN** only one `Intl.RelativeTimeFormat` constructor invocation occurs
#### Scenario: distinct style or numeric creates distinct cache entries
- **WHEN** `formatRelativeTime(-1000, "en-GB", { style: "long" })` and `formatRelativeTime(-1000, "en-GB", { style: "short" })` are called
- **THEN** two distinct `Intl.RelativeTimeFormat` instances are created and cached
**Verified by:** `packages/i18n/i18n-formatter-relative-time/src/formatter.test.ts::"identical key reuses cached Intl.RelativeTimeFormat"` AND `packages/i18n/i18n-formatter-relative-time/src/formatter.test.ts::"distinct style creates distinct cache entries"`
---
### Requirement: OTel histogram `i18n.format.relative_time.duration_ms`
The package SHALL emit an OTel histogram named exactly `i18n.format.relative_time.duration_ms` (unit `ms`) on every successful `formatRelativeTime` call, tagged with `locale`, `style`, and `unit`.
#### Scenario: histogram emitted on every call
- **GIVEN** an OTel test exporter capturing histogram observations
- **WHEN** `formatRelativeTime(-1000, "en-GB", { style: "long" })` is called
- **THEN** exactly one observation appears under `i18n.format.relative_time.duration_ms` with `locale`, `style`, and `unit` attributes
**Verified by:** `packages/i18n/i18n-formatter-relative-time/src/observability.test.ts::"emits i18n.format.relative_time.duration_ms histogram on every call"`
---
### Requirement: OTel cache-hit counter `i18n.format.cache_hit`
The package SHALL emit an OTel counter `i18n.format.cache_hit` tagged with `formatter: "relative_time"` on every cache hit. The counter SHALL NOT increment on the first (cache-miss) call.
#### Scenario: cache-hit counter increments only on second call
- **GIVEN** an OTel test exporter capturing counter observations and an empty cache
- **WHEN** `formatRelativeTime(-1000, "en-GB")` is called twice in sequence
- **THEN** the counter `i18n.format.cache_hit{formatter="relative_time"}` shows exactly one increment
**Verified by:** `packages/i18n/i18n-formatter-relative-time/src/observability.test.ts::"cache-hit counter increments only on second call with same options"`
---
### Requirement: Ergonomic helpers — `formatTimeAgo`, `formatTimeIn`, `selectRelativeUnit`
The package SHALL export three ergonomic helpers:
- `formatTimeAgo(deltaMs, locale)` — pre-binds `formatRelativeTime` with `style: "short"` and asserts negative delta
- `formatTimeIn(deltaMs, locale)` — pre-binds `formatRelativeTime` with `style: "short"` and asserts positive delta
- `selectRelativeUnit(deltaMs): RelativeTimeUnitSelection` — exports the auto-selection function standalone (returns `{unit, value}`)
#### Scenario: selectRelativeUnit returns chosen unit and value
- **WHEN** `selectRelativeUnit(-90_000)` is called
- **THEN** the result equals `{ unit: "minutes", value: -2 }` (rounded)
#### Scenario: formatTimeAgo formats short style
- **WHEN** `formatTimeAgo(-3600_000, "en-GB")` is called
- **THEN** the result is a short-style 1-hour-ago phrase
**Verified by:** `packages/i18n/i18n-formatter-relative-time/src/helpers.test.ts::"selectRelativeUnit returns chosen unit and value"` AND `packages/i18n/i18n-formatter-relative-time/src/helpers.test.ts::"formatTimeAgo formats short-style 1 hour ago"`
---
### Requirement: Subpath exports
The package SHALL declare subpath exports in `package.json` so consumers can import only the surfaces they need:
- `@suaveplan/i18n-formatter-relative-time` — root barrel
- `@suaveplan/i18n-formatter-relative-time/formatter` — `RelativeTimeFormatter` class + `formatRelativeTime` + types + schema only
- `@suaveplan/i18n-formatter-relative-time/helpers` — `formatTimeAgo`, `formatTimeIn`, `selectRelativeUnit` only
#### Scenario: every documented subpath resolves
- **WHEN** every documented subpath is imported in a consumer test fixture
- **THEN** every import resolves with no `Module not found` errors
**Verified by:** `packages/i18n/i18n-formatter-relative-time/src/index.test.ts::"every documented subpath export resolves"`
---
### Requirement: Side-effect-free package
The package SHALL declare `"sideEffects": false` in `package.json`.
#### Scenario: package.json declares sideEffects false
- **WHEN** `package.json` is parsed
- **THEN** the `sideEffects` field equals `false`
**Verified by:** `packages/i18n/i18n-formatter-relative-time/src/index.test.ts::"package.json declares sideEffects false"`

View File

@@ -0,0 +1,59 @@
# Tasks — `@suaveplan/i18n-formatter-relative-time`
> Reconciled retroactively after Wave 3 shipped under commit `1cb2f412`. Package is built, tested, and validated; this pass folds the spec delta into `openspec/specs/i18n-formatter-relative-time/spec.md` (9 requirements) and archives the change folder. All `**Verified by:**` paths in the spec have been confirmed to exist in `packages/i18n/i18n-formatter-relative-time/src/`.
## 1. Spec authoring
- [x] 1.1 Cross-cutting design + AGENTS sections read
- [x] 1.2 Source-mapping table populated
- [x] 1.3 `proposal.md` authored
- [x] 1.4 `specs/i18n-formatter-relative-time/spec.md` delta drafted (9 requirements)
- [x] 1.5 Every requirement carries a `**Verified by:**` line
- [x] 1.6 `openspec change validate i18n-formatter-relative-time` clean
## 2. Package scaffold
- [x] 2.1 `packages/i18n/i18n-formatter-relative-time/src/` structure
- [x] 2.2 `package.json` — `@suaveplan/i18n-formatter-relative-time@0.0.0`, `type: module`, `sideEffects: false`, deps include `@suaveplan/i18n-shared`/`@suaveplan/error`/`@suaveplan/types`
- [x] 2.3 `tsconfig.lib.json` extends shared library config
- [x] 2.4 `vite.config.ts` + `vitest.config.ts`
- [x] 2.5 `README.md` with Modules table
## 3. Implementation
- [x] 3.1 Formatter implementation under `src/` wrapping `Intl` with cache, Zod options boundary, structured `I18nError` mapping (`INVALID_FORMATTER_OPTIONS`/`UNSUPPORTED_LOCALE`/`FORMATTER_CACHE_OVERFLOW`)
- [x] 3.2 OTel spans + counters + cache-hit histograms
- [x] 3.3 Sibling `.md` per `.ts` implementation file
- [x] 3.4 Root `src/index.ts` — explicit named re-exports (no `export *`)
## 4. Tests (runner-neutral imports)
- [x] 4.1 Unit tests covering every documented scenario in the spec
- [x] 4.2 Property/edge-case tests
- [x] 4.3 Cache-eviction + overflow tests
- [x] 4.4 Every `it(...)` carries `{ timeout: 5000 }`
- [x] 4.5 Runner-neutral imports via `@suaveplan/testing/runner` — CLAUDE.md §11 grep clean (verified)
## 5. Pre-archive verification
- [x] 5.1 Biome clean
- [x] 5.2 `bunx turbo lint typecheck test --filter=@suaveplan/i18n-formatter-relative-time` green at commit time
- [x] 5.3 `bunx turbo test:coverage --filter=@suaveplan/i18n-formatter-relative-time` 100/100/100/100 at commit time
- [x] 5.4 Conformance script clean
- [x] 5.5 Build green
## 6. Pre-archive 7-row checklist
- [x] 6.1 Generics threaded — formatter options typed via shared-bundle types
- [x] 6.2 Zod schemas — options boundary validated
- [x] 6.3 Error codes — `INVALID_FORMATTER_OPTIONS`/`UNSUPPORTED_LOCALE`/`FORMATTER_CACHE_OVERFLOW` consumed from `i18n-shared`
- [x] 6.4 Strict-mode tested
- [x] 6.5 OTel — spans/counters/histograms
- [x] 6.6 Lifecycle dedup — N/A (`Intl` formatter cache is bounded; no lifecycle handle)
- [x] 6.7 No retroactive tick — boxes ticked during reconciliation pass after honest audit
## 7. Archive
- [x] 7.1 Spec delta folded into `openspec/specs/i18n-formatter-relative-time/spec.md` (canonical, status ACTIVE)
- [x] 7.2 Folder moved to `openspec/archive/2026-05-09-i18n-formatter-relative-time/`
- [x] 7.3 `MANIFEST.md` already lists the package