Skip to content
Closed
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
82 changes: 77 additions & 5 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 @@ -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(),
Expand All @@ -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<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));
$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<string, mixed>} $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';
Expand Down
163 changes: 163 additions & 0 deletions tests/php/unit/Dashbaord/CalendarWidgetTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down