Cómo gestionar los cambios de horario en eventos recurrentes del calendario

Desarrollador
scheduling

Los eventos recurrentes del calendario tienen un problema con el cambio de horario que solo aparece dos veces al año — y casi siempre en producción, no en los tests.

Un usuario crea una reunión recurrente: “todos los martes a las 9am, America/New_York.” En invierno, las 9am EST = 14:00 UTC. En verano, las 9am EDT = 13:00 UTC. La hora UTC cambia; la hora local permanece igual.

Si guardaste 14:00 UTC como la hora recurrente en lugar de 09:00 America/New_York, todos los martes de verano la reunión se disparará a las 10am en Nueva York. El usuario lo nota en marzo, se pregunta por qué su reunión recurrente “se ha movido” y abre un ticket.

La causa raíz

Hay dos cosas distintas que puede significar una hora recurrente:

  1. Hora del reloj de pared: “Esta reunión es siempre a las 9am en mi ciudad.” El equivalente UTC cambia con el horario de verano.
  2. Hora UTC fija: “Esta reunión se dispara siempre a las 14:00 UTC.” La hora local cambia con el horario de verano.

Para la agenda de cara al usuario — reuniones, recordatorios, standups — el usuario quiere decir hora del reloj de pared. Si guardas un offset UTC fijo, estás implementando lo otro, que casi nunca es lo que quieren.

El modelo correcto de almacenamiento

Guarda dos cosas:

  • La hora local y la regla de recurrencia (RRULE): DTSTART;TZID=America/New_York:20260106T090000 / RRULE:FREQ=WEEKLY;BYDAY=TU
  • El nombre de zona IANA: America/New_York

Al generar la siguiente ocurrencia, convierte la hora local a UTC en el momento del cálculo — no en el momento de la creación. Esto asegura que las ocurrencias post-cambio de horario usen el offset correcto (de verano).

No guardes un offset UTC fijo (+05:30, -05:00) como zona de un evento recurrente. Los offsets son instantáneas. Los nombres IANA son reglas.

iCalendar (RFC 5545) lo hace bien

La especificación iCalendar usa TZID en DTSTART exactamente por esta razón:

BEGIN:VEVENT
DTSTART;TZID=America/New_York:20260106T090000
RRULE:FREQ=WEEKLY;BYDAY=TU
SUMMARY:Weekly standup
END:VEVENT

Cuando un cliente de calendario expande esta recurrencia después del cambio de horario, calcula cada ocurrencia en America/New_York — así que las ocurrencias de junio se generan como 13:00 UTC y las de enero como 14:00 UTC, ambas mapeando correctamente a las 9am en hora local.

Si estás construyendo una herramienta de agenda, sigue este modelo.

Generar ocurrencias en código

JavaScript (con la API Temporal):

import { Temporal } from "@js-temporal/polyfill";

const timeZone = "America/New_York";
const startLocal = Temporal.PlainDateTime.from("2026-01-06T09:00:00");

// Generar las próximas 8 ocurrencias de martes
const occurrences = [];
let current = startLocal;
for (let i = 0; i < 8; i++) {
  const zonedDT = current.toZonedDateTime(timeZone);
  occurrences.push(zonedDT.toInstant().toString()); // instante UTC
  current = current.add({ weeks: 1 });
}
// Ocurrencia de enero → 2026-01-06T14:00:00Z (EST, UTC-5)
// Ocurrencia de junio → 2026-06-02T13:00:00Z (EDT, UTC-4) ✓

Python:

from datetime import datetime
from zoneinfo import ZoneInfo
from dateutil.rrule import rrule, WEEKLY, TU

tz = ZoneInfo('America/New_York')
start = datetime(2026, 1, 6, 9, 0, tzinfo=tz)

occurrences = list(rrule(WEEKLY, byweekday=TU, dtstart=start, count=8))
for dt in occurrences:
    print(dt.astimezone(ZoneInfo('UTC')))
# Cada ocurrencia se calcula en America/New_York — el horario de verano se gestiona automáticamente

Probar el manejo del cambio de horario

Escribe tests explícitos alrededor de las fechas de transición. Para America/New_York:

  • Adelanto de primavera: segundo domingo de marzo (los relojes saltan de las 2am a las 3am)
  • Retraso de otoño: primer domingo de noviembre (los relojes retroceden de las 2am a la 1am)

Comprueba que un evento recurrente fijado para las 9am el martes antes y después de cada transición se dispara a las 9am en hora local en ambas semanas — no a las 9am y luego a las 10am.

// Test: martes antes del adelanto de primavera → 14:00 UTC
// Test: martes después del adelanto de primavera → 13:00 UTC
// Ambos deben mostrarse como 09:00 America/New_York ✓

Este test es barato de escribir y cubre toda la categoría de bugs de eventos recurrentes relacionados con el cambio de horario.

Buy me a coffe