# 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` 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"`