Files
2026-05-27 23:29:53 +01:00

147 lines
8.4 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# Capability Spec: i18n-formatter-relative-time (i18n-formatter-relative-time)
**Package**: `@suaveplan/i18n-formatter-relative-time`
**Status**: ACTIVE
> Every requirement below carries a `**Verified by:**` line at authoring time. (See `openspec/AGENTS.md` Section 13.)
## 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"`