refactor(soma): share calendar pure helpers

astra
dev 2 weeks ago
parent 9403fa6539
commit 0b487715e7

@ -201,6 +201,37 @@ pasó, incluido build/smoke de `apps/base`. La primera suite global falló sólo
en dos pruebas de `boot-check` por acceso denegado al TEMP del sistema; con TEMP
escribible fuera del repositorio pasaron **471/471 archivos y 5470/5470 tests**.
### Pre-flight de Soma para helpers de Calendar y RangeCalendar (2026-09-22)
El usuario autorizó continuar la deduplicación interna sin cambiar comportamiento,
API ni el árbol `web/`. La comparación se limita al cálculo puro de límites de
semana y etiquetas de mes/año; no autoriza tocar selección, foco o navegación:
| Aspecto | [React Aria](https://react-spectrum.adobe.com/RangeCalendar) | [Ark UI](https://ark-ui.com/docs/components/date-picker) | [Bits UI](https://www.bits-ui.com/docs/components/range-calendar) | [Radix](https://www.radix-ui.com/primitives/docs/components) | Decisión UIX |
| --------------- | ------------------------------------------------------------ | -------------------------------------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------- |
| Semana y locale | `firstDayOfWeek` puede sustituir el valor del locale | `locale` y `startOfWeek` son entradas distintas | `locale` y `weekStartsOn` son props de Root | No ofrece primitiva Calendar | Compartir sólo el cálculo que ya respeta locale o sustitución explícita. |
| Selección | RangeCalendar conserva su valor de rango | Un mismo DatePicker admite modos de selección | Calendar y RangeCalendar son familias públicas separadas | No hay contrato comparable | Mantener providers, estado y eventos separados. |
| Mes y año | Las fechas se presentan según locale | El formateo recibe los datos de locale | Ambos componentes exponen formato de mes y año | No hay contrato comparable | Centralizar únicamente la función pura usada por ambos providers y sus selects. |
Propiedad exclusiva de Soma: los helpers permanecen en el módulo puro existente de
Calendar, que RangeCalendar ya consume. No se crea otro provider ni otra capa, y
no se modifican partes, props, eventos, tamaños, variantes, colores, demos, estilos,
selección, anuncios o efectos. El pre-flight y su alcance quedan aceptados por la
instrucción vigente de continuar el plan con refactorizaciones neutrales. La
validación exige línea base y repetición de los tests de ambos providers y helpers,
`check:gate`, auditoría dirigida de ambos componentes y gate completo.
Resultado: cinco familias de funciones duplicadas delegan ahora en tres helpers
puros. Calendar y RangeCalendar conservan sus providers, métodos públicos,
precedencia de formatos y lógica de selección. El diff de producción elimina 88
líneas e incorpora 69: **19 líneas netas menos**. Los tres archivos dirigidos
pasaron 28/28 pruebas antes y 29/29 después, incluida la regresión de dependencia
reactiva; el barrido AST transversal bajó de 13 a 8 grupos de funciones idénticas
en Soma. `docs:check` terminó con 0 errores y 0 avisos, `check:gate` aceptó los 89
errores del ledger, las dos auditorías dirigidas dieron PASS y el gate completo
pasó **473/473 archivos y 5484/5484 pruebas**, incluidos build y smoke de
`apps/base`.
### Pre-flight de Soma para las partes de ítem de los dos menús (2026-09-22)
El usuario autorizó la deduplicación interna sin cambios de comportamiento, API

@ -144,6 +144,17 @@ y garantiza que seleccionar nunca repagina. También colapsan el título de vari
meses de forma distinta. Sus suites caracterizan estas transiciones; no se debe
crear un provider común ni mover esas máquinas de estado.
El barrido AST posterior localizó cinco cuerpos duplicados entre ambos providers:
límites de semana, formatos de mes/año y los dos formateadores internos de sus
selectores. Ahora delegan en tres funciones puras de `calendar/helpers.ts`, módulo
que RangeCalendar ya consumía. Los providers mantienen sus métodos públicos y los
selectores conservan la precedencia del formato local sobre el formato del root.
El diff de producción elimina 88 líneas e incorpora 69: **19 líneas netas menos**
y cinco familias de cuerpos duplicados menos. Las pruebas dirigidas de helpers y
providers pasaron 28/28 antes y 29/29 después, incluida la regresión que evita una
dependencia de locale al usar callbacks propios. Las dos auditorías dirigidas dieron
PASS y el gate completo terminó con 473/473 archivos y 5484/5484 pruebas.
El barrido inicial encontró `SpinPressRepeater` y su factory duplicados en
CssField y NumberField. El código actual ya usa el módulo compartido
`spin-press-repeater.ts` en ambos providers. CssField incluye una prueba del
@ -157,13 +168,13 @@ adicionales al compartir Arrow, Item, RadioGroup, RadioItem, Sub y SubTrigger).
continúa siendo exclusivo de DropdownMenu; ninguna lógica de foco, dismissal o
posicionamiento se trasladó al módulo compartido.
| Clase | Hallazgo | Disposición propuesta |
| ------------------- | ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| Duplicación exacta | `SpinPressRepeater` + factory en el barrido inicial | Resuelto en módulo compartido; mantener pruebas de pulsación y cancelación. |
| Contrato compartido | CalendarNav y `gridWalk` ya cubren cabecera, paginación y teclado | Mantener providers separados: límites de vista, título y selección no son intercambiables |
| Riesgo funcional | L-138: retorno de foco de Dialog/Drawer | Mantenerlo como defecto de accesibilidad reproducible, fuera de refactor neutro |
| Riesgo funcional | L-140: Select.Value no conserva etiqueta con portal cerrado | Corregir sólo tras decidir owner persistente de value→label |
| Revisión masiva | L-152: reenvío de `bind:ref` en wrappers Eidos/Soma | Auditoría por familia y prueba cliente por wrapper; no sustitución global |
| Clase | Hallazgo | Disposición propuesta |
| ------------------- | ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| Duplicación exacta | `SpinPressRepeater` + factory en el barrido inicial | Resuelto en módulo compartido; mantener pruebas de pulsación y cancelación. |
| Contrato compartido | CalendarNav, `gridWalk` y helpers puros cubren navegación y formato | Mantener providers separados: límites de vista, título y selección no son intercambiables |
| Riesgo funcional | L-138: retorno de foco de Dialog/Drawer | Mantenerlo como defecto de accesibilidad reproducible, fuera de refactor neutro |
| Riesgo funcional | L-140: Select.Value no conserva etiqueta con portal cerrado | Corregir sólo tras decidir owner persistente de value→label |
| Revisión masiva | L-152: reenvío de `bind:ref` en wrappers Eidos/Soma | Auditoría por familia y prueba cliente por wrapper; no sustitución global |
## Sema

@ -18,8 +18,6 @@ import {
isSameDay,
startOfMonth,
endOfMonth,
getLastFirstDayOfWeek,
getNextLastDayOfWeek,
getLocalTimeZone,
CalendarDate,
today,
@ -30,6 +28,9 @@ import {
applySingleSelection,
computeDateFlags,
createMonths,
formatCalendarMonth,
formatCalendarYear,
getCalendarWeekBounds,
getIsoWeekNumber,
getSimpleWeekNumber,
getWeekdays,
@ -507,16 +508,7 @@ export class CalendarProvider implements CalendarNav {
* which is now the whole family's rule.
*/
private weekBounds(date: DateValue): { first: DateValue; last: DateValue } {
const weekStartsOn = this.opts.weekStartsOn.current;
return weekStartsOn === undefined
? {
first: getLastFirstDayOfWeek(date, 0, this.effectiveLocale),
last: getNextLastDayOfWeek(date, 0, this.effectiveLocale)
}
: {
first: getLastFirstDayOfWeek(date, weekStartsOn, 'en-US'),
last: getNextLastDayOfWeek(date, weekStartsOn, 'en-US')
};
return getCalendarWeekBounds(date, this.opts.weekStartsOn.current, this.effectiveLocale);
}
/**
@ -563,17 +555,11 @@ export class CalendarProvider implements CalendarNav {
// ── Formatters ────────────────────────────────────────────────────────────
formatMonth(month: number): string {
const fmt = this.opts.monthFormat.current;
if (typeof fmt === 'function') return fmt(month);
const anchor = new Date(Date.UTC(2024, month - 1, 15));
return new Intl.DateTimeFormat(this.effectiveLocale, { month: fmt ?? 'long' }).format(anchor);
return formatCalendarMonth(month, this.opts.monthFormat.current, () => this.effectiveLocale);
}
formatYear(year: number): string {
const fmt = this.opts.yearFormat.current;
if (typeof fmt === 'function') return fmt(year);
const anchor = new Date(Date.UTC(year, 5, 15));
return new Intl.DateTimeFormat(this.effectiveLocale, { year: fmt ?? 'numeric' }).format(anchor);
return formatCalendarYear(year, this.opts.yearFormat.current, () => this.effectiveLocale);
}
formatDay(date: DateValue): string {
@ -839,18 +825,11 @@ export class CalendarMonthSelectProvider {
);
readonly monthItems: CalendarMonthSelectSnippetProps['monthItems'] = $derived.by(() => {
const customFmt = this.opts.monthFormat.current;
const formatter = (m: number) => {
if (typeof customFmt === 'function') return customFmt(m);
if (typeof customFmt === 'string') {
const anchor = new Date(Date.UTC(2024, m - 1, 15));
return new Intl.DateTimeFormat(this.provider.effectiveLocale, {
month: customFmt
}).format(anchor);
}
return this.provider.formatMonth(m);
};
return this.opts.months.current.map((value) => ({ value, label: formatter(value) }));
const format = this.opts.monthFormat.current ?? this.provider.opts.monthFormat.current;
return this.opts.months.current.map((value) => ({
value,
label: formatCalendarMonth(value, format, () => this.provider.effectiveLocale)
}));
});
readonly selectedMonthItem = $derived.by(() => {
@ -919,18 +898,11 @@ export class CalendarYearSelectProvider {
);
readonly yearItems: CalendarYearSelectSnippetProps['yearItems'] = $derived.by(() => {
const customFmt = this.opts.yearFormat.current;
const formatter = (y: number) => {
if (typeof customFmt === 'function') return customFmt(y);
if (typeof customFmt === 'string') {
const anchor = new Date(Date.UTC(y, 5, 15));
return new Intl.DateTimeFormat(this.provider.effectiveLocale, {
year: customFmt
}).format(anchor);
}
return this.provider.formatYear(y);
};
return this.opts.years.current.map((value) => ({ value, label: formatter(value) }));
const format = this.opts.yearFormat.current ?? this.provider.opts.yearFormat.current;
return this.opts.years.current.map((value) => ({
value,
label: formatCalendarYear(value, format, () => this.provider.effectiveLocale)
}));
});
readonly selectedYearItem = $derived.by(() => {

@ -1,7 +1,13 @@
import { describe, expect, it } from 'vitest';
import { CalendarDate } from '$libs/days';
import { createMonths, getSimpleWeekNumber, isWeekend } from './helpers';
import {
createMonths,
formatCalendarMonth,
formatCalendarYear,
getSimpleWeekNumber,
isWeekend
} from './helpers';
// 2026-07-10 = Friday, 2026-07-11 = Saturday, 2026-07-12 = Sunday.
const FRI = new CalendarDate(2026, 7, 10);
@ -51,3 +57,17 @@ describe('getSimpleWeekNumber (absolute weekStartsOn frame)', () => {
expect(getSimpleWeekNumber(new CalendarDate(2026, 1, 5), 1)).toBe(2);
});
});
describe('custom calendar formatters', () => {
it('do not acquire a locale dependency', () => {
let localeReads = 0;
const getLocale = () => {
localeReads += 1;
return 'es-ES';
};
expect(formatCalendarMonth(7, (month) => `M${month}`, getLocale)).toBe('M7');
expect(formatCalendarYear(2026, (year) => `Y${year}`, getLocale)).toBe('Y2026');
expect(localeReads).toBe(0);
});
});

@ -30,6 +30,43 @@ export interface CreateMonthsOpts {
locale: string;
}
/** Resolve Home / End bounds using the same locale-or-explicit convention as the grid. */
export function getCalendarWeekBounds(
date: DateValue,
weekStartsOn: WeekStartsOn | undefined,
locale: string
): { first: DateValue; last: DateValue } {
return weekStartsOn === undefined
? {
first: getLastFirstDayOfWeek(date, 0, locale),
last: getNextLastDayOfWeek(date, 0, locale)
}
: {
first: getLastFirstDayOfWeek(date, weekStartsOn, 'en-US'),
last: getNextLastDayOfWeek(date, weekStartsOn, 'en-US')
};
}
export function formatCalendarMonth(
month: number,
format: Intl.DateTimeFormatOptions['month'] | ((month: number) => string) | undefined,
getLocale: () => string
): string {
if (typeof format === 'function') return format(month);
const anchor = new Date(Date.UTC(2024, month - 1, 15));
return new Intl.DateTimeFormat(getLocale(), { month: format ?? 'long' }).format(anchor);
}
export function formatCalendarYear(
year: number,
format: Intl.DateTimeFormatOptions['year'] | ((year: number) => string) | undefined,
getLocale: () => string
): string {
if (typeof format === 'function') return format(year);
const anchor = new Date(Date.UTC(year, 5, 15));
return new Intl.DateTimeFormat(getLocale(), { year: format ?? 'numeric' }).format(anchor);
}
/**
* Produce the visible grids. Each grid walks from the week-start before the
* 1st of the month through the week-end after the last day, chunked into

@ -19,8 +19,6 @@ import {
isSameDay,
startOfMonth,
endOfMonth,
getLastFirstDayOfWeek,
getNextLastDayOfWeek,
getLocalTimeZone,
CalendarDate,
today,
@ -30,6 +28,9 @@ import {
import {
computeDateFlags,
createMonths,
formatCalendarMonth,
formatCalendarYear,
getCalendarWeekBounds,
getIsoWeekNumber,
getSimpleWeekNumber,
getWeekdays,
@ -611,16 +612,7 @@ export class RangeCalendarProvider implements CalendarNav {
* which is now the whole family's rule.
*/
private weekBounds(date: DateValue): { first: DateValue; last: DateValue } {
const weekStartsOn = this.opts.weekStartsOn.current;
return weekStartsOn === undefined
? {
first: getLastFirstDayOfWeek(date, 0, this.effectiveLocale),
last: getNextLastDayOfWeek(date, 0, this.effectiveLocale)
}
: {
first: getLastFirstDayOfWeek(date, weekStartsOn, 'en-US'),
last: getNextLastDayOfWeek(date, weekStartsOn, 'en-US')
};
return getCalendarWeekBounds(date, this.opts.weekStartsOn.current, this.effectiveLocale);
}
handleDayKeydown(e: SomaKeyboardEvent<HTMLElement>, date: DateValue): boolean {
@ -686,17 +678,11 @@ export class RangeCalendarProvider implements CalendarNav {
// ── Formatters ────────────────────────────────────────────────────────────
formatMonth(month: number): string {
const fmt = this.opts.monthFormat.current;
if (typeof fmt === 'function') return fmt(month);
const anchor = new Date(Date.UTC(2024, month - 1, 15));
return new Intl.DateTimeFormat(this.effectiveLocale, { month: fmt ?? 'long' }).format(anchor);
return formatCalendarMonth(month, this.opts.monthFormat.current, () => this.effectiveLocale);
}
formatYear(year: number): string {
const fmt = this.opts.yearFormat.current;
if (typeof fmt === 'function') return fmt(year);
const anchor = new Date(Date.UTC(year, 5, 15));
return new Intl.DateTimeFormat(this.effectiveLocale, { year: fmt ?? 'numeric' }).format(anchor);
return formatCalendarYear(year, this.opts.yearFormat.current, () => this.effectiveLocale);
}
formatDay(date: DateValue): string {
@ -915,18 +901,11 @@ export class RangeCalendarMonthSelectProvider {
}
readonly monthItems: RangeCalendarMonthSelectSnippetProps['monthItems'] = $derived.by(() => {
const customFmt = this.opts.monthFormat.current;
const formatter = (m: number) => {
if (typeof customFmt === 'function') return customFmt(m);
if (typeof customFmt === 'string') {
const anchor = new Date(Date.UTC(2024, m - 1, 15));
return new Intl.DateTimeFormat(this.provider.effectiveLocale, {
month: customFmt
}).format(anchor);
}
return this.provider.formatMonth(m);
};
return this.opts.months.current.map((value) => ({ value, label: formatter(value) }));
const format = this.opts.monthFormat.current ?? this.provider.opts.monthFormat.current;
return this.opts.months.current.map((value) => ({
value,
label: formatCalendarMonth(value, format, () => this.provider.effectiveLocale)
}));
});
readonly selectedMonthItem = $derived.by(() => {
@ -987,18 +966,11 @@ export class RangeCalendarYearSelectProvider {
}
readonly yearItems: RangeCalendarYearSelectSnippetProps['yearItems'] = $derived.by(() => {
const customFmt = this.opts.yearFormat.current;
const formatter = (y: number) => {
if (typeof customFmt === 'function') return customFmt(y);
if (typeof customFmt === 'string') {
const anchor = new Date(Date.UTC(y, 5, 15));
return new Intl.DateTimeFormat(this.provider.effectiveLocale, {
year: customFmt
}).format(anchor);
}
return this.provider.formatYear(y);
};
return this.opts.years.current.map((value) => ({ value, label: formatter(value) }));
const format = this.opts.yearFormat.current ?? this.provider.opts.yearFormat.current;
return this.opts.years.current.map((value) => ({
value,
label: formatCalendarYear(value, format, () => this.provider.effectiveLocale)
}));
});
readonly selectedYearItem = $derived.by(() => {

Loading…
Cancel
Save

Powered by TurnKey Linux.