Let workflows move cards by start date instead of due date
Build package / xml-lint (push) Successful in 3m32s
Build package / unit-tests (push) Successful in 4m49s
Build package / php-lint (8.5) (push) Successful in 4m52s
Build package / php-lint (8.4) (push) Successful in 4m56s
Build package / package (push) Successful in 2m28s

Each workflow now has a trigger date (due or start): the runner compares
either the card's due date or its start date against now, read straight
from Deck's Card entity. Mails and Nextcloud notifications say whether
the card was overdue or reached its start date. Cards without the chosen
date stay put.

Deck only has a start date since 1.18 and Entity getters are magic, so
DeckIntegrationService::supportsStartdate() checks the property; without
it the form rejects start-date workflows and the runner skips them
without disabling them.

WorkflowRunner::isOverdue() becomes hasPassed(), as it now covers both
dates. Existing workflows keep the due date (migration Version1005).
Bumps to 35.4.0 so the migration runs.
This commit is contained in:
Patrick Niebeling committed 2026-10-01 13:19:32 +02:00
1 parent 5bd3181f10
commit 8268f1068d
18 files changed
+286 -71

No files matched your search

+24 -24
View File
@@ -14,54 +14,54 @@ class WorkflowRunnerTest extends TestCase {
return new DateTimeImmutable($time, new DateTimeZone('UTC'));
}
public function testIsOverdueReturnsFalseWithoutDueDate(): void {
$this->assertFalse(WorkflowRunner::isOverdue(null, self::utc('2026-01-15 12:00:00')));
public function testHasPassedReturnsFalseWithoutDate(): void {
$this->assertFalse(WorkflowRunner::hasPassed(null, self::utc('2026-01-15 12:00:00')));
}
public function testIsOverdueReturnsFalseForFutureDueDate(): void {
public function testHasPassedReturnsFalseForFutureDate(): void {
$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));
$this->assertFalse(WorkflowRunner::hasPassed($now->modify('+1 hour'), $now));
$this->assertFalse(WorkflowRunner::hasPassed($now->modify('+3 days'), $now));
}
public function testIsOverdueReturnsTrueForPastDueDate(): void {
public function testHasPassedReturnsTrueForPastDate(): void {
$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));
$this->assertTrue(WorkflowRunner::hasPassed($now->modify('-1 hour'), $now));
$this->assertTrue(WorkflowRunner::hasPassed($now->modify('-30 days'), $now));
}
public function testIsOverdueReturnsTrueForADueTimeEarlierTheSameDay(): void {
public function testHasPassedReturnsTrueForATimeEarlierTheSameDay(): void {
// Regression: comparing whole calendar days (as Deck's own
// Card::getDaysUntilDue() does) reports a card due earlier today as
// "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.
// fell due. hasPassed() must compare the real timestamps instead.
$now = self::utc('2026-01-15 12:00:00');
$this->assertTrue(WorkflowRunner::isOverdue(self::utc('2026-01-15 06:00:00'), $now));
$this->assertTrue(WorkflowRunner::hasPassed(self::utc('2026-01-15 06:00:00'), $now));
}
public function testIsOverdueReadsTheStoredWallClockAsUtc(): void {
public function testHasPassedReadsTheStoredWallClockAsUtc(): 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'));
$date = 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')));
// Taken at face value that would be 10:00 UTC and already passed.
$this->assertFalse(WorkflowRunner::hasPassed($date, self::utc('2026-07-15 11:30:00')));
$this->assertTrue(WorkflowRunner::hasPassed($date, self::utc('2026-07-15 12:00:01')));
}
public function testIsOverdueComparesNowAsAnAbsoluteInstant(): void {
$due = self::utc('2026-07-15 12:00:00');
public function testHasPassedComparesNowAsAnAbsoluteInstant(): void {
$date = 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)));
// 13:30 in Berlin (CEST) is 11:30 UTC - not yet passed.
$this->assertFalse(WorkflowRunner::hasPassed($date, new DateTimeImmutable('2026-07-15 13:30:00', $berlin)));
// 14:30 in Berlin is 12:30 UTC - passed.
$this->assertTrue(WorkflowRunner::hasPassed($date, new DateTimeImmutable('2026-07-15 14:30:00', $berlin)));
}
public function testIsOverdueIsFalseAtTheExactDueInstant(): void {
public function testHasPassedIsFalseAtTheExactInstant(): void {
$now = self::utc('2026-01-15 12:00:00');
$this->assertFalse(WorkflowRunner::isOverdue($now, $now));
$this->assertFalse(WorkflowRunner::hasPassed($now, $now));
}
}