Por qué tus eventos aparecen a otra hora: zonas horarias en ICS
Las tres formas en las que un archivo ICS puede expresar una hora de inicio, para qué sirve el bloque VTIMEZONE y por qué falta tantas veces, cómo diagnosticar un desfase por el número de horas, qué hace el horario de verano con los eventos recurrentes y en qué se diferencian Outlook y Google al adivinar.
Cuando un evento importado aparece dos horas desplazado, el archivo casi nunca guarda el número equivocado: guarda una hora sin decir a qué zona pertenece, y la aplicación ha adivinado. iCalendar permite escribir DTSTART de tres maneras, y solo una es inequívoca por sí sola. El hueco entre lo que quiso decir quien exportó y lo que supuso quien importa es el origen de todos los desfases.
Las tres formas de DTSTART
La RFC 5545 define exactamente tres maneras de escribir una fecha y hora, y se distinguen de un vistazo.
Forma 1: hora local flotante
DTSTART:20260315T090000, sin Z y sin parámetro TZID. Significa "las nueve de la mañana, donde sea que esté quien lo lea". El estándar la llama fecha-hora con hora local y no lleva desplazamiento a propósito. Sirve para un aviso que debe sonar a las 09:00 en cada oficina y para poco más. Es la causa más habitual de eventos desplazados: un exportador que se olvidó de escribir la zona produce exactamente lo mismo que uno que lo hizo a conciencia.
Forma 2: UTC
DTSTART:20260315T080000Z: la Z final marca el valor como UTC y no admite interpretación. Cualquier app lo convierte a la zona local de quien lo ve. Si tu archivo usa Z de forma consistente y los eventos siguen apareciendo mal, el problema está en la zona configurada en el propio calendario, no en el archivo.
Forma 3: hora local con referencia a zona horaria
DTSTART;TZID=Europe/Madrid:20260315T090000: una hora de reloj más la zona a la que pertenece. Es la forma que quieres para eventos recurrentes: una reunión diaria a las 09:00 sigue a las 09:00 en marzo y en noviembre, aunque el desplazamiento respecto a UTC cambie entre medias.
Un cuarto caso no es una fecha-hora: DTSTART;VALUE=DATE:20260315 para eventos de todo el día, sin hora ni zona por diseño. Los que se van al día anterior o al siguiente vienen de un exportador que los escribió como fechas-hora de medianoche a medianoche en vez de usar VALUE=DATE.
Para qué sirve VTIMEZONE y por qué desaparece
Un TZID como Europe/Madrid es solo una etiqueta. El archivo debería definir qué significa, en un componente VTIMEZONE dentro del mismo VCALENDAR. Ese bloque declara el identificador y luego uno o varios subcomponentes STANDARD y DAYLIGHT, cada uno con un DTSTART que marca cuándo entra en vigor la regla, un TZOFFSETFROM y un TZOFFSETTO con los desplazamientos a cada lado del cambio, normalmente una RRULE con la repetición anual del salto y, opcionalmente, un TZNAME como CET o CEST.
La RFC 5545 exige que cualquier TZID referenciado por un evento esté definido por un VTIMEZONE en el mismo objeto iCalendar. En la práctica esa exigencia se incumple sin parar:
- El exportador da por hecho que quien lee tiene la base IANA de zonas horarias y puede resolver el nombre solo. La mayoría de clientes modernos pueden, así que el archivo funciona y nadie nota la ausencia.
- El archivo lo generó un script. El módulo zoneinfo de Python, el objeto Intl de JavaScript y casi cualquier librería de fechas producen una cadena TZID sin problema; escribir un VTIMEZONE completo con sus desplazamientos y sus RRULE es otro trabajo, y los scripts se lo saltan.
- El archivo se editó a mano y el bloque VTIMEZONE, que parece relleno, acabó recortado. O un paso de conversión reescribió los eventos sin arrastrar las definiciones.
La ausencia deja de ser inofensiva en cuanto el TZID no es un nombre que el cliente reconozca. Los identificadores estilo Windows, como "Romance Standard Time", aparecen en archivos exportados desde productos de Microsoft, y un cliente que solo tiene la base IANA y no dispone de un VTIMEZONE al que recurrir no puede resolverlos.
El tamaño del desfase como diagnóstico
Las horas que se ha movido el evento acotan la causa más rápido que leerse el archivo.
- Una hora exacta y solo durante parte del año: desajuste de horario de verano. O las reglas del VTIMEZONE están desfasadas, o el archivo fijó una hora UTC con el desplazamiento estacional equivocado.
- Un número entero de horas igual a tu desplazamiento respecto a UTC: una hora flotante se ha leído como UTC, o al revés. Es la confusión clásica entre la forma 1 y la forma 2.
- Un número entero de horas igual a la diferencia entre dos ciudades: se ha ignorado el TZID y el cliente ha puesto su zona por defecto.
- Todos los eventos desplazados lo mismo: problema del archivo entero, casi seguro un TZID ausente o irresoluble.
- Solo algunos desplazados: el archivo mezcla formas, algo habitual cuando los eventos venían de varios orígenes.
- Un desfase que no son horas enteras: mira si hay alguna zona con desplazamiento de 30 o 45 minutos (India, Nepal, partes de Australia) antes de dar el archivo por corrupto.
La trampa del horario de verano en los eventos recurrentes
Una reunión semanal escrita como DTSTART;TZID=Europe/Madrid:20260115T090000 con RRULE:FREQ=WEEKLY se queda a las 09:00 locales todo el año. La misma escrita como DTSTART:20260115T080000Z está a las 09:00 en enero y a las 10:00 desde el último domingo de marzo, porque UTC no aplica horario de verano y el desplazamiento local cambió por debajo.
Por eso pasarlo todo a UTC es un buen arreglo para eventos puntuales y uno malo para series. Un evento recurrente que deba mantener su hora de reloj necesita la forma 3 con un VTIMEZONE en condiciones. Y cuando DTSTART lleva TZID, el valor UNTIL de la RRULE debe ir en UTC terminado en Z; los archivos que fallan aquí se importan como una única repetición.
Outlook y Google reaccionan distinto
Los dos clientes dominantes adivinan cosas distintas, y por eso un evento se ve bien en el calendario de un compañero y mal en el tuyo.
Outlook trata el bloque VTIMEZONE como la fuente autorizada. Si el archivo define la zona, usa esos desplazamientos y esas reglas aunque contradigan su propia base de datos, lo que convierte un VTIMEZONE desactualizado en algo peligroso: los eventos caen en el desplazamiento que dice el archivo, no en el que toca hoy. Trabaja además de forma nativa con los nombres de zona de Windows, así que un nombre que no esté en ninguna de las dos listas acaba en la zona del buzón. Una hora flotante la lee como hora local del usuario.
Google Calendar se apoya en su propia copia de la base IANA. Un TZID válido se resuelve bien haya o no haya VTIMEZONE, y de ahí que archivos técnicamente inválidos entren sin problema en Google y luego revienten en Outlook. Un TZID que no reconoce lo ignora en silencio y coloca el evento en la zona del propio calendario, así que nada te avisa. Una hora flotante la interpreta en la zona configurada en el calendario, no en la del navegador.
Existe también X-WR-TIMEZONE, una propiedad no estándar que Google escribe al principio de los archivos que exporta para dejar constancia de la zona del calendario. No está en la RFC 5545: unos clientes la respetan como valor por defecto para las horas flotantes y otros la ignoran. Es una pista, no una especificación.
Cómo arreglar un archivo
- Abre el .ics en un editor de texto y mira una línea DTSTART. Eso te dice cuál de las tres formas usa el archivo.
- Si hay TZID, busca BEGIN:VTIMEZONE. Si no está, añade la definición o cambia el identificador por un nombre IANA que los clientes puedan resolver solos.
- Sustituye los nombres de Windows por sus equivalentes IANA: "Romance Standard Time" pasa a Europe/Madrid, "Eastern Standard Time" a America/New_York.
- Para eventos puntuales sin zona, decide qué horas se querían decir y reescríbelas en UTC con la Z final.
- Para eventos recurrentes, mantén TZID más un VTIMEZONE válido y comprueba que todo UNTIL acabe en Z.
- Revisa DTEND igual que DTSTART. Si uno lleva TZID y el otro no, salen duraciones imposibles.
Si prefieres no editar texto iCalendar a mano, iCalConverter.com tiene una herramienta de corrección de zonas horarias que hace esa pasada sola: mapea los identificadores de Windows a nombres IANA, genera los VTIMEZONE correspondientes y puede normalizar un archivo entero a una zona concreta o a UTC, todo en el navegador.
En resumen
Mira DTSTART antes que nada. Sin Z y sin TZID, el archivo nunca dijo qué significaban esas horas y cada cliente adivinará distinto. Un TZID sin VTIMEZONE funciona hasta que llega a un cliente incapaz de resolver el nombre. Y un recurrente guardado en UTC se desplaza una hora cuando cambia la hora.