From b54d42e2305730660be4cc253ddc79097395b1c5 Mon Sep 17 00:00:00 2001 From: dev Date: Mon, 25 May 2026 12:08:57 +0200 Subject: [PATCH] feat(format/dates): add formatRelative to engine + route RelativeTime through runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit $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) --- src/arts/format/README.md | 10 ++++++++ src/arts/format/dates/active-dates.svelte.ts | 2 ++ src/arts/format/dates/engine-dates.ts | 24 +++++++++++++++++- .../format/dates/test/engine-dates.test.ts | 25 +++++++++++++++++++ src/arts/format/dates/types.ts | 24 ++++++++++++++++++ src/libs/days/format.ts | 25 +++++++++++++++++++ .../eidos/components/relative-time/README.md | 16 ++++++------ .../relative-time/relative-time.svelte | 16 +++++++++--- 8 files changed, 129 insertions(+), 13 deletions(-) diff --git a/src/arts/format/README.md b/src/arts/format/README.md index ae77fcf54..99ede829f 100644 --- a/src/arts/format/README.md +++ b/src/arts/format/README.md @@ -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 diff --git a/src/arts/format/dates/active-dates.svelte.ts b/src/arts/format/dates/active-dates.svelte.ts index a025aa3f8..0a3005fd8 100644 --- a/src/arts/format/dates/active-dates.svelte.ts +++ b/src/arts/format/dates/active-dates.svelte.ts @@ -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, diff --git a/src/arts/format/dates/engine-dates.ts b/src/arts/format/dates/engine-dates.ts index 9074fe4b1..2bcee1692 100644 --- a/src/arts/format/dates/engine-dates.ts +++ b/src/arts/format/dates/engine-dates.ts @@ -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); } }; } diff --git a/src/arts/format/dates/test/engine-dates.test.ts b/src/arts/format/dates/test/engine-dates.test.ts index f32bd204f..0944e9b3d 100644 --- a/src/arts/format/dates/test/engine-dates.test.ts +++ b/src/arts/format/dates/test/engine-dates.test.ts @@ -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 diff --git a/src/arts/format/dates/types.ts b/src/arts/format/dates/types.ts index b36bb8efc..df8a5e516 100644 --- a/src/arts/format/dates/types.ts +++ b/src/arts/format/dates/types.ts @@ -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 { diff --git a/src/libs/days/format.ts b/src/libs/days/format.ts index 9aa64baae..a89da9b8a 100644 --- a/src/libs/days/format.ts +++ b/src/libs/days/format.ts @@ -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(); + +/** + * 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; +} diff --git a/src/uix/eidos/components/relative-time/README.md b/src/uix/eidos/components/relative-time/README.md index 21ae60d45..89fb0e507 100644 --- a/src/uix/eidos/components/relative-time/README.md +++ b/src/uix/eidos/components/relative-time/README.md @@ -43,14 +43,14 @@ Familia con ``, ``, ``. `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`. - **`