Parte VIII · Ampliaciones

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.

AVANZADO Tiempo de lectura: ~70 min Prerrequisitos: capítulos 1, 5 y 9

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 Intl y 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, timestamptz en 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.

Analogía: el aeropuerto internacional

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:

No es un problema de frontend

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  │
└─────────────────────────────────────────────────────────────┘
Locale no es idioma

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).

src/app/tasks/task-list.component.html
<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>
src/app/tasks/task-count.ts
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.

fragmento messages.es.xlf
<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.

src/assets/i18n/es-ES.json
{
  "tasks.list.title": "Tus tareas",
  "tasks.list.empty": "No hay tareas pendientes",
  "tasks.list.add": "Nueva tarea",
  "tasks.count": "{{count}} tareas pendientes"
}
uso típico con pipe (idea)
<h2>{{ 'tasks.list.title' | translate }}</h2>
<p>{{ 'tasks.count' | translate:{ count: total } }}</p>

29.4.3 Tabla comparativa honesta

Criterio@angular/localize + XLIFFngx-translate / Transloco
Momento de resoluciónCompilación / build por localeTiempo de ejecución
Cambio de idioma en calienteCostoso (otra app o recarga de artefacto)Natural
Integración con TMS / traductoresExcelente (XLIFF es estándar de la industria)Buena si exportas/importas JSON con disciplina
Tamaño del despliegueUn bundle (o carpeta) por localeUn bundle + JSON bajo demanda
Detección de textos sin marcarMás fácil de auditar en plantillas marcadasFácil dejar literales sueltos
ICU / pluralesSoportados en el flujo oficialDepende de la lib y de cómo escribas las cadenas
SSR / hidrataciónMuy predecible si el locale está fijado al renderHay que alinear locale de servidor y cliente
Curva y «magia»Más ceremonia de build y despliegueArranque rápido, deuda silenciosa posible
Cuándo encaja en TaskFlowPocos idiomas estables, marketing/SEO por locale, disciplina de releaseMuchos idiomas, cambio frecuente, app autenticada tipo SaaS
Recomendación práctica para TaskFlow

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.

nginx (idea de enrutado por prefijo)
# /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.

SEO y app autenticada

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.

incorrecto.tsINCORRECTO
// Orden fijo del español: se rompe en japonés, alemán, etc.
const msg = 'La tarea "' + titulo + '" fue asignada a ' + nombre;
correcto.tsCORRECTO
// ICU / MessageFormat: el traductor reordena {titulo} y {nombre}
const msg = $localize`:@@tasks.assigned:La tarea "{$titulo}" fue asignada a {$nombre}:titulo:${titulo}:nombre:${nombre}:`;
HTML dentro del mensaje

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.

plural-mal.tsINCORRECTO
function resumen(n: number): string {
  return n === 1 ? '1 tarea pendiente' : n + ' tareas pendientes';
}
plural-bien.xlf / ICUCORRECTO
// Forma conceptual ICU (sintaxis según herramienta):
// {count, plural,
//   =0 {No hay tareas pendientes}
//   one {# tarea pendiente}
//   other {# tareas pendientes}}
ejemplo de mensaje ICU en catálogo JSON (Transloco/ngx)
{
  "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»).

mensaje con select (idea ICU)
// {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

reusar-trozos.tsINCORRECTO
// «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
mensaje-completo.tsCORRECTO
// Una clave = una frase con contexto
const label = t('tasks.actions.openTask');
// es: "Abrir tarea" / en: "Open task" / de: "Aufgabe öffnen"
Contexto para el traductor

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.

pseudo locale (idea)
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

src/app/shared/formatters.ts
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);
}
ejemplos de salida (ilustrativos)
# 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).

src/app/app.config.ts (idea)
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';
}
plantilla con pipes
<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>
El pipe 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:

  1. Preferencia explícita del usuario autenticado (base de datos).
  2. Prefijo de ruta o cookie de sesión en superficies públicas.
  3. Negociación con Accept-Language contra la lista de locales soportados.
  4. Fallback final: es-ES (o el mercado por defecto del producto).
src/app/i18n/negotiate-locale.ts
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.

format-units.ts
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.

Temporal Proposal

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.

migración SQL (idea)
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.

parseo-ambiguo.tsINCORRECTO
// 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
parseo-explicito.tsCORRECTO
// 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.

sumar-ms.tsINCORRECTO
function masDias(d: Date, n: number): Date {
  return new Date(d.getTime() + n * 86_400_000);
}
sumar-civil.tsCORRECTO
// 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()!;
}
«Hoy» es una pregunta con zona

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.

rango «hoy» en zona del usuario
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

src/i18n/locale.middleware.ts
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

src/users/dto/update-preferences.dto.ts
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;
}
src/users/timezone.util.ts
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;
  }
}
serializar-mal.tsINCORRECTO
// Depende del TZ del proceso Node
return { dueAt: dueAt.toString() };
// "Thu Jul 31 2026 20:00:00 GMT+0200 (CEST)"
serializar-bien.tsCORRECTO
return { dueAt: dueAt.toISOString() };
// "2026-07-31T18:00:00.000Z"

29.8.3 DTOs: entrada y salida

src/tasks/dto/create-task.dto.ts
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».

src/tasks/dto/task-response.dto.ts
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,
  };
}
ClassSerializerInterceptor y Date

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:

error-mal.tsINCORRECTO
throw new BadRequestException('Due date must be in the future');
error-bien.tsCORRECTO
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.

filtro de excepciones (idea)
@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

src/tasks/task.entity.ts
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.

mikro-orm.config.ts (fragmento)
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.
});
Dockerfile / runtime
ENV TZ=UTC
# Equivalente en el entrypoint:
# export TZ=UTC
migracion-mal.sqlINCORRECTO
-- Sin zona: el significado depende de quién lea
ALTER TABLE task
  ADD COLUMN reminder_at timestamp NULL;
migracion-bien.sqlCORRECTO
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.

migración ilustrativa
-- 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';
No «arregles» restando horas a mano

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.

task.dates.spec.ts (idea)
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.

package.json / CI
{
  "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.

primer día de semana (idea)
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:

Bugs de medianoche

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.

medianoche-local.tsINCORRECTO
function parseDueOn(dueOn: string): Date {
  const [y, m, d] = dueOn.split('-').map(Number);
  return new Date(y, m - 1, d); // zona del proceso
}
fecha-civil.tsCORRECTO
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.

styles.css (fragmento)
.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);
}
index.html / app shell
<html lang="ar" dir="rtl">
  <!-- lang y dir deben actualizarse al cambiar locale -->
</html>
aplicarDir.ts
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

Iconos y marcas

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.

src/app/tasks/due-picker.ts
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()!;
}
src/mail/reminder-mailer.ts
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}.`;
}
correo-mal.tsINCORRECTO
// Formatea con el huso del worker (UTC en producción)
const when = new Date(dueAtIso).toLocaleString('es-ES');
correo-bien.tsCORRECTO
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»).

Analogía: la partitura y el metrónomo

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:

sanitizar o no marcar HTML
// 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.

src/app/shared/format-cache.ts
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().

Por qué seguimos lidiando con Date

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

  1. El desarrollador añade una clave nueva con mensaje fuente en el idioma de desarrollo (por ejemplo es-ES) y contexto.
  2. CI detecta claves nuevas o modificadas y las publica al TMS (Crowdin, Lokalise, Phrase, Weblate, etc.) o genera un artefacto XLIFF/JSON para traductores.
  3. Los traductores entregan; CI importa y falla si faltan locales obligatorios en el release.
  4. 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).
tools/check-i18n-keys.mjs (idea)
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;
}
.github/workflows/i18n.yml (fragmento)
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
El idioma fuente es un contrato

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íntomaCausa habitualSolución
La tarea «de hoy» sale como mañanaComparar fechas en UTC con «hoy» local o usar timestamp sin zonaCalcular 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íaParseo de solo-fecha como UTC midnightNo parsear fechas civiles con Date; guardar YYYY-MM-DD
Desfase de 1–2 h al desplegarProceso Node con TZ distinto y columnas sin zonaTZ=UTC + timestamptz + ISO-8601 en API
«1 tareas pendientes»Plural a mano con ternarioICU MessageFormat / plural del locale
Traducción incoherente al concatenarTrocear frases en claves de una palabraMensajes completos con interpolación
Importes con punto/coma incorrectosFormatear con toFixed o locale del servidorIntl.NumberFormat / pipe con locale del usuario
SSR y cliente muestran horas distintasPipe date sin zona en servidorPasar timeZone y locale explícitos
Recordatorios duplicados en otoñoCron en local sin manejar hora ambigua DSTLibrería aware de zona; política para horas inexistentes/ambiguas
Semana empieza malAsumir lunes para todosPrimer día según locale / preferencia
UI RTL con márgenes rotosCSS físico (margin-left)Propiedades lógicas + dir
Claves que «faltan» en producciónJSON no desplegado / caché CDNVersionar catálogos; CI de paridad
Claves huérfanas eternasSin job de limpiezaScript en CI que falle o avise
Offset raro en JSONDate#toString() o serializer localtoISOString() / DTO explícito
Usuario cambia de zona y «rompe» historialReinterpretar instantes antiguos con zona nuevaLos instantes no cambian; solo la presentación
Test verde en local, rojo en CICI en UTC, portátil en MadridFijar TZ=UTC en tests; fixtures en ISO Z

29.14 Buenas y malas prácticas

  • Persiste instantes en timestamptz y expón ISO-8601 con Z en la API.
  • Guarda locale y zona IANA como preferencias separadas del usuario.
  • Formatea con Intl o 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 dir desde 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 zone para eventos absolutos.
  • No sumes días con 86400000 ms.
  • No parsees YYYY-MM-DD con new 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.

Nivel 1 · básico
  1. E1. Define en una frase la diferencia entre i18n y l10n y pon un ejemplo de cada una en TaskFlow.
  2. E2. Explica por qué timestamp without time zone es una mala elección para created_at.
  3. E3. Reescribe el mensaje «Tienes » + n + « tareas» usando la idea de ICU plural (puedes dejarlo en pseudocódigo).
  4. E4. Dado el instante 2026-07-31T18:00:00.000Z, ¿qué debe mostrar una UI en Europe/Madrid en julio (CEST, UTC+2) para fecha y hora media?
Nivel 2 · intermedio
  1. E5. Diseña el DTO de preferencias de usuario (locale, timeZone, currency) con validaciones y justifica por qué currency va separada del locale.
  2. E6. Implementa (en papel o código) rangoHoyUtc(zona) y escribe la query conceptual que lista tareas con due_at en ese rango.
  3. E7. Enumera tres riesgos de usar ngx-translate sin CI de claves y propón un check automático para cada uno.
  4. E8. Un compañero propone guardar due_at ya 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.
Nivel 3 · avanzado
  1. E9. Migra mentalmente una columna due_at timestamp (sin zona, valores escritos desde un servidor en Madrid) a timestamptz. Escribe el SQL USING y lista los tests que exigirías antes de mergear.
  2. 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?
  3. 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.
  4. 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».

es-ES.json (fragmento)
{
  "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.

tasks.service.ts (idea)
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:

migración
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:

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.

esqueleto de validación en pipe Nest
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, dir y pruebas reales, no un espejo cosmético.
  • CI debe cazar claves huérfanas, paridad de locales y regresiones de parseo de fechas.
Enlace con el resto del libro

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

Cierre operativo

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.