29. Internacionalización, formatos y zonas horarias
Una aplicación que funciona perfectamente en Madrid puede mentir en Ciudad de México, confundir a un usuario árabe y cobrar mal a un cliente en Tokio. Este capítulo trata de i18n, l10n, formatos con Intl, zonas horarias de extremo a extremo y el diseño de TaskFlow para que fechas, monedas e idiomas dejen de ser una fuente silenciosa de bugs en producción.
29.1 Qué vas a poder hacer al terminar
- Distinguir con precisión internacionalización (i18n) de localización (l10n) y justificar dónde encaja cada pieza en Angular, NestJS y PostgreSQL.
- Elegir entre el i18n oficial de Angular (
@angular/localize+ XLIFF) y bibliotecas en tiempo de ejecución (ngx-translate, Transloco) según el producto, el número de idiomas y el ciclo de traducción. - Escribir mensajes con plurales, género e interpolación usando ICU MessageFormat, y detectar los que se rompen al traducir.
- Formatear fechas, números, monedas y unidades con la API
Intly con los pipes de Angular, negociando el locale del usuario de forma explícita. - Diseñar el flujo de fechas de TaskFlow: UTC en servidor,
timestamptzen PostgreSQL, ISO-8601 en la API y zona del usuario solo en la presentación. - Explicar Instant frente a LocalDateTime, el error de
new Date('2024-01-01')y por qué sumar días con milisegundos falla en el cambio de hora (DST). - Implementar en NestJS la aceptación de
Accept-Language, la validación de zonas IANA y la serialización coherente de fechas en DTOs. - Configurar MikroORM y el driver de PostgreSQL para no perder ni desplazar instantes al persistir.
- Preparar la interfaz para RTL (árabe, hebreo) con CSS lógico y el atributo
dir. - Montar un flujo de catálogos, CI que detecte claves huérfanas y una lista de errores típicos con su remedio.
29.2 El problema: por qué fechas, monedas e idiomas rompen aplicaciones en producción
TaskFlow nació en un equipo de Madrid. Las demos se hacían a las once de la mañana, las fechas se veían «bien» en Chrome en español de España, y los importes de las facturas internas se mostraban con el símbolo del euro pegado a la derecha. Nadie discutió el modelo de tiempo porque, en apariencia, no había modelo: se guardaba un Date, se enviaba un string y Angular lo pintaba con el pipe date. El día que el primer cliente en Ciudad de México abrió el tablero, las tareas con vencimiento «hoy» aparecían como «mañana», una reunión marcada a las 09:00 se mostraba a las 02:00 y un informe semanal empezaba en domingo en lugar de lunes. El equipo habló de «bug de timezone» como si fuera un fallo aislado. No lo era: era la consecuencia inevitable de no haber diseñado nunca el contrato de las fechas.
El mismo patrón se repite con el dinero y con el idioma. Un número 1234.5 significa mil doscientos treinta y cuatro con cinco décimas en un locale y mil doscientos treinta y cuatro con cinco décimas… o no: en alemán el punto es separador de miles y la coma es decimal. Un mensaje hardcodeado «1 tareas pendientes» es gracioso en desarrollo y humillante en producción. Y un botón «Delete» en una interfaz por lo demás en castellano no es un detalle cosmética: es la señal de que el producto no ha decidido aún a quién sirve.
Piensa en TaskFlow como un aeropuerto. Los aviones (los datos) llegan y salen en UTC: es la hora del sistema de control, la única en la que los controladores de distintos países pueden coordinarse sin ambigüedad. Los paneles de cada terminal (la UI) muestran la hora local de esa ciudad, con su idioma, su formato de fecha y su moneda en las tiendas duty-free. Si un panel empieza a mostrar la hora de control en crudo, o si un vuelo se anuncia en la zona horaria del origen sin convertir, los pasajeros pierden conexiones. El error no está en el avión: está en no haber separado el instante absoluto de su presentación local. Exactamente eso es lo que falla cuando mezclas almacenamiento, transporte y renderizado de fechas en el mismo tipo Date sin política.
29.2.1 Síntomas típicos en producción
Conviene reconocer el patrón antes de entrar en definiciones. Si en TaskFlow observas alguno de estos síntomas, casi seguro tienes un problema de i18n o de tiempo mal modelado:
- Una tarea creada «hoy» aparece con fecha de ayer o mañana según el huso del usuario.
- Los filtros «esta semana» discrepan entre el cliente y el servidor porque uno usa lunes como primer día y el otro domingo.
- Tras el cambio de hora de marzo u octubre, hay un hueco de una hora o un solapamiento en los recordatorios.
- Los importes se formatean con el locale del servidor (Node.js en UTC/en-US) en lugar del del usuario.
- Las traducciones concatenan cadenas («Hola, » + nombre) y en alemán o japonés el orden queda imposible.
- El HTML tiene
dir="ltr"fijo y un usuario hebreo ve iconos de chevron apuntando al lado equivocado.
Es tentador asignar «i18n» al equipo de Angular y «timezones» a quien toque el pipe date. En la práctica, el contrato lo fija el backend: qué se persiste, en qué tipo SQL, con qué zona del driver, qué forma tiene el JSON y qué cabeceras se respetan. Si NestJS serializa un Date con el offset del proceso y MikroORM interpreta timestamp without time zone como hora local del servidor, ninguna magia en Angular lo arregla de forma fiable. Este capítulo trata el stack completo a propósito.
29.3 i18n frente a l10n: definición precisa
Internacionalización (i18n, de internationalization: la i, dieciocho letras, la n) es el trabajo de ingeniería que hace que un producto pueda adaptarse a distintos idiomas, regiones y convenciones culturales sin reescribir la lógica de negocio. Es arquitectura: separar mensajes del código, no asumir LTR, no asumir el euro, no asumir que la semana empieza en lunes, exponer locales y zonas como datos de configuración del usuario.
Localización (l10n, de localization) es el trabajo de adaptar el producto a un mercado concreto: traducir al francés de Francia o al francés de Canadá, elegir formatos, imágenes, tono, moneda por defecto, posiblemente requisitos legales de facturación. Es contenido y producto, no solo ingeniería.
La confusión habitual es llamar «i18n» a «haber metido ngx-translate». Tener un fichero es.json es un fragmento de l10n. Haber diseñado TaskFlow para que los mensajes salgan de un catálogo, las fechas se almacenen como instantes y la UI respete dir es i18n. Sin lo segundo, lo primero se queda en un diccionario incompleto.
┌─────────────────────────────────────────────────────────────┐
│ i18n (ingeniería, una vez + mantenimiento) │
│ · catálogos extraíbles · formatos vía Intl │
│ · fechas como Instant · CSS lógico / dir │
│ · Accept-Language · zona IANA del usuario │
└───────────────────────────────┬─────────────────────────────┘
│ habilita
▼
┌─────────────────────────────────────────────────────────────┐
│ l10n (por locale / mercado) │
│ · es-ES, en-GB, ar-SA … · traducciones + revisión │
│ · moneda y tono · pruebas con hablantes nativos │
└─────────────────────────────────────────────────────────────┘
Un locale (por ejemplo es-MX o en-GB) combina idioma y región. Dos locales del mismo idioma pueden diferir en formato de fecha, separador decimal, primer día de la semana y hasta en vocabulario («ordenador» frente a «computadora»). En TaskFlow conviene persistir el locale preferido del usuario (es-ES) y, por separado, su zona IANA (Europe/Madrid): son ejes independientes. Un español en viaje a Nueva York sigue queriendo la UI en castellano, pero las horas «hoy a las 09:00» deben mostrarse en America/New_York si así lo ha configurado.
29.4 Angular i18n oficial frente a bibliotecas en tiempo de ejecución
Angular ofrece un sistema de i18n de compilación basado en @angular/localize: marcas en plantillas y en código, extracción a XLIFF (u otros formatos), y un build por locale. En paralelo, el ecosistema usa bibliotecas que cargan diccionarios en tiempo de ejecución (ngx-translate, Transloco, entre otras). No son equivalentes: resuelven problemas distintos y tienen costes distintos. Elegir mal condena al equipo a convivir años con la decisión.
29.4.1 El camino oficial: @angular/localize y XLIFF
Con el i18n oficial marcas textos en la plantilla con el atributo i18n (y variantes para atributos, i18n-title, etc.) o con las APIs de $localize en TypeScript. La herramienta de extracción genera ficheros XLIFF que un traductor o una plataforma TMS puede editar. En build, Angular produce un artefacto por locale: las cadenas quedan sustituidas de forma estática. El locale activo suele fijarse al arrancar la aplicación (por ejemplo con providers de LOCALE_ID y, en despliegues clásicos, sirviendo /es/ y /en/ como aplicaciones distintas o con redirección en el servidor).
<h2 i18n="@@tasks.list.title">Tus tareas</h2>
<p i18n="@@tasks.list.empty">No hay tareas pendientes.</p>
<button
type="button"
i18n-aria-label="@@tasks.list.addAria"
aria-label="Crear tarea">
<span i18n="@@tasks.list.add">Nueva tarea</span>
</button>
import { $localize } from '@angular/localize/init';
export function etiquetaConteo(n: number): string {
return $localize`:@@tasks.count:${n}:count: tareas pendientes`;
}
La extracción típica en un proyecto Angular moderno usa el builder de extracción del CLI (por ejemplo ng extract-i18n), que genera un XLIFF base. Los traductores trabajan sobre copias por idioma; el CI puede fallar si faltan unidades o si hay IDs duplicados.
<trans-unit id="tasks.list.title" datatype="html">
<source>Tus tareas</source>
<target>Your tasks</target>
</trans-unit>
29.4.2 ngx-translate y Transloco: diccionarios en runtime
Las bibliotecas de runtime cargan JSON (o similares) en el cliente y resuelven claves en el momento de pintar. Cambiar de idioma no exige un rebuild completo ni recargar otra aplicación «hermana»: basta con sustituir el diccionario activo. Eso encaja en productos con muchos idiomas, con traducción continua, o donde el usuario cambia de idioma sin salir de la sesión. El precio es otro: las claves viven en ficheros que el compilador no valida tan profundamente, el árbol de plantillas no «conoce» los textos en build, y es más fácil dejar claves huérfanas o textos hardcodeados que se cuelan en producción.
{
"tasks.list.title": "Tus tareas",
"tasks.list.empty": "No hay tareas pendientes",
"tasks.list.add": "Nueva tarea",
"tasks.count": "{{count}} tareas pendientes"
}
<h2>{{ 'tasks.list.title' | translate }}</h2>
<p>{{ 'tasks.count' | translate:{ count: total } }}</p>
29.4.3 Tabla comparativa honesta
| Criterio | @angular/localize + XLIFF | ngx-translate / Transloco |
|---|---|---|
| Momento de resolución | Compilación / build por locale | Tiempo de ejecución |
| Cambio de idioma en caliente | Costoso (otra app o recarga de artefacto) | Natural |
| Integración con TMS / traductores | Excelente (XLIFF es estándar de la industria) | Buena si exportas/importas JSON con disciplina |
| Tamaño del despliegue | Un bundle (o carpeta) por locale | Un bundle + JSON bajo demanda |
| Detección de textos sin marcar | Más fácil de auditar en plantillas marcadas | Fácil dejar literales sueltos |
| ICU / plurales | Soportados en el flujo oficial | Depende de la lib y de cómo escribas las cadenas |
| SSR / hidratación | Muy predecible si el locale está fijado al render | Hay que alinear locale de servidor y cliente |
| Curva y «magia» | Más ceremonia de build y despliegue | Arranque rápido, deuda silenciosa posible |
| Cuándo encaja en TaskFlow | Pocos idiomas estables, marketing/SEO por locale, disciplina de release | Muchos idiomas, cambio frecuente, app autenticada tipo SaaS |
Para un SaaS autenticado con tres a diez idiomas y usuarios que cambian preferencias en el perfil, Transloco o ngx-translate suelen dar mejor ergonomía. Si TaskFlow publica landing y documentación indexable por idioma con URLs /es/, /en/, el i18n oficial brilla en esas superficies. No es raro usar ambos en el mismo producto: localize en marketing, runtime en la aplicación. Lo que no debes hacer es mezclarlos sin fronteras claras dentro del mismo módulo de UI.
Independientemente de la herramienta, los formatos de fecha y moneda no deberían vivir en el diccionario de traducciones: deben salir de Intl (o de los pipes de Angular configurados con el LOCALE_ID correcto). Traducir «31/01/2026» a mano es la forma más rápida de mentir al usuario.
29.4.4 Despliegue por locale y cambio de idioma
Con el i18n oficial, un patrón habitual es generar dist/es-ES, dist/en-GB, etc., y configurar el servidor (Nginx, CDN, CloudFront) para servir el artefacto según el prefijo de ruta o según una cookie. El cambio de idioma es entonces una navegación a otra base href. Con runtime libraries, el despliegue es un único artefacto SPA y un conjunto de JSON; el cambio de idioma es una llamada a setActiveLang (o equivalente) y, si usas SSR, una invalidación coherente del HTML cacheado por locale.
# /es/ → dist/es-ES/
# /en/ → dist/en-GB/
# La API /api/ no se traduce ni se cachea como HTML de locale
location /api/ {
proxy_pass http://nestjs:3000;
}
No olvides el <base href> correcto en cada build de Angular y la exclusión de la API en cualquier regla de «fallback a index.html». Un fallo clásico es servir el index.html español para una deep-link inglesa y dejar la app a medias entre dos mundos.
Las URLs por locale importan para contenido público indexable. Dentro del tablero autenticado de TaskFlow, las URLs estables sin prefijo de idioma suelen ser preferibles: el idioma es preferencia de usuario, no de documento. Mezclar ambos criterios en la misma estrategia de routing sin pensarlo genera redirecciones en bucle y estados de sesión confusos.
29.5 Mensajes, plurales, género, interpolación e ICU MessageFormat
El texto visible no es una cadena inerte: es un mensaje con huecos, reglas de plural y, en algunos idiomas, concordancia de género. ICU MessageFormat es el formalismo que Unicode CLDR y la mayoría de herramientas serias usan para expresar esas reglas de forma traducible. Angular i18n y varias bibliotecas de runtime lo soportan (con matices de sintaxis). Ignorarlo produce el clásico «1 tareas» o, peor, frases reordenadas a martillazos con concatenación.
29.5.1 Interpolación: el hueco es del traductor
Nunca construyas una frase uniendo trozos en el orden del castellano. El traductor debe poder reordenar los huecos. En ICU, los argumentos nombrados permiten exactamente eso.
// Orden fijo del español: se rompe en japonés, alemán, etc.
const msg = 'La tarea "' + titulo + '" fue asignada a ' + nombre;
// ICU / MessageFormat: el traductor reordena {titulo} y {nombre}
const msg = $localize`:@@tasks.assigned:La tarea "{$titulo}" fue asignada a {$nombre}:titulo:${titulo}:nombre:${nombre}:`;
Evita incrustar etiquetas HTML en las cadenas traducibles salvo que tu pipeline lo controle (sanitización, subset permitido). Un traductor que introduce un <script> o un enlace malicioso no debería tener esa superficie. Preferible: traducir textos planos y componer la estructura en la plantilla Angular alrededor de los huecos.
29.5.2 Plurales: no son «singular / plural»
El castellano distingue uno y resto. El inglés también, con matices en cero. El árabe tiene varias categorías de plural (zero, one, two, few, many, other). ICU expresa esas categorías; el motor elige según el locale. Hardcodear n === 1 ? … : … es incorrecto en cuanto añades un tercer idioma.
function resumen(n: number): string {
return n === 1 ? '1 tarea pendiente' : n + ' tareas pendientes';
}
// Forma conceptual ICU (sintaxis según herramienta):
// {count, plural,
// =0 {No hay tareas pendientes}
// one {# tarea pendiente}
// other {# tareas pendientes}}
{
"tasks.pending": "{count, plural, =0 {No hay tareas pendientes} one {# tarea pendiente} other {# tareas pendientes}}"
}
El símbolo # en ICU se sustituye por el número formateado según el locale. No concatenes el número a mano fuera del mensaje: perderías separadores de miles correctos.
29.5.3 Género y select
Algunos idiomas flexionan adjetivos y participios según género. ICU ofrece select para ramas explícitas (male, female, other) cuando el producto conoce el género gramatical relevante. No inventes género a partir del nombre propio: o lo declara el usuario, o usas formulaciones neutras («Persona asignada: María»).
// {gender, select,
// female {La responsable {name} cerró la tarea}
// male {El responsable {name} cerró la tarea}
// other {{name} cerró la tarea}}
29.5.4 Patrones que parecen inofensivos y no lo son
// «Reutilizar» palabras sueltas destruye el contexto
const abrir = t('common.open'); // "Abrir"
const tarea = t('common.task'); // "tarea"
const label = abrir + ' ' + tarea; // EN: "Open task" ok; DE: orden/caso mal
// Una clave = una frase con contexto
const label = t('tasks.actions.openTask');
// es: "Abrir tarea" / en: "Open task" / de: "Aufgabe öffnen"
Tanto en XLIFF como en JSON, documenta el sitio donde aparece el mensaje (pantalla, botón, tooltip). La misma palabra castellana «Estado» puede ser «Status», «State» o «Condition» en inglés según el dominio. Sin contexto, la l10n es lotería.
29.5.5 Pseudolocalización
Antes de pagar traducciones completas, genera un locale falso que alarga cadenas, añade acentos ASCII raros y rodea el texto con marcadores ([!!! Tus tareas !!!]). Si el layout de TaskFlow «aguanta» ese locale, sobrevivirá mejor al alemán o al finlandés. Si los botones explotan, lo descubres en desarrollo, no en la víspera del lanzamiento en Berlín.
export function pseudolocalize(s: string): string {
const map: Record<string, string> = {
a: 'á', e: 'é', i: 'í', o: 'ó', u: 'ú', n: 'ñ',
};
const stretched = s.replace(/[aeioun]/gi, (ch) => map[ch.toLowerCase()] ?? ch);
return `[!!! ${stretched} !!!]`;
}
Integra la pseudolocalización como un locale más (en-XA es una convención vista en la industria) seleccionable solo en builds internos. No lo envíes a producción.
29.6 Formatos: fechas, números, monedas y unidades
La API Intl del lenguaje ECMAScript (disponible en navegadores modernos y en Node.js) es la base correcta para formatear. Angular expone pipes (DatePipe, DecimalPipe, CurrencyPipe, PercentPipe) que, bien configurados con LOCALE_ID y los datos de locale registrados, delegan en esa misma infraestructura. No reinventes formatos con padStart y barras.
29.6.1 Intl en crudo
export function formatearFecha(
isoUtc: string,
locale: string,
timeZone: string,
): string {
const d = new Date(isoUtc); // instante absoluto si isoUtc trae Z u offset
return new Intl.DateTimeFormat(locale, {
dateStyle: 'medium',
timeStyle: 'short',
timeZone,
}).format(d);
}
export function formatearMoneda(
amount: number,
locale: string,
currency: string,
): string {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency,
}).format(amount);
}
export function formatearNumero(n: number, locale: string): string {
return new Intl.NumberFormat(locale, {
maximumFractionDigits: 2,
}).format(n);
}
# Mismo instante 2026-07-31T18:00:00.000Z
# es-ES + Europe/Madrid → 31 jul 2026, 20:00
# en-US + America/New_York → Jul 31, 2026, 2:00 PM
# Moneda 1234.5:
# es-ES + EUR → 1234,50 €
# en-US + USD → $1,234.50
29.6.2 Pipes de Angular y registro de locales
Por defecto, Angular puede incluir solo el locale en-US en ciertos modos de build, o el que configures. Si usas pipes con es-ES sin registrar datos de locale, obtienes formatos incorrectos o errores en tiempo de ejecución según versión y configuración. Registra los locales que tu producto soporte y fija LOCALE_ID al preferido del usuario (o al de la app en i18n de build).
import { ApplicationConfig, LOCALE_ID } from '@angular/core';
import { registerLocaleData } from '@angular/common';
import localeEs from '@angular/common/locales/es';
import localeEsExtra from '@angular/common/locales/extra/es';
registerLocaleData(localeEs, 'es-ES', localeEsExtra);
export const appConfig: ApplicationConfig = {
providers: [
{ provide: LOCALE_ID, useFactory: () => leerLocaleUsuario() },
],
};
function leerLocaleUsuario(): string {
// Preferencia persistida, con fallback negociado
return localStorage.getItem('tf.locale') ?? 'es-ES';
}
<time [attr.datetime]="tarea.venceEn">
{{ tarea.venceEn | date: 'medium': usuario.timeZone: usuario.locale }}
</time>
<span>{{ tarea.costeEstimado | currency: usuario.currency:'symbol':'1.2-2':usuario.locale }}</span>
date y la zona
El tercer parámetro del DatePipe es la zona horaria. Si lo omites, el formato usa la zona del entorno donde corre el código (el navegador del usuario en cliente; en SSR, la del servidor). En Server-Side Rendering eso produce hidratación inconsistente si no fijas zona y locale de forma explícita y alineada entre servidor y cliente. Pasa siempre zona y locale cuando el valor importa.
29.6.3 Negociación de locale
El navegador envía Accept-Language; el usuario puede tener una preferencia en perfil; la URL puede llevar un prefijo /es/; el sistema operativo tiene un locale. Necesitas una política de precedencia documentada. Una política razonable para TaskFlow:
- Preferencia explícita del usuario autenticado (base de datos).
- Prefijo de ruta o cookie de sesión en superficies públicas.
- Negociación con
Accept-Languagecontra la lista de locales soportados. - Fallback final:
es-ES(o el mercado por defecto del producto).
const SOPORTADOS = ['es-ES', 'es-MX', 'en-GB', 'en-US', 'ca-ES'] as const;
type LocaleSoportado = (typeof SOPORTADOS)[number];
export function negociarLocale(
preferido: string | null,
acceptLanguage: string | null,
): LocaleSoportado {
if (preferido && (SOPORTADOS as readonly string[]).includes(preferido)) {
return preferido as LocaleSoportado;
}
const candidatos = parsearAcceptLanguage(acceptLanguage);
for (const tag of candidatos) {
const exacto = SOPORTADOS.find((s) => s.toLowerCase() === tag.toLowerCase());
if (exacto) return exacto;
const base = tag.split('-')[0]?.toLowerCase();
const porIdioma = SOPORTADOS.find((s) => s.split('-')[0].toLowerCase() === base);
if (porIdioma) return porIdioma;
}
return 'es-ES';
}
function parsearAcceptLanguage(h: string | null): string[] {
if (!h) return [];
return h
.split(',')
.map((part) => {
const [tag, ...params] = part.trim().split(';');
const q = params.find((p) => p.trim().startsWith('q='));
const quality = q ? Number(q.split('=')[1]) : 1;
return { tag: tag.trim(), quality: Number.isFinite(quality) ? quality : 1 };
})
.sort((a, b) => b.quality - a.quality)
.map((x) => x.tag);
}
Preferencia usuario ──┐
Prefijo URL / cookie ─┼─► negociarLocale() ─► LOCALE_ID + catálogo
Accept-Language ──────┤
Fallback es-ES ───────┘
│
├─► Intl / pipes (fechas, números, monedas)
└─► zona IANA aparte (Europe/Madrid, …)
Nota: negociar el locale de la UI no implica negociar la zona horaria. Son preferencias distintas. Un usuario puede querer en-GB y Asia/Tokyo.
29.6.4 Unidades y listas
Además de moneda y fecha, Intl.NumberFormat con style: 'unit' e Intl.ListFormat cubren casos que a menudo se resuelven con concatenaciones frágiles: «3 h 15 min», «Ana, Luis y Marta». Úsalos cuando TaskFlow muestre duraciones estimadas o listas de responsables.
export function formatearDuracionHoras(horas: number, locale: string): string {
return new Intl.NumberFormat(locale, {
style: 'unit',
unit: 'hour',
unitDisplay: 'short',
maximumFractionDigits: 1,
}).format(horas);
}
export function formatearLista(nombres: string[], locale: string): string {
return new Intl.ListFormat(locale, {
style: 'long',
type: 'conjunction',
}).format(nombres);
}
Comprueba el soporte de la unidad concreta en tus navegadores objetivo; el conjunto de unidades de Intl es amplio pero no infinito, y el display corto/largo varía por locale de forma intencionada (es datos CLDR, no un bug).
29.7 Zonas horarias a fondo
Este es el núcleo del capítulo y donde más dinero se pierde en producción. La regla de oro de TaskFlow es simple de enunciar y exigente de cumplir: el servidor piensa en instantes UTC; la base de datos guarda instantes; la API transporta ISO-8601 con offset o Z; solo la capa de presentación aplica la zona IANA del usuario.
29.7.1 Instant frente a LocalDateTime
Un Instant (o «punto en la línea temporal») es un momento absoluto: «cuando el segundo atómico tal ocurrió». En JavaScript, un Date bien interpretado representa eso (internamente como milisegundos desde el Unix epoch en UTC). Un LocalDateTime es un reloj de pared sin zona: «el 31 de julio de 2026 a las 09:00» sin decir en qué ciudad. Ambos son legítimos, pero resuelven problemas distintos.
- Instant: creación de una tarea, último login, marca de auditoría, instante en que se envió una notificación. Debe persistirse de forma absoluta (
timestamptz, ISO con Z). - LocalDate / LocalDateTime: «fecha de cumpleaños», «día laborable de vencimiento civil» cuando el producto dice «vence el 15 de marzo» sin hora, o una cita que debe permanecer a las 09:00 locales aunque cambien las reglas DST. Aquí a veces se guarda la fecha civil y la zona por separado, no un instante mal interpretado.
El proposal Temporal de TC39 (aún no es estándar integrado de forma universal en todos los motores como API estable y completa en el momento de escribir este libro; comprueba el soporte antes de adoptarlo en producción) introduce tipos explícitos: Temporal.Instant, Temporal.PlainDateTime, Temporal.ZonedDateTime, etc. Su motivación es precisamente eliminar la ambigüedad de Date. Mientras Temporal no sea tu línea base, disciplina de equipo + ISO-8601 + zonas IANA + librerías maduras (por ejemplo Luxon o date-fns-tz en el cliente, y cuidado extremo en el servidor) son el camino. No inventes un mini-Temporal casero.
Usuario (Europe/Madrid) API / NestJS PostgreSQL
┌─────────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ UI: 31/07/2026 20:00│ │ ISO: │ │ timestamptz │
│ zona: Europe/Madrid │ <── │ 2026-07-31T18:00 │ <── │ 18:00:00+00 │
│ (solo presentación) │ ──> │ :00.000Z │ ──> │ (instant UTC) │
└─────────────────────┘ └──────────────────┘ └─────────────────┘
▲ │
│ │ validar zona IANA del perfil
└──── nunca persistir ───────┘ «hora local del servidor»
29.7.2 PostgreSQL: timestamptz frente a timestamp
En PostgreSQL, timestamp with time zone (timestamptz) almacena un instante absoluto (internamente en UTC). timestamp without time zone almacena un reloj de pared sin zona: al leerlo, la interpretación depende de la sesión y de la aplicación. Para eventos absolutos de TaskFlow (created_at, updated_at, completed_at, reminder_at) usa siempre timestamptz. Reserva date o timestamp sin zona solo cuando el dominio sea genuinamente civil y lo documentes.
CREATE TABLE task (
id uuid PRIMARY KEY,
title text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
due_at timestamptz NULL,
-- fecha civil opcional si el producto habla de «día» sin hora:
due_on date NULL
);
29.7.3 El error clásico de new Date('2024-01-01')
La especificación de ECMAScript trata de forma distinta las cadenas solo-fecha (YYYY-MM-DD) y las cadenas con hora. Una fecha solo, en muchos motores, se interpreta como UTC medianoche. Una cadena con hora sin zona puede interpretarse como hora local. El resultado: el mismo literal produce días distintos según API y entorno.
// En muchos motores: UTC midnight → en Madrid (UTC+1) es
// 01/01/2024 01:00 local… o el día anterior en Americas.
const d = new Date('2024-01-01');
const dia = d.getDate(); // depende del huso del runtime
// Instant: exige Z u offset explícito
const instant = new Date('2024-01-01T00:00:00.000Z');
// Fecha civil: no uses Date para aritmética de calendario;
// guarda '2024-01-01' como string date o usa librería/Temporal.
const dueOn = '2024-01-01';
29.7.4 El error de sumar días con milisegundos
Un día no siempre tiene 86 400 000 milisegundos. En el cambio de hora (DST) puede tener 23 o 25 horas. Sumar n * 86400000 a un instante desplaza el reloj de pared de forma incorrecta alrededor de esos cortes.
function masDias(d: Date, n: number): Date {
return new Date(d.getTime() + n * 86_400_000);
}
// Aritmética civil en la zona del usuario (Luxon ilustrativo)
import { DateTime } from 'luxon';
function masDiasCivil(isoUtc: string, zona: string, n: number): string {
return DateTime.fromISO(isoUtc, { zone: 'utc' })
.setZone(zona)
.plus({ days: n })
.toUTC()
.toISO()!;
}
El filtro «tareas que vencen hoy» no puede calcularse en el servidor con CURRENT_DATE de PostgreSQL en UTC si el usuario vive en Tokio. O calculas el intervalo [startOfDay, endOfDay) en la zona IANA del usuario y lo conviertes a UTC para la query, o persistes y consultas en términos de fecha civil (due_on). Mezclar ambos modelos sin documentarlo produce tickets eternamente abiertos.
import { DateTime } from 'luxon';
export function rangoHoyUtc(zona: string): { desde: string; hasta: string } {
const inicio = DateTime.now().setZone(zona).startOf('day');
const fin = inicio.plus({ days: 1 });
return {
desde: inicio.toUTC().toISO()!,
hasta: fin.toUTC().toISO()!,
};
}
29.8 NestJS: Accept-Language, zonas y serialización
El API de TaskFlow debe ser aburridamente predecible: fechas en ISO-8601, zonas validadas contra el dataset IANA, idioma negociado o tomado del perfil, nunca «lo que imprima Date#toString() en el servidor de Irlanda».
29.8.1 Middleware o interceptor de idioma
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import { negociarLocale } from './negotiate-locale';
@Injectable()
export class LocaleMiddleware implements NestMiddleware {
use(req: Request, _res: Response, next: NextFunction) {
const preferido = (req.headers['x-user-locale'] as string) || null;
const accept = req.headers['accept-language'] ?? null;
(req as Request & { locale: string }).locale = negociarLocale(preferido, accept);
next();
}
}
La cabecera personalizada X-User-Locale permite al cliente autenticado imponer la preferencia del perfil sin depender solo de Accept-Language del navegador (que refleja el SO, no necesariamente la cuenta).
29.8.2 Validar zonas IANA
import { IsIn, IsString, Matches } from 'class-validator';
const LOCALES = ['es-ES', 'es-MX', 'en-GB', 'en-US', 'ca-ES'] as const;
export class UpdatePreferencesDto {
@IsIn(LOCALES)
locale!: (typeof LOCALES)[number];
@IsString()
@Matches(/^[A-Za-z0-9_+\-]+\/[A-Za-z0-9_+\-]+$/, {
message: 'timeZone must look like an IANA name (e.g. Europe/Madrid)',
})
timeZone!: string;
}
export function esZonaIanaValida(zona: string): boolean {
try {
// Intl lanza RangeError si el nombre no es reconocible
new Intl.DateTimeFormat('en-US', { timeZone: zona }).format(0);
return true;
} catch {
return false;
}
}
// Depende del TZ del proceso Node
return { dueAt: dueAt.toString() };
// "Thu Jul 31 2026 20:00:00 GMT+0200 (CEST)"
return { dueAt: dueAt.toISOString() };
// "2026-07-31T18:00:00.000Z"
29.8.3 DTOs: entrada y salida
import { IsISO8601, IsOptional, IsString, MaxLength } from 'class-validator';
export class CreateTaskDto {
@IsString()
@MaxLength(200)
title!: string;
/** Instant absoluto en ISO-8601 (recomendado con Z). */
@IsOptional()
@IsISO8601()
dueAt?: string;
/** Fecha civil YYYY-MM-DD si el vencimiento es «el día», no la hora. */
@IsOptional()
@MatchesCivilDate()
dueOn?: string;
}
/** Validador ilustrativo: ^\d{4}-\d{2}-\d{2}$ */
function MatchesCivilDate(): PropertyDecorator {
return IsISO8601({ strict: true }) as PropertyDecorator; // o custom regex
}
En la práctica, para dueOn preferirás un validador custom con la expresión regular ^\d{4}-\d{2}-\d{2}$ y comprobación de calendario, porque IsISO8601 acepta más formas de las que quieres para una fecha civil. El punto de diseño es: no uses un solo campo ambiguo para «día» e «instante».
export class TaskResponseDto {
id!: string;
title!: string;
createdAt!: string; // ISO-8601 Z
dueAt!: string | null;
dueOn!: string | null; // YYYY-MM-DD o null
}
export function toTaskResponse(entity: {
id: string;
title: string;
createdAt: Date;
dueAt: Date | null;
dueOn: string | null;
}): TaskResponseDto {
return {
id: entity.id,
title: entity.title,
createdAt: entity.createdAt.toISOString(),
dueAt: entity.dueAt ? entity.dueAt.toISOString() : null,
dueOn: entity.dueOn,
};
}
Si usas el interceptor de serialización de NestJS / class-transformer, configura explícitamente cómo se transforman las fechas (o mapea a DTO a mano como arriba). Dejar que un Date se convierta según la configuración por defecto del proceso es invitar a offsets fantasma entre entornos (local en Madrid, CI en UTC, producción en otro huso).
29.8.4 Mensajes de API y códigos estables
Además de las fechas, NestJS participa en la i18n cuando responde errores. El antipatrón es devolver literales en el idioma del desarrollador:
throw new BadRequestException('Due date must be in the future');
throw new BadRequestException({
code: 'task.dueAt.mustBeFuture',
// opcional: params para ICU en el cliente
params: { field: 'dueAt' },
});
El cliente traduce task.dueAt.mustBeFuture con su catálogo. Si necesitas mensajes ya localizados (cliente no-SPA, integración de terceros), mantén un catálogo servidor indexado por locale negociado y nunca mezcles ambos estilos en el mismo endpoint sin versionar el contrato.
@Catch(BadRequestException)
export class LocalizedExceptionFilter implements ExceptionFilter {
catch(exception: BadRequestException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const req = ctx.getRequest<Request & { locale?: string }>();
const res = ctx.getResponse();
const body = exception.getResponse();
// Si body es { code, params }, puedes adjuntar message ya resuelto:
// message: t(req.locale ?? 'es-ES', code, params)
res.status(400).json(typeof body === 'string' ? { message: body } : body);
}
}
En pruebas de contrato (Pact, esquemas OpenAPI, clientes generados), los code estables son oro: el copy puede cambiar con cada release de l10n; el código no debería.
29.9 MikroORM y fechas
MikroORM, sobre PostgreSQL, refleja en el esquema lo que declares en la entidad. Si eliges el tipo SQL equivocado o el driver interpreta las marcas de tiempo en la zona de la sesión, el bug viaja silencioso desde la base hasta el JSON. Esta sección fija convenciones concretas para TaskFlow.
29.9.1 Tipos en la entidad
import { Entity, PrimaryKey, Property } from '@mikro-orm/core';
import { v4 } from 'uuid';
@Entity({ tableName: 'task' })
export class Task {
@PrimaryKey({ type: 'uuid' })
id: string = v4();
@Property({ type: 'text' })
title!: string;
@Property({ type: 'timestamptz' })
createdAt: Date = new Date();
@Property({ type: 'timestamptz', nullable: true })
dueAt?: Date | null;
/** Fecha civil YYYY-MM-DD; no es un instante. */
@Property({ type: 'date', nullable: true })
dueOn?: string | null;
@Property({ type: 'timestamptz', onUpdate: () => new Date() })
updatedAt: Date = new Date();
}
Usa timestamptz (o el mapeo equivalente documentado en tu versión de MikroORM para «timestamp with time zone») para instantes. Para fechas civiles, el tipo date de PostgreSQL evita la tentación de guardar medianoches inventadas. Lee siempre la documentación de la versión que uses: los nombres de tipo y el mapeo a JavaScript pueden matizarse entre majors.
29.9.2 Timezone del driver y de la sesión
El cliente de PostgreSQL (por ejemplo pg) y la configuración de MikroORM pueden fijar la zona de la sesión. Una práctica segura es ejecutar el servidor Node en UTC (TZ=UTC) y asegurarse de que las columnas timestamptz redondean el viaje de ida y vuelta a un Date correcto. Si alguien arranca el API con TZ=Europe/Madrid y además usa columnas timestamp without time zone, los valores «se desplazan» al desplegar en otro continente.
import { defineConfig } from '@mikro-orm/postgresql';
export default defineConfig({
entities: ['dist/**/*.entity.js'],
entitiesTs: ['src/**/*.entity.ts'],
clientUrl: process.env.DATABASE_URL,
// Mantén el proceso en UTC; no «corrijas» aquí con offsets manuales.
// Revisa en tu versión opciones de timezone del driver si las expone.
});
ENV TZ=UTC
# Equivalente en el entrypoint:
# export TZ=UTC
-- Sin zona: el significado depende de quién lea
ALTER TABLE task
ADD COLUMN reminder_at timestamp NULL;
ALTER TABLE task
ADD COLUMN reminder_at timestamptz NULL;
29.9.3 Migraciones: convertir sin perder significado
Si heredaste columnas timestamp sin zona, una migración a timestamptz exige declarar qué significaban esos valores (¿hora de Madrid? ¿UTC ya?). PostgreSQL puede hacer AT TIME ZONE, pero la decisión es de dominio, no del motor. Documenta el supuesto en el comentario de la migración y añade un test que inserte un instante conocido y lo lea de vuelta.
-- Supuesto: los valores antiguos eran «hora de Europe/Madrid» de pared.
ALTER TABLE task
ALTER COLUMN due_at TYPE timestamptz
USING due_at AT TIME ZONE 'Europe/Madrid';
Código del estilo date.setHours(date.getHours() - 2) para «pasar a UTC» es frágil: ignora DST, falla dos veces al año y depende del huso del proceso. Si crees necesitarlo, el modelo de datos está mal: corrige tipos y zonas, no parchees aritmética.
29.9.4 Pruebas de ida y vuelta
Un test de integración barato evita regresiones caras. Inserta un instante fijo, léelo con MikroORM y compara el ISO. Ejecuta el suite con TZ=UTC y, si puedes, una segunda pasada con TZ=America/New_York en CI matrix: el resultado del ISO debe ser idéntico si el modelo es correcto.
it('persiste dueAt como instante absoluto', async () => {
const iso = '2026-03-29T01:30:00.000Z'; // cerca de DST en Europa
const task = em.create(Task, {
title: 'Probe',
dueAt: new Date(iso),
});
await em.persistAndFlush(task);
em.clear();
const leida = await em.findOneOrFail(Task, task.id);
expect(leida.dueAt!.toISOString()).toBe(iso);
});
Elige a propósito fechas cercanas a cambios DST en al menos dos zonas. Si el test solo usa el 15 de junio a mediodía UTC, te estarás felicitando por un camino que no ejerce la rama difícil del calendario.
{
"scripts": {
"test:dates": "TZ=UTC jest --testPathPattern=dates",
"test:dates:ny": "TZ=America/New_York jest --testPathPattern=dates"
}
}
29.10 Calendarios, semanas, primer día de la semana, DST y medianoche
El calendario gregoriano no es el único, pero sí el que TaskFlow usará en la mayoría de mercados occidentales. Aun así, dentro del gregoriano hay trampas: primer día de la semana, numeración ISO de semanas, DST y el concepto de «medianoche».
29.10.1 Semanas y primer día
En España y gran parte de Europa la semana empieza en lunes (alineado con ISO 8601). En Estados Unidos es habitual el domingo. Intl.Locale expone información de week info en entornos que lo implementan; no asumas lunes en el servidor «porque nosotros somos europeos» si tienes usuarios en en-US.
export function primerDiaSemana(locale: string): number {
// 1 = lunes, 7 = domingo en muchos esquemas ISO;
// comprueba la API disponible en tu motor.
const loc = new Intl.Locale(locale);
const info = (loc as Intl.Locale & { weekInfo?: { firstDay: number } }).weekInfo;
return info?.firstDay ?? 1;
}
Vista «Semana» TaskFlow
┌────────────────────────────────────────────┐
│ locale es-ES → L M X J V S D │
│ locale en-US → D L M X J V S │
│ │
│ Consulta SQL: siempre en UTC │
│ [lunes 00:00 zona user , próximo +7d) │
│ convertido a instante UTC │
└────────────────────────────────────────────┘
29.10.2 DST: el cambio de hora
En Europe/Madrid, al inicio del horario de verano se salta una hora (típicamente de 02:00 a 03:00) y al final se repite una hora. Consecuencias prácticas:
- Una cita local a las 02:30 en el día del salto de primavera puede no existir.
- Una cita a las 02:30 en el día del retroceso de otoño es ambigua (dos instantes posibles).
- Los trabajos programados «cada día a las 09:00» deben anclarse a la zona IANA y a reglas civiles, no a un cron en UTC fijo sin más (porque 09:00 CET y 09:00 CEST no son el mismo offset).
Convertir un date civil a Date con new Date(y, m, d) usa la zona local del runtime. En SSR o en tests con TZ=UTC, «2026-04-01» puede volverse el 31 de marzo por la tarde en pantallas de América. Para fechas civiles, mantén strings YYYY-MM-DD hasta el momento de formatear con Intl o una librería que distinga PlainDate. Nunca uses la medianoche local como sustituto de una fecha.
function parseDueOn(dueOn: string): Date {
const [y, m, d] = dueOn.split('-').map(Number);
return new Date(y, m - 1, d); // zona del proceso
}
function validarDueOn(dueOn: string): string {
if (!/^\d{4}-\d{2}-\d{2}$/.test(dueOn)) {
throw new Error('dueOn must be YYYY-MM-DD');
}
return dueOn; // se persiste como date, no como Date ambiguo
}
29.11 RTL: árabe, hebreo y CSS lógico
Las lenguas de escritura de derecha a izquierda (RTL) no son un «espejo cosmético» del layout. Afectan a la dirección del texto, a la alineación, al orden de iconos de navegación, a las animaciones de deslizamiento y a la puntuación bidireccional (URLs y números dentro de texto árabe).
29.11.1 dir y CSS lógico
Pon dir="rtl" (o ltr) en el elemento raíz según el locale activo. Sustituye propiedades físicas (margin-left, padding-right, text-align: left) por lógicas (margin-inline-start, padding-inline-end, text-align: start). Así el mismo CSS sirve en ambos modos.
.task-row {
display: flex;
gap: 0.75rem;
padding-inline: 1rem;
border-inline-start: 4px solid var(--accent);
}
.task-row .chevron {
/* En RTL el «adelante» apunta al otro lado */
transform: scaleX(1);
}
[dir='rtl'] .task-row .chevron {
transform: scaleX(-1);
}
<html lang="ar" dir="rtl">
<!-- lang y dir deben actualizarse al cambiar locale -->
</html>
const RTL = new Set(['ar', 'he', 'fa', 'ur']);
export function aplicarDir(locale: string): void {
const base = locale.split('-')[0] ?? locale;
const dir = RTL.has(base) ? 'rtl' : 'ltr';
document.documentElement.lang = locale;
document.documentElement.dir = dir;
}
29.11.2 Cómo probar RTL sin fingir
- Incluye al menos un locale RTL en el catálogo de CI aunque el mercado aún no esté abierto: detecta regresiones de CSS físico.
- Prueba navegación con chevrons, drawers, barras de progreso y timelines horizontales.
- Verifica formularios: el orden de tabulación sigue el DOM; el DOM debe estar en orden de lectura lógico.
- No especularmente «inviertas» capturas: pide revisión a hablantes nativos para truncados y puntuación bidi.
Los logos no se espejan. Las flechas de «atrás/adelante» sí. Las barras de progreso de lectura suelen crecer en dirección de lectura. Decide caso por caso y documenta en el design system; no apliques un transform: scaleX(-1) global al layout.
29.11.3 Caso TaskFlow: del formulario al correo
Para cerrar el arco técnico antes del flujo de catálogos, conviene ver un recorrido completo. Una usuaria en America/Mexico_City con locale es-MX crea una tarea con vencimiento «mañana a las 09:00» desde el selector de fecha de Angular. El cliente no debe enviar «mañana»: debe resolver el instante (o la fecha civil, según el modo del formulario) y transmitir ISO-8601. NestJS valida, MikroORM persiste timestamptz, y horas más tarde un worker envía un correo de recordatorio. Ese correo sí formatea en servidor, porque no hay Angular al otro lado.
import { DateTime } from 'luxon';
/** Interpreta la elección del datepicker como instante UTC. */
export function dueAtDesdeLocal(
fecha: string, // YYYY-MM-DD del control
hora: string, // HH:mm
zona: string,
): string {
const dt = DateTime.fromISO(`${fecha}T${hora}`, { zone: zona });
if (!dt.isValid) {
throw new Error(dt.invalidReason ?? 'invalid local due');
}
return dt.toUTC().toISO()!;
}
import { DateTime } from 'luxon';
export function cuerpoRecordatorio(opts: {
titulo: string;
dueAtIso: string;
locale: string;
timeZone: string;
}): string {
const when = DateTime.fromISO(opts.dueAtIso, { zone: 'utc' })
.setZone(opts.timeZone)
.setLocale(opts.locale)
.toLocaleString(DateTime.DATETIME_MED);
// Plantilla simple; en producción usa catálogo ICU también en servidor
return `Recordatorio TaskFlow: «${opts.titulo}» vence el ${when}.`;
}
// Formatea con el huso del worker (UTC en producción)
const when = new Date(dueAtIso).toLocaleString('es-ES');
const when = new Intl.DateTimeFormat(user.locale, {
dateStyle: 'medium',
timeStyle: 'short',
timeZone: user.timeZone,
}).format(new Date(dueAtIso));
El mismo instante alimenta la lista de Angular, el filtro «hoy», el correo y un posible webhook externo. Si una de esas superficies reinterpretara la cadena sin zona, solo esa superficie mentiría: los bugs de tiempo suelen ser asimétricos y por eso engañan en QA («en la app se ve bien, en el mail no»).
El instante en UTC es la partitura con marcas absolutas. La zona IANA es el metrónomo y la clave en los que cada músico (pantalla, correo, informe) interpreta su parte. Si cada músico afina el metrónomo a su gusto —el worker en UTC, el navegador en México, el PDF en el locale del servidor de impresión—, el concierto desafina aunque la partitura sea correcta. La disciplina no está en tocar más fuerte: está en que todos lean la misma marca absoluta y apliquen explícitamente su clave.
29.11.4 Seguridad y privacidad en i18n
Los catálogos y las preferencias de locale parecen inofensivos, pero hay superficies reales:
- XSS vía traducción: si interpolas HTML desde el catálogo sin sanitizar, un TMS comprometido o un traductor malicioso inyecta scripts. Trata las traducciones como contenido no confiable si admiten markup.
- Enumeración de idiomas: exponer en APIs públicas la lista completa de locales internos no suele ser crítico, pero los mensajes de error demasiado verbosos en un idioma y genéricos en otro pueden filtrar detalles.
- PII en claves: no pongas nombres reales ni correos dentro de claves o comentarios del XLIFF que acaban en repositorios de traductores externos.
- Zona como dato sensible débil: la zona IANA puede aproximar la ubicación. Trátala como dato personal en políticas de privacidad y no la registres en logs de acceso públicos.
// Preferible: texto plano en catálogo
t('tasks.delete.confirm'); // "¿Eliminar esta tarea?"
// Si necesitas enlace dentro del mensaje, compón en plantilla:
// <span>{{ 'tasks.delete.prefix' | translate }}</span>
// <a routerLink="/help/delete">{{ 'tasks.delete.help' | translate }}</a>
En NestJS, cuando localices mensajes de error para clientes, evita devolver stack traces o nombres de columna SQL «porque el locale es español y queremos ser útiles». La utilidad se traduce; el detalle interno se queda en el log correlacionado por requestId.
29.11.5 Rendimiento de Intl y de los catálogos
Crear un Intl.DateTimeFormat tiene un coste no trivial si lo haces en cada celda de una tabla virtualizada con miles de filas. La práctica habitual es cachear formateadores por tupla (locale, timeZone, options). Los pipes de Angular ya intentan ser razonables, pero en bucles muy calientes un formateador reutilizado gana.
const cache = new Map<string, Intl.DateTimeFormat>();
export function formatterFecha(locale: string, timeZone: string): Intl.DateTimeFormat {
const key = `${locale}|${timeZone}|med-short`;
let fmt = cache.get(key);
if (!fmt) {
fmt = new Intl.DateTimeFormat(locale, {
dateStyle: 'medium',
timeStyle: 'short',
timeZone,
});
cache.set(key, fmt);
}
return fmt;
}
En cuanto a catálogos runtime, carga por locale (lazy) y no empaquetes veinte idiomas en el bundle inicial. Con i18n de build, el coste se paga en artefactos y en CDN, no en el parseo de un JSON gigante al arrancar. Mide: en TaskFlow, el arranco percibido importa más que microoptimizar un t().
JavaScript heredó un objeto Date inspirado en java.util.Date —precisamente la clase que Java marcó como legacy hace años—. Carece de tipos para fecha civil, zona y offset como ciudadanos de primer orden. Toda la industria ha construido bibliotecas encima. Temporal es el intento del comité de corregir el modelo; hasta que tu línea base de motores lo soporte de forma uniforme, las reglas de este capítulo (contratos ISO, IANA, UTC en servidor) son el puente estable.
29.12 Catálogos, flujos de traducción y CI
Sin proceso, el i18n se pudre: claves huérfanas, textos hardcodeados, traducciones obsoletas y PRs que mezclan features con «arreglos de copy». TaskFlow necesita un flujo explícito.
29.12.1 Flujo recomendado
- El desarrollador añade una clave nueva con mensaje fuente en el idioma de desarrollo (por ejemplo
es-ES) y contexto. - CI detecta claves nuevas o modificadas y las publica al TMS (Crowdin, Lokalise, Phrase, Weblate, etc.) o genera un artefacto XLIFF/JSON para traductores.
- Los traductores entregan; CI importa y falla si faltan locales obligatorios en el release.
- Un job adicional busca claves en código que no existan en el catálogo y claves en el catálogo que nadie referencie (huérfanas).
import fs from 'node:fs';
import path from 'node:path';
const catalog = JSON.parse(fs.readFileSync('src/assets/i18n/es-ES.json', 'utf8'));
const keys = new Set(Object.keys(catalog));
const used = new Set();
const codeRoots = ['src/app'];
function walk(dir) {
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, ent.name);
if (ent.isDirectory()) walk(p);
else if (/\.(ts|html)$/.test(ent.name)) {
const src = fs.readFileSync(p, 'utf8');
for (const m of src.matchAll(/['"]([a-z0-9]+(?:\.[a-z0-9]+)+)['"]/gi)) {
if (keys.has(m[1])) used.add(m[1]);
}
}
}
}
codeRoots.forEach(walk);
const orphan = [...keys].filter((k) => !used.has(k));
const missing = []; // ampliar: claves usadas en translate() no presentes
if (orphan.length) {
console.error('Claves huérfanas:', orphan.slice(0, 50));
process.exitCode = 1;
}
name: i18n
on: [pull_request]
jobs:
keys:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: node tools/check-i18n-keys.mjs
- name: Fail if English lagging
run: node tools/check-locale-parity.mjs es-ES en-GB
Elige un locale fuente (en TaskFlow, es-ES o en-GB) y no dejes que cada desarrollador escriba primero en su idioma preferido dentro del mismo catálogo. Las diferencias de tono y de clave duplicada aparecen en semanas.
29.12.2 Versionado y despliegue
Con i18n de build, cada locale es un artefacto: versiona y despliega juntos. Con runtime, puedes cargar JSON versionados por CDN (/i18n/es-ES.v42.json) y cachearlos. Invalida al publicar traducciones; no dependas de «Ctrl+F5» del usuario.
29.13 Errores comunes y cómo solucionarlos
| Error / síntoma | Causa habitual | Solución |
|---|---|---|
| La tarea «de hoy» sale como mañana | Comparar fechas en UTC con «hoy» local o usar timestamp sin zona | Calcular el rango del día en la zona IANA del usuario y filtrar en UTC; usar timestamptz |
new Date('2024-01-01') cambia de día | Parseo de solo-fecha como UTC midnight | No parsear fechas civiles con Date; guardar YYYY-MM-DD |
| Desfase de 1–2 h al desplegar | Proceso Node con TZ distinto y columnas sin zona | TZ=UTC + timestamptz + ISO-8601 en API |
| «1 tareas pendientes» | Plural a mano con ternario | ICU MessageFormat / plural del locale |
| Traducción incoherente al concatenar | Trocear frases en claves de una palabra | Mensajes completos con interpolación |
| Importes con punto/coma incorrectos | Formatear con toFixed o locale del servidor | Intl.NumberFormat / pipe con locale del usuario |
| SSR y cliente muestran horas distintas | Pipe date sin zona en servidor | Pasar timeZone y locale explícitos |
| Recordatorios duplicados en otoño | Cron en local sin manejar hora ambigua DST | Librería aware de zona; política para horas inexistentes/ambiguas |
| Semana empieza mal | Asumir lunes para todos | Primer día según locale / preferencia |
| UI RTL con márgenes rotos | CSS físico (margin-left) | Propiedades lógicas + dir |
| Claves que «faltan» en producción | JSON no desplegado / caché CDN | Versionar catálogos; CI de paridad |
| Claves huérfanas eternas | Sin job de limpieza | Script en CI que falle o avise |
| Offset raro en JSON | Date#toString() o serializer local | toISOString() / DTO explícito |
| Usuario cambia de zona y «rompe» historial | Reinterpretar instantes antiguos con zona nueva | Los instantes no cambian; solo la presentación |
| Test verde en local, rojo en CI | CI en UTC, portátil en Madrid | Fijar TZ=UTC en tests; fixtures en ISO Z |
29.14 Buenas y malas prácticas
- Persiste instantes en
timestamptzy expón ISO-8601 con Z en la API. - Guarda locale y zona IANA como preferencias separadas del usuario.
- Formatea con
Intlo pipes configurados; nunca «a mano». - Usa mensajes completos con ICU para plurales e interpolación.
- Ejecuta Node y CI con
TZ=UTC. - Documenta si un campo es Instant o fecha civil.
- Aplica CSS lógico y
dirdesde el primer layout serio. - Automatiza detección de claves huérfanas y paridad de locales en CI.
- Calcula «hoy» / «esta semana» en la zona del usuario.
- Revisa con hablantes nativos antes de abrir un mercado.
- No uses
timestamp without time zonepara eventos absolutos. - No sumes días con
86400000ms. - No parsees
YYYY-MM-DDconnew Date(...)para lógica de negocio. - No concatenes trozos traducibles en el orden del español.
- No formatees moneda con el locale del servidor.
- No almacenes offsets fijos (+02:00) como si fueran zona IANA.
- No «corrijas» fechas restando horas según el huso del portátil.
- No mezcles i18n de build y runtime en el mismo módulo sin fronteras.
- No espejes logos ni toda la UI con un scaleX global.
- No dejes literales de UI fuera del catálogo «porque es temporal».
29.15 Preguntas frecuentes
¿Por qué no guardar siempre la hora ya convertida a la zona del usuario?
Porque la zona del usuario cambia (viajes, mudanzas, preferencia) y las reglas DST de una región pueden actualizarse. Un instante absoluto sigue siendo el mismo hecho histórico; la presentación se recalcula. Si guardas «09:00 en Madrid» como si fuera un UTC inventado, corrompes auditorías y colaboraciones entre husos.
¿ISO-8601 con offset (+02:00) es aceptable en la API o debe ser siempre Z?
Ambos representan un instante. La convención más simple para APIs es normalizar a UTC con Z en las respuestas, para que todos los clientes comparen cadenas o parseen sin sorpresas. En la entrada puedes aceptar offset y convertir a UTC al persistir. Sé consistente: no mezcles formatos en el mismo recurso sin documentarlo.
¿Debo usar Luxon, date-fns-tz, Day.js o esperar a Temporal?
Si solo formateas, Intl basta. Si haces aritmética civil en zonas IANA (añadir días laborables, startOf('day') en Tokio), una librería madura ayuda. Luxon y date-fns-tz son opciones habituales; Day.js con plugins también, con menor tipado. Temporal es la dirección del lenguaje, pero sigue siendo proposal/adopción uneven según motor y fecha: no lo des por ubicuo en producción sin comprobar soporte o polyfill.
¿El i18n oficial de Angular sirve para una SPA autenticada con cambio de idioma en el perfil?
Puede, pero el cambio de idioma suele implicar cargar otro artefacto o recargar la aplicación del locale nuevo. Para SaaS donde el cambio en caliente es requisito de producto, Transloco o ngx-translate suelen ser más ergonómicos. El i18n oficial destaca en sitios con URL por idioma y builds estáticos.
¿Cómo negocio Accept-Language si el usuario ya tiene preferencia en base de datos?
La preferencia persistida gana. Usa Accept-Language solo para invitados o para el primer valor por defecto al registrar. Enviar también X-User-Locale desde Angular evita que un navegador en inglés pise una cuenta configurada en castellano.
¿Qué hago con los mensajes de error de validación de class-validator?
No devuelvas el mensaje por defecto en inglés del decorador al cliente final. Devuelve códigos estables (task.title.required) y traduce en el cliente, o traduce en el servidor con un catálogo alineado al locale de la petición. Mezclar ambos sin criterio produce errores en un idioma y UI en otro.
¿Por qué mi test de fechas falla solo en la pipeline?
Casi siempre: el runner está en UTC y tu máquina en Europe/Madrid (o al revés), y el test depende de new Date() local, de strings sin Z o de snapshots de texto formateado. Fija TZ=UTC en el job, usa instantes fijos en fixtures y aserta ISO o valores numéricos epoch, no cadenas locales.
¿Puedo usar el offset del navegador (getTimezoneOffset) en lugar de una zona IANA?
El offset es una foto del momento; no sirve para futuros ni para reglas DST. Guarda Europe/Madrid, no -60. El offset puede usarse como pista inicial para sugerir una zona, pero la preferencia debe ser un nombre IANA validado.
¿Las fechas de cumpleaños son timestamptz?
No. Un cumpleaños es una fecha civil: el 15 de marzo. Si lo guardas como instante a medianoche UTC, en Americas puede mostrarse el 14. Usa tipo date o string YYYY-MM-DD.
¿Cómo evito que los traductores rompan placeholders?
Usa nombres de variables claros, valida en CI que los placeholders del locale fuente existan en los destinos, y prefiere ICU a concatenación. Las plataformas TMS suelen resaltar huecos; actívalo y falla el merge si falta {count}.
¿NestJS debe formatear fechas para el locale del usuario?
En APIs JSON de TaskFlow, no: entrega instantes ISO y deja el formato a Angular. Formatear en servidor tiene sentido en correos, PDFs o reportes generados donde no hay Intl del cliente. Incluso entonces, pasa locale y zona explícitos al generador.
¿Qué es CLDR y por qué debería importarme?
Unicode CLDR es el repositorio de datos culturales (plurales, calendarios, nombres de zonas, formatos). Intl en los motores se alimenta de esos datos. Cuando un locale «se comporta raro», a menudo es CLDR + implementación del motor, no tu pipe. Consultar CLDR evita pelearte con el framework.
¿Abrir árabe implica solo traducir el JSON?
No. Implica RTL (CSS lógico, dir), tipografías adecuadas, posibles cambios de densidad de texto (±30 % es habitual), y pruebas de componentes que asumen LTR. Presupuéstalo como feature de UI, no como ticket de copy.
29.16 Ejercicios
Trabaja sobre el dominio TaskFlow. No hace falta un repositorio real: puedes razonar sobre DTOs, SQL y fragmentos Angular. Cuando se pida código, prioriza claridad sobre completitud de módulos Nest.
- E1. Define en una frase la diferencia entre i18n y l10n y pon un ejemplo de cada una en TaskFlow.
- E2. Explica por qué
timestamp without time zonees una mala elección paracreated_at. - E3. Reescribe el mensaje «Tienes » + n + « tareas» usando la idea de ICU plural (puedes dejarlo en pseudocódigo).
- E4. Dado el instante
2026-07-31T18:00:00.000Z, ¿qué debe mostrar una UI enEurope/Madriden julio (CEST, UTC+2) para fecha y hora media?
- E5. Diseña el DTO de preferencias de usuario (locale, timeZone, currency) con validaciones y justifica por qué currency va separada del locale.
- E6. Implementa (en papel o código)
rangoHoyUtc(zona)y escribe la query conceptual que lista tareas condue_aten ese rango. - E7. Enumera tres riesgos de usar ngx-translate sin CI de claves y propón un check automático para cada uno.
- E8. Un compañero propone guardar
due_atya formateado como string «31/07/2026 20:00» para «ir más rápido en el front». Redacta la respuesta técnica con la que lo rechazas en la revisión del PR.
- E9. Migra mentalmente una columna
due_at timestamp(sin zona, valores escritos desde un servidor en Madrid) atimestamptz. Escribe el SQLUSINGy lista los tests que exigirías antes de mergear. - E10. Diseña el comportamiento de un recordatorio diario «a las 09:00 en la zona del usuario» atravesando el cambio de hora de primavera en Madrid. ¿Qué haces con la hora inexistente 02:30 si alguien la hubiera configurado?
- E11. Propón la arquitectura de i18n para TaskFlow si el marketing público necesita
/es/y/en/SEO y la app autenticada permite cambiar idioma sin recargar todo el shell. Dibuja fronteras de módulos. - E12. Añade soporte RTL: lista cambios de CSS, pruebas e2e mínimas y un criterio de aceptación medible.
Solución comentada · E3 (plural ICU)
El ternario n === 1 no generaliza. Un mensaje ICU del estilo {count, plural, =0 {No tienes tareas} one {Tienes # tarea} other {Tienes # tareas}} delega la categoría de plural en el locale. En el catálogo JSON de runtime quedaría una sola clave tasks.summary; en Angular i18n oficial, una unidad con ICU en el XLIFF. El # se formatea según el locale (separadores de miles). No traduzcas por separado «Tienes» y «tareas».
{
"tasks.summary": "{count, plural, =0 {No tienes tareas} one {Tienes # tarea} other {Tienes # tareas}}"
}
Solución comentada · E6 (rango «hoy»)
El servidor no debe usar CURRENT_DATE de PostgreSQL como «hoy del usuario» si la sesión está en UTC. Calculas inicio y fin del día civil en la zona IANA y conviertes ambos a UTC. La query queda due_at >= :desde AND due_at < :hasta (intervalo semiabierto). Así evitas inclusiones dobles en el límite de medianoche.
const { desde, hasta } = rangoHoyUtc(user.timeZone);
const tasks = await this.em.find(Task, {
dueAt: { $gte: new Date(desde), $lt: new Date(hasta) },
owner: user.id,
});
Si el producto usa due_on (fecha civil), el filtro es más simple: due_on = fechaCivilHoyEnZona(user.timeZone), sin convertir a instante. No mezcles ambos criterios en la misma pantalla sin etiquetar la semántica.
Solución comentada · E9 (migración timestamp → timestamptz)
El paso crítico es declarar el significado histórico. Si todos los writers estaban en Madrid y escribían hora de pared local sin zona, entonces:
ALTER TABLE task
ALTER COLUMN due_at TYPE timestamptz
USING due_at AT TIME ZONE 'Europe/Madrid';
Tests mínimos: (1) insertar un valor conocido pre-migración en un entorno staging clonado; (2) comprobar que el instante UTC resultante corresponde a la pared de Madrid en esa fecha (cuidado con DST: elige una fila en agosto y otra en enero); (3) round-trip desde Nest leyendo toISOString(); (4) congelar el job si hay writers activos durante el ALTER (ventana de mantenimiento o bloqueo). Si una parte de los datos ya era UTC «de facto», la migración ingenua los desplazará: audita antes con muestreo.
Solución comentada · E8 (rechazo en code review)
Respuesta tipo: «Ese string no es un dato, es una presentación. Rompe ordenación, filtros, clientes móviles, correos y cualquier usuario fuera de es-ES. La API debe seguir devolviendo ISO-8601; el pipe date o Intl formatean en el cliente con locale y zona del perfil. Si el problema es rendimiento, cachea el resultado de Intl.DateTimeFormat por locale/zona, no corrompas el contrato.»
Ampliación opcional para quien quiera profundizar: escribe un interceptor de Nest que lea Accept-Language y X-User-Timezone, valide la zona con Intl, y deje ambos valores en un AsyncLocalStorage o en el objeto request para servicios de reporting que sí formatean en servidor (PDF). Incluye tests con cabeceras mal formadas (zona Foo/Bar) esperando 400.
Checklist de autoevaluación antes de dar por cerrado un PR «con i18n» en TaskFlow:
- ¿Todo texto de UI nuevo pasa por catálogo o marca
i18n? ¿Hay contexto para el traductor? - ¿Las fechas del API van en ISO-8601? ¿Algún
toString()se ha colado en un DTO? - ¿Los tests fijan
TZ=UTCy usan fixtures con Z? - ¿«Hoy» / «esta semana» usan la zona del usuario si el filtro es civil?
- ¿El CSS nuevo usa propiedades lógicas si afecta a márgenes o alineación?
- ¿CI de paridad de claves sigue verde?
Si puedes responder sí a todas, el PR probablemente no introduzca una mentira cultural o temporal. Si alguna falla, no es un detalle de polish: es deuda que el próximo huso horario cobrará con intereses.
import {
PipeTransform, Injectable, BadRequestException,
} from '@nestjs/common';
import { esZonaIanaValida } from '../users/timezone.util';
@Injectable()
export class IanaTimeZonePipe implements PipeTransform<string, string> {
transform(value: string): string {
if (!value || !esZonaIanaValida(value)) {
throw new BadRequestException('Invalid IANA time zone');
}
return value;
}
}
29.17 Resumen del capítulo
- i18n prepara el producto para adaptarse; l10n es la adaptación a cada mercado. No son sinónimos.
- Angular localize + XLIFF brilla en builds por locale y TMS; ngx-translate/Transloco brillan en cambio de idioma en runtime. Elige según producto, no por moda.
- Los mensajes necesitan ICU: plurales, select e interpolación. Concatenar trozos es un anti-patrón.
- Formatos de fecha, número y moneda salen de
Intl/ pipes con locale negociado; no del diccionario de traducciones. - Instantes en UTC (
timestamptz, ISO-8601); fechas civiles aparte; zona IANA solo en presentación y en reglas «hoy/semana». new Date('YYYY-MM-DD')y sumar días con milisegundos son trampas clásicas; DST las agrava.- NestJS valida locales y zonas, serializa ISO y no formatea para la UI JSON.
- MikroORM + driver: tipos correctos, proceso en
TZ=UTC, migraciones con significado declarado. - RTL exige CSS lógico,
diry pruebas reales, no un espejo cosmético. - CI debe cazar claves huérfanas, paridad de locales y regresiones de parseo de fechas.
Este capítulo se apoya en el 1 (JavaScript y tipos), en el 5 (Angular y pipes/DI) y en el 9 (NestJS, DTOs y validación). Conecta con el modelado de datos y migraciones que viste al trabajar con MikroORM, y con cualquier capítulo de API donde el contrato JSON deba ser estable entre husos. Cuando generes PDFs, correos o informes, reutiliza las mismas reglas de locale y zona; no inventes un segundo sistema de tiempo «solo para adjuntos».
29.18 Recursos adicionales
- MDN · Intl — referencia de formateo de fechas, números, listas y negociación de locales.
- MDN · Intl.DateTimeFormat — opciones
timeZone,dateStyle,timeStyle. - Unicode CLDR — datos culturales detrás de plurales, calendarios y formatos.
- TC39 · Temporal proposal — estado de la propuesta; comprueba soporte antes de adoptar en producción.
- angular.dev · i18n —
@angular/localize, extracción y despliegue por locale. - NestJS · Validation — DTOs y pipes aplicables a locale/zona.
- PostgreSQL · Date/Time types —
timestamptz,timestamp,dateyAT TIME ZONE. - ICU MessageFormat — plurales, select e interpolación.
- W3C · Bidireccionalidad — base para RTL y texto mixto.
- Luxon — aritmética en zonas IANA sobre
Intl(librería de terceros de uso habitual). - IANA Time Zone Database — fuente canónica de nombres
Continent/Cityy reglas DST. - ECMA-402 — especificación de
Intlen ECMAScript.
Si mañana solo puedes imponer tres reglas en TaskFlow, que sean estas: UTC y timestamptz para instantes; ISO-8601 en la frontera HTTP; locale y zona IANA como preferencias separadas aplicadas solo al presentar o al calcular rangos civiles. Con eso, el resto del capítulo deja de ser teoría y pasa a ser mantenimiento rutinario.
La internacionalización bien hecha no se nota: el usuario en Ciudad de México, en Tokio o en Madrid ve «hoy» cuando es hoy, lee plurales correctos y no descubre nunca el offset del servidor. Cuando se nota, casi siempre es porque faltó una de esas tres reglas.
Lleva este capítulo a la práctica en el siguiente sprint de TaskFlow con un único objetivo medible: cero tickets de «fecha incorrecta» reproducibles por huso en staging, y un locale no español pasando la suite de captura visual básica (incluido un smoke RTL si ya tenéis ar o he en el catálogo interno).
Con eso cierras el círculo entre producto, API y base de datos: el tiempo deja de ser un detalle de presentación y pasa a ser un contrato de dominio.
Cuando dudes entre una solución «rápida» y una correcta, recuerda el aeropuerto del principio: los paneles pueden mentir; el control aéreo, no. En TaskFlow, el control aéreo es UTC.