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..d14ffbd 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", or "+00:19:32" for a local mean time. + */ + private static function fixedOffsetZone(int $offset): DateTimeZone { + $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)); + } + /** * 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..e35eb07 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], '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); + 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"); + } + } +});