diff --git a/lib/Service/WorkflowRunner.php b/lib/Service/WorkflowRunner.php index 18f9707..284f638 100644 --- a/lib/Service/WorkflowRunner.php +++ b/lib/Service/WorkflowRunner.php @@ -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')); } /** diff --git a/tests/Unit/Service/WorkflowRunnerTest.php b/tests/Unit/Service/WorkflowRunnerTest.php index 75870de..f326f05 100644 --- a/tests/Unit/Service/WorkflowRunnerTest.php +++ b/tests/Unit/Service/WorkflowRunnerTest.php @@ -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)); } }