Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 40 additions & 4 deletions lib/Dashboard/CalendarWidget.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -146,16 +147,17 @@
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;
}

Expand All @@ -177,6 +179,40 @@
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<string, mixed>} $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));

Check failure on line 206 in lib/Dashboard/CalendarWidget.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis dev-stable34

ArgumentTypeCoercion

lib/Dashboard/CalendarWidget.php:206:32: ArgumentTypeCoercion: Argument 1 of DateTimeZone::__construct expects non-empty-string, but parent type string provided (see https://psalm.dev/193)

Check failure on line 206 in lib/Dashboard/CalendarWidget.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis dev-stable33

ArgumentTypeCoercion

lib/Dashboard/CalendarWidget.php:206:32: ArgumentTypeCoercion: Argument 1 of DateTimeZone::__construct expects non-empty-string, but parent type string provided (see https://psalm.dev/193)

Check failure on line 206 in lib/Dashboard/CalendarWidget.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis dev-master

ArgumentTypeCoercion

lib/Dashboard/CalendarWidget.php:206:32: ArgumentTypeCoercion: Argument 1 of DateTimeZone::__construct expects non-empty-string, but parent type string provided (see https://psalm.dev/193)

Check failure on line 206 in lib/Dashboard/CalendarWidget.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis dev-stable32

ArgumentTypeCoercion

lib/Dashboard/CalendarWidget.php:206:32: ArgumentTypeCoercion: Argument 1 of DateTimeZone::__construct expects non-empty-string, but parent type string provided (see https://psalm.dev/193)
$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';
Expand Down
76 changes: 76 additions & 0 deletions tests/php/unit/Dashbaord/CalendarWidgetTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading