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;
|
namespace OCA\WorkflowDeckAutomation\Service;
|
||||||
|
|
||||||
|
use DateTimeImmutable;
|
||||||
use DateTimeInterface;
|
use DateTimeInterface;
|
||||||
|
use DateTimeZone;
|
||||||
use OCA\Deck\Db\Card;
|
use OCA\Deck\Db\Card;
|
||||||
use OCA\WorkflowDeckAutomation\Db\Workflow;
|
use OCA\WorkflowDeckAutomation\Db\Workflow;
|
||||||
use OCA\WorkflowDeckAutomation\Db\WorkflowMapper;
|
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
|
* 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
|
* instead matches what Deck's own UI shows (e.g. "vor 2 Stunden") and has no
|
||||||
* day-rounding step to get wrong.
|
* 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 {
|
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;
|
namespace OCA\WorkflowDeckAutomation\Tests\Unit\Service;
|
||||||
|
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
|
use DateTimeZone;
|
||||||
use OCA\WorkflowDeckAutomation\Service\WorkflowRunner;
|
use OCA\WorkflowDeckAutomation\Service\WorkflowRunner;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
class WorkflowRunnerTest extends TestCase {
|
class WorkflowRunnerTest extends TestCase {
|
||||||
|
private static function utc(string $time): DateTimeImmutable {
|
||||||
|
return new DateTimeImmutable($time, new DateTimeZone('UTC'));
|
||||||
|
}
|
||||||
|
|
||||||
public function testIsOverdueReturnsFalseWithoutDueDate(): void {
|
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 {
|
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('+1 hour'), $now));
|
||||||
$this->assertFalse(WorkflowRunner::isOverdue($now->modify('+3 days'), $now));
|
$this->assertFalse(WorkflowRunner::isOverdue($now->modify('+3 days'), $now));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testIsOverdueReturnsTrueForPastDueDate(): void {
|
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('-1 hour'), $now));
|
||||||
$this->assertTrue(WorkflowRunner::isOverdue($now->modify('-30 days'), $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
|
// "0 days until due", not overdue, until the calendar date rolls
|
||||||
// over - holding every card back for up to 24h after it actually
|
// over - holding every card back for up to 24h after it actually
|
||||||
// fell due. isOverdue() must compare the real timestamps instead.
|
// fell due. isOverdue() must compare the real timestamps instead.
|
||||||
$now = new DateTimeImmutable('2026-01-15 12:00:00');
|
$now = self::utc('2026-01-15 12:00:00');
|
||||||
$this->assertTrue(WorkflowRunner::isOverdue(new DateTimeImmutable('2026-01-15 06:00:00'), $now));
|
$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