Skip to content
Merged
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
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ Built to the specification in [SPECIFICATION.md](./SPECIFICATION.md).
- **Apply** for annual, sick, unpaid or special leave (configurable types) with a live
balance preview. The working-day count is prefilled from your availability and
public holidays in personal settings.
- **Approve / reject** as a line manager, with team-coverage conflict warnings.
- **Approve / reject** as a line manager, with team-coverage conflict warnings and a
short-notice warning when the leave starts sooner than the notice period the admin
expects (default two weeks).
- **Escalation**: pending requests a manager ignores are automatically escalated to HR.
- **Full balance tracking**: entitlement, used, pending, remaining and carry-over,
with a configurable year-rollover policy. *My leave* shows each balance as an
Expand Down Expand Up @@ -148,7 +150,8 @@ The default leave types (annual, sick, unpaid, special) are seeded on install.
## Configuration

Admin settings live under **Administration settings → Absence** (HR group, default
entitlement, escalation window, carry-over policy, coverage threshold, CalDAV targets).
entitlement, escalation window, carry-over policy, coverage threshold, expected notice
period, CalDAV targets).

Personal settings are appended to the built-in **Personal settings → Availability**
page. They prefill the "Working days" field on new requests: working weekdays come
Expand Down
15 changes: 15 additions & 0 deletions SPECIFICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,20 @@ is what counts (§7).
or exceeds a configurable threshold (admin setting **max concurrent absences per
team**, default e.g. 2, or a percentage), show a prominent warning in the review
panel. It is a warning, not a hard block.
- **Short-notice warning:** a request whose leave starts sooner than the admin's
**expected notice period** (calendar days, default 14; `0` switches the check off)
is flagged to the line manager and to HR. Like the conflict warning it informs a
decision and blocks nothing, and it appears wherever the decision is made: on the
request's Details tab, in the notification and in the subject line of the email
that asks for a decision (§11) — including the escalation to HR and the pending
reminder, by which point the notice given has shrunk further.
- Calendar days, not working days: "two weeks' notice" is a fortnight on the wall
calendar. Measured against the *server's* today, so one request gets one answer
for the manager, for HR and for the job that mails them.
- Only while a decision is outstanding (`PENDING` / `ESCALATED`), and so never for
leave with no approval workflow — sick leave is recorded after the fact and
auto-approved types are booked straight through (§4.1), so nobody is weighing the
notice, and nobody can give notice of falling ill.
- Provide an API endpoint to query overlaps for a date range + scope (team/company).

---
Expand Down Expand Up @@ -581,6 +595,7 @@ new "Absence" settings section or "Personal info"/"Administration"):
| Carry-over cap (days) | 5 | Used when `capped`. |
| Carry-over expiry | none / date (e.g. Mar 31) | §6.2. |
| Max concurrent team absences | 2 | Conflict threshold (§8). |
| Expected notice period | 14 calendar days | Short-notice threshold (§8); `0` disables. |
| CalDAV: write personal events | true | §10. |
| CalDAV: write shared team calendar | true | §10. |
| Shared calendar type-visibility | neutral | Reveal type vs "Absent" on shared cal. |
Expand Down

Large diffs are not rendered by default.

Large diffs are not rendered by default.

16 changes: 8 additions & 8 deletions js/absence-main.mjs

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion js/absence-main.mjs.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion js/absence-personal-settings.mjs

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions js/index-D9p6E40W.chunk.mjs → js/index-EdbAt5mR.chunk.mjs

Large diffs are not rendered by default.

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions lib/ConfigLexicon.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ class ConfigLexicon implements ILexicon {
public const KEY_CARRYOVER_CAP = 'carryover_cap';
public const KEY_CARRYOVER_EXPIRY = 'carryover_expiry';
public const KEY_MAX_CONCURRENT = 'max_concurrent_absences';
public const KEY_NOTICE_PERIOD = 'notice_period_days';
public const KEY_CALDAV_PERSONAL = 'caldav_personal';
public const KEY_CALDAV_SHARED = 'caldav_shared';
public const KEY_SHARED_VISIBILITY = 'shared_calendar_visibility';
Expand Down Expand Up @@ -68,6 +69,8 @@ public function getAppConfigs(): array {
'Day (MM-DD) in the new year when carried-over days expire; empty = never', lazy: true),
new Entry(self::KEY_MAX_CONCURRENT, ValueType::INT, 2,
'Concurrent team absences above which a coverage warning is shown'),
new Entry(self::KEY_NOTICE_PERIOD, ValueType::INT, 14,
'Calendar days of advance notice expected for a leave request; one starting sooner is flagged as short notice to the manager and HR (0 disables the warning)', lazy: true),
new Entry(self::KEY_CALDAV_PERSONAL, ValueType::BOOL, true,
'Write approved leave to the employee\'s personal calendar', lazy: true),
new Entry(self::KEY_CALDAV_SHARED, ValueType::BOOL, true,
Expand Down
33 changes: 24 additions & 9 deletions lib/Notification/Notifier.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
namespace OCA\Absence\Notification;

use OCA\Absence\Service\ConfigService;
use OCA\Absence\Service\NoticeService;
use OCA\Absence\Service\NotificationService;
use OCP\IURLGenerator;
use OCP\IUserManager;
Expand Down Expand Up @@ -44,17 +45,24 @@ public function prepare(INotification $notification, string $languageCode): INot
$params = $notification->getSubjectParameters();
$employee = $this->displayName((string)($params['employee'] ?? ''));
$requestId = (string)($params['requestId'] ?? $notification->getObjectId());
// Notifications stored before notes were carried have neither key.
// Notifications stored before notes were carried have none of these keys.
$note = trim((string)($params['note'] ?? ''));
$noteAuthor = $this->displayName((string)($params['noteAuthor'] ?? ''));
$notice = isset($params['noticeDays'], $params['noticePeriod'])
? ['days' => (int)$params['noticeDays'], 'noticePeriod' => (int)$params['noticePeriod']]
: null;

[$subject, $message] = match ($notification->getSubject()) {
NotificationService::SUBJECT_NEW_REQUEST => [
$l->t('New leave request from %s', [$employee]),
$notice !== null
? $l->t('Short notice: leave request from %s', [$employee])
: $l->t('New leave request from %s', [$employee]),
$l->t('Review it in Absence.'),
],
NotificationService::SUBJECT_ESCALATION => [
$l->t('Leave request from %s needs HR', [$employee]),
$notice !== null
? $l->t('Short notice: leave request from %s needs HR', [$employee])
: $l->t('Leave request from %s needs HR', [$employee]),
$l->t('This request was escalated and needs a decision.'),
],
NotificationService::SUBJECT_APPROVED => [
Expand All @@ -66,7 +74,9 @@ public function prepare(INotification $notification, string $languageCode): INot
'',
],
NotificationService::SUBJECT_REMINDER => [
$l->t('Reminder: %s is waiting for a decision', [$employee]),
$notice !== null
? $l->t('Short notice: %s is still waiting for a decision', [$employee])
: $l->t('Reminder: %s is waiting for a decision', [$employee]),
'',
],
NotificationService::SUBJECT_WITHDRAWAL => [
Expand Down Expand Up @@ -94,11 +104,16 @@ public function prepare(INotification $notification, string $languageCode): INot
default => throw new UnknownNotificationException('Unknown subject'),
};

// What someone actually wrote beats the boilerplate that would otherwise fill
// this line: "Review it in Absence." says nothing the Review button doesn't,
// while the reason or decision comment is the reason to look at all.
if ($note !== '') {
$message = $note;
// The substance beats the boilerplate that would otherwise fill this line:
// "Review it in Absence." says nothing the Review button doesn't, while how
// short the notice is and what the employee wrote are the reasons to look at
// all. The warning leads, because it bears on the answer rather than the ask.
$detail = array_filter([
$notice !== null ? NoticeService::sentence($l, $notice) : '',
$note,
]);
if ($detail !== []) {
$message = implode(' ', $detail);
}

$notification->setParsedSubject($subject);
Expand Down
10 changes: 10 additions & 0 deletions lib/Service/ConfigService.php
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,14 @@ public function getMaxConcurrentAbsences(): int {
return $this->appConfig->getValueInt(self::APP_ID, ConfigLexicon::KEY_MAX_CONCURRENT);
}

/**
* Calendar days of advance notice a leave request is expected to give. Zero
* disables the short-notice warning entirely.
*/
public function getNoticePeriodDays(): int {
return $this->appConfig->getValueInt(self::APP_ID, ConfigLexicon::KEY_NOTICE_PERIOD);
}

public function isCalDavPersonalEnabled(): bool {
return $this->appConfig->getValueBool(self::APP_ID, ConfigLexicon::KEY_CALDAV_PERSONAL);
}
Expand Down Expand Up @@ -100,6 +108,7 @@ public function getAdminConfig(): array {
ConfigLexicon::KEY_CARRYOVER_CAP => $this->getCarryOverCap(),
ConfigLexicon::KEY_CARRYOVER_EXPIRY => $this->getCarryOverExpiry(),
ConfigLexicon::KEY_MAX_CONCURRENT => $this->getMaxConcurrentAbsences(),
ConfigLexicon::KEY_NOTICE_PERIOD => $this->getNoticePeriodDays(),
ConfigLexicon::KEY_CALDAV_PERSONAL => $this->isCalDavPersonalEnabled(),
ConfigLexicon::KEY_CALDAV_SHARED => $this->isCalDavSharedEnabled(),
ConfigLexicon::KEY_SHARED_VISIBILITY => $this->getSharedCalendarVisibility(),
Expand Down Expand Up @@ -133,6 +142,7 @@ public function setAdminValue(string $key, mixed $value): void {
case ConfigLexicon::KEY_ESCALATION_WINDOW:
case ConfigLexicon::KEY_REMINDER_LEAD:
case ConfigLexicon::KEY_MAX_CONCURRENT:
case ConfigLexicon::KEY_NOTICE_PERIOD:
$int = (int)$value;
if ($int < 0) {
throw new ValidationException('This setting must be zero or greater.');
Expand Down
110 changes: 110 additions & 0 deletions lib/Service/NoticeService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
<?php

declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Absence\Service;

use OCA\Absence\Db\LeaveRequest;
use OCP\IL10N;

/**
* The notice-period check (§8): did this request give the company the advance
* warning it expects, and if not, how short was it?
*
* A companion to the coverage conflict warning — both tell a decider something
* about a request that the dates alone do not, and neither blocks anything.
*
* Two deliberate limits on when a warning is produced:
*
* - Only while the request is still awaiting a decision. Short notice is a fact
* about a decision somebody has to make now; on leave already approved,
* rejected or cancelled it is history, and repeating it there would leave a
* permanent reproach on the record.
* - Never for leave that has no approval workflow. Sick leave is recorded by HR
* after the fact and auto-approved types are booked straight through (§4.1),
* so nobody is being asked to weigh the notice given — and nobody can give
* notice of falling ill. Both reach APPROVED without ever being pending, so
* the status gate covers them without needing to know the type.
*/
class NoticeService {
/** Statuses in which somebody still has to decide, so a warning is actionable. */
private const PENDING_DECISION = [
LeaveRequest::STATUS_PENDING,
LeaveRequest::STATUS_ESCALATED,
];

public function __construct(
private ConfigService $config,
private ClockService $clock,
) {
}

/**
* Calendar days from today to the first day of leave. Negative once the leave
* has started — an edit or a late HR correction can put it in the past.
*
* Calendar days, not working days: "two weeks' notice" is a fortnight on the
* wall calendar, and unlike the escalation window (which counts the days a
* manager actually had a chance to answer in) nothing here is about working time.
*
* The company's clock, not the viewer's: the notice period is one policy applied
* to one request, so it has to give the same answer for the manager, for HR and
* for the background job that mails them — a per-viewer boundary would let the
* sidebar and the email disagree by a day.
*/
public function daysUntilStart(LeaveRequest $request): int {
$today = new \DateTimeImmutable($this->clock->serverToday());
$start = new \DateTimeImmutable($request->getStartDate());
return (int)$today->diff($start)->format('%r%a');
}

/**
* The short-notice warning for a request, or null when there is nothing to warn
* about: notice was sufficient, the admin switched the check off, or nobody is
* being asked to decide (see the class docblock).
*
* @return ?array{days:int,noticePeriod:int}
*/
public function warningFor(LeaveRequest $request): ?array {
$noticePeriod = $this->config->getNoticePeriodDays();
if ($noticePeriod <= 0 || !in_array($request->getStatus(), self::PENDING_DECISION, true)) {
return null;
}
$days = $this->daysUntilStart($request);
if ($days >= $noticePeriod) {
return null;
}
return ['days' => $days, 'noticePeriod' => $noticePeriod];
}

/**
* The warning as a sentence, for whoever is about to read it.
*
* Static, and here rather than in the notifier or the mailer, because the same
* warning is shown in the notification, in the email and in the request sidebar:
* one string to translate and one wording to keep true, instead of three that
* drift. Takes the reader's IL10N because the same request is described to
* people in different languages.
*
* @param array{days:int,noticePeriod:int} $notice
*/
public static function sentence(IL10N $l, array $notice): string {
$period = $notice['noticePeriod'];
if ($notice['days'] > 0) {
return $l->n(
'The leave starts in %n day, less than the %s days of notice expected.',
'The leave starts in %n days, less than the %s days of notice expected.',
$notice['days'],
[$period],
);
}
if ($notice['days'] === 0) {
return $l->t('The leave starts today, with none of the %s days of notice expected.', [$period]);
}
return $l->t('The leave has already started, though %s days of notice are expected.', [$period]);
}
}
Loading
Loading