Read Deck due dates as UTC regardless of PHP's default timezone

Deck stores duedate as a zoneless DATETIME holding UTC wall-clock time,
and Entity hydrates it in PHP's default zone. That is only correct
because Nextcloud forces the default to UTC; pin the wall clock to UTC
explicitly so the overdue check compares real instants either way.
This commit is contained in:
Patrick Niebeling committed 2026-09-30 10:27:13 +02:00
1 parent ed684cca4f
commit 27717205cd
2 files changed
+60 -6

No files matched your search

+25 -1
View File
@@ -4,7 +4,9 @@ declare(strict_types=1);
namespace OCA\WorkflowDeckAutomation\Service;
use DateTimeImmutable;
use DateTimeInterface;
use DateTimeZone;
use OCA\Deck\Db\Card;
use OCA\WorkflowDeckAutomation\Db\Workflow;
use OCA\WorkflowDeckAutomation\Db\WorkflowMapper;
@@ -325,9 +327,31 @@ class WorkflowRunner {
* every card for up to 24h after it actually fell due; comparing the raw datetime
* instead matches what Deck's own UI shows (e.g. "vor 2 Stunden") and has no
* day-rounding step to get wrong.
*
* Both sides are compared as absolute instants, and the due date is first
* pinned to UTC via asStoredUtc() — see there for why.
*/
public static function isOverdue(?DateTimeInterface $duedate, DateTimeInterface $now): bool {
return $duedate !== null && $duedate < $now;
return $duedate !== null && self::asStoredUtc($duedate) < $now;
}
/**
* Deck's `duedate` is a DATETIME column without a zone that holds UTC
* wall-clock time: Deck's frontend sends `toISOString()`, and the value is
* written as plain `Y-m-d H:i:s`. Entity hydrates it with
* `new \DateTime($string)`, i.e. in PHP's *default* timezone — correct only
* because Nextcloud sets that default to UTC during bootstrap. Reading the
* wall clock and pinning it to UTC explicitly makes the comparison
* independent of that default, so a server or CLI running with a local
* default zone cannot shift every due date by its UTC offset.
*
* Deck itself has two timezone-sensitive paths this deliberately avoids:
* Card::getDaysUntilDue() (day-truncated, see isOverdue()) and
* CardMapper::findOverdue(), which compares the UTC column against the
* database's `NOW()` — the DB session's local time.
*/
private static function asStoredUtc(DateTimeInterface $duedate): DateTimeImmutable {
return new DateTimeImmutable($duedate->format('Y-m-d H:i:s.u'), new DateTimeZone('UTC'));
}
/**
+35 -5
View File
@@ -5,22 +5,27 @@ declare(strict_types=1);
namespace OCA\WorkflowDeckAutomation\Tests\Unit\Service;
use DateTimeImmutable;
use DateTimeZone;
use OCA\WorkflowDeckAutomation\Service\WorkflowRunner;
use PHPUnit\Framework\TestCase;
class WorkflowRunnerTest extends TestCase {
private static function utc(string $time): DateTimeImmutable {
return new DateTimeImmutable($time, new DateTimeZone('UTC'));
}
public function testIsOverdueReturnsFalseWithoutDueDate(): void {
$this->assertFalse(WorkflowRunner::isOverdue(null, new DateTimeImmutable('2026-01-15 12:00:00')));
$this->assertFalse(WorkflowRunner::isOverdue(null, self::utc('2026-01-15 12:00:00')));
}
public function testIsOverdueReturnsFalseForFutureDueDate(): void {
$now = new DateTimeImmutable('2026-01-15 12:00:00');
$now = self::utc('2026-01-15 12:00:00');
$this->assertFalse(WorkflowRunner::isOverdue($now->modify('+1 hour'), $now));
$this->assertFalse(WorkflowRunner::isOverdue($now->modify('+3 days'), $now));
}
public function testIsOverdueReturnsTrueForPastDueDate(): void {
$now = new DateTimeImmutable('2026-01-15 12:00:00');
$now = self::utc('2026-01-15 12:00:00');
$this->assertTrue(WorkflowRunner::isOverdue($now->modify('-1 hour'), $now));
$this->assertTrue(WorkflowRunner::isOverdue($now->modify('-30 days'), $now));
}
@@ -31,7 +36,32 @@ class WorkflowRunnerTest extends TestCase {
// "0 days until due", not overdue, until the calendar date rolls
// over - holding every card back for up to 24h after it actually
// fell due. isOverdue() must compare the real timestamps instead.
$now = new DateTimeImmutable('2026-01-15 12:00:00');
$this->assertTrue(WorkflowRunner::isOverdue(new DateTimeImmutable('2026-01-15 06:00:00'), $now));
$now = self::utc('2026-01-15 12:00:00');
$this->assertTrue(WorkflowRunner::isOverdue(self::utc('2026-01-15 06:00:00'), $now));
}
public function testIsOverdueReadsTheStoredWallClockAsUtc(): void {
// What Entity hydration produces if PHP's default zone is not UTC:
// the stored UTC wall clock "12:00", tagged with the local zone.
$due = new DateTimeImmutable('2026-07-15 12:00:00', new DateTimeZone('Europe/Berlin'));
// Taken at face value that would be 10:00 UTC and already overdue.
$this->assertFalse(WorkflowRunner::isOverdue($due, self::utc('2026-07-15 11:30:00')));
$this->assertTrue(WorkflowRunner::isOverdue($due, self::utc('2026-07-15 12:00:01')));
}
public function testIsOverdueComparesNowAsAnAbsoluteInstant(): void {
$due = self::utc('2026-07-15 12:00:00');
$berlin = new DateTimeZone('Europe/Berlin');
// 13:30 in Berlin (CEST) is 11:30 UTC - not yet due.
$this->assertFalse(WorkflowRunner::isOverdue($due, new DateTimeImmutable('2026-07-15 13:30:00', $berlin)));
// 14:30 in Berlin is 12:30 UTC - due.
$this->assertTrue(WorkflowRunner::isOverdue($due, new DateTimeImmutable('2026-07-15 14:30:00', $berlin)));
}
public function testIsOverdueIsFalseAtTheExactDueInstant(): void {
$now = self::utc('2026-01-15 12:00:00');
$this->assertFalse(WorkflowRunner::isOverdue($now, $now));
}
}