Guardas o muestras una fecha en JavaScript y el día sale equivocado: una venta registrada a las 19:17 aparece con la fecha de mañana, o un <input type="date"> guarda el día anterior al que elegiste. En una máquina con zona horaria UTC-5 puedes reproducirlo así:
new Date('2026-08-18T19:17:00-05:00').toISOString().slice(0, 10)
// '2026-08-19' (se esperaba '2026-08-18')
new Date('2026-07-09').getDate()
// 8 (se esperaba 9)
Le pasa a quien tiene usuarios en una zona horaria distinta de UTC y mezcla toISOString(), new Date('YYYY-MM-DD') y getters locales como getDate(). Aquí verás la causa exacta y una forma de manejar fechas que no depende de la zona horaria del servidor.
Por qué pasa
Intervienen dos reglas de JavaScript que parecen inofensivas por separado:
toISOString()siempre devuelve la hora en UTC. MDN lo resume así: la zona horaria es siempre UTC, indicada por el sufijoZ.- Un string de solo fecha (
YYYY-MM-DD) se interpreta como medianoche UTC, mientras que un string con fecha y hora sin desfase (2019-01-01T00:00:00) se interpreta en la zona local. Esa diferencia está documentada en la página deDate.parse()de MDN.
En UTC-5, las 19:00 locales son las 00:00 UTC del día siguiente. Por eso una venta de las 19:17 sale con la fecha de mañana si la formateas con toISOString(). Y al revés: new Date('2026-07-09') es el 9 de julio a las 00:00 UTC, que en tu zona local es el 8 de julio a las 19:00. Si luego lees el día con getDate() o toLocaleDateString(), obtienes el 8.
En zonas al este de UTC ocurre lo contrario: las horas de la madrugada local caen en el día anterior de UTC. Y en un servidor configurado en UTC no verás el problema, que es justo lo que lo hace tan fácil de pasar por alto: funciona en el servidor y falla en el navegador del usuario (o al revés).
Solución
Primero distingue qué tipo de valor tienes, porque se tratan distinto:
- Instante: un momento exacto (la hora de una venta, un
createdAt). Se debe formatear en la zona horaria del negocio o del usuario. - Fecha pura: un día de calendario sin hora (un cumpleaños, una fecha de vencimiento, el valor de un
<input type="date">). No debería pasar por UTC.
-
Instantes: formatea en una zona explícita. Con
date-fns-tz(instaladate-fnsydate-fns-tz):import { formatInTimeZone } from 'date-fns-tz'; const TZ = 'America/Bogota'; // zona de ejemplo con UTC-5 export function fechaLocal(instante: Date, tz: string = TZ): string { return formatInTimeZone(instante, tz, 'yyyy-MM-dd'); } fechaLocal(new Date('2026-08-19T00:17:00Z')); // '2026-08-18'Sin dependencias,
Intl.DateTimeFormatcon la configuración regionalen-CAproduce el formato año-mes-día:new Intl.DateTimeFormat('en-CA', { timeZone: 'America/Bogota' }) .format(new Date('2026-08-19T00:17:00Z')); // '2026-08-18'Ese formato depende de los datos de localización del entorno, así que protege esta función con un test (paso 4).
-
Fechas puras: no las conviertas a UTC. Lo más seguro es conservar el string
YYYY-MM-DDtal cual, tanto en el formulario como al enviarlo a la API. Si necesitas un objetoDate, créalo con los componentes, que se interpretan en hora local (y el mes empieza en 0):const [y, m, d] = '2026-07-09'.split('-').map(Number); const fecha = new Date(y, m - 1, d); // 9 de julio, medianoche local fecha.getDate(); // 9Si ya tienes fechas puras guardadas como medianoche UTC, no las recalcules: muéstralas indicando la zona UTC.
formatInTimeZone(new Date('2026-07-09'), 'UTC', 'yyyy-MM-dd'); // '2026-07-09' -
Rangos por día: convierte los extremos desde la zona del negocio.
fromZonedTimeinterpreta un string sin zona como hora local de la zona indicada y devuelve el instante UTC equivalente:import { fromZonedTime } from 'date-fns-tz'; export function rangoDelDia(fecha: string, tz: string = TZ) { return { desde: fromZonedTime(`${fecha}T00:00:00`, tz), hasta: fromZonedTime(`${fecha}T23:59:59.999`, tz), }; } rangoDelDia('2026-08-18'); // desde: 2026-08-18T05:00:00.000Z // hasta: 2026-08-19T04:59:59.999ZCon ese rango, una consulta por día incluye las ventas de las 19:17 en lugar de moverlas al día siguiente.
-
Agrega un test con una hora entre las 19:00 y las 23:59. Es el caso que rompe el código ingenuo. Como la función recibe la zona de forma explícita, el test no depende de la configuración de la máquina:
import { describe, expect, it } from 'vitest'; import { fechaLocal } from './fecha'; describe('fechaLocal', () => { it('no adelanta el día entre las 19:00 y las 23:59 (UTC-5)', () => { expect(fechaLocal(new Date('2026-08-19T00:17:00Z'))).toBe('2026-08-18'); }); it('a la medianoche local ya es el día siguiente', () => { expect(fechaLocal(new Date('2026-08-19T05:00:00Z'))).toBe('2026-08-19'); }); });
Cómo verificar que quedó resuelto
- Ejecuta la suite de tests: los dos casos de arriba deben pasar (
2026-08-19T00:17Zdebe dar2026-08-18). - Compara ambos enfoques en una consola Node.js. En una máquina en UTC-5 verás:
toISOString().slice(0, 10)da2026-08-19, mientras quefechaLocal(...)da2026-08-18. - Prueba el flujo completo con una venta creada entre las 19:00 y las 23:59: la fecha en pantalla, en la exportación a Excel o PDF y en el filtro por día debe ser la misma.
- Elige una fecha en el
<input type="date">, guárdala, recarga y comprueba que sigue siendo el mismo día.
Variantes y errores relacionados
Agregar "T00:00:00" cambia el significado del string
new Date('2026-07-09') es UTC, pero new Date('2026-07-09T00:00:00') es hora local. Según la zona, la diferencia entre ambos es de varias horas (5 horas en UTC-5). No lo uses como parche sin saber cuál de los dos necesitas.
Funciones del paquete con nombres antiguos
Si encuentras ejemplos con zonedTimeToUtc o utcToZonedTime, son los nombres anteriores a la versión 3 de date-fns-tz: pasaron a llamarse fromZonedTime y toZonedTime, según las notas de la versión 3.0.0.
Si prefieres no depender de en-CA
function fechaISO(instante: Date, timeZone: string): string {
const partes = new Intl.DateTimeFormat('en-US', {
timeZone, year: 'numeric', month: '2-digit', day: '2-digit',
}).formatToParts(instante);
const valor = (tipo: string) => partes.find((p) => p.type === tipo)!.value;
return `${valor('year')}-${valor('month')}-${valor('day')}`;
}
No mezcles los dos tipos de valor
Una fecha pura que se guardó como medianoche UTC y luego se convierte a la zona local retrocede un día. Decide desde el modelo de datos si cada campo es un instante o una fecha pura, y mantén esa decisión de punta a punta: formulario, API, base de datos y exportaciones.
Resumen
toISOString()formatea siempre en UTC, ynew Date('YYYY-MM-DD')es medianoche UTC; en UTC-5 eso cambia el día a las 19:00 locales.- Para instantes, formatea con una zona explícita:
formatInTimeZoneoIntl.DateTimeFormatcontimeZone. - Para fechas puras, conserva el string
YYYY-MM-DDo crea elDateconnew Date(y, m - 1, d). - Para rangos por día, usa
fromZonedTimedesdeT00:00:00hastaT23:59:59.999. - Cubre el caso con un test entre las 19:00 y las 23:59.
Comentarios
Publicar un comentario