feat(format/dates): add formatRelative to engine + route RelativeTime through runtime

$libs/days/format.ts:
- Nuevo `getCachedRelativeTimeFormat(locale, options)` — twin del cache
  ya existente `getCachedDateFormat`. Key `${locale}:${JSON.stringify(opts)}`.
  Sites que renderizan listas de timestamps relativos (feeds, activity
  logs) hit identical pairs y mantienen el loop caliente.

arts/format/dates/engine-dates.ts + types.ts:
- Nuevo `formatRelative(value, unit, options?, locale?)` en EngineDates.
  Defaults: `numeric: 'auto'` (colapsa 0/-1/+1 en "yesterday"/"today"),
  `style: 'long'`. Per-call locale override no muta engine state.

arts/format/dates/active-dates.svelte.ts:
- formatRelative wired via `readFrom(runtime, engine.formatRelative)`
  para que el $derived del componente sea reactivo al cambio de locale.

Componente relative-time.svelte:
- Drop del bypass directo a `new Intl.RelativeTimeFormat(locale, opts)`.
  Ahora SIEMPRE rutea por `uix.format.dates.formatRelative(magnitude,
  unit, { numeric, style }, locale)`. El componente sigue haciendo la
  matematica del delta + auto-unit + magnitud — el engine solo expone
  la operacion atomica.
- Fallback a Intl directo solo cuando no hay runtime (eidos fuera de
  UixApp).

Tests: 17/17 en engine-dates.test.ts (+3 nuevos: formatRelative con
locale del engine, numeric/style options, per-call locale override sin
mutar state).

Docs: arts/format/README.md sub-seccion en Dates con la nueva API.
relative-time/README.md decisiones actualizadas (ya no es la excepcion).

Cierra el ultimo follow-up del sprint de service components — los 4
componentes (Trans, FormatNumber, FormatDate, RelativeTime) ahora
rutean uniformemente por el runtime UIX.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
active-uix
dev 5 months ago
parent 7b01397aab
commit b54d42e230

@ -255,12 +255,22 @@ formats.dates.getHourCycle();
formats.dates.formatDate(new Date());
formats.dates.formatTime(new Date());
formats.dates.formatDateTime(new Date());
formats.dates.formatRelative(-2, 'hour'); // "hace 2 horas"
formats.dates.formatRelative(3, 'day'); // "dentro de 3 días"
formats.dates.formatRelative(0, 'day'); // "hoy" (numeric:'auto')
```
`getHourCycle()` deriva del locale a traves de las utilidades de `days`; si el
usuario fija `setHourCycle(12)` o `setHourCycle(24)`, esa preferencia queda
bloqueada hasta `clearHourCycle()`.
`formatRelative(value, unit, options?, locale?)` envuelve
`Intl.RelativeTimeFormat` con cache compartido (`getCachedRelativeTimeFormat`
en `$libs/days`). El caller pasa magnitud (`-1` past, `+3` future) y unidad
(`'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year'`).
Defaults: `numeric: 'auto'` (colapsa 0/-1/+1 en palabras, "yesterday"/"today"),
`style: 'long'`.
### Override de locale por llamada (Handoff 2026-05-25)
`formatDate / formatTime / formatDateTime` aceptan un tercer argumento

@ -42,6 +42,8 @@ export function createActiveDates(options: ActiveDatesOptions = {}): ActiveDates
formatDateTime: readFrom(runtime, engine.formatDateTime),
formatRelative: readFrom(runtime, engine.formatRelative),
onPreferenceChange: runtime.onChange,
onLocaleChange: runtime.onLocaleChange,

@ -1,4 +1,9 @@
import { getCachedDateFormat, resolveDateOrder, resolveHourCycle } from '$libs/days';
import {
getCachedDateFormat,
getCachedRelativeTimeFormat,
resolveDateOrder,
resolveHourCycle
} from '$libs/days';
import { createAutoState } from '../auto-state';
import { createFormatLocaleState } from '../locale-state';
import type {
@ -180,6 +185,23 @@ export function createEngineDates(options: EngineDatesOptions = {}): EngineDates
withHourCycle({ ...base, ...options }, resolveCallHourCycle(locale)),
locale
);
},
formatRelative(value, unit, options, locale) {
// Defaults match `Intl.RelativeTimeFormat`'s most natural
// output for narrative copy ("hace 2 horas", "in 3 days"):
// `numeric: 'auto'` collapses 0/-1/+1 into words
// ("yesterday", "today"); `style: 'long'` matches the i18n
// catalog convention.
const finalOptions: Intl.RelativeTimeFormatOptions = {
numeric: 'auto',
style: 'long',
...options
};
return getCachedRelativeTimeFormat(
locale ?? getLocale(),
finalOptions
).format(value, unit);
}
};
}

@ -164,6 +164,31 @@ describe('createEngineDates()', () => {
).toBe('14:30');
});
it('formatRelative routes through Intl.RelativeTimeFormat with engine locale', () => {
const dates = createEngineDates({ locale: 'es-ES' });
expect(dates.formatRelative(-1, 'hour')).toBe('hace 1 hora');
expect(dates.formatRelative(3, 'day')).toBe('dentro de 3 días');
// numeric: 'auto' (default) collapses 0/-1/+1 into words.
expect(dates.formatRelative(0, 'day')).toBe('hoy');
expect(dates.formatRelative(-1, 'day')).toBe('ayer');
expect(dates.formatRelative(1, 'day')).toBe('mañana');
});
it('formatRelative honours numeric/style options', () => {
const dates = createEngineDates({ locale: 'en-US' });
expect(dates.formatRelative(-1, 'day')).toBe('yesterday');
expect(dates.formatRelative(-1, 'day', { numeric: 'always' })).toBe('1 day ago');
expect(dates.formatRelative(-2, 'hour', { style: 'short' })).toBe('2 hr. ago');
expect(dates.formatRelative(-2, 'hour', { style: 'narrow' })).toBe('2h ago');
});
it('formatRelative honours per-call locale override without mutating state', () => {
const dates = createEngineDates({ locale: 'es-ES' });
expect(dates.formatRelative(-1, 'hour', undefined, 'en-US')).toBe('1 hour ago');
expect(dates.getLocale()).toBe('es-ES');
expect(dates.formatRelative(-1, 'hour')).toBe('hace 1 hora');
});
it('formatDateTime drops BOTH presets when any per-axis field is present', () => {
// `Intl.DateTimeFormat` is strict: a single per-axis field
// (`weekday`, `hour`, …) makes BOTH `dateStyle` and

@ -8,6 +8,10 @@ export type HourCycleMode = 'auto' | HourCycle;
export interface DatesFormatOptions extends Intl.DateTimeFormatOptions {}
export interface DatesRelativeFormatOptions extends Intl.RelativeTimeFormatOptions {}
export type RelativeTimeUnit = Intl.RelativeTimeFormatUnit;
export interface EngineDatesOptions {
locale?: FormatLocaleInput;
dateOrder?: DateOrderMode;
@ -48,6 +52,26 @@ export interface EngineDates {
options?: DatesFormatOptions,
locale?: string
) => string;
/**
* Formats a relative time string ("hace 2 horas", "in 3 days") via
* `Intl.RelativeTimeFormat`. Caller supplies the magnitude and unit
* directly — the engine does NOT compute the delta from a date pair.
* Cache is shared with the rest of the framework via
* `getCachedRelativeTimeFormat`, keyed by `(locale × options)`.
*
* @param value Magnitude (negative = past, positive = future). Use
* `0` to render the "now" phrase for the unit.
* @param unit Granularity (`'second'`, `'minute'`, `'hour'`,
* `'day'`, `'week'`, `'month'`, `'quarter'`, `'year'`).
* @param options Optional `numeric` / `style` overrides.
* @param locale Per-call locale override (does not mutate engine state).
*/
formatRelative: (
value: number,
unit: RelativeTimeUnit,
options?: DatesRelativeFormatOptions,
locale?: string
) => string;
}
export interface ActiveDatesOptions extends Omit<EngineDatesOptions, 'locale'> {

@ -79,3 +79,28 @@ export function getCachedDateFormat(
}
return fmt;
}
// ── Intl.RelativeTimeFormat cache ───────────────────────────────────────────
//
// Same shape as `formatCache`. Sites that render many relative timestamps in
// a list (feed, activity log, table of recent items) hit identical
// (locale × options) pairs repeatedly — caching keeps the tight loop hot.
const relativeFormatCache = new Map<string, Intl.RelativeTimeFormat>();
/**
* Returns a cached `Intl.RelativeTimeFormat` for the given locale and
* options. Cache key is the locale plus the JSON-serialised options.
*/
export function getCachedRelativeTimeFormat(
locale: string,
options?: Intl.RelativeTimeFormatOptions
): Intl.RelativeTimeFormat {
const key = `${locale}:${JSON.stringify(options ?? {})}`;
let fmt = relativeFormatCache.get(key);
if (!fmt) {
fmt = new Intl.RelativeTimeFormat(locale || undefined, options);
relativeFormatCache.set(key, fmt);
}
return fmt;
}

@ -43,14 +43,14 @@ Familia con `<Trans>`, `<FormatDate>`, `<FormatNumber>`.
`5min` para `hour`, `1h` para `day`. Para units mayores no hay tick.
- **`updateInterval={0}`** apaga el auto-refresh — util para snapshots
estaticos en tablas / patterns.
- **No usa el runtime de dates** para la operacion principal — invoca
`Intl.RelativeTimeFormat` directamente porque (a) `RelativeTimeFormat`
no esta cacheado por el engine actual, (b) la creacion es barata, (c)
el unit/style/numeric tuple ya se cachea implicitamente por V8/SpiderMonkey.
El runtime solo se consulta para el locale activo (`uix.format.dates.getLocale()`).
- **Reactivo al locale del runtime**: si no se pasa `locale` prop, lee el
locale del runtime de dates. El `$derived` re-corre cuando el locale
cambia porque la lectura de `getLocale()` esta dentro del derived.
- **Siempre rutea por `uix.format.dates.formatRelative`** (no llama a
`Intl.RelativeTimeFormat` directamente excepto cuando no hay runtime).
El engine envuelve el cache compartido (`getCachedRelativeTimeFormat`
en `$libs/days`), preserva la reactividad del locale activo, y acepta
el `locale` prop como override por llamada sin mutar engine state.
- **El componente sigue haciendo la matematica del delta + auto-unit + magnitud**
— el engine solo expone la operacion atomica `(magnitude, unit,
options, locale) → string`.
- **`<time datetime="ISO 8601">` por defecto** para a11y / SEO.
- **`data-unit`** expuesto en el DOM para CSS/tooling que quiera estilar
por granularidad (ej. iconos diferentes para "second" vs "year").

@ -102,10 +102,18 @@
const resolved = $derived.by(() => {
if (!parsedValue) return fallback;
const resolvedLocale =
locale ?? (uix.format?.dates?.getLocale() as string | undefined) ?? undefined;
const formatter = new Intl.RelativeTimeFormat(resolvedLocale, { numeric, style });
return formatter.format(magnitude, resolvedUnit);
const dates = uix.format?.dates;
// Route through the runtime when available — it owns the
// `Intl.RelativeTimeFormat` cache and the active locale. Per-call
// `locale` override doesn't mutate engine state. Fall back to a
// bare `Intl.RelativeTimeFormat` only when no runtime is wired.
if (dates) {
return dates.formatRelative(magnitude, resolvedUnit, { numeric, style }, locale);
}
return new Intl.RelativeTimeFormat(locale ?? undefined, { numeric, style }).format(
magnitude,
resolvedUnit
);
});
const datetimeAttr = $derived(parsedValue ? parsedValue.toISOString() : undefined);

Loading…
Cancel
Save

Powered by TurnKey Linux.