Skip to content

Commit 737e3e7

Browse files
committed
feat(user_status): set busy status for single calendar events without attendees
Events without attendees (solo/personal blocks) now set IUserStatus::MESSAGE_CALENDAR_BUSY_SINGLE ("Busy") instead of MESSAGE_CALENDAR_BUSY ("In a meeting"). Events with attendees continue to set the meeting status. When both are present simultaneously, the meeting status takes priority. Assisted-by: ClaudeCode:claude-sonnet-4-6 Signed-off-by: Anna Larch <anna@nextcloud.com>
1 parent 8c58197 commit 737e3e7

5 files changed

Lines changed: 156 additions & 31 deletions

File tree

apps/dav/lib/CalDAV/Status/StatusService.php

Lines changed: 42 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ public function processCalendarStatus(string $userId): void {
6161
if (empty($calendarEvents)) {
6262
try {
6363
$this->userStatusService->revertUserStatus($userId, IUserStatus::MESSAGE_CALENDAR_BUSY);
64+
$this->userStatusService->revertUserStatus($userId, IUserStatus::MESSAGE_CALENDAR_BUSY_SINGLE);
6465
} catch (Exception $e) {
6566
if ($e->getReason() === Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) {
6667
// A different process might have written another status
@@ -92,32 +93,40 @@ public function processCalendarStatus(string $userId): void {
9293
return;
9394
}
9495

95-
// Filter events to see if we have any that apply to the calendar status
96-
$applicableEvents = array_filter($calendarEvents, static function (array $calendarEvent) use ($userStatusTimestamp): bool {
96+
// Filter events to see if we have any that apply to the calendar status,
97+
// and split them into meetings (with attendees) and solo busy events (no attendees).
98+
$meetingEvents = [];
99+
$singleEvents = [];
100+
foreach ($calendarEvents as $calendarEvent) {
97101
if (empty($calendarEvent['objects'])) {
98-
return false;
102+
continue;
99103
}
100104
$component = $calendarEvent['objects'][0];
101105
if (isset($component['X-NEXTCLOUD-OUT-OF-OFFICE'])) {
102-
return false;
106+
continue;
103107
}
104108
if (isset($component['DTSTART']) && $userStatusTimestamp !== null) {
105109
/** @var DateTimeImmutable $dateTime */
106110
$dateTime = $component['DTSTART'][0];
107111
if ($dateTime instanceof DateTimeImmutable && $userStatusTimestamp > $dateTime->getTimestamp()) {
108-
return false;
112+
continue;
109113
}
110114
}
111115
// Ignore events that are transparent
112116
if (isset($component['TRANSP']) && strcasecmp($component['TRANSP'][0], 'TRANSPARENT') === 0) {
113-
return false;
117+
continue;
118+
}
119+
if (!empty($component['ATTENDEE'])) {
120+
$meetingEvents[] = $calendarEvent;
121+
} else {
122+
$singleEvents[] = $calendarEvent;
114123
}
115-
return true;
116-
});
124+
}
117125

118-
if (empty($applicableEvents)) {
126+
if (empty($meetingEvents) && empty($singleEvents)) {
119127
try {
120128
$this->userStatusService->revertUserStatus($userId, IUserStatus::MESSAGE_CALENDAR_BUSY);
129+
$this->userStatusService->revertUserStatus($userId, IUserStatus::MESSAGE_CALENDAR_BUSY_SINGLE);
121130
} catch (Exception $e) {
122131
if ($e->getReason() === Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) {
123132
// A different process might have written another status
@@ -132,20 +141,30 @@ public function processCalendarStatus(string $userId): void {
132141
return;
133142
}
134143

135-
// Only update the status if it's neccesary otherwise we mess up the timestamp
136-
if ($currentStatus === null || $currentStatus->getMessageId() !== IUserStatus::MESSAGE_CALENDAR_BUSY) {
137-
// One event that fulfills all status conditions is enough
138-
// 1. Not an OOO event
139-
// 2. Current user status (that is not a calendar status) was not set after the start of this event
140-
// 3. Event is not set to be transparent
141-
$count = count($applicableEvents);
142-
$this->logger->debug("Found $count applicable event(s), changing user status", ['user' => $userId]);
143-
$this->userStatusService->setUserStatus(
144-
$userId,
145-
IUserStatus::BUSY,
146-
IUserStatus::MESSAGE_CALENDAR_BUSY,
147-
true
148-
);
144+
// Meetings (with attendees) take priority over solo busy events.
145+
// Only update the status if it's necessary otherwise we mess up the timestamp.
146+
if (!empty($meetingEvents)) {
147+
if ($currentStatus === null || $currentStatus->getMessageId() !== IUserStatus::MESSAGE_CALENDAR_BUSY) {
148+
$count = count($meetingEvents);
149+
$this->logger->debug("Found $count meeting event(s), changing user status to meeting", ['user' => $userId]);
150+
$this->userStatusService->setUserStatus(
151+
$userId,
152+
IUserStatus::BUSY,
153+
IUserStatus::MESSAGE_CALENDAR_BUSY,
154+
true
155+
);
156+
}
157+
} else {
158+
if ($currentStatus === null || $currentStatus->getMessageId() !== IUserStatus::MESSAGE_CALENDAR_BUSY_SINGLE) {
159+
$count = count($singleEvents);
160+
$this->logger->debug("Found $count single busy event(s), changing user status to busy", ['user' => $userId]);
161+
$this->userStatusService->setUserStatus(
162+
$userId,
163+
IUserStatus::BUSY,
164+
IUserStatus::MESSAGE_CALENDAR_BUSY_SINGLE,
165+
true
166+
);
167+
}
149168
}
150169
}
151170

apps/dav/tests/unit/CalDAV/Status/StatusServiceTest.php

Lines changed: 96 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ public function testNoCalendars(): void {
161161
->method('getDateTime');
162162
$this->calendarManager->expects(self::never())
163163
->method('searchForPrincipal');
164-
$this->userStatusService->expects(self::once())
164+
$this->userStatusService->expects(self::exactly(2))
165165
->method('revertUserStatus');
166166
$this->logger->expects(self::once())
167167
->method('debug');
@@ -203,7 +203,7 @@ public function testNoCalendarEvents(): void {
203203
$this->calendarManager->expects(self::once())
204204
->method('searchForPrincipal')
205205
->willReturn([]);
206-
$this->userStatusService->expects(self::once())
206+
$this->userStatusService->expects(self::exactly(2))
207207
->method('revertUserStatus');
208208
$this->logger->expects(self::once())
209209
->method('debug');
@@ -248,7 +248,7 @@ public function testCalendarNoEventObjects(): void {
248248
$this->calendarManager->expects(self::once())
249249
->method('searchForPrincipal')
250250
->willReturn([['objects' => []]]);
251-
$this->userStatusService->expects(self::once())
251+
$this->userStatusService->expects(self::exactly(2))
252252
->method('revertUserStatus');
253253
$this->logger->expects(self::once())
254254
->method('debug');
@@ -296,7 +296,99 @@ public function testCalendarEvent(): void {
296296
$this->logger->expects(self::once())
297297
->method('debug');
298298
$this->userStatusService->expects(self::once())
299-
->method('setUserStatus');
299+
->method('setUserStatus')
300+
->with('admin', IUserStatus::BUSY, IUserStatus::MESSAGE_CALENDAR_BUSY_SINGLE, true);
301+
302+
$this->service->processCalendarStatus('admin');
303+
}
304+
305+
public function testCalendarEventWithAttendees(): void {
306+
$user = $this->createConfiguredMock(IUser::class, [
307+
'getUID' => 'admin',
308+
]);
309+
310+
$this->userManager->expects(self::once())
311+
->method('get')
312+
->willReturn($user);
313+
$this->availabilityCoordinator->expects(self::once())
314+
->method('getCurrentOutOfOfficeData')
315+
->willReturn(null);
316+
$this->availabilityCoordinator->expects(self::never())
317+
->method('isInEffect');
318+
$this->cache->expects(self::once())
319+
->method('get')
320+
->willReturn(null);
321+
$this->cache->expects(self::once())
322+
->method('set');
323+
$this->calendarManager->expects(self::once())
324+
->method('getCalendarsForPrincipal')
325+
->willReturn([$this->createMock(CalendarImpl::class)]);
326+
$this->calendarManager->expects(self::once())
327+
->method('newQuery')
328+
->willReturn(new CalendarQuery('admin'));
329+
$this->timeFactory->expects(self::exactly(2))
330+
->method('getDateTime')
331+
->willReturn(new \DateTime());
332+
$this->userStatusService->expects(self::once())
333+
->method('findByUserId')
334+
->willThrowException(new DoesNotExistException(''));
335+
$this->calendarManager->expects(self::once())
336+
->method('searchForPrincipal')
337+
->willReturn([['objects' => [['ATTENDEE' => [['mailto:other@example.com', []]]]]]]);
338+
$this->userStatusService->expects(self::never())
339+
->method('revertUserStatus');
340+
$this->logger->expects(self::once())
341+
->method('debug');
342+
$this->userStatusService->expects(self::once())
343+
->method('setUserStatus')
344+
->with('admin', IUserStatus::BUSY, IUserStatus::MESSAGE_CALENDAR_BUSY, true);
345+
346+
$this->service->processCalendarStatus('admin');
347+
}
348+
349+
public function testCalendarEventMeetingTakesPriorityOverSingle(): void {
350+
$user = $this->createConfiguredMock(IUser::class, [
351+
'getUID' => 'admin',
352+
]);
353+
354+
$this->userManager->expects(self::once())
355+
->method('get')
356+
->willReturn($user);
357+
$this->availabilityCoordinator->expects(self::once())
358+
->method('getCurrentOutOfOfficeData')
359+
->willReturn(null);
360+
$this->availabilityCoordinator->expects(self::never())
361+
->method('isInEffect');
362+
$this->cache->expects(self::once())
363+
->method('get')
364+
->willReturn(null);
365+
$this->cache->expects(self::once())
366+
->method('set');
367+
$this->calendarManager->expects(self::once())
368+
->method('getCalendarsForPrincipal')
369+
->willReturn([$this->createMock(CalendarImpl::class)]);
370+
$this->calendarManager->expects(self::once())
371+
->method('newQuery')
372+
->willReturn(new CalendarQuery('admin'));
373+
$this->timeFactory->expects(self::exactly(2))
374+
->method('getDateTime')
375+
->willReturn(new \DateTime());
376+
$this->userStatusService->expects(self::once())
377+
->method('findByUserId')
378+
->willThrowException(new DoesNotExistException(''));
379+
$this->calendarManager->expects(self::once())
380+
->method('searchForPrincipal')
381+
->willReturn([
382+
['objects' => [[]]],
383+
['objects' => [['ATTENDEE' => [['mailto:other@example.com', []]]]]],
384+
]);
385+
$this->userStatusService->expects(self::never())
386+
->method('revertUserStatus');
387+
$this->logger->expects(self::once())
388+
->method('debug');
389+
$this->userStatusService->expects(self::once())
390+
->method('setUserStatus')
391+
->with('admin', IUserStatus::BUSY, IUserStatus::MESSAGE_CALENDAR_BUSY, true);
300392

301393
$this->service->processCalendarStatus('admin');
302394
}

apps/user_status/lib/Service/PredefinedStatusService.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,8 @@ public function getIconForId(string $id): ?string {
152152
return '';
153153
case self::CALL:
154154
return '💬';
155+
case IUserStatus::MESSAGE_CALENDAR_BUSY_SINGLE:
156+
return '🔴';
155157
default:
156158
return null;
157159
}
@@ -180,6 +182,8 @@ public function getTranslatedStatusForId(string $id): ?string {
180182
return $this->l10n->t('In a call');
181183
case self::BE_RIGHT_BACK:
182184
return $this->l10n->t('Be right back');
185+
case IUserStatus::MESSAGE_CALENDAR_BUSY_SINGLE:
186+
return $this->l10n->t('Busy');
183187
default:
184188
return null;
185189
}
@@ -203,6 +207,7 @@ public function isValidId(string $id): bool {
203207
IUserStatus::MESSAGE_VACATION,
204208
IUserStatus::MESSAGE_CALENDAR_BUSY,
205209
IUserStatus::MESSAGE_CALENDAR_BUSY_TENTATIVE,
210+
IUserStatus::MESSAGE_CALENDAR_BUSY_SINGLE,
206211
], true);
207212
}
208213
}

apps/user_status/lib/Service/StatusService.php

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -252,16 +252,19 @@ public function setUserStatus(string $userId,
252252
$updateStatus = false;
253253
if ($messageId === IUserStatus::MESSAGE_OUT_OF_OFFICE) {
254254
// OUT_OF_OFFICE trumps AVAILABILITY, CALL and CALENDAR status
255-
$updateStatus = $userStatus->getMessageId() === IUserStatus::MESSAGE_AVAILABILITY || $userStatus->getMessageId() === IUserStatus::MESSAGE_CALL || $userStatus->getMessageId() === IUserStatus::MESSAGE_CALENDAR_BUSY;
255+
$updateStatus = $userStatus->getMessageId() === IUserStatus::MESSAGE_AVAILABILITY || $userStatus->getMessageId() === IUserStatus::MESSAGE_CALL || $userStatus->getMessageId() === IUserStatus::MESSAGE_CALENDAR_BUSY || $userStatus->getMessageId() === IUserStatus::MESSAGE_CALENDAR_BUSY_SINGLE;
256256
} elseif ($messageId === IUserStatus::MESSAGE_AVAILABILITY) {
257257
// AVAILABILITY trumps CALL and CALENDAR status
258-
$updateStatus = $userStatus->getMessageId() === IUserStatus::MESSAGE_CALL || $userStatus->getMessageId() === IUserStatus::MESSAGE_CALENDAR_BUSY;
258+
$updateStatus = $userStatus->getMessageId() === IUserStatus::MESSAGE_CALL || $userStatus->getMessageId() === IUserStatus::MESSAGE_CALENDAR_BUSY || $userStatus->getMessageId() === IUserStatus::MESSAGE_CALENDAR_BUSY_SINGLE;
259259
} elseif ($messageId === IUserStatus::MESSAGE_CALL) {
260260
// CALL trumps CALENDAR status
261-
$updateStatus = $userStatus->getMessageId() === IUserStatus::MESSAGE_CALENDAR_BUSY;
261+
$updateStatus = $userStatus->getMessageId() === IUserStatus::MESSAGE_CALENDAR_BUSY || $userStatus->getMessageId() === IUserStatus::MESSAGE_CALENDAR_BUSY_SINGLE;
262+
} elseif ($messageId === IUserStatus::MESSAGE_CALENDAR_BUSY) {
263+
// A meeting trumps a solo busy event
264+
$updateStatus = $userStatus->getMessageId() === IUserStatus::MESSAGE_CALENDAR_BUSY_SINGLE;
262265
}
263266

264-
if ($messageId === IUserStatus::MESSAGE_OUT_OF_OFFICE || $messageId === IUserStatus::MESSAGE_AVAILABILITY || $messageId === IUserStatus::MESSAGE_CALL || $messageId === IUserStatus::MESSAGE_CALENDAR_BUSY) {
267+
if ($messageId === IUserStatus::MESSAGE_OUT_OF_OFFICE || $messageId === IUserStatus::MESSAGE_AVAILABILITY || $messageId === IUserStatus::MESSAGE_CALL || $messageId === IUserStatus::MESSAGE_CALENDAR_BUSY || $messageId === IUserStatus::MESSAGE_CALENDAR_BUSY_SINGLE) {
265268
if ($updateStatus) {
266269
$this->logger->debug('User ' . $userId . ' is currently NOT available, overwriting status [status: ' . $userStatus->getStatus() . ', messageId: ' . json_encode($userStatus->getMessageId()) . ']', ['app' => 'dav']);
267270
} else {

lib/public/UserStatus/IUserStatus.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,12 @@ interface IUserStatus {
8989
*/
9090
public const MESSAGE_CALENDAR_BUSY_TENTATIVE = 'busy-tentative';
9191

92+
/**
93+
* @var string
94+
* @since 35.0.0
95+
*/
96+
public const MESSAGE_CALENDAR_BUSY_SINGLE = 'calendar-busy';
97+
9298
/**
9399
* Get the user this status is connected to
94100
*

0 commit comments

Comments
 (0)