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
1 change: 1 addition & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
// HR reporting & export
['name' => 'report#balances', 'url' => '/api/reports/balances', 'verb' => 'GET'],
['name' => 'report#trends', 'url' => '/api/reports/trends', 'verb' => 'GET'],
['name' => 'report#sickLeave', 'url' => '/api/reports/sick-leave', 'verb' => 'GET'],
['name' => 'export#requests', 'url' => '/api/export/requests', 'verb' => 'GET'],
['name' => 'export#balances', 'url' => '/api/export/balances', 'verb' => 'GET'],

Expand Down
31 changes: 31 additions & 0 deletions js/NcCheckboxRadioSwitch-DVdt5Hkq-Dfqo0Dmj.chunk.mjs

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions js/NcCheckboxRadioSwitch-DVdt5Hkq-Dfqo0Dmj.chunk.mjs.map

Large diffs are not rendered by default.

26 changes: 13 additions & 13 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.

12 changes: 2 additions & 10 deletions js/absence-personal-settings.mjs

Large diffs are not rendered by default.

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

Large diffs are not rendered by default.

23 changes: 0 additions & 23 deletions js/holidays-BoDDj6rx.chunk.mjs

This file was deleted.

1 change: 0 additions & 1 deletion js/holidays-BoDDj6rx.chunk.mjs.map

This file was deleted.

4 changes: 2 additions & 2 deletions js/index-RBNm1VLy.chunk.mjs → js/index-C0SbjTRS.chunk.mjs

Large diffs are not rendered by default.

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion lib/BackgroundJob/EscalationJob.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
namespace OCA\Absence\BackgroundJob;

use OCA\Absence\Db\LeaveRequestMapper;
use OCA\Absence\Service\ClockService;
use OCA\Absence\Service\ConfigService;
use OCA\Absence\Service\RequestService;
use OCP\AppFramework\Utility\ITimeFactory;
Expand All @@ -23,6 +24,7 @@ class EscalationJob extends TimedJob {

public function __construct(
ITimeFactory $time,
private ClockService $clock,
private LeaveRequestMapper $requestMapper,
private RequestService $requestService,
private ConfigService $config,
Expand All @@ -38,7 +40,9 @@ protected function run($argument): void {
// does not burn its manager's window over the weekend. With the midnight
// cut-off, a request escalates once its manager had the full window.
$window = max(1, $this->config->getEscalationWindowDays());
$today = new \DateTimeImmutable('today', new \DateTimeZone('UTC'));
// Deliberately the server's timezone, not a user's: this decides which
// requests are due across the whole company, and there is no user to ask.
$today = $this->clock->serverNow()->setTime(0, 0);
$cutoff = $this->subtractWorkingDays($today, $window);
foreach ($this->requestMapper->findPendingOlderThan($cutoff) as $request) {
$this->requestService->escalate($request);
Expand Down
6 changes: 5 additions & 1 deletion lib/BackgroundJob/ReminderJob.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
namespace OCA\Absence\BackgroundJob;

use OCA\Absence\Db\LeaveRequestMapper;
use OCA\Absence\Service\ClockService;
use OCA\Absence\Service\ConfigService;
use OCA\Absence\Service\NotificationService;
use OCP\AppFramework\Utility\ITimeFactory;
Expand All @@ -23,6 +24,7 @@ class ReminderJob extends TimedJob {

public function __construct(
ITimeFactory $time,
private ClockService $clock,
private LeaveRequestMapper $requestMapper,
private NotificationService $notifications,
private ConfigService $config,
Expand All @@ -43,7 +45,9 @@ protected function run($argument): void {
// pending request is reminded once, not on every daily run (avoids spam).
// Skip weekend runs entirely: Sat/Sun/Mon would all resolve to the same
// working-day band and remind the same cohort up to three times.
$today = new \DateTimeImmutable('today', new \DateTimeZone('UTC'));
// Deliberately the server's timezone, not a user's: this decides which
// requests are due across the whole company, and there is no user to ask.
$today = $this->clock->serverNow()->setTime(0, 0);
if ((int)$today->format('N') > 5) {
return;
}
Expand Down
5 changes: 4 additions & 1 deletion lib/BackgroundJob/YearRolloverJob.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
namespace OCA\Absence\BackgroundJob;

use OCA\Absence\ConfigLexicon;
use OCA\Absence\Service\ClockService;
use OCA\Absence\Service\ConfigService;
use OCA\Absence\Service\EntitlementService;
use OCP\AppFramework\Utility\ITimeFactory;
Expand All @@ -23,6 +24,7 @@ class YearRolloverJob extends TimedJob {
public function __construct(
ITimeFactory $time,
private EntitlementService $entitlementService,
private ClockService $clock,
private IAppConfig $appConfig,
) {
parent::__construct($time);
Expand All @@ -32,7 +34,8 @@ public function __construct(

#[\Override]
protected function run($argument): void {
$currentYear = (int)date('Y');
// no user is running this: the server's calendar year
$currentYear = $this->clock->serverYear();

// Carry-over from last year into this year — run once per year.
$lastRollover = $this->appConfig->getValueInt(ConfigService::APP_ID, ConfigLexicon::KEY_LAST_ROLLOVER_YEAR);
Expand Down
4 changes: 3 additions & 1 deletion lib/Controller/ExportController.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

namespace OCA\Absence\Controller;

use OCA\Absence\Service\ClockService;
use OCA\Absence\Service\ExportService;
use OCA\Absence\Service\PermissionService;
use OCP\AppFramework\Controller;
Expand All @@ -25,6 +26,7 @@ public function __construct(
private ?string $userId,
private ExportService $service,
private PermissionService $permission,
private ClockService $clock,
) {
parent::__construct($appName, $request);
}
Expand All @@ -45,7 +47,7 @@ public function balances(?int $year = null): DataResponse|DataDownloadResponse {
if (!$this->permission->isHr((string)$this->userId)) {
return new DataResponse(['message' => 'HR role required'], Http::STATUS_FORBIDDEN);
}
$export = $this->service->balancesCsv($year ?? (int)date('Y'));
$export = $this->service->balancesCsv($year ?? $this->clock->userYear());
return new DataDownloadResponse($export['content'], $export['filename'], 'text/csv');
}
}
17 changes: 16 additions & 1 deletion lib/Controller/ReportController.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

namespace OCA\Absence\Controller;

use OCA\Absence\Service\ClockService;
use OCA\Absence\Service\PermissionService;
use OCA\Absence\Service\ReportService;
use OCP\AppFramework\Controller;
Expand All @@ -24,6 +25,7 @@ public function __construct(
private ?string $userId,
private ReportService $service,
private PermissionService $permission,
private ClockService $clock,
) {
parent::__construct($appName, $request);
}
Expand All @@ -32,7 +34,20 @@ public function __construct(
public function balances(?int $year = null, ?string $group = null): DataResponse {
return $this->handle(function () use ($year, $group) {
$this->permission->assertHr((string)$this->userId);
return $this->service->balancesReport($year ?? (int)date('Y'), $group);
return $this->service->balancesReport($year ?? $this->clock->userYear(), $group);
});
}

/**
* Sick-leave overview: every employee ranked by days lost. HR only — this is
* health-adjacent data about named people, so unlike the coverage views it is
* never visible to line managers.
*/
#[NoAdminRequired]
public function sickLeave(?int $year = null, ?string $group = null, ?int $typeId = null): DataResponse {
return $this->handle(function () use ($year, $group, $typeId) {
$this->permission->assertHr((string)$this->userId);
return $this->service->sickLeaveReport($year ?? $this->clock->userYear(), $group, $typeId);
});
}

Expand Down
4 changes: 3 additions & 1 deletion lib/Dashboard/AbsenceWidget.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use OCA\Absence\Db\LeaveRequestMapper;
use OCA\Absence\Db\LeaveTypeMapper;
use OCA\Absence\Service\BalanceService;
use OCA\Absence\Service\ClockService;
use OCA\Absence\Service\ManagerResolver;
use OCA\Absence\Service\PermissionService;
use OCP\Dashboard\IAPIWidget;
Expand All @@ -38,6 +39,7 @@ public function __construct(
private IURLGenerator $urlGenerator,
private IUserManager $userManager,
private LeaveRequestMapper $requestMapper,
private ClockService $clock,
private LeaveTypeMapper $leaveTypeMapper,
private BalanceService $balanceService,
private PermissionService $permission,
Expand Down Expand Up @@ -187,7 +189,7 @@ private function balanceSummary(string $userId): ?array {
* @return LeaveRequest[]
*/
private function ownUpcoming(string $userId): array {
$today = date('Y-m-d');
$today = $this->clock->userToday();
$requests = array_filter(
$this->requestMapper->findAllForEmployee($userId),
static fn (LeaveRequest $r): bool => in_array($r->getStatus(), LeaveRequest::ACTIVE_STATUSES, true) && $r->getEndDate() >= $today,
Expand Down
3 changes: 2 additions & 1 deletion lib/Service/BalanceService.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,12 @@ public function __construct(
private EntitlementMapper $entitlementMapper,
private LeaveTypeMapper $leaveTypeMapper,
private ConfigService $config,
private ClockService $clock,
) {
}

public function currentYear(): int {
return (int)date('Y');
return $this->clock->userYear();
}

/**
Expand Down
74 changes: 74 additions & 0 deletions lib/Service/ClockService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<?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 OCP\AppFramework\Utility\ITimeFactory;
use OCP\IDateTimeZone;

/**
* Where "today" and "this year" come from.
*
* Nextcloud sets PHP's default timezone to UTC for the whole request, so a bare
* `date('Y-m-d')` answers in UTC no matter where anybody is. For a timestamp
* that is fine. For a *day boundary* compared against a date the user typed it
* is not: at 09:00 on 2 January in Auckland it is still 1 January in UTC, so an
* employee is told their leave "is entirely in the past" for a day that has not
* finished where they live. Berlin has the same problem in the other direction
* for the last hour of the day.
*
* Which boundary is correct depends on who is asking, so the two are separate
* methods rather than one ambiguous `today()`:
*
* - `userToday()` / `userYear()` — for anything an employee sees or is judged
* against, resolved in their own timezone.
* - `serverToday()` / `serverYear()` — for background jobs and company-wide
* policy, where there is no user to ask and the server's configured timezone
* is the only sensible answer.
*
* The instant comes from ITimeFactory so tests can pin it.
*/
class ClockService {
public function __construct(
private ITimeFactory $timeFactory,
private IDateTimeZone $dateTimeZone,
) {
}

/** Today as the signed-in user sees it, 'Y-m-d'. */
public function userToday(): string {
return $this->userNow()->format('Y-m-d');
}

/** The calendar year the signed-in user is currently in. */
public function userYear(): int {
return (int)$this->userNow()->format('Y');
}

/** Today in the server's configured timezone, 'Y-m-d'. */
public function serverToday(): string {
return $this->serverNow()->format('Y-m-d');
}

/** The calendar year the server is currently in. */
public function serverYear(): int {
return (int)$this->serverNow()->format('Y');
}

public function userNow(): \DateTimeImmutable {
return $this->at($this->dateTimeZone->getTimeZone());
}

public function serverNow(): \DateTimeImmutable {
return $this->at($this->dateTimeZone->getDefaultTimeZone());
}

private function at(\DateTimeZone $timeZone): \DateTimeImmutable {
return (new \DateTimeImmutable('@' . $this->timeFactory->getTime()))->setTimezone($timeZone);
}
}
4 changes: 3 additions & 1 deletion lib/Service/EntitlementService.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ public function __construct(
private LeaveTypeMapper $leaveTypeMapper,
private BalanceService $balanceService,
private ConfigService $config,
private ClockService $clock,
private ActivityPublisher $activity,
private IUserManager $userManager,
private IGroupManager $groupManager,
Expand Down Expand Up @@ -208,7 +209,8 @@ public function expireCarryOver(int $year): int {
if ($expiry === '') {
return 0;
}
$today = date('Y-m-d');
// company policy, run from a background job: the server's today
$today = $this->clock->serverToday();
if ($today < sprintf('%04d-%s', $year, $expiry)) {
return 0;
}
Expand Down
Loading
Loading