diff --git a/lib/Dashboard/CalendarWidget.php b/lib/Dashboard/CalendarWidget.php index f3974e2b21..664e03cc6f 100644 --- a/lib/Dashboard/CalendarWidget.php +++ b/lib/Dashboard/CalendarWidget.php @@ -11,6 +11,7 @@ use DateInterval; use DateTime; use DateTimeImmutable; +use DateTimeZone; use OCA\Calendar\AppInfo\Application; use OCA\Calendar\Service\JSDataService; use OCP\AppFramework\Services\IInitialState; @@ -146,16 +147,17 @@ public function getItems(string $userId, ?string $since = null, int $limit = 7): foreach ($searchResult as $calendarEvent) { // Find first recurrence in the future $recurrence = null; + $startDate = null; foreach ($calendarEvent['objects'] as $object) { - /** @var DateTimeImmutable $startDate */ - $startDate = $object['DTSTART'][0]; - if ($startDate->getTimestamp() >= $dateTime->getTimestamp()) { + $objectStartDate = $this->normalizeFixedOffsetDateTime($object['DTSTART']); + if ($objectStartDate->getTimestamp() >= $dateTime->getTimestamp()) { $recurrence = $object; + $startDate = $objectStartDate; break; } } - if ($recurrence === null) { + if ($recurrence === null || $startDate === null) { continue; } @@ -177,6 +179,40 @@ public function getItems(string $userId, ?string $since = null, int $limit = 7): return $widgetItems; } + /** + * Sabre VObject falls back to PHP's default timezone when it cannot resolve + * non-IANA fixed-offset TZIDs such as UTC-04:00. Preserve the parsed wall + * clock and attach the explicit offset before the widget emits a timestamp. + * + * @param array{0: DateTimeImmutable, 1?: array} $dateTimeProperty + */ + private function normalizeFixedOffsetDateTime(array $dateTimeProperty): DateTimeImmutable { + $dateTime = $dateTimeProperty[0]; + $parameters = $dateTimeProperty[1] ?? []; + $tzid = isset($parameters['TZID']) ? (string)$parameters['TZID'] : ''; + $valueType = isset($parameters['VALUE']) ? (string)$parameters['VALUE'] : ''; + + if (strcasecmp($valueType, 'DATE') === 0 + || preg_match('/^UTC([+-])(\d{2}):?(\d{2})$/i', $tzid, $matches) !== 1) { + return $dateTime; + } + + $hours = (int)$matches[2]; + $minutes = (int)$matches[3]; + if ($hours > 23 || $minutes > 59) { + return $dateTime; + } + + $timeZone = new DateTimeZone(sprintf('%s%02d:%02d', $matches[1], $hours, $minutes)); + $normalized = DateTimeImmutable::createFromFormat( + '!Y-m-d H:i:s.u', + $dateTime->format('Y-m-d H:i:s.u'), + $timeZone, + ); + + return $normalized ?: $dateTime; + } + private function getCalendarDotIconUrl(?string $color): string { $sanitizedColor = ltrim(trim((string)$color), '#'); $validColor = '#0082c9'; diff --git a/tests/php/unit/Dashbaord/CalendarWidgetTest.php b/tests/php/unit/Dashbaord/CalendarWidgetTest.php index 4a087b3f65..40e95f9657 100644 --- a/tests/php/unit/Dashbaord/CalendarWidgetTest.php +++ b/tests/php/unit/Dashbaord/CalendarWidgetTest.php @@ -175,6 +175,82 @@ public function testGetItems() : void { $this->assertEquals($widgets[0], $widget); } + public function testGetItemsNormalizesFixedOffsetTimeZone(): void { + $userId = 'admin'; + $calendar = $this->createMock(ITestCalendar::class); + $time = (new DateTimeImmutable('2026-07-28 04:30:00 UTC'))->getTimestamp(); + $rangeStart = (new DateTimeImmutable())->setTimestamp($time); + $backendStart = new DateTimeImmutable('2026-07-28 11:15:00 UTC'); + $expectedStart = new DateTimeImmutable('2026-07-28 11:15:00 -04:00'); + $options = [ + 'timerange' => [ + 'start' => $rangeStart, + 'end' => $rangeStart->add(new \DateInterval('P14D')), + ], + ]; + $result = [ + 'id' => '3601', + 'uid' => 'fixed-offset-event', + 'uri' => 'fixed-offset-event.ics', + 'objects' => [[ + 'DTSTART' => [ + $backendStart, + ['TZID' => 'UTC-04:00'], + ], + 'SUMMARY' => ['Fixed offset event'], + ]], + ]; + + $this->calendarManager->expects(self::once()) + ->method('getCalendarsForPrincipal') + ->with('principals/users/' . $userId) + ->willReturn([$calendar]); + $this->timeFactory->expects(self::once()) + ->method('getTime') + ->willReturn($time); + $calendar->expects(self::once()) + ->method('isEnabled') + ->willReturn(true); + $calendar->expects(self::once()) + ->method('isDeleted') + ->willReturn(false); + $calendar->expects(self::once()) + ->method('search') + ->with('', [], $options, 7) + ->willReturn([$result]); + $calendar->expects(self::once()) + ->method('getDisplayColor') + ->willReturn('#ffffff'); + $this->dateTimeFormatter->expects(self::once()) + ->method('formatTimeSpan') + ->with(self::callback(static fn (\DateTime $dateTime): bool => $dateTime->getTimestamp() === $expectedStart->getTimestamp())) + ->willReturn('in 10 hours'); + $this->urlGenerator->expects(self::once()) + ->method('getAbsoluteURL') + ->willReturn('fixed-offset-event'); + + $widgets = $this->widget->getItems($userId); + + $this->assertCount(1, $widgets); + $this->assertSame((string)$expectedStart->getTimestamp(), $widgets[0]->getSinceId()); + $this->assertSame('in 10 hours', $widgets[0]->getSubtitle()); + $this->assertSame($backendStart, $result['objects'][0]['DTSTART'][0]); + $this->assertSame('UTC-04:00', $result['objects'][0]['DTSTART'][1]['TZID']); + } + + public function testFixedOffsetNormalizationLeavesAllDayValueUnchanged(): void { + $start = new DateTimeImmutable('2026-07-28 00:00:00 UTC'); + $normalized = self::invokePrivate($this->widget, 'normalizeFixedOffsetDateTime', [[ + $start, + [ + 'TZID' => 'UTC-04:00', + 'VALUE' => 'DATE', + ], + ]]); + + $this->assertSame($start, $normalized); + } + public function testGetItemsCachesCalendarDotPerRequest(): void { $userId = 'admin'; $calendarA = $this->createMock(ITestCalendar::class);