Skip to content

Commit 4c7588f

Browse files
authored
Merge pull request #41 from nextcloud/fix/noid/whos-off-leave-type-icons
fix(whos-off): stop showing sick colleagues as sunbathing
2 parents 585aebc + 0e15d83 commit 4c7588f

7 files changed

Lines changed: 140 additions & 16 deletions

File tree

SPECIFICATION.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -493,6 +493,21 @@ is what counts (§7).
493493
- **Team calendar / who's-off:** managers see their direct reports; HR sees the
494494
whole company; every employee sees their own team (peers who share the same
495495
`manager_uid`). A month/timeline view rendered from approved + pending requests.
496+
- **Whose leave type is shown.** The type travels with an event when the admin set
497+
the shared-calendar visibility to `reveal`, or when the viewer is somebody who
498+
could open that request and read the type off it anyway — its owner, their line
499+
manager, or HR (§2, `canView`). Everyone else gets `typeId: null` and the client
500+
labels the absence generically. With no viewer the type is withheld from all
501+
(fail-closed).
502+
- The policy protects a *colleague's* privacy — a peer must not learn that
503+
somebody is on sick leave. It is not a restriction on HR, who record sick
504+
leave, nor on the line manager who approved the absence, and withholding it
505+
from them only degraded their own view: with no type to label the absence,
506+
the client fell back to a generic marker, so an HR timeline of sick
507+
colleagues read as a row of holidays.
508+
- The generic marker must be **neutral about the reason**. Withholding why
509+
somebody is away and then implying a cheerful reason is worse than either
510+
revealing it or saying nothing.
496511
- **Conflict warning:** when a manager reviews a request, compute the maximum number
497512
of concurrently-absent team members on any day in the requested range. If it meets
498513
or exceeds a configurable threshold (admin setting **max concurrent absences per

js/absence-main.mjs

Lines changed: 4 additions & 4 deletions
Large diffs are not rendered by default.

js/absence-main.mjs.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

lib/Service/CoverageService.php

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,18 @@ public function resolveScopeUids(string $actorUid, string $scope): array {
5252
* Who's-off events + per-day concurrency for a set of employees in a range.
5353
*
5454
* The leave *type* of another employee is only revealed when the admin set the
55-
* shared-calendar visibility to "reveal"; under the default "neutral" policy the
56-
* viewer sees that a colleague is absent but not the category (e.g. sick leave),
57-
* mirroring {@see CalendarService::sharedTitle}. The viewer always sees their own
58-
* types. When no viewer is given, types are neutralised for everyone under the
59-
* neutral policy (fail-closed).
55+
* shared-calendar visibility to "reveal", or when the viewer is somebody who may
56+
* open that request and read the type off it anyway — its owner, their line
57+
* manager, or HR ({@see PermissionService::canView()}). Under the default
58+
* "neutral" policy everyone else sees that a colleague is absent but not the
59+
* category, mirroring {@see CalendarService::sharedTitle}. When no viewer is
60+
* given, types are neutralised for everyone (fail-closed).
61+
*
62+
* The policy exists to stop a *colleague's* sick leave becoming visible to the
63+
* team (§8); withholding the type from HR, who record sick leave in the first
64+
* place, protects nothing and actively misinforms — the client has no type to
65+
* label the absence with and has to fall back to a generic marker, so an HR
66+
* timeline of sick colleagues used to read as a row of holidays.
6067
*
6168
* @param string[] $employeeUids
6269
* @return array{events:list<array<string,mixed>>,byDate:array<string,int>,maxConcurrent:int,threshold:int,conflict:bool}
@@ -67,18 +74,30 @@ public function getCoverage(array $employeeUids, string $from, string $to, ?int
6774
$statuses = [LeaveRequest::STATUS_APPROVED, LeaveRequest::STATUS_PENDING, LeaveRequest::STATUS_ESCALATED, LeaveRequest::STATUS_WITHDRAWAL_PENDING];
6875
$requests = $this->requestMapper->findForEmployeesInRange($employeeUids, $from, $to, $statuses);
6976

77+
// Resolve the viewer's reach once rather than asking canView() per request: a
78+
// company-wide month can hold hundreds of rows, and the answer depends only on
79+
// who is looking, not on which request.
80+
$viewerIsHr = !$revealTypes && $viewerUid !== null && $this->permission->isHr($viewerUid);
81+
$viewerReports = (!$revealTypes && !$viewerIsHr && $viewerUid !== null)
82+
? $this->managerResolver->getDirectReports($viewerUid)
83+
: [];
84+
7085
$events = [];
7186
$byDate = [];
7287
foreach ($requests as $request) {
7388
if ($excludeRequestId !== null && $request->getId() === $excludeRequestId) {
7489
continue;
7590
}
76-
$ownEvent = $viewerUid !== null && $request->getEmployeeUid() === $viewerUid;
91+
$employeeUid = $request->getEmployeeUid();
92+
$maySeeType = $revealTypes
93+
|| $viewerIsHr
94+
|| ($viewerUid !== null && $employeeUid === $viewerUid)
95+
|| in_array($employeeUid, $viewerReports, true);
7796
$events[] = [
7897
'requestId' => $request->getId(),
79-
'employeeUid' => $request->getEmployeeUid(),
80-
'displayName' => $this->displayName($request->getEmployeeUid()),
81-
'typeId' => ($revealTypes || $ownEvent) ? $request->getTypeId() : null,
98+
'employeeUid' => $employeeUid,
99+
'displayName' => $this->displayName($employeeUid),
100+
'typeId' => $maySeeType ? $request->getTypeId() : null,
82101
'status' => $request->getStatus(),
83102
'start' => $request->getStartDate(),
84103
'end' => $request->getEndDate(),

src/store.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,12 @@ export const store = reactive({
4444
leaveType(id) {
4545
// A null/undefined id means the server withheld the leave type (neutral
4646
// shared-calendar visibility): show a generic "Absent" marker, not "Unknown".
47+
// The marker has to say nothing about *why* somebody is away. A palm tree here
48+
// was not neutral — it read as a holiday, so a colleague on sick leave was
49+
// shown sunbathing. Withholding the reason and then inventing a cheerful one
50+
// is worse than either revealing it or saying nothing.
4751
if (id === null || id === undefined) {
48-
return { label: t('absence', 'Absent'), color: '#888', icon: '🌴' }
52+
return { label: t('absence', 'Absent'), color: '#888', icon: '' }
4953
}
5054
return this.leaveTypes.find((t) => t.id === id) || { label: t('absence', 'Unknown'), color: '#888', icon: '❔' }
5155
},

src/views/MyLeave.vue

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,11 +153,18 @@ export default {
153153
const type = store.leaveType(r.typeId)
154154
const range = formatRange(r.startDate, r.endDate)
155155
if (r.startDate <= today) {
156+
// The hero already renders the leave type's own icon next to this text, so
157+
// the palm that used to be baked into the string was a second, type-blind
158+
// one — it wished a holiday on whatever the absence actually was. Sick
159+
// leave is not something to enjoy, either.
160+
const headline = type.key === 'sick'
161+
? t('absence', 'Get well soon.')
162+
: t('absence', 'Enjoy your {type}!', { type: type.label.toLowerCase() })
156163
return {
157164
icon: type.icon,
158165
color: type.color,
159166
eyebrow: t('absence', 'You are off right now'),
160-
headline: t('absence', 'Enjoy your {type}! 🌴', { type: type.label.toLowerCase() }),
167+
headline,
161168
sub: range,
162169
live: false,
163170
}

tests/Unit/Service/CoverageServiceTest.php

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,85 @@ public function testRevealPolicyExposesType(): void {
8787
self::assertSame(3, $result['events'][0]['typeId']);
8888
}
8989

90+
public function testHrSeesTheTypeUnderTheNeutralPolicy(): void {
91+
// HR record sick leave and can open any request to read its type, so hiding it
92+
// on the who's-off timeline protects nothing — it only leaves the client with
93+
// no type to label the absence with, which used to render as a holiday.
94+
$this->config->method('getSharedCalendarVisibility')->willReturn(ConfigService::VISIBILITY_NEUTRAL);
95+
$this->config->method('getMaxConcurrentAbsences')->willReturn(0);
96+
$this->permission->method('isHr')->with('viewer')->willReturn(true);
97+
$this->requestMapper->method('findForEmployeesInRange')->willReturn([
98+
$this->request(2, 'colleague'),
99+
]);
100+
101+
$result = $this->service->getCoverage(['colleague'], '2026-01-01', '2026-01-31', null, 'viewer');
102+
103+
self::assertSame(3, $result['events'][0]['typeId']);
104+
}
105+
106+
public function testAManagerSeesTheTypeOfTheirOwnReports(): void {
107+
$this->config->method('getSharedCalendarVisibility')->willReturn(ConfigService::VISIBILITY_NEUTRAL);
108+
$this->config->method('getMaxConcurrentAbsences')->willReturn(0);
109+
$this->permission->method('isHr')->willReturn(false);
110+
$this->managerResolver->method('getDirectReports')->with('boss')->willReturn(['report']);
111+
$this->requestMapper->method('findForEmployeesInRange')->willReturn([
112+
$this->request(2, 'report'),
113+
$this->request(3, 'somebody-elses-report'),
114+
]);
115+
116+
$result = $this->service->getCoverage(['report', 'somebody-elses-report'], '2026-01-01', '2026-01-31', null, 'boss');
117+
$byUid = [];
118+
foreach ($result['events'] as $event) {
119+
$byUid[$event['employeeUid']] = $event['typeId'];
120+
}
121+
122+
self::assertSame(3, $byUid['report'], 'Their own report, whose requests they decide');
123+
self::assertNull($byUid['somebody-elses-report'], 'Not their report — still withheld');
124+
}
125+
126+
public function testAPeerStillLearnsNothingAboutAColleague(): void {
127+
// The protection the policy exists for: a plain colleague sees that somebody is
128+
// away and nothing about why.
129+
$this->config->method('getSharedCalendarVisibility')->willReturn(ConfigService::VISIBILITY_NEUTRAL);
130+
$this->config->method('getMaxConcurrentAbsences')->willReturn(0);
131+
$this->permission->method('isHr')->willReturn(false);
132+
$this->managerResolver->method('getDirectReports')->willReturn([]);
133+
$this->requestMapper->method('findForEmployeesInRange')->willReturn([
134+
$this->request(2, 'colleague'),
135+
]);
136+
137+
$result = $this->service->getCoverage(['colleague'], '2026-01-01', '2026-01-31', null, 'peer');
138+
139+
self::assertNull($result['events'][0]['typeId']);
140+
}
141+
142+
public function testAnAnonymousCallerLearnsNothing(): void {
143+
// No viewer to check permissions against, so fail closed rather than reveal.
144+
$this->config->method('getSharedCalendarVisibility')->willReturn(ConfigService::VISIBILITY_NEUTRAL);
145+
$this->config->method('getMaxConcurrentAbsences')->willReturn(0);
146+
$this->permission->expects(self::never())->method('isHr');
147+
$this->requestMapper->method('findForEmployeesInRange')->willReturn([
148+
$this->request(2, 'colleague'),
149+
]);
150+
151+
$result = $this->service->getCoverage(['colleague'], '2026-01-01', '2026-01-31', null, null);
152+
153+
self::assertNull($result['events'][0]['typeId']);
154+
}
155+
156+
public function testTheRevealPolicyDoesNotBotherResolvingPermissions(): void {
157+
// Everyone sees every type anyway, so the group and manager lookups are waste.
158+
$this->config->method('getSharedCalendarVisibility')->willReturn(ConfigService::VISIBILITY_REVEAL);
159+
$this->config->method('getMaxConcurrentAbsences')->willReturn(0);
160+
$this->permission->expects(self::never())->method('isHr');
161+
$this->managerResolver->expects(self::never())->method('getDirectReports');
162+
$this->requestMapper->method('findForEmployeesInRange')->willReturn([
163+
$this->request(2, 'colleague'),
164+
]);
165+
166+
self::assertSame(3, $this->service->getCoverage(['colleague'], '2026-01-01', '2026-01-31', null, 'viewer')['events'][0]['typeId']);
167+
}
168+
90169
public function testRejectsInvalidRange(): void {
91170
$this->expectException(ValidationException::class);
92171
$this->service->getCoverage(['viewer'], '2026-13-99', '2026-01-31', null, 'viewer');

0 commit comments

Comments
 (0)