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

8.4 KiB

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"