From 57d92571f9fbd3b7b12efcba8c942be7f3aad7f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roman=20O=C5=BEana?= Date: Mon, 28 Sep 2026 20:41:26 +0200 Subject: [PATCH 1/2] fix(timezone): compare the UTC UNTIL of VTIMEZONE rules in UTC TimezoneGuesser expanded observance rules with local times read as UTC, but UNTIL is in UTC (RFC 5545, section 3.3.10). East of UTC the last transition of a rule that ended was dropped, so e.g. Europe/Moscow before 2011 under a non-IANA TZID did not resolve; west of UTC a transition after UNTIL could be kept. Rules are now expanded in the fixed TZOFFSETFROM offset, which gives UTC timestamps. Across all PHP timezones and three ranges, definitions from VTimezoneBuilder under a renamed TZID now resolve to the same transitions (203 of 5028 cases failed before). Closes #110 --- CHANGELOG.md | 2 ++ src/TimezoneGuesser.php | 29 +++++++++++++------- tests/Unit/Timezone/resolvers.phpt | 32 ++++++++++++++++++++++ tests/Unit/Timezone/vtimezone-builder.phpt | 27 ++++++++++++++++++ 4 files changed, 80 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e90f504..35a5c74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -80,6 +80,8 @@ See [UPGRADING.md](UPGRADING.md). The fixes of 4.1.4 (#37, #88) are included. ### Fixed +- VTIMEZONE rules with `UNTIL` are compared in UTC: definitions east of UTC whose daylight saving time ended (e.g. Europe/Moscow + before 2011) are resolved again, west of UTC no transition after `UNTIL` is kept (#110) - `RDATE` without `RRULE` no longer fails with `TypeError` and adds no yearly occurrences (#37, also in 4.1.4) - `RDATE` values are always part of the recurrence set (one was lost together with `COUNT`) - a `RECURRENCE-ID` replaces only the matching instance of the same UID, compared as an instant in any timezone diff --git a/src/TimezoneGuesser.php b/src/TimezoneGuesser.php index 42b29f6..6c31147 100644 --- a/src/TimezoneGuesser.php +++ b/src/TimezoneGuesser.php @@ -60,26 +60,26 @@ public static function guess(array $observances, ?DateTimeImmutable $reference = * @return array{int, list} */ private static function transitions(array $observances, int $from, int $until): array { - $utc = new DateTimeZone('UTC'); $all = []; foreach ($observances as $observance) { $offsetFrom = self::offset($observance['offsetFrom']); $offsetTo = self::offset($observance['offsetTo']); - // wall-clock times are expanded as if they were UTC, then shifted by TZOFFSETFROM - $start = new DateTimeImmutable($observance['start'], $utc); - $local = []; + // local times are in TZOFFSETFROM; the UNTIL of a rule is in UTC (RFC 5545, section 3.3.10), + // so the rule is expanded in the fixed offset and gives UTC timestamps + $zone = self::fixedOffsetZone($offsetFrom); + $start = new DateTimeImmutable($observance['start'], $zone); + $instants = [$start->getTimestamp()]; if (!empty($observance['rrule'])) { + $instants = []; foreach (new Expander(Rule::fromString($observance['rrule'], true), $start, $until + 2 * 86400) as $timestamp) { - $local[] = $timestamp; // includes DTSTART + $instants[] = $timestamp; // includes DTSTART } - } else { - $local[] = $start->getTimestamp(); } foreach ($observance['rdates'] ?? [] as $rdate) { - $local[] = (new DateTimeImmutable($rdate, $utc))->getTimestamp(); + $instants[] = (new DateTimeImmutable($rdate, $zone))->getTimestamp(); } - foreach ($local as $timestamp) { - $all[] = [$timestamp - $offsetFrom, $offsetTo]; + foreach ($instants as $timestamp) { + $all[] = [$timestamp, $offsetTo]; } } usort($all, static fn(array $a, array $b): int => $a[0] <=> $b[0]); @@ -145,6 +145,15 @@ private static function fixedOffset(int $offset): DateTimeZone { return new DateTimeZone(sprintf('%s%02d:%02d', $sign, intdiv($offset, 3600), intdiv($offset % 3600, 60))); } + /** + * Timezone with a fixed offset, e.g. "+03:00"; offsets with seconds are rounded to minutes, + * which PHP timezones cannot represent. + */ + private static function fixedOffsetZone(int $offset): DateTimeZone { + $minutes = intdiv(abs($offset) + 30, 60); + return new DateTimeZone(sprintf('%s%02d:%02d', $offset < 0 ? '-' : '+', intdiv($minutes, 60), $minutes % 60)); + } + /** * Parse a UTC-OFFSET value ("+0100", "-053000"). */ diff --git a/tests/Unit/Timezone/resolvers.phpt b/tests/Unit/Timezone/resolvers.phpt index 8278d9d..7ccbb8e 100644 --- a/tests/Unit/Timezone/resolvers.phpt +++ b/tests/Unit/Timezone/resolvers.phpt @@ -113,6 +113,38 @@ test('A definition with RDATE transitions', function () { Assert::same(3600, (new DateTimeImmutable('2026-12-01', $resolved->timezone))->getOffset()); }); +test('UNTIL of an observance is in UTC, also east of UTC (#110)', function () { + // Moscow 2008-2013 under a name the IANA resolver does not know + $calendar = calendarWith(implode("\r\n", [ + 'BEGIN:VTIMEZONE', 'TZID:Custom/Moscow', + 'BEGIN:STANDARD', 'DTSTART:19961027T030000', 'TZOFFSETFROM:+0400', 'TZOFFSETTO:+0300', 'RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU;UNTIL=20101030T230000Z', 'END:STANDARD', + 'BEGIN:DAYLIGHT', 'DTSTART:19930328T020000', 'TZOFFSETFROM:+0300', 'TZOFFSETTO:+0400', 'RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU;UNTIL=20100327T230000Z', 'END:DAYLIGHT', + 'BEGIN:STANDARD', 'DTSTART:20110327T020000', 'TZOFFSETFROM:+0300', 'TZOFFSETTO:+0400', 'END:STANDARD', + 'BEGIN:STANDARD', 'DTSTART:20141026T020000', 'TZOFFSETFROM:+0400', 'TZOFFSETTO:+0300', 'END:STANDARD', + 'END:VTIMEZONE', + ])); + foreach (['2010-06-01', '2011-06-01'] as $reference) { + $resolved = (new VTimezoneResolver(reference: new DateTimeImmutable($reference)))->resolve('Custom/Moscow', $calendar); + Assert::notNull($resolved, $reference); + Assert::same(TimezoneSource::VTimezone, $resolved->source); + foreach (['2010-03-27 12:00' => 10800, '2010-07-01' => 14400, '2010-12-01' => 10800, '2011-07-01' => 14400, '2012-01-01' => 14400] as $date => $offset) { + Assert::same($offset, (new DateTimeImmutable($date, $resolved->timezone))->getOffset(), "$reference $date"); + } + } +}); + +test('UNTIL of an observance is in UTC, west of UTC no transition after it is kept (#110)', function () { + // daylight time until 2009: 2010-03-14 02:00 local is 07:00 UTC, after UNTIL + $calendar = calendarWith(implode("\r\n", [ + 'BEGIN:VTIMEZONE', 'TZID:Custom/Eastern', + 'BEGIN:STANDARD', 'DTSTART:20071104T020000', 'TZOFFSETFROM:-0400', 'TZOFFSETTO:-0500', 'RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU', 'END:STANDARD', + 'BEGIN:DAYLIGHT', 'DTSTART:20070311T020000', 'TZOFFSETFROM:-0500', 'TZOFFSETTO:-0400', 'RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU;UNTIL=20100314T030000Z', 'END:DAYLIGHT', + 'END:VTIMEZONE', + ])); + $resolved = (new VTimezoneResolver(reference: new DateTimeImmutable('2009-06-01')))->resolve('Custom/Eastern', $calendar); + Assert::null($resolved); // not America/New_York, which has daylight time in 2010 +}); + test('Invalid definitions are not resolved', function () { $calendar = calendarWith("BEGIN:VTIMEZONE\r\nTZID:Broken\r\nBEGIN:STANDARD\r\nDTSTART:x\r\nTZOFFSETFROM:+01\r\nTZOFFSETTO:bad\r\nEND:STANDARD\r\nEND:VTIMEZONE"); Assert::null((new VTimezoneResolver())->resolve('Broken', $calendar)); diff --git a/tests/Unit/Timezone/vtimezone-builder.phpt b/tests/Unit/Timezone/vtimezone-builder.phpt index 542c46d..854bbcf 100644 --- a/tests/Unit/Timezone/vtimezone-builder.phpt +++ b/tests/Unit/Timezone/vtimezone-builder.phpt @@ -9,6 +9,7 @@ use om\ICal\Component; use om\ICal\Event; use om\ICal\Serializer; use om\ICal\Timezone\VTimezoneBuilder; +use om\ICal\Timezone\VTimezoneResolver; use om\ICal\Value\UtcOffset; use om\RRule\Expander; use om\RRule\Rule; @@ -165,3 +166,29 @@ test('forComponents(): a definition for every TZID used, for the range of the da Assert::same(['Europe/Prague', 'America/New_York'], array_map(fn(Component $c) => $c->property('TZID')->value, $definitions)); Assert::same([], VTimezoneBuilder::forComponents([new Component('VEVENT', [om\ICal\Property::create('DTSTART', '20260105T093000', ['TZID' => 'Custom Zone'])])]), 'only IANA timezones'); }); + +test('Definitions with UNTIL resolve east and west of UTC under another TZID (#110)', function () { + $offsets = function (DateTimeZone $timezone, int $from, int $to): array { + $result = []; + foreach (array_slice($timezone->getTransitions($from, $to) ?: [], 1) as $transition) { + if (($result === [] ? null : end($result)[1]) !== $transition['offset']) { + $result[] = [$transition['ts'], $transition['offset']]; + } + } + return $result; + }; + foreach (['Europe/Moscow' => [2008, 2013], 'Europe/Minsk' => [2008, 2013], 'America/Sao_Paulo' => [2015, 2022], 'Europe/Istanbul' => [2013, 2019]] as $name => [$first, $last]) { + $timezone = new DateTimeZone($name); + $vtimezone = VTimezoneBuilder::build($timezone, new DateTimeImmutable("$first-01-01", $timezone), new DateTimeImmutable("$last-12-31", $timezone), 'Custom/Zone'); + $vtimezone = new Component('VTIMEZONE', array_values(array_filter($vtimezone->properties, fn($property) => $property->name !== 'X-LIC-LOCATION')), $vtimezone->components); + Assert::contains('UNTIL=', Serializer::serialize($vtimezone), $name); + $calendar = new Component('VCALENDAR', [], [$vtimezone]); + for ($year = $first + 1; $year <= $last - 2; $year++) { + $resolved = (new VTimezoneResolver(reference: new DateTimeImmutable("$year-06-01")))->resolve('Custom/Zone', $calendar); + Assert::notNull($resolved, "$name $year"); + $from = gmmktime(0, 0, 0, 1, 1, $year - 1); + $to = gmmktime(0, 0, 0, 1, 1, $year + 2); + Assert::same($offsets($timezone, $from, $to), $offsets($resolved->timezone, $from, $to), "$name $year"); + } + } +}); From e4f111892738a917f35b81dc31fc01028022b374 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roman=20O=C5=BEana?= Date: Mon, 28 Sep 2026 20:58:43 +0200 Subject: [PATCH 2/2] fix(timezone): keep the seconds of TZOFFSETFROM when expanding observances The fixed-offset zone rounded local mean time offsets like +001932 to minutes, so every transition was off by the rounding and no IANA timezone matched (e.g. Europe/Amsterdam 1934-1940). PHP timezones keep seconds. Refs #110 --- src/TimezoneGuesser.php | 8 ++++---- tests/Unit/Timezone/vtimezone-builder.phpt | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/TimezoneGuesser.php b/src/TimezoneGuesser.php index 6c31147..d14ffbd 100644 --- a/src/TimezoneGuesser.php +++ b/src/TimezoneGuesser.php @@ -146,12 +146,12 @@ private static function fixedOffset(int $offset): DateTimeZone { } /** - * Timezone with a fixed offset, e.g. "+03:00"; offsets with seconds are rounded to minutes, - * which PHP timezones cannot represent. + * Timezone with a fixed offset, e.g. "+03:00", or "+00:19:32" for a local mean time. */ private static function fixedOffsetZone(int $offset): DateTimeZone { - $minutes = intdiv(abs($offset) + 30, 60); - return new DateTimeZone(sprintf('%s%02d:%02d', $offset < 0 ? '-' : '+', intdiv($minutes, 60), $minutes % 60)); + $seconds = abs($offset); + $name = sprintf('%s%02d:%02d', $offset < 0 ? '-' : '+', intdiv($seconds, 3600), intdiv($seconds % 3600, 60)); + return new DateTimeZone($seconds % 60 === 0 ? $name : sprintf('%s:%02d', $name, $seconds % 60)); } /** diff --git a/tests/Unit/Timezone/vtimezone-builder.phpt b/tests/Unit/Timezone/vtimezone-builder.phpt index 854bbcf..e35eb07 100644 --- a/tests/Unit/Timezone/vtimezone-builder.phpt +++ b/tests/Unit/Timezone/vtimezone-builder.phpt @@ -177,7 +177,7 @@ test('Definitions with UNTIL resolve east and west of UTC under another TZID (#1 } return $result; }; - foreach (['Europe/Moscow' => [2008, 2013], 'Europe/Minsk' => [2008, 2013], 'America/Sao_Paulo' => [2015, 2022], 'Europe/Istanbul' => [2013, 2019]] as $name => [$first, $last]) { + foreach (['Europe/Moscow' => [2008, 2013], 'Europe/Minsk' => [2008, 2013], 'America/Sao_Paulo' => [2015, 2022], 'Europe/Istanbul' => [2013, 2019], 'Europe/Amsterdam' => [1934, 1940]] as $name => [$first, $last]) { $timezone = new DateTimeZone($name); $vtimezone = VTimezoneBuilder::build($timezone, new DateTimeImmutable("$first-01-01", $timezone), new DateTimeImmutable("$last-12-31", $timezone), 'Custom/Zone'); $vtimezone = new Component('VTIMEZONE', array_values(array_filter($vtimezone->properties, fn($property) => $property->name !== 'X-LIC-LOCATION')), $vtimezone->components);