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:
1 parent
ed684cca4f
commit
27717205cd
2 files changed
+60
-6
No files matched your search
@@ -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'));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user