From 5c74c2e66798eae154866106e1bfbbc47e18745f Mon Sep 17 00:00:00 2001 From: luxick Date: Fri, 3 Apr 2026 15:40:06 +0200 Subject: [PATCH] Fix timezone parsing for calendars --- src/CalendarService.php | 59 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 54 insertions(+), 5 deletions(-) diff --git a/src/CalendarService.php b/src/CalendarService.php index e7b9a9d..5abd9d4 100644 --- a/src/CalendarService.php +++ b/src/CalendarService.php @@ -138,12 +138,13 @@ class CalendarService if ($calendar === null) continue; try { + $uidTimezones = self::buildUidTimezoneMap($calendar); $expanded = $calendar->expand($rangeStart, $rangeEnd); if (!($expanded instanceof VCalendar)) continue; for ($day = 1; $day <= $daysInMonth; $day++) { $dateIso = sprintf('%04d-%02d-%02d', $year, $month, $day); - $events = self::collectFromCalendar($expanded, $slot->getKey(), $dateIso); + $events = self::collectFromCalendar($expanded, $slot->getKey(), $dateIso, $uidTimezones); $cacheKey = $slot->getKey() . '|' . $dateIso; self::$dayCache[$cacheKey] = $events; @@ -222,6 +223,28 @@ class CalendarService } } + /** + * Build a UID → TZID map from the original (non-expanded) calendar. + * + * VCalendar::expand() normalizes all timezone-aware datetimes to UTC, losing + * the original TZID. This map lets us restore the correct timezone afterwards. + * + * @param VCalendar $calendar + * @return array uid => tzid + */ + protected static function buildUidTimezoneMap(VCalendar $calendar): array + { + $map = []; + foreach ($calendar->select('VEVENT') as $vevent) { + if (!isset($vevent->DTSTART)) continue; + $uid = trim((string)($vevent->UID ?? '')); + if ($uid === '' || isset($map[$uid])) continue; + $tzid = (string)($vevent->DTSTART['TZID'] ?? ''); + if ($tzid !== '') $map[$uid] = $tzid; + } + return $map; + } + /** * Parse events from a local ICS file for a specific date. * @@ -236,6 +259,8 @@ class CalendarService if ($calendar === null) return []; try { + $uidTimezones = self::buildUidTimezoneMap($calendar); + $utc = new DateTimeZone('UTC'); $rangeStart = new DateTimeImmutable($dateIso . ' 00:00:00', $utc); $rangeStart = $rangeStart->sub(new DateInterval('P1D')); @@ -244,7 +269,7 @@ class CalendarService $expanded = $calendar->expand($rangeStart, $rangeEnd); if (!($expanded instanceof VCalendar)) return []; - return self::collectFromCalendar($expanded, $slotKey, $dateIso); + return self::collectFromCalendar($expanded, $slotKey, $dateIso, $uidTimezones); } catch (Throwable $e) { return []; } @@ -256,9 +281,10 @@ class CalendarService * @param VCalendar $calendar * @param string $slotKey * @param string $dateIso + * @param array $uidTimezones uid => tzid, for restoring timezone after expand() * @return CalendarEvent[] */ - protected static function collectFromCalendar(VCalendar $calendar, string $slotKey, string $dateIso): array + protected static function collectFromCalendar(VCalendar $calendar, string $slotKey, string $dateIso, array $uidTimezones = []): array { $result = []; $seen = []; @@ -266,7 +292,7 @@ class CalendarService // VEVENTs foreach ($calendar->select('VEVENT') as $vevent) { if (!($vevent instanceof VEvent)) continue; - $event = self::normalizeVEventForDay($vevent, $slotKey, $dateIso); + $event = self::normalizeVEventForDay($vevent, $slotKey, $dateIso, $uidTimezones); if ($event === null) continue; $dedupeKey = $event->uid . '|' . $event->recurrenceId . '|' . $event->startIso . '|' . $event->summary; @@ -285,9 +311,10 @@ class CalendarService * @param VEvent $vevent * @param string $slotKey * @param string $dateIso + * @param array $uidTimezones uid => tzid, for restoring timezone after expand() * @return CalendarEvent|null */ - protected static function normalizeVEventForDay(VEvent $vevent, string $slotKey, string $dateIso): ?CalendarEvent + protected static function normalizeVEventForDay(VEvent $vevent, string $slotKey, string $dateIso, array $uidTimezones = []): ?CalendarEvent { if (!isset($vevent->DTSTART)) return null; @@ -295,8 +322,30 @@ class CalendarService $start = self::toImmutable($vevent->DTSTART->getDateTime()); if ($start === null) return null; + // VCalendar::expand() normalizes all timezone-aware datetimes to UTC, losing + // the original TZID. Restore it using the pre-expansion UID→TZID map so that + // display times and day-boundary checks use the event's original timezone. + $restoredTz = null; + if (!$isAllDay && $uidTimezones !== [] && $start->getTimezone()->getName() === 'UTC') { + $uid = trim((string)($vevent->UID ?? '')); + $tzid = $uidTimezones[$uid] ?? ''; + if ($tzid !== '') { + try { + $restoredTz = new DateTimeZone($tzid); + $start = $start->setTimezone($restoredTz); + } catch (Throwable $e) { + $restoredTz = null; + } + } + } + $end = self::resolveEnd($vevent, $start, $isAllDay); + // Apply the same timezone restoration to the end datetime. + if ($restoredTz !== null && $end->getTimezone()->getName() === 'UTC') { + $end = $end->setTimezone($restoredTz); + } + if (!self::intersectsDay($start, $end, $isAllDay, $dateIso)) return null; $event = new CalendarEvent();