# `@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.