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. (Seeopenspec/AGENTS.mdSection 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
I18nErrorwith 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
I18nErrorwith code"I18N.UNSUPPORTED_LOCALE"AND the error'scauseis the originalRangeError
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.RelativeTimeFormatconstructor invocation occurs
Scenario: distinct style or numeric creates distinct cache entries
- WHEN
formatRelativeTime(-1000, "en-GB", { style: "long" })andformatRelativeTime(-1000, "en-GB", { style: "short" })are called - THEN two distinct
Intl.RelativeTimeFormatinstances 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_mswithlocale,style, andunitattributes
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-bindsformatRelativeTimewithstyle: "short"and asserts negative deltaformatTimeIn(deltaMs, locale)— pre-bindsformatRelativeTimewithstyle: "short"and asserts positive deltaselectRelativeUnit(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—RelativeTimeFormatterclass +formatRelativeTime+ types + schema only@suaveplan/i18n-formatter-relative-time/helpers—formatTimeAgo,formatTimeIn,selectRelativeUnitonly
Scenario: every documented subpath resolves
- WHEN every documented subpath is imported in a consumer test fixture
- THEN every import resolves with no
Module not founderrors
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.jsonis parsed - THEN the
sideEffectsfield equalsfalse
Verified by: packages/i18n/i18n-formatter-relative-time/src/index.test.ts::"package.json declares sideEffects false"