Initial Commit
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
export {
|
||||
clearUseRelativeTimeCache,
|
||||
useRelativeTime,
|
||||
} from "./use-relative-time.js";
|
||||
export type {
|
||||
UseRelativeTimeNumeric,
|
||||
UseRelativeTimeOptions,
|
||||
UseRelativeTimeStyle,
|
||||
UseRelativeTimeUnit,
|
||||
} from "./use-relative-time.types.js";
|
||||
@@ -0,0 +1,86 @@
|
||||
# useRelativeTime
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`useRelativeTime(deltaMs, options?)` formats a millisecond delta as a
|
||||
localised relative-time string ("in 5 minutes", "3 hours ago", "il y a
|
||||
2 jours"). Wraps `Intl.RelativeTimeFormat` directly with a per-(locale,
|
||||
style, numeric) cache so the formatter constructor is paid once per
|
||||
process per option combination. Memoised on `(resolvedLocale, deltaMs,
|
||||
options-hash)` so a no-op re-render does not reach into the cache or
|
||||
the formatter.
|
||||
|
||||
When the `unit` option is omitted the hook auto-selects the largest
|
||||
unit whose absolute value is ≥ 1 (per the universal-core
|
||||
`formatRelativeTime` contract). Pass `unit` explicitly when a fixed
|
||||
granularity matters — e.g. always render activity timestamps in
|
||||
minutes within the first hour for consistency with a refresh interval.
|
||||
|
||||
## Features
|
||||
|
||||
- Auto-selects unit (seconds → years) when `unit` is omitted.
|
||||
- Forwards `style` (`'long' | 'short' | 'narrow'`) and `numeric` (`'always' | 'auto'`).
|
||||
- Per-(locale, style, numeric) cache of `Intl.RelativeTimeFormat` instances.
|
||||
- Memoised on `(locale, deltaMs, options-hash)` — no re-format on identical inputs.
|
||||
- Production no-Provider fallback uses `'en-GB'` so the hook always returns a real string.
|
||||
- Re-renders on `languageChanged` via the inherited `useLocale` subscription.
|
||||
- `clearUseRelativeTimeCache()` exposed for tests / hot-reload scenarios.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```tsx
|
||||
import { useRelativeTime } from "@suaveplan/react-i18n";
|
||||
|
||||
export function PostedAt(props: { readonly postedAt: Date }) {
|
||||
const delta = props.postedAt.getTime() - Date.now();
|
||||
const formatted = useRelativeTime(delta);
|
||||
return <time dateTime={props.postedAt.toISOString()}>{formatted}</time>;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
For activity feeds that update once per second, pin the unit to
|
||||
`'seconds'` for the first minute and `'minutes'` thereafter. The
|
||||
pattern below shows a row that picks the unit explicitly to avoid
|
||||
stuttering between auto-selected units near boundary thresholds.
|
||||
|
||||
```tsx
|
||||
import { useRelativeTime } from "@suaveplan/react-i18n";
|
||||
|
||||
export function LiveTimestamp(props: { readonly when: Date }) {
|
||||
const delta = props.when.getTime() - Date.now();
|
||||
const abs = Math.abs(delta);
|
||||
const unit = abs < 60_000 ? "seconds" : "minutes";
|
||||
const formatted = useRelativeTime(delta, {
|
||||
style: "short",
|
||||
numeric: "always",
|
||||
unit,
|
||||
});
|
||||
return <span data-live="true">{formatted}</span>;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `useRelativeTime(deltaMs: number, options?: UseRelativeTimeOptions): string`
|
||||
|
||||
- **Parameters**:
|
||||
- `deltaMs` — delta in milliseconds. Positive = future, negative = past, zero = now.
|
||||
- `options?` — `style`, `numeric`, `unit`. When `unit` is omitted, the hook auto-selects.
|
||||
- **Returns**: the formatted relative-time string.
|
||||
- **Throws**: `ReactI18nError(REACT_I18N.NO_PROVIDER)` outside a `<TranslationProvider>` in dev mode (inherited from `useLocale → useI18n`).
|
||||
|
||||
### `clearUseRelativeTimeCache(): void`
|
||||
|
||||
Test-only escape hatch to reset the process-level `Intl.RelativeTimeFormat` cache.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The auto-select thresholds match the universal-core `formatRelativeTime` table: `60 s → minutes`, `60 min → hours`, `24 h → days`, `7 d → weeks`, `30.4375 d → months` (Gregorian average), `365.25 d → years` (Julian average). This keeps React-side and server-side renders stable across the boundary.
|
||||
- The unit conversion rounds via `Math.round` on the way down. Intentionally — a 30.5-second delta rounds to "in 31 seconds" rather than truncating to "in 30 seconds" which would feel wrong to the user staring at a one-second-resolution clock.
|
||||
- The cache key is `(locale, style, numeric)` — `unit` does NOT enter the cache because `Intl.RelativeTimeFormat.format(value, unit)` accepts `unit` per call. One formatter handles all units for the same (locale, style, numeric) combination.
|
||||
- The default locale `'en-GB'` on the no-Provider fallback matches the broader package convention. This keeps the hook from throwing in production while still producing meaningful output.
|
||||
@@ -0,0 +1,166 @@
|
||||
import { createI18n } from "@suaveplan/i18n/plugin";
|
||||
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { renderComponent } from "@suaveplan/testing-dom/render";
|
||||
import { act } from "@testing-library/react";
|
||||
import { TranslationProvider } from "../../provider/provider.js";
|
||||
import { resetWarnOncePerProcess } from "../../utils/warn-once-per-process.js";
|
||||
import {
|
||||
clearUseRelativeTimeCache,
|
||||
useRelativeTime,
|
||||
} from "./use-relative-time.js";
|
||||
|
||||
interface ProcessShape {
|
||||
env?: { NODE_ENV?: string };
|
||||
}
|
||||
|
||||
function withEnv<T>(value: string, fn: () => T): T {
|
||||
const proc = (globalThis as unknown as { process?: ProcessShape }).process;
|
||||
const previous = proc?.env?.NODE_ENV;
|
||||
if (proc?.env !== undefined) proc.env.NODE_ENV = value;
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
if (proc?.env !== undefined) {
|
||||
if (previous === undefined) delete proc.env.NODE_ENV;
|
||||
else proc.env.NODE_ENV = previous;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Probe(props: {
|
||||
readonly deltaMs: number;
|
||||
readonly options?: Parameters<typeof useRelativeTime>[1];
|
||||
}) {
|
||||
const formatted = useRelativeTime(props.deltaMs, props.options);
|
||||
return <span data-testid="probe">{formatted}</span>;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
resetWarnOncePerProcess();
|
||||
clearUseRelativeTimeCache();
|
||||
});
|
||||
|
||||
describe("useRelativeTime", () => {
|
||||
it("renders a future minutes delta in English", { timeout: 5000 }, () => {
|
||||
const i18n = createI18n({
|
||||
defaultLocale: "en-GB",
|
||||
messages: { "en-GB": {} },
|
||||
});
|
||||
const { getByTestId } = renderComponent(
|
||||
<TranslationProvider i18n={i18n}>
|
||||
<Probe deltaMs={5 * 60_000} />
|
||||
</TranslationProvider>
|
||||
);
|
||||
expect(getByTestId("probe").textContent).toMatch(/in 5 minutes/);
|
||||
});
|
||||
|
||||
it("renders a past hours delta with negative sign", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const i18n = createI18n({
|
||||
defaultLocale: "en-GB",
|
||||
messages: { "en-GB": {} },
|
||||
});
|
||||
const { getByTestId } = renderComponent(
|
||||
<TranslationProvider i18n={i18n}>
|
||||
<Probe deltaMs={-3 * 3_600_000} />
|
||||
</TranslationProvider>
|
||||
);
|
||||
expect(getByTestId("probe").textContent).toMatch(/3 hours ago/);
|
||||
});
|
||||
|
||||
it("re-renders with French phrasing on locale change", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const i18n = createI18n({
|
||||
defaultLocale: "en-GB",
|
||||
messages: { "en-GB": {}, "fr-FR": {} },
|
||||
});
|
||||
const { getByTestId } = renderComponent(
|
||||
<TranslationProvider i18n={i18n}>
|
||||
<Probe deltaMs={5 * 60_000} />
|
||||
</TranslationProvider>
|
||||
);
|
||||
const en = getByTestId("probe").textContent ?? "";
|
||||
expect(en).toMatch(/in 5 minutes/);
|
||||
await act(async () => {
|
||||
await i18n.changeLanguage("fr-FR");
|
||||
});
|
||||
const fr = getByTestId("probe").textContent ?? "";
|
||||
expect(fr).not.toBe(en);
|
||||
expect(fr).toMatch(/dans 5 minutes/);
|
||||
});
|
||||
|
||||
it("respects an explicit unit override", { timeout: 5000 }, () => {
|
||||
const i18n = createI18n({
|
||||
defaultLocale: "en-GB",
|
||||
messages: { "en-GB": {} },
|
||||
});
|
||||
const { getByTestId } = renderComponent(
|
||||
<TranslationProvider i18n={i18n}>
|
||||
<Probe
|
||||
deltaMs={3_600_000}
|
||||
options={{ unit: "minutes", numeric: "always" }}
|
||||
/>
|
||||
</TranslationProvider>
|
||||
);
|
||||
expect(getByTestId("probe").textContent).toMatch(/in 60 minutes/);
|
||||
});
|
||||
|
||||
it("respects style: 'short'", { timeout: 5000 }, () => {
|
||||
const i18n = createI18n({
|
||||
defaultLocale: "en-GB",
|
||||
messages: { "en-GB": {} },
|
||||
});
|
||||
const { getByTestId } = renderComponent(
|
||||
<TranslationProvider i18n={i18n}>
|
||||
<Probe deltaMs={5 * 60_000} options={{ style: "short" }} />
|
||||
</TranslationProvider>
|
||||
);
|
||||
expect(getByTestId("probe").textContent).toMatch(/min\b/);
|
||||
});
|
||||
|
||||
it("auto-selects seconds, days, weeks, months, years when below thresholds", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const i18n = createI18n({
|
||||
defaultLocale: "en-GB",
|
||||
messages: { "en-GB": {} },
|
||||
});
|
||||
const cases: Array<[number, RegExp]> = [
|
||||
[10_000, /10 seconds|seconds/],
|
||||
[3 * 86_400_000, /3 days/],
|
||||
[3 * 604_800_000, /3 weeks/],
|
||||
[3 * 2_629_800_000, /3 months/],
|
||||
[3 * 31_557_600_000, /3 years/],
|
||||
];
|
||||
for (const [delta, regex] of cases) {
|
||||
const { getByTestId, unmount } = renderComponent(
|
||||
<TranslationProvider i18n={i18n}>
|
||||
<Probe deltaMs={delta} options={{ numeric: "always" }} />
|
||||
</TranslationProvider>
|
||||
);
|
||||
expect(getByTestId("probe").textContent).toMatch(regex);
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("formats via en-GB on the production no-Provider fallback", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const original = console.warn;
|
||||
console.warn = (() => {}) as typeof console.warn;
|
||||
try {
|
||||
withEnv("production", () => {
|
||||
const { getByTestId } = renderComponent(
|
||||
<Probe deltaMs={5 * 60_000} />
|
||||
);
|
||||
expect(getByTestId("probe").textContent).toMatch(
|
||||
/in 5 minutes/
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
console.warn = original;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useMemo } from "react";
|
||||
import { useLocale } from "../use-locale/use-locale.js";
|
||||
import type {
|
||||
UseRelativeTimeOptions,
|
||||
UseRelativeTimeUnit,
|
||||
} from "./use-relative-time.types.js";
|
||||
|
||||
const DEFAULT_LOCALE = "en-GB";
|
||||
|
||||
const UNIT_TO_INTL: Record<UseRelativeTimeUnit, Intl.RelativeTimeFormatUnit> = {
|
||||
seconds: "second",
|
||||
minutes: "minute",
|
||||
hours: "hour",
|
||||
days: "day",
|
||||
weeks: "week",
|
||||
months: "month",
|
||||
years: "year",
|
||||
};
|
||||
|
||||
const MS_PER_UNIT: Record<UseRelativeTimeUnit, number> = {
|
||||
seconds: 1000,
|
||||
minutes: 60_000,
|
||||
hours: 3_600_000,
|
||||
days: 86_400_000,
|
||||
weeks: 604_800_000,
|
||||
months: 2_629_800_000, // 30.4375 days (Gregorian average)
|
||||
years: 31_557_600_000, // 365.25 days
|
||||
};
|
||||
|
||||
const THRESHOLDS_MS = {
|
||||
minute: 60_000,
|
||||
hour: 3_600_000,
|
||||
day: 86_400_000,
|
||||
week: 604_800_000,
|
||||
month: 2_629_800_000,
|
||||
year: 31_557_600_000,
|
||||
};
|
||||
|
||||
interface AutoSelection {
|
||||
readonly unit: UseRelativeTimeUnit;
|
||||
readonly value: number;
|
||||
}
|
||||
|
||||
function autoSelectUnit(deltaMs: number): AutoSelection {
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
const cache = new Map<string, Intl.RelativeTimeFormat>();
|
||||
|
||||
function getOrCreateFormatter(
|
||||
locale: string,
|
||||
style: NonNullable<UseRelativeTimeOptions["style"]>,
|
||||
numeric: NonNullable<UseRelativeTimeOptions["numeric"]>
|
||||
): Intl.RelativeTimeFormat {
|
||||
const key = `${locale}|${style}|${numeric}`;
|
||||
const existing = cache.get(key);
|
||||
if (existing !== undefined) return existing;
|
||||
const fresh = new Intl.RelativeTimeFormat(locale, { style, numeric });
|
||||
cache.set(key, fresh);
|
||||
return fresh;
|
||||
}
|
||||
|
||||
/**
|
||||
* Locale-aware relative-time formatter hook.
|
||||
*
|
||||
* Renders a delta in milliseconds (positive = future, negative = past) as
|
||||
* a localised relative-time string. When the `unit` option is omitted the
|
||||
* hook auto-selects the largest unit whose absolute value is ≥ 1 (per
|
||||
* the universal-core `formatRelativeTime` contract).
|
||||
*
|
||||
* Memoised on `(resolvedLocale, deltaMs, options-hash)`. Subscribes via
|
||||
* `useLocale` so a `setLocale('fr-FR')` re-renders the consumer with the
|
||||
* French phrasing on the same React commit cycle as the bus emission.
|
||||
*/
|
||||
export function useRelativeTime(
|
||||
deltaMs: number,
|
||||
options?: UseRelativeTimeOptions
|
||||
): string {
|
||||
const { locale: activeLocale } = useLocale();
|
||||
const resolvedLocale = activeLocale ?? DEFAULT_LOCALE;
|
||||
return useMemo(() => {
|
||||
const style = options?.style ?? "long";
|
||||
const numeric = options?.numeric ?? "auto";
|
||||
const formatter = getOrCreateFormatter(resolvedLocale, style, numeric);
|
||||
if (options?.unit !== undefined) {
|
||||
const value = deltaMs / MS_PER_UNIT[options.unit];
|
||||
return formatter.format(
|
||||
Math.round(value),
|
||||
UNIT_TO_INTL[options.unit]
|
||||
);
|
||||
}
|
||||
const auto = autoSelectUnit(deltaMs);
|
||||
return formatter.format(auto.value, UNIT_TO_INTL[auto.unit]);
|
||||
}, [resolvedLocale, deltaMs, options]);
|
||||
}
|
||||
|
||||
/** Test-only: clear the cached `Intl.RelativeTimeFormat` registry. */
|
||||
export function clearUseRelativeTimeCache(): void {
|
||||
cache.clear();
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/** Style options accepted by `Intl.RelativeTimeFormat`. */
|
||||
export type UseRelativeTimeStyle = "long" | "short" | "narrow";
|
||||
|
||||
/** Numeric mode accepted by `Intl.RelativeTimeFormat`. */
|
||||
export type UseRelativeTimeNumeric = "always" | "auto";
|
||||
|
||||
/** Unit names supported by the auto-selector. */
|
||||
export type UseRelativeTimeUnit =
|
||||
| "seconds"
|
||||
| "minutes"
|
||||
| "hours"
|
||||
| "days"
|
||||
| "weeks"
|
||||
| "months"
|
||||
| "years";
|
||||
|
||||
/**
|
||||
* Options accepted by `useRelativeTime`. When `unit` is omitted the hook
|
||||
* auto-selects the largest unit whose value is ≥ 1.
|
||||
*/
|
||||
export interface UseRelativeTimeOptions {
|
||||
readonly style?: UseRelativeTimeStyle;
|
||||
readonly numeric?: UseRelativeTimeNumeric;
|
||||
readonly unit?: UseRelativeTimeUnit;
|
||||
}
|
||||
Reference in New Issue
Block a user