diff --git a/src/CalendarLink/CHANGELOG.md b/src/CalendarLink/CHANGELOG.md index be28be539c9..283b9feca6e 100644 --- a/src/CalendarLink/CHANGELOG.md +++ b/src/CalendarLink/CHANGELOG.md @@ -3,6 +3,7 @@ ## 3.5 - Make `IcsBuilder` internal +- Serialize timed events in a named time zone with `;TZID=` and a matching `VTIMEZONE`, so recurring events no longer drift by an hour across DST ## 3.1 diff --git a/src/CalendarLink/src/Ics/IcsBuilder.php b/src/CalendarLink/src/Ics/IcsBuilder.php index fbbd2a0c281..d1c833192a2 100644 --- a/src/CalendarLink/src/Ics/IcsBuilder.php +++ b/src/CalendarLink/src/Ics/IcsBuilder.php @@ -24,6 +24,7 @@ final class IcsBuilder { private const CRLF = "\r\n"; private const PRODID = '-//Symfony//UX Calendar Link//EN'; + private const TWO_YEARS = 63072000; private readonly UuidFactory $uuidFactory; @@ -40,6 +41,7 @@ public function build(CalendarEvent $event): string 'PRODID:'.self::PRODID, 'CALSCALE:GREGORIAN', 'METHOD:PUBLISH', + ...$this->formatVtimezones($event), 'BEGIN:VEVENT', 'UID:'.$this->uuidFactory->create()->toRfc4122(), 'DTSTAMP:'.$this->formatUtc(new \DateTimeImmutable('now', new \DateTimeZone('UTC'))), @@ -93,11 +95,158 @@ private function formatDateLines(CalendarEvent $event): array } return [ - 'DTSTART:'.$this->formatUtc($event->start), - 'DTEND:'.$this->formatUtc($event->end), + $this->formatTimedLine('DTSTART', $event->start), + $this->formatTimedLine('DTEND', $event->end), ]; } + /** + * A region time zone is serialized as local time anchored with `;TZID=`, so a recurring + * local time (e.g. 09:00) stays fixed across DST instead of drifting by an hour. UTC and + * fixed offsets carry no DST, so the lossless `...Z` form is kept. + */ + private function formatTimedLine(string $property, \DateTimeImmutable $dt): string + { + $tz = $dt->getTimezone(); + + if ($this->isRegionZone($tz)) { + return $property.';TZID='.$tz->getName().':'.$dt->format('Ymd\THis'); + } + + return $property.':'.$this->formatUtc($dt); + } + + /** + * @return list + */ + private function formatVtimezones(CalendarEvent $event): array + { + $lines = []; + foreach ($this->timezones($event) as $timezone) { + array_push($lines, ...$this->formatVtimezone($timezone, $event->start)); + } + + return $lines; + } + + /** + * @return list<\DateTimeZone> + */ + private function timezones(CalendarEvent $event): array + { + if ($event->allDay) { + return []; + } + + $timezones = []; + foreach ([$event->start->getTimezone(), $event->end->getTimezone()] as $tz) { + if ($this->isRegionZone($tz)) { + $timezones[$tz->getName()] = $tz; + } + } + + return array_values($timezones); + } + + private function isRegionZone(\DateTimeZone $tz): bool + { + $name = $tz->getName(); + + // UTC and fixed numeric offsets (e.g. "+02:00") carry no DST, so no VTIMEZONE is needed. + return 'UTC' !== $name && !preg_match('/^[+-]\d{2}:\d{2}$/', $name); + } + + /** + * @return list + */ + private function formatVtimezone(\DateTimeZone $tz, \DateTimeImmutable $reference): array + { + $lines = ['BEGIN:VTIMEZONE', 'TZID:'.$tz->getName()]; + + foreach ($this->timezoneRules($tz, $reference) as $rule) { + array_push($lines, ...$rule); + } + + $lines[] = 'END:VTIMEZONE'; + + return $lines; + } + + /** + * The STANDARD and DAYLIGHT rules in effect around $reference, each expressed as a yearly + * recurring transition so the VTIMEZONE stays valid for open-ended recurrences. + * + * @return list> + */ + private function timezoneRules(\DateTimeZone $tz, \DateTimeImmutable $reference): array + { + $refTs = $reference->getTimestamp(); + $transitions = $tz->getTransitions($refTs - self::TWO_YEARS, $refTs + self::TWO_YEARS); + + // Keep the most recent STANDARD and DAYLIGHT transitions; they define the yearly rule. + $latest = []; + for ($i = 1, $count = \count($transitions); $i < $count; ++$i) { + $type = $transitions[$i]['isdst'] ? 'DAYLIGHT' : 'STANDARD'; + $latest[$type] = [$transitions[$i], $transitions[$i - 1]]; + } + + if ([] === $latest) { + // A zone without DST transitions: a single fixed STANDARD offset. + return [$this->timezoneRule('STANDARD', $transitions[0], $transitions[0], false)]; + } + + $rules = []; + foreach ($latest as $type => [$transition, $previous]) { + $rules[] = $this->timezoneRule($type, $transition, $previous, true); + } + + return $rules; + } + + /** + * @param array{ts: int, time: string, offset: int, isdst: bool, abbr: string} $transition + * @param array{ts: int, time: string, offset: int, isdst: bool, abbr: string} $previous + * + * @return list + */ + private function timezoneRule(string $type, array $transition, array $previous, bool $recurring): array + { + $onset = $transition['ts'] + $previous['offset']; + + $lines = [ + 'BEGIN:'.$type, + 'DTSTART:'.gmdate('Ymd\THis', $onset), + 'TZOFFSETFROM:'.$this->formatOffset($previous['offset']), + 'TZOFFSETTO:'.$this->formatOffset($transition['offset']), + 'TZNAME:'.$transition['abbr'], + ]; + + if ($recurring) { + $lines[] = 'RRULE:'.$this->yearlyRule($onset); + } + + $lines[] = 'END:'.$type; + + return $lines; + } + + private function yearlyRule(int $onset): string + { + $days = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA']; + $dayOfMonth = (int) gmdate('j', $onset); + $ordinal = $dayOfMonth + 7 > (int) gmdate('t', $onset) ? -1 : (int) ceil($dayOfMonth / 7); + + return \sprintf('FREQ=YEARLY;BYMONTH=%d;BYDAY=%d%s', (int) gmdate('n', $onset), $ordinal, $days[(int) gmdate('w', $onset)]); + } + + private function formatOffset(int $seconds): string + { + $sign = $seconds < 0 ? '-' : '+'; + $seconds = abs($seconds); + + return \sprintf('%s%02d%02d', $sign, intdiv($seconds, 3600), intdiv($seconds % 3600, 60)); + } + /** * @return list */ diff --git a/src/CalendarLink/tests/Ics/IcsBuilderTest.php b/src/CalendarLink/tests/Ics/IcsBuilderTest.php index 7572618e65f..88c112bc9f1 100644 --- a/src/CalendarLink/tests/Ics/IcsBuilderTest.php +++ b/src/CalendarLink/tests/Ics/IcsBuilderTest.php @@ -79,6 +79,54 @@ public function testAllDayEventUsesValueDateAndIncrementsEnd() $this->assertStringContainsString("DTEND;VALUE=DATE:20260516\r\n", $ics); } + public function testTimedEventInNamedZoneUsesTzidInsteadOfUtc() + { + $event = new CalendarEvent( + title: 'Standup', + start: new \DateTimeImmutable('2026-07-01 09:00', new \DateTimeZone('Europe/Paris')), + end: new \DateTimeImmutable('2026-07-01 09:30', new \DateTimeZone('Europe/Paris')), + ); + + $ics = $this->builder->build($event); + + $this->assertStringContainsString("DTSTART;TZID=Europe/Paris:20260701T090000\r\n", $ics); + $this->assertStringContainsString("DTEND;TZID=Europe/Paris:20260701T093000\r\n", $ics); + } + + public function testNamedZoneEmitsVtimezoneWithDstRules() + { + $event = new CalendarEvent( + title: 'Standup', + start: new \DateTimeImmutable('2026-07-01 09:00', new \DateTimeZone('Europe/Paris')), + end: new \DateTimeImmutable('2026-07-01 09:30', new \DateTimeZone('Europe/Paris')), + recurrence: CalendarRecurrence::weekly(), + ); + + $ics = $this->builder->build($event); + + $this->assertStringContainsString("BEGIN:VTIMEZONE\r\nTZID:Europe/Paris\r\n", $ics); + $this->assertStringContainsString("BEGIN:DAYLIGHT\r\n", $ics); + $this->assertStringContainsString("TZOFFSETTO:+0200\r\n", $ics); + $this->assertStringContainsString("RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU\r\n", $ics); + $this->assertStringContainsString("BEGIN:STANDARD\r\n", $ics); + $this->assertStringContainsString("TZOFFSETTO:+0100\r\n", $ics); + $this->assertStringContainsString("RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU\r\n", $ics); + } + + public function testUtcEventDoesNotEmitVtimezone() + { + $event = new CalendarEvent( + title: 'Demo', + start: new \DateTimeImmutable('2026-05-14 09:00', new \DateTimeZone('UTC')), + end: new \DateTimeImmutable('2026-05-14 10:00', new \DateTimeZone('UTC')), + ); + + $ics = $this->builder->build($event); + + $this->assertStringNotContainsString('BEGIN:VTIMEZONE', $ics); + $this->assertStringContainsString("DTSTART:20260514T090000Z\r\n", $ics); + } + public function testTextEscaping() { $event = new CalendarEvent(