diff --git a/lib/Dashboard/CalendarWidget.php b/lib/Dashboard/CalendarWidget.php index 38ff358740..83217e9ba7 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; @@ -133,22 +134,31 @@ 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()) { + $displayStartDate = $this->normalizeDisplayDateTime( + $objectStartDate, + $dateTime->getTimezone(), + $object['DTSTART'], + ); $recurrence = $object; + $startDate = $objectStartDate; break; } } - if ($recurrence === null) { + if ($recurrence === null || $startDate === null) { continue; } $widget = new WidgetItem( $recurrence['SUMMARY'][0] ?? 'New Event', - $this->dateTimeFormatter->formatTimeSpan(DateTime::createFromImmutable($startDate)), + $this->dateTimeFormatter->formatTimeSpan( + DateTime::createFromImmutable($displayStartDate), + DateTime::createFromImmutable($dateTime), + ), $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute('calendar.view.index', ['objectId' => $calendarEvent['uid']])), $this->getCalendarDotIconUrl($calendar->getDisplayColor()), (string)$startDate->getTimestamp(), @@ -164,6 +174,68 @@ 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; + } + + /** + * Use one timezone for relative-day comparison. Timed values represent an + * instant and are converted; all-day values represent a calendar date and + * must retain that date when attached to the display timezone. + * + * @param array{0: DateTimeImmutable, 1?: array} $dateTimeProperty + */ + private function normalizeDisplayDateTime( + DateTimeImmutable $dateTime, + DateTimeZone $displayTimeZone, + array $dateTimeProperty, + ): DateTimeImmutable { + $parameters = $dateTimeProperty[1] ?? []; + $valueType = isset($parameters['VALUE']) ? (string)$parameters['VALUE'] : ''; + + if (strcasecmp($valueType, 'DATE') !== 0) { + return $dateTime->setTimezone($displayTimeZone); + } + + $normalized = DateTimeImmutable::createFromFormat( + '!Y-m-d', + $dateTime->format('Y-m-d'), + $displayTimeZone, + ); + + 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..42bc0deb6b 100644 --- a/tests/php/unit/Dashbaord/CalendarWidgetTest.php +++ b/tests/php/unit/Dashbaord/CalendarWidgetTest.php @@ -9,6 +9,7 @@ namespace OCA\Calendar\Dashboard; use DateTimeImmutable; +use DateTimeZone; use OCA\Calendar\Service\JSDataService; use OCP\AppFramework\Services\IInitialState; use OCP\AppFramework\Utility\ITimeFactory; @@ -175,6 +176,168 @@ 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()), + self::callback(static fn (\DateTime $dateTime): bool => $dateTime->getTimestamp() === $time), + ) + ->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 testGetItemsFormatsRelativeDayInDashboardTimeZone(): void { + $previousTimeZone = date_default_timezone_get(); + date_default_timezone_set('America/Toronto'); + + try { + $userId = 'admin'; + $calendar = $this->createMock(ITestCalendar::class); + $now = new DateTimeImmutable('2026-09-08 23:30:00', new DateTimeZone('America/Toronto')); + $start = new DateTimeImmutable('2026-09-10 13:00:00 UTC'); + $rangeStart = (new DateTimeImmutable())->setTimestamp($now->getTimestamp()); + $options = [ + 'timerange' => [ + 'start' => $rangeStart, + 'end' => $rangeStart->add(new \DateInterval('P14D')), + ], + ]; + $result = [ + 'id' => '3602', + 'uid' => 'late-evening-relative-day', + 'uri' => 'late-evening-relative-day.ics', + 'objects' => [[ + 'DTSTART' => [$start], + 'SUMMARY' => ['Physio'], + ]], + ]; + + $this->calendarManager->expects(self::once()) + ->method('getCalendarsForPrincipal') + ->with('principals/users/' . $userId) + ->willReturn([$calendar]); + $this->timeFactory->expects(self::once()) + ->method('getTime') + ->willReturn($now->getTimestamp()); + $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->format('Y-m-d H:i:s e') === '2026-09-10 09:00:00 America/Toronto'), + self::callback(static fn (\DateTime $dateTime): bool => $dateTime->format('Y-m-d H:i:s e') === '2026-09-08 23:30:00 America/Toronto'), + ) + ->willReturn('in 2 days'); + $this->urlGenerator->expects(self::once()) + ->method('getAbsoluteURL') + ->willReturn('late-evening-relative-day'); + + $widgets = $this->widget->getItems($userId); + + $this->assertCount(1, $widgets); + $this->assertSame('in 2 days', $widgets[0]->getSubtitle()); + $this->assertSame((string)$start->getTimestamp(), $widgets[0]->getSinceId()); + } finally { + date_default_timezone_set($previousTimeZone); + } + } + + 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 testDisplayNormalizationKeepsAllDayCalendarDate(): void { + $start = new DateTimeImmutable('2026-09-10 00:00:00 UTC'); + $displayTimeZone = new DateTimeZone('America/Toronto'); + $normalized = self::invokePrivate($this->widget, 'normalizeDisplayDateTime', [ + $start, + $displayTimeZone, + [ + $start, + ['VALUE' => 'DATE'], + ], + ]); + + $this->assertSame('2026-09-10 00:00:00 America/Toronto', $normalized->format('Y-m-d H:i:s e')); + $this->assertSame('2026-09-10', $start->format('Y-m-d')); + } + public function testGetItemsCachesCalendarDotPerRequest(): void { $userId = 'admin'; $calendarA = $this->createMock(ITestCalendar::class);