Initial Commit
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
import z from "zod";
|
||||
|
||||
export const RelativeTimeFormatterOptionsSchema = z.object({
|
||||
style: z.enum(["long", "short", "narrow"]).optional(),
|
||||
numeric: z.enum(["always", "auto"]).optional(),
|
||||
unit: z
|
||||
.enum([
|
||||
"seconds",
|
||||
"minutes",
|
||||
"hours",
|
||||
"days",
|
||||
"weeks",
|
||||
"months",
|
||||
"years",
|
||||
])
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type RelativeTimeFormatterOptionsInput = z.input<
|
||||
typeof RelativeTimeFormatterOptionsSchema
|
||||
>;
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Spec-conformance pins for RelativeTimeFormatter — Zod rejection paths
|
||||
* and OTel histogram tagging.
|
||||
*/
|
||||
import { I18N_CODES, I18nError } from "@suaveplan/i18n-shared";
|
||||
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
|
||||
import type {
|
||||
RelativeTimeFormatterOptions,
|
||||
RelativeTimeStyle,
|
||||
RelativeTimeUnit,
|
||||
} from "./formatter/formatter.js";
|
||||
import {
|
||||
clearRelativeTimeFormatterCache,
|
||||
formatRelativeTime,
|
||||
RelativeTimeFormatter,
|
||||
RelativeTimeFormatterOptionsSchema,
|
||||
selectRelativeUnit,
|
||||
} from "./formatter/formatter.js";
|
||||
import {
|
||||
getRelativeTimeFormatMetricsState,
|
||||
resetRelativeTimeFormatMetricsState,
|
||||
} from "./observability/observability.js";
|
||||
|
||||
describe("RelativeTimeFormatter spec-conformance", () => {
|
||||
it("rejects non-object options shape (no path → field='options')", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
formatRelativeTime(0, "en-US", "not-an-object" as never);
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
expect((err as I18nError).code).toBe(
|
||||
I18N_CODES.INVALID_FORMATTER_OPTIONS
|
||||
);
|
||||
expect((err as I18nError).context).toMatchObject({
|
||||
field: "options",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects unknown style with INVALID_FORMATTER_OPTIONS", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
new RelativeTimeFormatter({ style: "rad" as never });
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
expect((err as I18nError).code).toBe(
|
||||
I18N_CODES.INVALID_FORMATTER_OPTIONS
|
||||
);
|
||||
expect((err as I18nError).context).toMatchObject({
|
||||
field: "style",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects unknown numeric with INVALID_FORMATTER_OPTIONS", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
formatRelativeTime(0, "en-US", { numeric: "magic" as never });
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
expect((err as I18nError).code).toBe(
|
||||
I18N_CODES.INVALID_FORMATTER_OPTIONS
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("maps Intl RangeError to UNSUPPORTED_LOCALE", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
clearRelativeTimeFormatterCache();
|
||||
try {
|
||||
formatRelativeTime(0, "en US"); // syntactically invalid
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
expect((err as I18nError).code).toBe(I18N_CODES.UNSUPPORTED_LOCALE);
|
||||
}
|
||||
});
|
||||
|
||||
it("tags histogram with auto-selected unit", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
clearRelativeTimeFormatterCache();
|
||||
resetRelativeTimeFormatMetricsState();
|
||||
formatRelativeTime(90_000, "en-US"); // → minutes
|
||||
const state = getRelativeTimeFormatMetricsState();
|
||||
expect(state.relativeTimeDurationSamples).toHaveLength(1);
|
||||
expect(state.relativeTimeDurationSamples[0]?.attributes).toMatchObject({
|
||||
locale: "en-US",
|
||||
style: "long",
|
||||
unit: "minutes",
|
||||
});
|
||||
});
|
||||
|
||||
it("tags histogram with explicit unit override", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
clearRelativeTimeFormatterCache();
|
||||
resetRelativeTimeFormatMetricsState();
|
||||
formatRelativeTime(90_000, "en-US", { unit: "hours" });
|
||||
const state = getRelativeTimeFormatMetricsState();
|
||||
expect(state.relativeTimeDurationSamples[0]?.attributes.unit).toBe(
|
||||
"hours"
|
||||
);
|
||||
});
|
||||
|
||||
it("cache hit increments counter on identical key", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
clearRelativeTimeFormatterCache();
|
||||
resetRelativeTimeFormatMetricsState();
|
||||
formatRelativeTime(60_000, "en-US");
|
||||
formatRelativeTime(60_000, "en-US");
|
||||
const state = getRelativeTimeFormatMetricsState();
|
||||
expect(state.cacheHitSamples).toHaveLength(1);
|
||||
expect(state.cacheHitSamples[0]?.attributes.formatter).toBe(
|
||||
"relative_time"
|
||||
);
|
||||
});
|
||||
|
||||
it("selectRelativeUnit picks each tier across the threshold table", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(selectRelativeUnit(30_000).unit).toBe("seconds");
|
||||
expect(selectRelativeUnit(90_000).unit).toBe("minutes");
|
||||
expect(selectRelativeUnit(2 * 3_600_000).unit).toBe("hours");
|
||||
expect(selectRelativeUnit(3 * 86_400_000).unit).toBe("days");
|
||||
expect(selectRelativeUnit(2 * 604_800_000).unit).toBe("weeks");
|
||||
expect(selectRelativeUnit(2 * 2_629_800_000).unit).toBe("months");
|
||||
expect(selectRelativeUnit(2 * 31_557_600_000).unit).toBe("years");
|
||||
});
|
||||
|
||||
it("type assertions pin the public surface", { timeout: 5000 }, () => {
|
||||
expectTypeOf<RelativeTimeStyle>().toEqualTypeOf<
|
||||
"long" | "short" | "narrow"
|
||||
>();
|
||||
expectTypeOf<RelativeTimeUnit>().toEqualTypeOf<
|
||||
| "seconds"
|
||||
| "minutes"
|
||||
| "hours"
|
||||
| "days"
|
||||
| "weeks"
|
||||
| "months"
|
||||
| "years"
|
||||
>();
|
||||
expectTypeOf<RelativeTimeFormatterOptions>().toMatchTypeOf<{
|
||||
readonly style?: RelativeTimeStyle;
|
||||
}>();
|
||||
});
|
||||
|
||||
it("schema accepts a well-formed options bag", { timeout: 5000 }, () => {
|
||||
const parsed = RelativeTimeFormatterOptionsSchema.safeParse({
|
||||
style: "short",
|
||||
numeric: "auto",
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
/** Relative-time formatter type definitions. */
|
||||
|
||||
export type RelativeTimeUnit =
|
||||
| "seconds"
|
||||
| "minutes"
|
||||
| "hours"
|
||||
| "days"
|
||||
| "weeks"
|
||||
| "months"
|
||||
| "years";
|
||||
|
||||
export type RelativeTimeStyle = "long" | "short" | "narrow";
|
||||
export type RelativeTimeNumeric = "always" | "auto";
|
||||
|
||||
export interface RelativeTimeFormatterOptions {
|
||||
readonly style?: RelativeTimeStyle;
|
||||
readonly numeric?: RelativeTimeNumeric;
|
||||
/** Force a specific unit; skips auto-selection. */
|
||||
readonly unit?: RelativeTimeUnit;
|
||||
}
|
||||
|
||||
/** Result of auto-unit selection: chosen unit and the rounded value. */
|
||||
export interface RelativeTimeUnitSelection {
|
||||
readonly unit: RelativeTimeUnit;
|
||||
readonly value: number;
|
||||
}
|
||||
@@ -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,156 @@
|
||||
import { I18N_CODES, I18nError } from "@suaveplan/i18n-shared";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
clearRelativeTimeFormatterCache,
|
||||
formatRelativeTime,
|
||||
RelativeTimeFormatter,
|
||||
} from "./formatter.js";
|
||||
|
||||
describe("formatRelativeTime auto-unit selection", () => {
|
||||
it("auto-selects seconds for sub-minute deltas", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = formatRelativeTime(30_000, "en-US");
|
||||
expect(result).toContain("second");
|
||||
expect(result).not.toContain("minute");
|
||||
});
|
||||
|
||||
it("rolls over to minutes at the 60s boundary", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = formatRelativeTime(90_000, "en-US");
|
||||
expect(result).toContain("minute");
|
||||
});
|
||||
|
||||
it("renders past-tense phrasing for negative deltas", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = formatRelativeTime(-3_600_000, "en-US", {
|
||||
numeric: "auto",
|
||||
});
|
||||
expect(result).toBe("1 hour ago");
|
||||
});
|
||||
|
||||
it("honours explicit unit override", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = formatRelativeTime(90_000, "en-US", {
|
||||
unit: "seconds",
|
||||
});
|
||||
expect(result).toContain("second");
|
||||
});
|
||||
|
||||
it("auto-selects hours, days, weeks, months, years across thresholds", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// 2 hours
|
||||
expect(formatRelativeTime(2 * 3_600_000, "en-US")).toContain("hour");
|
||||
// 3 days
|
||||
expect(formatRelativeTime(3 * 86_400_000, "en-US")).toContain("day");
|
||||
// 2 weeks
|
||||
expect(formatRelativeTime(2 * 604_800_000, "en-US")).toContain("week");
|
||||
// 2 months
|
||||
expect(formatRelativeTime(2 * 2_629_800_000, "en-US")).toContain(
|
||||
"month"
|
||||
);
|
||||
// 2 years
|
||||
expect(formatRelativeTime(2 * 31_557_600_000, "en-US")).toContain(
|
||||
"year"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatRelativeTime style and numeric overrides", () => {
|
||||
it("renders short style", { timeout: 5000 }, () => {
|
||||
const result = formatRelativeTime(86_400_000, "en-US", {
|
||||
style: "short",
|
||||
});
|
||||
// Short renders "in 1 day" — the unit is shown in short form by Intl.
|
||||
expect(result).toContain("day");
|
||||
});
|
||||
|
||||
it("renders 'yesterday' under numeric: auto", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = formatRelativeTime(-86_400_000, "en-US", {
|
||||
numeric: "auto",
|
||||
});
|
||||
expect(result).toBe("yesterday");
|
||||
});
|
||||
|
||||
it("RelativeTimeFormatter rejects invalid options with INVALID_FORMATTER_OPTIONS", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
new RelativeTimeFormatter({ style: "rad" as never });
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
expect((err as I18nError).code).toBe(
|
||||
I18N_CODES.INVALID_FORMATTER_OPTIONS
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("unsupported locale throws UNSUPPORTED_LOCALE with cause", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
clearRelativeTimeFormatterCache();
|
||||
try {
|
||||
formatRelativeTime(0, "xx-bogus-tag-!!!");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
expect((err as I18nError).code).toBe(I18N_CODES.UNSUPPORTED_LOCALE);
|
||||
expect((err as I18nError).cause).toBeInstanceOf(RangeError);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("RelativeTimeFormatter class", () => {
|
||||
it("formats via stored options", { timeout: 5000 }, () => {
|
||||
const formatter = new RelativeTimeFormatter({ style: "short" });
|
||||
expect(formatter.format(60_000, "en-US")).toContain("min");
|
||||
});
|
||||
|
||||
it("constructs with no options (defaults to {})", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const formatter = new RelativeTimeFormatter();
|
||||
expect(formatter.format(60_000, "en-US")).toContain("minute");
|
||||
});
|
||||
|
||||
it("validates options at construction", { timeout: 5000 }, () => {
|
||||
try {
|
||||
new RelativeTimeFormatter({ style: "rad" as never });
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as Error).message).toContain("style");
|
||||
}
|
||||
});
|
||||
|
||||
it("class output twins standalone helper output", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const opts = { style: "short" as const, numeric: "auto" as const };
|
||||
const klass = new RelativeTimeFormatter(opts).format(
|
||||
-86_400_000,
|
||||
"en-US"
|
||||
);
|
||||
const standalone = formatRelativeTime(-86_400_000, "en-US", opts);
|
||||
expect(klass).toBe(standalone);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearRelativeTimeFormatterCache", () => {
|
||||
it("drops cached Intl.RelativeTimeFormat instances", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
clearRelativeTimeFormatterCache();
|
||||
formatRelativeTime(60_000, "en-US");
|
||||
formatRelativeTime(60_000, "en-US");
|
||||
clearRelativeTimeFormatterCache();
|
||||
// Subsequent call must miss the cache (no throw).
|
||||
expect(formatRelativeTime(60_000, "en-US")).toContain("minute");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
import { I18N_CODES, I18nError } from "@suaveplan/i18n-shared";
|
||||
import { RelativeTimeFormatterOptionsSchema } from "../formatter.schemas.js";
|
||||
import type {
|
||||
RelativeTimeFormatterOptions,
|
||||
RelativeTimeUnit,
|
||||
RelativeTimeUnitSelection,
|
||||
} from "../formatter.types.js";
|
||||
import {
|
||||
recordRelativeTimeCacheHit,
|
||||
recordRelativeTimeFormat,
|
||||
} from "../observability/observability.js";
|
||||
|
||||
const cache = new Map<string, Intl.RelativeTimeFormat>();
|
||||
|
||||
/**
|
||||
* Threshold table mapping the upper bound (exclusive) of one unit to the
|
||||
* next. `60_000 ms` (60 s) rolls over to minutes; `3_600_000 ms` (60 min)
|
||||
* rolls over to hours; etc.
|
||||
*/
|
||||
const THRESHOLDS_MS = {
|
||||
minute: 60_000,
|
||||
hour: 3_600_000,
|
||||
day: 86_400_000,
|
||||
week: 604_800_000,
|
||||
month: 2_629_800_000, // 30.4375 d (Gregorian average)
|
||||
year: 31_557_600_000, // 365.25 d
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Intl.RelativeTimeFormat unit names (singular). Maps from our plural
|
||||
* convention to the Intl convention.
|
||||
*/
|
||||
const UNIT_TO_INTL: Record<RelativeTimeUnit, Intl.RelativeTimeFormatUnit> = {
|
||||
seconds: "second",
|
||||
minutes: "minute",
|
||||
hours: "hour",
|
||||
days: "day",
|
||||
weeks: "week",
|
||||
months: "month",
|
||||
years: "year",
|
||||
};
|
||||
|
||||
const MS_PER_UNIT: Record<RelativeTimeUnit, number> = {
|
||||
seconds: 1000,
|
||||
minutes: 60_000,
|
||||
hours: 3_600_000,
|
||||
days: 86_400_000,
|
||||
weeks: 604_800_000,
|
||||
months: THRESHOLDS_MS.month,
|
||||
years: THRESHOLDS_MS.year,
|
||||
};
|
||||
|
||||
/**
|
||||
* Standalone helper: pick the largest unit whose absolute value is >= 1
|
||||
* for a given delta (ms). Exposed so consumers can inspect the chosen
|
||||
* unit without rendering through `Intl.RelativeTimeFormat`.
|
||||
*/
|
||||
export function selectRelativeUnit(deltaMs: number): RelativeTimeUnitSelection {
|
||||
const abs = Math.abs(deltaMs);
|
||||
if (abs < THRESHOLDS_MS.minute) {
|
||||
return { unit: "seconds", value: Math.round(deltaMs / 1000) };
|
||||
}
|
||||
if (abs < THRESHOLDS_MS.hour) {
|
||||
return { unit: "minutes", value: Math.round(deltaMs / 60_000) };
|
||||
}
|
||||
if (abs < THRESHOLDS_MS.day) {
|
||||
return { unit: "hours", value: Math.round(deltaMs / 3_600_000) };
|
||||
}
|
||||
if (abs < THRESHOLDS_MS.week) {
|
||||
return { unit: "days", value: Math.round(deltaMs / 86_400_000) };
|
||||
}
|
||||
if (abs < THRESHOLDS_MS.month) {
|
||||
return { unit: "weeks", value: Math.round(deltaMs / 604_800_000) };
|
||||
}
|
||||
if (abs < THRESHOLDS_MS.year) {
|
||||
return {
|
||||
unit: "months",
|
||||
value: Math.round(deltaMs / THRESHOLDS_MS.month),
|
||||
};
|
||||
}
|
||||
return { unit: "years", value: Math.round(deltaMs / THRESHOLDS_MS.year) };
|
||||
}
|
||||
|
||||
function validateOptions(options: RelativeTimeFormatterOptions): void {
|
||||
const parsed = RelativeTimeFormatterOptionsSchema.safeParse(options);
|
||||
if (parsed.success) return;
|
||||
const field = String(parsed.error.issues[0]?.path[0] ?? "options");
|
||||
throw new I18nError(
|
||||
I18N_CODES.INVALID_FORMATTER_OPTIONS,
|
||||
`RelativeTimeFormatter: invalid options (field: ${field})`,
|
||||
{ context: { field, issues: parsed.error.issues } }
|
||||
);
|
||||
}
|
||||
|
||||
function buildIntl(
|
||||
locale: string,
|
||||
style: "long" | "short" | "narrow",
|
||||
numeric: "always" | "auto"
|
||||
): Intl.RelativeTimeFormat {
|
||||
try {
|
||||
return new Intl.RelativeTimeFormat(locale, { style, numeric });
|
||||
} catch (cause) {
|
||||
throw new I18nError(
|
||||
I18N_CODES.UNSUPPORTED_LOCALE,
|
||||
`RelativeTimeFormatter: unsupported locale '${locale}'`,
|
||||
{ cause, context: { locale } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getOrCreate(
|
||||
locale: string,
|
||||
style: "long" | "short" | "narrow",
|
||||
numeric: "always" | "auto"
|
||||
): { formatter: Intl.RelativeTimeFormat; cached: boolean } {
|
||||
const key = `${locale}|${style}|${numeric}`;
|
||||
const existing = cache.get(key);
|
||||
if (existing) return { formatter: existing, cached: true };
|
||||
const formatter = buildIntl(locale, style, numeric);
|
||||
cache.set(key, formatter);
|
||||
return { formatter, cached: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Standalone relative-time helper. Auto-selects the best unit when none
|
||||
* is supplied; otherwise honours the explicit `options.unit`.
|
||||
*/
|
||||
export function formatRelativeTime(
|
||||
deltaMs: number,
|
||||
locale: string,
|
||||
options: RelativeTimeFormatterOptions = {}
|
||||
): string {
|
||||
validateOptions(options);
|
||||
const t0 = performance.now();
|
||||
const style = options.style ?? "long";
|
||||
const numeric = options.numeric ?? "always";
|
||||
const selection: RelativeTimeUnitSelection = options.unit
|
||||
? {
|
||||
unit: options.unit,
|
||||
value: Math.round(deltaMs / MS_PER_UNIT[options.unit]),
|
||||
}
|
||||
: selectRelativeUnit(deltaMs);
|
||||
const { formatter, cached: hit } = getOrCreate(locale, style, numeric);
|
||||
const out = formatter.format(selection.value, UNIT_TO_INTL[selection.unit]);
|
||||
if (hit) recordRelativeTimeCacheHit();
|
||||
recordRelativeTimeFormat(
|
||||
locale,
|
||||
style,
|
||||
selection.unit,
|
||||
performance.now() - t0
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class wrapper for `formatRelativeTime` with construction-time validation
|
||||
* of stable options.
|
||||
*/
|
||||
export class RelativeTimeFormatter {
|
||||
private readonly options: RelativeTimeFormatterOptions;
|
||||
|
||||
public constructor(options: RelativeTimeFormatterOptions = {}) {
|
||||
validateOptions(options);
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
public format(deltaMs: number, locale: string): string {
|
||||
return formatRelativeTime(deltaMs, locale, this.options);
|
||||
}
|
||||
}
|
||||
|
||||
/** Test-only: clear the per-(locale, style, numeric) cache. */
|
||||
export function clearRelativeTimeFormatterCache(): void {
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
export { RelativeTimeFormatterOptionsSchema } from "../formatter.schemas.js";
|
||||
export type {
|
||||
RelativeTimeFormatterOptions,
|
||||
RelativeTimeNumeric,
|
||||
RelativeTimeStyle,
|
||||
RelativeTimeUnit,
|
||||
RelativeTimeUnitSelection,
|
||||
} from "../formatter.types.js";
|
||||
@@ -0,0 +1,14 @@
|
||||
export type {
|
||||
RelativeTimeFormatterOptions,
|
||||
RelativeTimeNumeric,
|
||||
RelativeTimeStyle,
|
||||
RelativeTimeUnit,
|
||||
RelativeTimeUnitSelection,
|
||||
} from "./formatter.js";
|
||||
export {
|
||||
clearRelativeTimeFormatterCache,
|
||||
formatRelativeTime,
|
||||
RelativeTimeFormatter,
|
||||
RelativeTimeFormatterOptionsSchema,
|
||||
selectRelativeUnit,
|
||||
} from "./formatter.js";
|
||||
@@ -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,54 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
formatRelativeTimeStyled,
|
||||
formatTimeAgo,
|
||||
formatTimeIn,
|
||||
selectRelativeUnit,
|
||||
} from "./helpers.js";
|
||||
|
||||
describe("formatTimeAgo", () => {
|
||||
it("returns past-tense relative string", { timeout: 5000 }, () => {
|
||||
expect(formatTimeAgo(60_000, "en-US")).toBe("1 minute ago");
|
||||
});
|
||||
|
||||
it("treats positive input as past (negates internally)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// 1 hour ago, regardless of caller sign.
|
||||
expect(formatTimeAgo(-3_600_000, "en-US")).toBe("1 hour ago");
|
||||
expect(formatTimeAgo(3_600_000, "en-US")).toBe("1 hour ago");
|
||||
});
|
||||
|
||||
it("respects style override", { timeout: 5000 }, () => {
|
||||
expect(formatTimeAgo(60_000, "en-US", { style: "short" })).toContain(
|
||||
"min"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatTimeIn", () => {
|
||||
it("returns future-tense relative string", { timeout: 5000 }, () => {
|
||||
expect(formatTimeIn(60_000, "en-US")).toBe("in 1 minute");
|
||||
});
|
||||
|
||||
it("treats negative input as future (abs internally)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(formatTimeIn(-3_600_000, "en-US")).toBe("in 1 hour");
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectRelativeUnit (helpers re-export)", () => {
|
||||
it("returns the same chosen unit as the formatter root export", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(selectRelativeUnit(30_000).unit).toBe("seconds");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatRelativeTimeStyled", () => {
|
||||
it("forwards style without options bag", { timeout: 5000 }, () => {
|
||||
const result = formatRelativeTimeStyled(86_400_000, "en-US", "short");
|
||||
expect(result).toContain("day");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { formatRelativeTime } from "../formatter/formatter.js";
|
||||
import type {
|
||||
RelativeTimeFormatterOptions,
|
||||
RelativeTimeStyle,
|
||||
} from "../formatter.types.js";
|
||||
|
||||
/**
|
||||
* 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 internally so callers
|
||||
* never have to remember the past = negative convention of
|
||||
* `Intl.RelativeTimeFormat`.
|
||||
*/
|
||||
export function formatTimeAgo(
|
||||
ageMs: number,
|
||||
locale: string,
|
||||
options: Omit<RelativeTimeFormatterOptions, "numeric"> = {}
|
||||
): string {
|
||||
const abs = Math.abs(ageMs);
|
||||
return formatRelativeTime(-abs, 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.
|
||||
*/
|
||||
export function formatTimeIn(
|
||||
intervalMs: number,
|
||||
locale: string,
|
||||
options: Omit<RelativeTimeFormatterOptions, "numeric"> = {}
|
||||
): string {
|
||||
const abs = Math.abs(intervalMs);
|
||||
return formatRelativeTime(abs, locale, { ...options });
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-export of `selectRelativeUnit` for the helpers subpath. Consumers
|
||||
* that import only `@suaveplan/i18n-formatter-relative-time/helpers`
|
||||
* for tree-shaking still get access to the auto-unit-selection helper.
|
||||
*/
|
||||
export { selectRelativeUnit } from "../formatter/formatter.js";
|
||||
|
||||
/**
|
||||
* Sanity helper exported for symmetry — re-binds the style argument.
|
||||
* Useful when the caller only needs to override style and wants to
|
||||
* skip the options bag.
|
||||
*/
|
||||
export function formatRelativeTimeStyled(
|
||||
deltaMs: number,
|
||||
locale: string,
|
||||
style: RelativeTimeStyle
|
||||
): string {
|
||||
return formatRelativeTime(deltaMs, locale, { style });
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
formatRelativeTimeStyled,
|
||||
formatTimeAgo,
|
||||
formatTimeIn,
|
||||
selectRelativeUnit,
|
||||
} from "./helpers.js";
|
||||
@@ -0,0 +1,67 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import * as root from "./index.js";
|
||||
|
||||
const PACKAGE_JSON_PATH = fileURLToPath(
|
||||
new URL("../package.json", import.meta.url)
|
||||
);
|
||||
|
||||
describe("@suaveplan/i18n-formatter-relative-time root barrel", () => {
|
||||
it("re-exports RelativeTimeFormatter class", { timeout: 5000 }, () => {
|
||||
expect(typeof root.RelativeTimeFormatter).toBe("function");
|
||||
});
|
||||
|
||||
it("re-exports formatRelativeTime standalone", { timeout: 5000 }, () => {
|
||||
expect(typeof root.formatRelativeTime).toBe("function");
|
||||
});
|
||||
|
||||
it("re-exports clearRelativeTimeFormatterCache", { timeout: 5000 }, () => {
|
||||
expect(typeof root.clearRelativeTimeFormatterCache).toBe("function");
|
||||
});
|
||||
|
||||
it("re-exports RelativeTimeFormatterOptionsSchema", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(root.RelativeTimeFormatterOptionsSchema).toBeDefined();
|
||||
});
|
||||
|
||||
it("re-exports selectRelativeUnit", { timeout: 5000 }, () => {
|
||||
expect(typeof root.selectRelativeUnit).toBe("function");
|
||||
});
|
||||
|
||||
it("re-exports helpers", { timeout: 5000 }, () => {
|
||||
expect(typeof root.formatTimeAgo).toBe("function");
|
||||
expect(typeof root.formatTimeIn).toBe("function");
|
||||
expect(typeof root.formatRelativeTimeStyled).toBe("function");
|
||||
});
|
||||
|
||||
it("re-exports observability surface", { timeout: 5000 }, () => {
|
||||
expect(typeof root.getRelativeTimeFormatMetricsState).toBe("function");
|
||||
expect(typeof root.resetRelativeTimeFormatMetricsState).toBe(
|
||||
"function"
|
||||
);
|
||||
expect(typeof root.recordRelativeTimeFormat).toBe("function");
|
||||
expect(typeof root.recordRelativeTimeCacheHit).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("@suaveplan/i18n-formatter-relative-time package.json invariants", () => {
|
||||
it("package.json declares sideEffects false", { timeout: 5000 }, () => {
|
||||
const raw = readFileSync(PACKAGE_JSON_PATH, "utf8");
|
||||
const pkg = JSON.parse(raw) as { sideEffects?: boolean };
|
||||
expect(pkg.sideEffects).toBe(false);
|
||||
});
|
||||
|
||||
it("every documented subpath export resolves", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const raw = readFileSync(PACKAGE_JSON_PATH, "utf8");
|
||||
const pkg = JSON.parse(raw) as { exports?: Record<string, unknown> };
|
||||
const exports = pkg.exports ?? {};
|
||||
const required = [".", "./formatter", "./helpers"];
|
||||
for (const subpath of required) {
|
||||
expect(exports).toHaveProperty(subpath);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
// Root barrel for @suaveplan/i18n-formatter-relative-time. Explicit
|
||||
// named re-exports only — no `export *` per CLAUDE.md §10.
|
||||
|
||||
export type {
|
||||
RelativeTimeFormatterOptions,
|
||||
RelativeTimeNumeric,
|
||||
RelativeTimeStyle,
|
||||
RelativeTimeUnit,
|
||||
RelativeTimeUnitSelection,
|
||||
} from "./formatter/index.js";
|
||||
export {
|
||||
clearRelativeTimeFormatterCache,
|
||||
formatRelativeTime,
|
||||
RelativeTimeFormatter,
|
||||
RelativeTimeFormatterOptionsSchema,
|
||||
selectRelativeUnit,
|
||||
} from "./formatter/index.js";
|
||||
export {
|
||||
formatRelativeTimeStyled,
|
||||
formatTimeAgo,
|
||||
formatTimeIn,
|
||||
} from "./helpers/index.js";
|
||||
export {
|
||||
type FormatMetricSample,
|
||||
getRelativeTimeFormatMetricsState,
|
||||
type RelativeTimeFormatMetricsState,
|
||||
recordRelativeTimeCacheHit,
|
||||
recordRelativeTimeFormat,
|
||||
resetRelativeTimeFormatMetricsState,
|
||||
} from "./observability/index.js";
|
||||
@@ -0,0 +1,10 @@
|
||||
export type {
|
||||
FormatMetricSample,
|
||||
RelativeTimeFormatMetricsState,
|
||||
} from "./observability.js";
|
||||
export {
|
||||
getRelativeTimeFormatMetricsState,
|
||||
recordRelativeTimeCacheHit,
|
||||
recordRelativeTimeFormat,
|
||||
resetRelativeTimeFormatMetricsState,
|
||||
} from "./observability.js";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
clearRelativeTimeFormatterCache,
|
||||
formatRelativeTime,
|
||||
} from "../formatter/formatter.js";
|
||||
import {
|
||||
getRelativeTimeFormatMetricsState,
|
||||
recordRelativeTimeCacheHit,
|
||||
recordRelativeTimeFormat,
|
||||
resetRelativeTimeFormatMetricsState,
|
||||
} from "./observability.js";
|
||||
|
||||
describe("RelativeTimeFormatter observability", () => {
|
||||
it("emits i18n.format.relative_time.duration_ms histogram on every call", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
clearRelativeTimeFormatterCache();
|
||||
resetRelativeTimeFormatMetricsState();
|
||||
formatRelativeTime(90_000, "en-GB"); // → minutes
|
||||
const state = getRelativeTimeFormatMetricsState();
|
||||
expect(state.relativeTimeDurationSamples).toHaveLength(1);
|
||||
expect(state.relativeTimeDurationSamples[0]?.attributes).toMatchObject({
|
||||
locale: "en-GB",
|
||||
style: "long",
|
||||
unit: "minutes",
|
||||
});
|
||||
});
|
||||
|
||||
it("cache-hit counter increments only on second call with same options", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
clearRelativeTimeFormatterCache();
|
||||
resetRelativeTimeFormatMetricsState();
|
||||
formatRelativeTime(60_000, "en-GB");
|
||||
expect(
|
||||
getRelativeTimeFormatMetricsState().cacheHitSamples
|
||||
).toHaveLength(0);
|
||||
formatRelativeTime(60_000, "en-GB");
|
||||
const state = getRelativeTimeFormatMetricsState();
|
||||
expect(state.cacheHitSamples).toHaveLength(1);
|
||||
expect(state.cacheHitSamples[0]?.attributes.formatter).toBe(
|
||||
"relative_time"
|
||||
);
|
||||
});
|
||||
|
||||
it("recordRelativeTimeFormat exposed for direct use", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
resetRelativeTimeFormatMetricsState();
|
||||
recordRelativeTimeFormat("fr-FR", "short", "hours", 0.1);
|
||||
const state = getRelativeTimeFormatMetricsState();
|
||||
expect(state.relativeTimeDurationSamples).toHaveLength(1);
|
||||
expect(state.relativeTimeDurationSamples[0]?.attributes).toMatchObject({
|
||||
locale: "fr-FR",
|
||||
style: "short",
|
||||
unit: "hours",
|
||||
});
|
||||
});
|
||||
|
||||
it("recordRelativeTimeCacheHit can be invoked directly", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
resetRelativeTimeFormatMetricsState();
|
||||
recordRelativeTimeCacheHit();
|
||||
recordRelativeTimeCacheHit();
|
||||
const state = getRelativeTimeFormatMetricsState();
|
||||
expect(state.cacheHitSamples).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("instruments are reused across resets (no double-construction)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
resetRelativeTimeFormatMetricsState();
|
||||
recordRelativeTimeFormat("en-GB", "long", "minutes", 0.1);
|
||||
recordRelativeTimeFormat("en-GB", "long", "minutes", 0.2);
|
||||
const state = getRelativeTimeFormatMetricsState();
|
||||
expect(state.relativeTimeDurationSamples).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
|
||||
|
||||
/**
|
||||
* OTel instruments for the relative-time formatter.
|
||||
*
|
||||
* Histogram records per-format wall-clock duration; the counter
|
||||
* increments on every cache reuse so consumers can observe cache
|
||||
* effectiveness.
|
||||
*/
|
||||
|
||||
export interface FormatMetricSample {
|
||||
readonly attributes: Record<string, string | number>;
|
||||
}
|
||||
|
||||
export interface RelativeTimeFormatMetricsState {
|
||||
readonly relativeTimeDurationSamples: FormatMetricSample[];
|
||||
readonly cacheHitSamples: FormatMetricSample[];
|
||||
}
|
||||
|
||||
let cached:
|
||||
| {
|
||||
readonly relativeTimeDurationMs: Histogram;
|
||||
readonly cacheHit: Counter;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
const state: RelativeTimeFormatMetricsState = {
|
||||
relativeTimeDurationSamples: [],
|
||||
cacheHitSamples: [],
|
||||
};
|
||||
|
||||
function ensureInstruments() {
|
||||
if (cached) return cached;
|
||||
const meter = metrics.getMeter("suaveplan.i18n-formatter-relative-time");
|
||||
cached = {
|
||||
relativeTimeDurationMs: meter.createHistogram(
|
||||
"i18n.format.relative_time.duration_ms",
|
||||
{
|
||||
description:
|
||||
"Duration in ms of RelativeTimeFormatter.format; labels: locale, style, unit.",
|
||||
unit: "ms",
|
||||
}
|
||||
),
|
||||
cacheHit: meter.createCounter("i18n.format.cache_hit", {
|
||||
description:
|
||||
'Format-time cache reuse counter; labels: formatter ("relative_time").',
|
||||
}),
|
||||
};
|
||||
return cached;
|
||||
}
|
||||
|
||||
export function recordRelativeTimeFormat(
|
||||
locale: string,
|
||||
style: string,
|
||||
unit: string,
|
||||
durationMs: number
|
||||
): void {
|
||||
const ins = ensureInstruments();
|
||||
const attributes = { locale, style, unit };
|
||||
ins.relativeTimeDurationMs.record(durationMs, attributes);
|
||||
state.relativeTimeDurationSamples.push({ attributes });
|
||||
}
|
||||
|
||||
export function recordRelativeTimeCacheHit(): void {
|
||||
const ins = ensureInstruments();
|
||||
const attributes = { formatter: "relative_time" };
|
||||
ins.cacheHit.add(1, attributes);
|
||||
state.cacheHitSamples.push({ attributes });
|
||||
}
|
||||
|
||||
export function getRelativeTimeFormatMetricsState(): RelativeTimeFormatMetricsState {
|
||||
return state;
|
||||
}
|
||||
|
||||
export function resetRelativeTimeFormatMetricsState(): void {
|
||||
state.relativeTimeDurationSamples.length = 0;
|
||||
state.cacheHitSamples.length = 0;
|
||||
}
|
||||
Reference in New Issue
Block a user