first commit
This commit is contained in:
109
docs/localization/i18n/i18n-formatter-relative-time/README.md
Normal file
109
docs/localization/i18n/i18n-formatter-relative-time/README.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# @suaveplan/i18n-formatter-relative-time - Locale-aware relative-time formatting backed by Intl.RelativeTimeFormat. Class wrapper + standalone helper + ergonomic helpers (formatTimeAgo, formatTimeIn, selectRelativeUnit), Zod-validated options, per-(locale, style, numeric) cache, OTel histogram + cache-hit counter. Wave 3 of the @suaveplan/i18n modular split.
|
||||
|
||||
## Summary
|
||||
|
||||
Locale-aware relative-time formatting backed by `Intl.RelativeTimeFormat`.
|
||||
Wave 3 of the 14-package i18n modular split. Provides both a class
|
||||
(`RelativeTimeFormatter`) and a standalone helper
|
||||
(`formatRelativeTime`) plus ergonomic shortcuts (`formatTimeAgo`,
|
||||
`formatTimeIn`, `selectRelativeUnit`,
|
||||
`formatRelativeTimeStyled`) on top of a per-`(locale, style, numeric)`
|
||||
cache, Zod-validated options, and the canonical OTel histogram +
|
||||
cache-hit counter that every Wave 3 formatter package emits.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [formatter](./src/formatter/formatter.md) — Locale-aware relative-time formatting backed by `Intl.RelativeTimeFormat`. Given a delta in milliseconds (positive for…
|
||||
- [formatter](./src/formatter/formatter.md) — Locale-aware relative-time formatting backed by `Intl.RelativeTimeFormat`. Given a delta in milliseconds (positive for…
|
||||
- [helpers](./src/helpers/helpers.md) — Pre-bound shortcuts over `formatRelativeTime` for the two most common relative-time shapes in product UIs: past-tense (…
|
||||
- [helpers](./src/helpers/helpers.md) — Pre-bound shortcuts over `formatRelativeTime` for the two most common relative-time shapes in product UIs: past-tense (…
|
||||
- [observability](./src/observability/observability.md) — Per-package OpenTelemetry instruments for `@suaveplan/i18n-formatter-relative-time`. Exposes a histogram (`i18n.format.…
|
||||
- [observability](./src/observability/observability.md) — Per-package OpenTelemetry instruments for `@suaveplan/i18n-formatter-relative-time`. Exposes a histogram (`i18n.format.…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Why this exists
|
||||
|
||||
Each `Intl.*` wrapper lives in its own sibling package so consumers
|
||||
can pull only the formatters they actually use. This package is the
|
||||
relative-time slice of Wave 3, with the same shape as
|
||||
`@suaveplan/i18n-formatter-number` (per-(locale, options-hash) cache,
|
||||
Zod schema, OTel metrics, helper + class twin surface).
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/i18n-formatter-relative-time
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```ts
|
||||
import {
|
||||
formatRelativeTime,
|
||||
formatTimeAgo,
|
||||
formatTimeIn,
|
||||
RelativeTimeFormatter,
|
||||
selectRelativeUnit,
|
||||
} from "@suaveplan/i18n-formatter-relative-time";
|
||||
|
||||
formatRelativeTime(-3_600_000, "en-US"); // → "1 hour ago"
|
||||
formatRelativeTime(86_400_000, "en-US", {
|
||||
numeric: "auto",
|
||||
}); // → "tomorrow"
|
||||
|
||||
formatTimeAgo(60_000, "en-US"); // → "1 minute ago"
|
||||
formatTimeIn(60_000, "en-US"); // → "in 1 minute"
|
||||
selectRelativeUnit(90_000); // → { unit: "minutes", value: 2 }
|
||||
|
||||
const compact = new RelativeTimeFormatter({ style: "short" });
|
||||
compact.format(60_000, "en-US"); // → "in 1 min."
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
| Subpath | Description |
|
||||
|---|---|
|
||||
| `@suaveplan/i18n-formatter-relative-time` | Root barrel — class + standalone + helpers + observability |
|
||||
| `@suaveplan/i18n-formatter-relative-time/formatter` | `RelativeTimeFormatter`, `formatRelativeTime`, `selectRelativeUnit`, `clearRelativeTimeFormatterCache`, types, schema |
|
||||
| `@suaveplan/i18n-formatter-relative-time/helpers` | `formatTimeAgo`, `formatTimeIn`, `formatRelativeTimeStyled`, `selectRelativeUnit` only |
|
||||
|
||||
## Modules Documentation
|
||||
|
||||
- [formatter.md](src/formatter.md) — `RelativeTimeFormatter` class + `formatRelativeTime`
|
||||
- [helpers.md](src/helpers.md) — `formatTimeAgo`, `formatTimeIn`, `selectRelativeUnit`, `formatRelativeTimeStyled`
|
||||
- [observability.md](src/observability.md) — OTel histogram + cache-hit counter
|
||||
|
||||
## Public Surface (snapshot)
|
||||
|
||||
| Export | Kind | Origin |
|
||||
|---|---|---|
|
||||
| `RelativeTimeFormatter` | class | `formatter.ts` |
|
||||
| `formatRelativeTime` | function | `formatter.ts` |
|
||||
| `selectRelativeUnit` | function | `formatter.ts` |
|
||||
| `clearRelativeTimeFormatterCache` | function | `formatter.ts` |
|
||||
| `RelativeTimeFormatterOptionsSchema` | const | `formatter.ts` |
|
||||
| `RelativeTimeUnit` | type | `formatter.types.ts` |
|
||||
| `RelativeTimeStyle` | type | `formatter.types.ts` |
|
||||
| `RelativeTimeNumeric` | type | `formatter.types.ts` |
|
||||
| `RelativeTimeFormatterOptions` | type | `formatter.types.ts` |
|
||||
| `RelativeTimeUnitSelection` | type | `formatter.types.ts` |
|
||||
| `formatTimeAgo` | function | `helpers.ts` |
|
||||
| `formatTimeIn` | function | `helpers.ts` |
|
||||
| `formatRelativeTimeStyled` | function | `helpers.ts` |
|
||||
| `recordRelativeTimeFormat` | function | `observability.ts` |
|
||||
| `recordRelativeTimeCacheHit` | function | `observability.ts` |
|
||||
| `getRelativeTimeFormatMetricsState` | function | `observability.ts` |
|
||||
| `resetRelativeTimeFormatMetricsState` | function | `observability.ts` |
|
||||
|
||||
## Versioning
|
||||
|
||||
This package is part of the i18n modular-split rollout. Stays at
|
||||
`0.1.0` until the slim core (Wave 6) cuts the family `1.0.0` release.
|
||||
|
||||
## Wave 3 Scope
|
||||
|
||||
This package is one of eight Wave 3 formatter packages. Pure
|
||||
addition — `packages/i18n/i18n/src/formatting/relative-time/` is
|
||||
preserved in the monolith until Wave 6 deletes it.
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# RelativeTimeFormatter
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Locale-aware relative-time formatting backed by `Intl.RelativeTimeFormat`. Given a delta in milliseconds (positive for future, negative for past), the formatter auto-selects the largest unit whose absolute value is ≥ 1 from the ordered set `seconds → minutes → hours → days → weeks → months → years` and renders the result through `Intl.RelativeTimeFormat`.
|
||||
|
||||
Thresholds are fixed: 60 s → 1 min, 60 min → 1 h, 24 h → 1 d, 7 d → 1 w, 30.4375 d → 1 mo, 365.25 d → 1 y. The Gregorian-average month and Julian-average year are used for thresholds; consumers needing calendar-precise computation should compute the unit themselves and pass `options.unit` explicitly.
|
||||
|
||||
A per-`(locale, style, numeric)` cache amortises `Intl.RelativeTimeFormat` construction. Cache reuse increments `i18n.format.cache_hit{formatter="relative_time"}`; every format call records a sample on `i18n.format.relative_time.duration_ms` tagged with `locale`, `style`, and the resolved `unit` (auto-selected or explicit).
|
||||
|
||||
## Features
|
||||
|
||||
- Auto-unit selection across the full `seconds | minutes | hours | days | weeks | months | years` range
|
||||
- Explicit `options.unit` to override auto-selection
|
||||
- `style: 'long' | 'short' | 'narrow'` (default `'long'`)
|
||||
- `numeric: 'always' | 'auto'` (default `'always'`); `auto` produces phrases like "yesterday", "tomorrow"
|
||||
- Class form (`RelativeTimeFormatter`) for fixed-options reuse + standalone helper (`formatRelativeTime`) for one-off calls
|
||||
- Stable error codes: `I18N.INVALID_FORMATTER_OPTIONS`, `I18N.UNSUPPORTED_LOCALE`
|
||||
- Process-wide `Intl.RelativeTimeFormat` cache; `clearRelativeTimeFormatterCache()` for tests
|
||||
- `selectRelativeUnit(deltaMs)` exposed as a standalone for callers that only need the chosen `(unit, value)` pair
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { formatRelativeTime } from "@suaveplan/i18n-formatter-relative-time";
|
||||
|
||||
formatRelativeTime(30_000, "en-US"); // → "in 30 seconds"
|
||||
formatRelativeTime(-3_600_000, "en-US"); // → "1 hour ago"
|
||||
formatRelativeTime(86_400_000, "en-US", { numeric: "auto" }); // → "tomorrow"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example — Class Form for Repeated Use
|
||||
|
||||
```typescript
|
||||
import { RelativeTimeFormatter } from "@suaveplan/i18n-formatter-relative-time";
|
||||
|
||||
const compact = new RelativeTimeFormatter({ style: "short", numeric: "auto" });
|
||||
|
||||
// Many call sites, single options validation:
|
||||
const summary = events.map((event) =>
|
||||
compact.format(event.timestamp - Date.now(), "en-US"),
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `formatRelativeTime(deltaMs, locale, options?): string`
|
||||
|
||||
Formats a millisecond delta. Auto-selects a unit when `options.unit` is omitted; otherwise honours the explicit unit. Validates options through Zod, throws `I18nError(I18N.INVALID_FORMATTER_OPTIONS)` on failure. Maps `Intl.RelativeTimeFormat`'s `RangeError` for unsupported locales to `I18nError(I18N.UNSUPPORTED_LOCALE)`.
|
||||
|
||||
### `class RelativeTimeFormatter`
|
||||
|
||||
- `constructor(options?: RelativeTimeFormatterOptions)` — validates options at construction
|
||||
- `format(deltaMs: number, locale: string): string` — formats with stored options
|
||||
|
||||
### `selectRelativeUnit(deltaMs): RelativeTimeUnitSelection`
|
||||
|
||||
Exposes the auto-unit-selection algorithm without rendering. Returns `{ unit, value }` where `unit` is one of `'seconds' | 'minutes' | ...` and `value` is the rounded count.
|
||||
|
||||
### `clearRelativeTimeFormatterCache(): void`
|
||||
|
||||
Test-only.
|
||||
|
||||
### `RelativeTimeFormatterOptionsSchema`
|
||||
|
||||
Zod schema validating the `RelativeTimeFormatterOptions` surface.
|
||||
|
||||
### Types
|
||||
|
||||
`RelativeTimeUnit`, `RelativeTimeStyle`, `RelativeTimeNumeric`, `RelativeTimeFormatterOptions`, `RelativeTimeUnitSelection`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The threshold table uses `30.4375 d` for "month" (Gregorian average: 365.25 / 12) and `365.25 d` for "year" (Julian average). These are the same approximations `moment.js` and `dayjs` use for relative-time. Calendar-precise rendering would require knowing the exact reference date, which the function deliberately doesn't take — it accepts a delta, not two dates.
|
||||
|
||||
`numeric: 'auto'` produces locale-defined idiom strings ("yesterday", "tomorrow", "last week") for specific deltas. The exact set is platform-defined by `Intl.RelativeTimeFormat`. Tests assert idioms only for `en-US -1 day → "yesterday"` because that's stable across V8/JSC/SpiderMonkey; other idioms vary by runtime.
|
||||
|
||||
The cache key is `${locale}|${style}|${numeric}` rather than a JSON-stringified options bag because RelativeTimeFormat has only three knobs. Adding more options to the cache key (e.g., `unit`) would be wrong because the same formatter instance can render multiple units — it's only the locale + style + numeric combo that determines the formatter's identity.
|
||||
@@ -0,0 +1,73 @@
|
||||
# Relative-Time Helper Formatters
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Pre-bound shortcuts over `formatRelativeTime` for the two most common relative-time shapes in product UIs: past-tense ("5 minutes ago") and future-tense ("in 5 minutes"). These are net-new ergonomic helpers introduced by the Wave-3 split — the monolith's `formatting/relative-time/` ships only the class and standalone, while every other Wave-3 formatter carries a sibling helper module.
|
||||
|
||||
The helpers route every call through `formatRelativeTime`, so they inherit Zod option validation, the per-`(locale, style, numeric)` cache, and the OTel histogram + cache-hit counter. `selectRelativeUnit` is re-exported from the helpers subpath so callers that import only `@suaveplan/i18n-formatter-relative-time/helpers` for tree-shaking still get the auto-unit-selection function.
|
||||
|
||||
## Features
|
||||
|
||||
- `formatTimeAgo(ageMs, locale, options?)` — past-tense shortcut that negates internally so callers never have to remember the past = negative convention
|
||||
- `formatTimeIn(intervalMs, locale, options?)` — future-tense shortcut that abs-es internally
|
||||
- `formatRelativeTimeStyled(deltaMs, locale, style)` — style-only override without an options bag
|
||||
- `selectRelativeUnit(deltaMs)` — re-export of the auto-unit-selection helper
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import {
|
||||
formatRelativeTimeStyled,
|
||||
formatTimeAgo,
|
||||
formatTimeIn,
|
||||
selectRelativeUnit,
|
||||
} from "@suaveplan/i18n-formatter-relative-time/helpers";
|
||||
|
||||
formatTimeAgo(3_600_000, "en-US"); // → "1 hour ago"
|
||||
formatTimeIn(60_000, "en-US"); // → "in 1 minute"
|
||||
formatRelativeTimeStyled(86_400_000, "en-US", "short"); // → "in 1 day"
|
||||
selectRelativeUnit(90_000); // → { unit: "minutes", value: 2 }
|
||||
```
|
||||
|
||||
## Usage: Advanced Example — Activity feed timestamps
|
||||
|
||||
```typescript
|
||||
import { formatTimeAgo } from "@suaveplan/i18n-formatter-relative-time/helpers";
|
||||
|
||||
function renderActivity(events, locale) {
|
||||
return events.map((event) => ({
|
||||
title: event.title,
|
||||
when: formatTimeAgo(Date.now() - event.timestamp, locale, {
|
||||
style: "short",
|
||||
}),
|
||||
}));
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `formatTimeAgo(ageMs, locale, options?)`
|
||||
|
||||
Pre-bound shortcut for past-tense relative-time strings. Accepts an absolute (non-negative) age in milliseconds and renders it as "5 seconds ago", "1 hour ago", etc. Negates the input internally so callers never have to remember the past = negative convention of `Intl.RelativeTimeFormat`.
|
||||
|
||||
### `formatTimeIn(intervalMs, locale, options?)`
|
||||
|
||||
Pre-bound shortcut for future-tense relative-time strings. Accepts a non-negative interval in milliseconds and renders it as "in 5 seconds", "in 1 hour", etc.
|
||||
|
||||
### `formatRelativeTimeStyled(deltaMs, locale, style)`
|
||||
|
||||
Re-binds the style argument so the caller can skip the options bag for a one-shot style override. Accepts the original signed-delta convention (positive = future, negative = past).
|
||||
|
||||
### `selectRelativeUnit(deltaMs)`
|
||||
|
||||
Re-export of the formatter's auto-unit-selection helper. Returns `{ unit, value }` so callers can inspect the chosen unit without rendering through `Intl.RelativeTimeFormat`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`formatTimeAgo` uses `Math.abs(ageMs)` and negates the result before passing to `formatRelativeTime` so callers can pass either a positive duration or the natural `Date.now() - eventTime` form (which can be either sign depending on clock skew) without surprises. The same pattern in `formatTimeIn` ensures the future-tense helper always renders future-tense output even if the caller accidentally supplies a negative value.
|
||||
|
||||
`formatRelativeTimeStyled` is intentionally kept as a separate function rather than a default-arg overload of `formatRelativeTime` because the latter would change the standalone helper's call signature, breaking the `formatBridge` design contract that the slim core wires up in Wave 6.
|
||||
@@ -0,0 +1,91 @@
|
||||
# RelativeTimeFormatter Observability
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Per-package OpenTelemetry instruments for `@suaveplan/i18n-formatter-relative-time`. Exposes a histogram (`i18n.format.relative_time.duration_ms`) recording wall-clock duration of every `formatRelativeTime` call (tagged with `locale`, `style`, and the resolved `unit`) and a counter (`i18n.format.cache_hit`) tagged with `formatter: "relative_time"` that increments on every cache reuse.
|
||||
|
||||
The two instruments are constructed once via `metrics.getMeter` and are lazily initialised on first record so cold-start latency stays low. The module also retains an in-memory sample ring so spec-conformance tests can assert exact attribute payloads without standing up a full SDK exporter.
|
||||
|
||||
## Features
|
||||
|
||||
- Lazy instrument construction
|
||||
- Stable histogram name `i18n.format.relative_time.duration_ms` with attributes `{ locale, style, unit }`
|
||||
- Stable counter name `i18n.format.cache_hit` with attributes `{ formatter: "relative_time" }`
|
||||
- In-memory sample ring (`relativeTimeDurationSamples`, `cacheHitSamples`)
|
||||
- `resetRelativeTimeFormatMetricsState()` clears both arrays so tests stay isolated
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import {
|
||||
recordRelativeTimeFormat,
|
||||
recordRelativeTimeCacheHit,
|
||||
getRelativeTimeFormatMetricsState,
|
||||
resetRelativeTimeFormatMetricsState,
|
||||
} from "@suaveplan/i18n-formatter-relative-time/observability";
|
||||
|
||||
resetRelativeTimeFormatMetricsState();
|
||||
recordRelativeTimeFormat("en-GB", "long", "minutes", 1.23);
|
||||
recordRelativeTimeCacheHit();
|
||||
const state = getRelativeTimeFormatMetricsState();
|
||||
state.relativeTimeDurationSamples.length; // 1
|
||||
state.cacheHitSamples.length; // 1
|
||||
```
|
||||
|
||||
## Usage: Advanced Example — Asserting OTel emission in a test
|
||||
|
||||
```typescript
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
formatRelativeTime,
|
||||
clearRelativeTimeFormatterCache,
|
||||
} from "@suaveplan/i18n-formatter-relative-time";
|
||||
import {
|
||||
getRelativeTimeFormatMetricsState,
|
||||
resetRelativeTimeFormatMetricsState,
|
||||
} from "@suaveplan/i18n-formatter-relative-time/observability";
|
||||
|
||||
describe("metrics emission", () => {
|
||||
it("emits duration histogram and cache-hit counter", { timeout: 5000 }, () => {
|
||||
clearRelativeTimeFormatterCache();
|
||||
resetRelativeTimeFormatMetricsState();
|
||||
formatRelativeTime(60_000, "en-GB");
|
||||
formatRelativeTime(60_000, "en-GB");
|
||||
const state = getRelativeTimeFormatMetricsState();
|
||||
expect(state.relativeTimeDurationSamples).toHaveLength(2);
|
||||
expect(state.cacheHitSamples).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `recordRelativeTimeFormat(locale, style, unit, durationMs): void`
|
||||
|
||||
Records one observation on the `i18n.format.relative_time.duration_ms` histogram and pushes a `{ attributes: { locale, style, unit } }` entry into the in-memory sample ring.
|
||||
|
||||
### `recordRelativeTimeCacheHit(): void`
|
||||
|
||||
Adds 1 to the `i18n.format.cache_hit` counter with `formatter: "relative_time"` and pushes a matching entry into the in-memory sample ring.
|
||||
|
||||
### `getRelativeTimeFormatMetricsState(): RelativeTimeFormatMetricsState`
|
||||
|
||||
Returns the in-memory sample ring (live reference — do not mutate).
|
||||
|
||||
### `resetRelativeTimeFormatMetricsState(): void`
|
||||
|
||||
Empties both sample arrays in place.
|
||||
|
||||
### `RelativeTimeFormatMetricsState`
|
||||
|
||||
`{ relativeTimeDurationSamples: FormatMetricSample[]; cacheHitSamples: FormatMetricSample[] }` where `FormatMetricSample = { attributes: Record<string, string | number> }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The instruments are built lazily so consumers who never call `formatRelativeTime` (e.g. tree-shaken builds that import only types) do not pay the OTel meter-lookup cost. The cache check is a strict identity check against the closure-scoped `cached` slot — once built, the instruments are never rebuilt, even across `resetRelativeTimeFormatMetricsState` calls (resetting state does not invalidate the meter).
|
||||
|
||||
The module deliberately keeps the in-memory sample ring unbounded. Tests should call `resetRelativeTimeFormatMetricsState()` between cases. Long-running production processes should not retain the ring; the slim-core wave (Wave 6) swaps the in-package observability module for the unified one in `@suaveplan/i18n` core. For now, every leaf package owns its own ring per the design doc's per-package shape.
|
||||
Reference in New Issue
Block a user