- */
- public function listParticipants(int $boardId): array {
- $uids = $this->call(fn () => $this->collectParticipantUids($this->readBoard($boardId)), []);
-
- $participants = [];
- foreach ($uids as $uid) {
- if (isset($participants[$uid])) {
- continue;
- }
- $participants[$uid] = [
- 'uid' => $uid,
- 'displayName' => $this->userManager->get($uid)?->getDisplayName() ?? $uid,
- ];
- }
-
- return array_values($participants);
- }
-
- /**
- * The enriched board — `$fullDetails = true` is what populates labels and
- * the ACL. Shared by every caller that needs one so a single Deck read
- * serves them all.
- */
- private function readBoard(int $boardId): mixed {
- return $this->resolve(BoardService::class)->find($boardId, true);
- }
-
- private function readLabels(int $boardId): array {
- return $this->readBoard($boardId)->getLabels() ?? [];
- }
-
- /**
- * @return string[] may contain duplicates
- */
- private function collectParticipantUids(mixed $board): array {
- $uids = [];
- $owner = $this->unwrapUid($board->getOwner());
- if ($owner !== null) {
- $uids[] = $owner;
- }
-
- foreach ($board->getAcl() ?? [] as $entry) {
- $principal = $this->extractParticipantUid($entry);
- if ($principal === null) {
- continue;
- }
- $type = method_exists($entry, 'getType') ? (int)$entry->getType() : self::ACL_TYPE_USER;
- if ($type === self::ACL_TYPE_GROUP) {
- array_push($uids, ...$this->groupMemberUids($principal));
- } elseif ($type === self::ACL_TYPE_USER) {
- $uids[] = $principal;
- }
- // Circles and federated shares are skipped: their members
- // cannot be resolved to plain uids here.
- }
-
- return $uids;
- }
-
- /**
- * @return string[]
- */
- private function groupMemberUids(string $groupId): array {
- $group = $this->groupManager->get($groupId);
- if ($group === null) {
- return [];
- }
- return array_map(static fn (IUser $user) => $user->getUID(), $group->getUsers());
- }
-
/**
* Cards in the given stack that are neither archived, deleted nor marked
- * done, enriched so getAssignedUsers()/getLabels() are populated.
+ * done.
*
* The `deletedAt` check is not redundant: Deck's
* `CardMapper::findAllByStack()` filters on `stack_id` and
@@ -499,17 +367,11 @@ class DeckIntegrationService {
*/
public function getActiveCardsInStack(int $stackId): array {
return $this->call(function () use ($stackId) {
- $cardMapper = $this->resolve(CardMapper::class);
- $cardService = $this->resolve(CardService::class);
-
- $cards = $cardMapper->findAllByStack($stackId);
- // enrichCards() mutates each $cards entry in place (labels, assigned users, ...) but
- // *returns* CardDetails wrappers whose own fields (duedate, stackId, ...) were never
- // copied from the wrapped card - only its overridden jsonSerialize() reads through to
- // them. Capturing that return value here made every card's getDuedate()/getDaysUntilDue()
- // resolve to null, so isOverdue() was always false and no workflow ever matched a card.
- // Keep using the original, now-enriched $cards instead.
- $cardService->enrichCards($cards);
+ // Deliberately not enriched: CardService::enrichCards() only adds
+ // labels, assignees and comment counts — none of which is used —
+ // and runs BoardService::find(), a permission check against
+ // Deck's frozen uid (see beginUserContext()).
+ $cards = $this->resolve(CardMapper::class)->findAllByStack($stackId);
return array_values(array_filter($cards, static function (Card $card) {
if (method_exists($card, 'getDeletedAt') && (int)$card->getDeletedAt() > 0) {
@@ -520,35 +382,6 @@ class DeckIntegrationService {
}, []);
}
- /**
- * @return string[]
- */
- public function getCardAssignedUserIds(Card $card): array {
- $assigned = $card->getAssignedUsers() ?? [];
- $uids = [];
- foreach ($assigned as $entry) {
- $uid = $this->extractParticipantUid($entry);
- if ($uid !== null) {
- $uids[] = $uid;
- }
- }
- return $uids;
- }
-
- /**
- * @return int[]
- */
- public function getCardLabelIds(Card $card): array {
- $labels = $card->getLabels() ?? [];
- $ids = [];
- foreach ($labels as $label) {
- if (method_exists($label, 'getId')) {
- $ids[] = (int)$label->getId();
- }
- }
- return $ids;
- }
-
/**
* Moves a card to another stack using Deck's own reorder logic
* (the exact same code path behind Deck's "move card" action).
@@ -595,31 +428,6 @@ class DeckIntegrationService {
return Server::get($class);
}
- /**
- * Deck's assignment/ACL entries are internal, undocumented value
- * objects whose exact accessor shape has changed across versions, so
- * we probe the common accessor names defensively instead of relying
- * on one fixed method signature.
- */
- private function extractParticipantUid(mixed $entry): ?string {
- $uid = $this->unwrapUid($entry);
- if ($uid !== null) {
- return $uid;
- }
- if (!is_object($entry)) {
- return null;
- }
- foreach (['getParticipant', 'getUid', 'getParticipantUid', 'getUserId'] as $method) {
- if (method_exists($entry, $method)) {
- $uid = $this->unwrapUid($entry->$method());
- if ($uid !== null) {
- return $uid;
- }
- }
- }
- return null;
- }
-
/**
* Turns whatever Deck hands out for a "participant"/"owner" field into
* a plain uid.
diff --git a/lib/Service/WorkflowRunner.php b/lib/Service/WorkflowRunner.php
index 60f646a..18f9707 100644
--- a/lib/Service/WorkflowRunner.php
+++ b/lib/Service/WorkflowRunner.php
@@ -209,10 +209,6 @@ class WorkflowRunner {
return;
}
- $filterUserIds = $workflow->getFilterUserIdsArray();
- $filterLabelIds = $workflow->getFilterLabelIdsArray();
- $this->warnAboutDeadFilters($workflow, $filterUserIds, $filterLabelIds);
-
// Resolved at most once per run, and only if a card actually moves
// and the workflow wants a mail — this is decoration, not a check.
$targets = null;
@@ -224,12 +220,6 @@ class WorkflowRunner {
continue;
}
- $assignedUserIds = $this->deckService->getCardAssignedUserIds($card);
- $labelIds = $this->deckService->getCardLabelIds($card);
- if (!self::cardMatchesFilters($assignedUserIds, $labelIds, $filterUserIds, $filterLabelIds)) {
- continue;
- }
-
$this->logger->info('Card {card} ("{title}") matches workflow {id}; moving to stack {target}.', [
'app' => 'workflow_deck_automation',
'card' => $card->getId(),
@@ -327,52 +317,6 @@ class WorkflowRunner {
return true;
}
- /**
- * A filter whose every entry has been deleted from the board is not an
- * error — the workflow still runs, it just cannot match a single card any
- * more, forever, without anything in the UI or the log saying so. That is
- * the one failure mode of this app that is completely invisible, so it
- * gets a log line on every run.
- *
- * Explicitly *not* a reason to disable the workflow or mail the user: a
- * filter with three labels of which one was deleted still works as
- * intended, and even a fully dead filter may be one board edit away from
- * being live again. The settings page shows the same condition in red.
- *
- * @param string[] $filterUserIds
- * @param int[] $filterLabelIds
- */
- private function warnAboutDeadFilters(Workflow $workflow, array $filterUserIds, array $filterLabelIds): void {
- if ($filterUserIds === [] && $filterLabelIds === []) {
- return;
- }
-
- try {
- $options = $this->deckService->findFilterOptions($workflow->getBoardId());
- } catch (DeckUnavailableException $e) {
- // Same rule as everywhere else: no answer is not a bad answer.
- $this->logger->debug('Could not check the filters of workflow {id}: ' . $e->getMessage(), [
- 'app' => 'workflow_deck_automation',
- 'id' => $workflow->getId(),
- ]);
- return;
- }
-
- $dead = self::findDeadFilters($filterUserIds, $filterLabelIds, $options['participantUids'], $options['labelIds']);
-
- if ($dead !== []) {
- $this->logger->warning(
- 'Workflow {id} can no longer match any card: none of its ' . implode('/', $dead)
- . ' filter entries exists on board {board} any more',
- [
- 'app' => 'workflow_deck_automation',
- 'id' => $workflow->getId(),
- 'board' => $workflow->getBoardId(),
- ],
- );
- }
- }
-
/**
* Compares the card's actual due timestamp against $now instead of Deck's own
* Card::getDaysUntilDue(), which truncates both sides to midnight before diffing
@@ -386,64 +330,6 @@ class WorkflowRunner {
return $duedate !== null && $duedate < $now;
}
- /**
- * Which filters cannot match anything any more, because not one of their
- * entries is still offered by the board. An empty filter is not dead, it
- * is "no restriction on this dimension" — the same reading as in
- * cardMatchesFilters(), and getting it backwards would report every
- * unfiltered workflow as broken.
- *
- * Static and side-effect free for the same reason as the matching logic:
- * testable without a Deck installation.
- *
- * @param string[] $filterUserIds
- * @param int[] $filterLabelIds
- * @param string[] $boardUserIds everyone who can hold a card on the board
- * @param int[] $boardLabelIds the board's remaining labels
- * @return string[] any of 'label', 'user', in that order
- */
- public static function findDeadFilters(
- array $filterUserIds,
- array $filterLabelIds,
- array $boardUserIds,
- array $boardLabelIds,
- ): array {
- $dead = [];
- if ($filterLabelIds !== [] && array_intersect($filterLabelIds, $boardLabelIds) === []) {
- $dead[] = 'label';
- }
- if ($filterUserIds !== [] && array_intersect($filterUserIds, $boardUserIds) === []) {
- $dead[] = 'user';
- }
- return $dead;
- }
-
- /**
- * Pure filter-matching logic (kept static/side-effect free so it can
- * be unit tested without real Deck objects). Both filters are OR
- * inside themselves and AND between each other: an empty filter list
- * means "no restriction on this dimension".
- *
- * @param string[] $cardAssignedUserIds
- * @param int[] $cardLabelIds
- * @param string[] $filterUserIds
- * @param int[] $filterLabelIds
- */
- public static function cardMatchesFilters(
- array $cardAssignedUserIds,
- array $cardLabelIds,
- array $filterUserIds,
- array $filterLabelIds,
- ): bool {
- if ($filterUserIds !== [] && array_intersect($filterUserIds, $cardAssignedUserIds) === []) {
- return false;
- }
- if ($filterLabelIds !== [] && array_intersect($filterLabelIds, $cardLabelIds) === []) {
- return false;
- }
- return true;
- }
-
/**
* @throws DeckUnavailableException if Deck cannot be pinned to $user
*/
diff --git a/package.json b/package.json
index 7e4e5ff..291c706 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "workflow_deck_automation",
- "version": "35.0.0",
+ "version": "35.1.0",
"private": true,
"type": "module",
"description": "Automates moving overdue Deck cards between stacks, configurable per user.",
diff --git a/src/PersonalSettings.vue b/src/PersonalSettings.vue
index 1de209b..f19154f 100644
--- a/src/PersonalSettings.vue
+++ b/src/PersonalSettings.vue
@@ -18,7 +18,6 @@
| Titel |
Von Stapel |
Nach Stapel |
- Filter |
E-Mail |
Aktiv |
|
@@ -35,7 +34,6 @@
{{ stackLabel(workflow.boardId, workflow.sourceStackId) }} |
{{ stackLabel(workflow.boardId, workflow.targetStackId) }} |
- {{ filterLabel(workflow) }} |
{{ yesNo(workflow.notifyEmail) }} |
{{ yesNo(workflow.enabled) }} |
@@ -90,24 +88,6 @@
:disabled="!form.boardId"
placeholder="Ziel-Stapel wählen" />
-
-
-
-
Benachrichtigungs-E-Mail an mich senden
@@ -146,8 +126,6 @@ import {
createWorkflow,
deleteWorkflow,
fetchBoards,
- fetchLabels,
- fetchParticipants,
fetchStacks,
fetchWorkflows,
updateWorkflow,
@@ -159,8 +137,6 @@ const workflows = ref([])
const boards = ref([])
// Per board, and `null` whenever the request failed — see loadStacksFor().
const stacksByBoard = reactive({})
-const labelsByBoard = reactive({})
-const participantsByBoard = reactive({})
const showForm = ref(false)
const saving = ref(false)
@@ -168,9 +144,7 @@ const formError = ref('')
const editingId = ref(null)
const stackOptions = ref([])
-const labelOptions = ref([])
-const participantOptions = ref([])
-// Which board the three lists above currently belong to.
+// Which board the list above currently belongs to.
const loadedBoardId = ref(null)
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })
@@ -184,7 +158,7 @@ const boardOptions = computed(() => boards.value
/**
* Alphabetical by the columns as displayed, left to right: board, title,
- * source stack, target stack, filter, e-mail, active.
+ * source stack, target stack, e-mail, active.
*/
const sortedWorkflows = computed(() => {
const sortKeys = (workflow) => [
@@ -192,7 +166,6 @@ const sortedWorkflows = computed(() => {
workflow.title,
stackLabel(workflow.boardId, workflow.sourceStackId),
stackLabel(workflow.boardId, workflow.targetStackId),
- filterLabel(workflow),
yesNo(workflow.notifyEmail),
yesNo(workflow.enabled),
]
@@ -217,15 +190,9 @@ const sourceStackOptions = computed(() => stackOptions.value.filter((option) =>
const targetStackOptions = computed(() => stackOptions.value.filter((option) => option.value !== form.sourceStackId))
/**
- * Workflows that stopped working, keyed by id. Two different kinds:
- *
- * - Board or stacks deleted in Deck. The background job switches these off
- * by itself, but a row that just stopped working needs to say so instead
- * of looking healthy.
- * - Every entry of a filter deleted from the board. Nothing switches those
- * off — deliberately, since one deleted label out of three is harmless —
- * yet a filter that cannot match a single card any more is otherwise
- * completely invisible: the workflow keeps running and moves nothing.
+ * Workflows whose board or stacks were deleted in Deck, keyed by id. The
+ * background job switches these off by itself, but a row that just stopped
+ * working needs to say so instead of looking healthy.
*/
const workflowIssues = computed(() => {
const issues = {}
@@ -247,39 +214,13 @@ const workflowIssues = computed(() => {
}
if (missing.length) {
issues[workflow.id] = `${missing.join(' und ')} nicht mehr vorhanden`
- continue
}
}
-
- const dead = []
- if (isDeadFilter(workflow.filterLabelIds, labelsByBoard[workflow.boardId], 'id')) {
- dead.push('Label-Filter')
- }
- if (isDeadFilter(workflow.filterUserIds, participantsByBoard[workflow.boardId], 'uid')) {
- dead.push('Benutzer-Filter')
- }
- if (dead.length > 1) {
- issues[workflow.id] = `${dead.join(' und ')} treffen keine Karte mehr`
- } else if (dead.length) {
- issues[workflow.id] = `${dead[0]} trifft keine Karte mehr`
- }
}
return issues
})
-/**
- * True only on positive knowledge: the filter has entries, the board's list
- * was actually loaded, and not one entry is still on it. A failed request
- * (null) says nothing and must not turn a healthy row red.
- */
-function isDeadFilter(selected, available, key) {
- if (!selected.length || !available) {
- return false
- }
- return !selected.some((value) => available.some((entry) => entry[key] === value))
-}
-
const form = reactive(emptyForm())
function emptyForm() {
@@ -288,8 +229,6 @@ function emptyForm() {
boardId: null,
sourceStackId: null,
targetStackId: null,
- filterUserIds: [],
- filterLabelIds: [],
notifyEmail: false,
enabled: true,
}
@@ -300,17 +239,6 @@ function boardLabel(boardId) {
return board ? board.title : `#${boardId}`
}
-function filterLabel(workflow) {
- const parts = []
- if (workflow.filterUserIds.length) {
- parts.push(`${workflow.filterUserIds.length} Benutzer`)
- }
- if (workflow.filterLabelIds.length) {
- parts.push(`${workflow.filterLabelIds.length} Label`)
- }
- return parts.length ? parts.join(', ') : '–'
-}
-
function yesNo(value) {
return value ? 'Ja' : 'Nein'
}
@@ -334,23 +262,6 @@ async function loadStacksFor(boardId) {
stacksByBoard[boardId] = await fetchStacks(boardId).catch(() => null)
}
-/**
- * Labels and participants of a board — needed only to judge whether a stored
- * filter can still match anything, so this is fetched just for the boards of
- * workflows that actually use filters.
- */
-async function loadFiltersFor(boardId) {
- if (!boardId || labelsByBoard[boardId]) {
- return
- }
- const [labels, participants] = await Promise.all([
- fetchLabels(boardId).catch(() => null),
- fetchParticipants(boardId).catch(() => null),
- ])
- labelsByBoard[boardId] = labels
- participantsByBoard[boardId] = participants
-}
-
async function loadAll() {
loading.value = true
loadError.value = ''
@@ -358,13 +269,7 @@ async function loadAll() {
const [loadedWorkflows, loadedBoards] = await Promise.all([fetchWorkflows(), fetchBoards()])
workflows.value = loadedWorkflows
boards.value = loadedBoards
- await Promise.all(loadedWorkflows.flatMap((workflow) => {
- const pending = [loadStacksFor(workflow.boardId)]
- if (workflow.filterLabelIds.length || workflow.filterUserIds.length) {
- pending.push(loadFiltersFor(workflow.boardId))
- }
- return pending
- }))
+ await Promise.all(loadedWorkflows.map((workflow) => loadStacksFor(workflow.boardId)))
} catch (e) {
loadError.value = 'Konnte Workflows oder Deck-Boards nicht laden. Ist die Deck-App aktiviert?'
} finally {
@@ -373,36 +278,25 @@ async function loadAll() {
}
/**
- * Fills the stack/label/user dropdowns for a board without touching the
- * current selection — that separation matters: editing a workflow has to
- * load the same options but must keep the stored stacks and filters.
+ * Fills the stack dropdowns for a board without touching the current
+ * selection — that separation matters: editing a workflow has to load the
+ * same options but must keep the stored stacks.
*/
async function loadBoardOptions(boardId) {
stackOptions.value = []
- labelOptions.value = []
- participantOptions.value = []
loadedBoardId.value = boardId ?? null
if (!boardId) {
return
}
- // One failing endpoint must not take the other two dropdowns down with it.
- const [stacks, labels, participants] = await Promise.all([
- fetchStacks(boardId).catch(() => null),
- fetchLabels(boardId).catch(() => null),
- fetchParticipants(boardId).catch(() => null),
- ])
+ const stacks = await fetchStacks(boardId).catch(() => null)
- // Feed the overview's caches too, so its red markers agree with what the
+ // Feed the overview's cache too, so its red markers agree with what the
// form just loaded. `null` propagates on purpose: it means "unknown".
stacksByBoard[boardId] = stacks
- labelsByBoard[boardId] = labels
- participantsByBoard[boardId] = participants
stackOptions.value = (stacks ?? []).map((stack) => ({ value: stack.id, label: stack.title }))
- labelOptions.value = (labels ?? []).map((label) => ({ value: label.id, label: label.title }))
- participantOptions.value = (participants ?? []).map((participant) => ({ value: participant.uid, label: participant.displayName }))
}
/**
@@ -417,8 +311,6 @@ async function onBoardChange(boardId) {
form.sourceStackId = null
form.targetStackId = null
- form.filterUserIds = []
- form.filterLabelIds = []
await loadBoardOptions(boardId)
}
@@ -437,8 +329,6 @@ async function editWorkflow(workflow) {
boardId: workflow.boardId,
sourceStackId: workflow.sourceStackId,
targetStackId: workflow.targetStackId,
- filterUserIds: [...workflow.filterUserIds],
- filterLabelIds: [...workflow.filterLabelIds],
notifyEmail: workflow.notifyEmail,
enabled: workflow.enabled,
})
diff --git a/src/api.js b/src/api.js
index da482c2..fea0634 100644
--- a/src/api.js
+++ b/src/api.js
@@ -33,13 +33,3 @@ export async function fetchStacks(boardId) {
const { data } = await axios.get(ocsUrl(`/boards/${boardId}/stacks`))
return data.ocs.data
}
-
-export async function fetchLabels(boardId) {
- const { data } = await axios.get(ocsUrl(`/boards/${boardId}/labels`))
- return data.ocs.data
-}
-
-export async function fetchParticipants(boardId) {
- const { data } = await axios.get(ocsUrl(`/boards/${boardId}/participants`))
- return data.ocs.data
-}
diff --git a/tests/Unit/Service/WorkflowRunnerFilterTest.php b/tests/Unit/Service/WorkflowRunnerFilterTest.php
deleted file mode 100644
index 93b01b3..0000000
--- a/tests/Unit/Service/WorkflowRunnerFilterTest.php
+++ /dev/null
@@ -1,83 +0,0 @@
-assertFalse(WorkflowRunner::isOverdue(null, new DateTimeImmutable('2026-01-15 12:00:00')));
- }
-
- public function testIsOverdueReturnsFalseForFutureDueDate(): void {
- $now = new DateTimeImmutable('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');
- $this->assertTrue(WorkflowRunner::isOverdue($now->modify('-1 hour'), $now));
- $this->assertTrue(WorkflowRunner::isOverdue($now->modify('-30 days'), $now));
- }
-
- public function testIsOverdueReturnsTrueForADueTimeEarlierTheSameDay(): 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.
- $now = new DateTimeImmutable('2026-01-15 12:00:00');
- $this->assertTrue(WorkflowRunner::isOverdue(new DateTimeImmutable('2026-01-15 06:00:00'), $now));
- }
-
- public function testNoFiltersMatchesAnyCard(): void {
- $this->assertTrue(WorkflowRunner::cardMatchesFilters(['alice'], [1, 2], [], []));
- $this->assertTrue(WorkflowRunner::cardMatchesFilters([], [], [], []));
- }
-
- public function testUserFilterIsOrAgainstAssignedUsers(): void {
- $this->assertTrue(WorkflowRunner::cardMatchesFilters(['alice', 'bob'], [], ['bob', 'carol'], []));
- $this->assertFalse(WorkflowRunner::cardMatchesFilters(['alice'], [], ['bob', 'carol'], []));
- }
-
- public function testLabelFilterIsOrAgainstCardLabels(): void {
- $this->assertTrue(WorkflowRunner::cardMatchesFilters([], [1, 2], [], [2, 3]));
- $this->assertFalse(WorkflowRunner::cardMatchesFilters([], [1], [], [2, 3]));
- }
-
- public function testUserAndLabelFiltersAreCombinedWithAnd(): void {
- // user matches, label does not -> overall false
- $this->assertFalse(WorkflowRunner::cardMatchesFilters(['alice'], [1], ['alice'], [99]));
- // both match -> true
- $this->assertTrue(WorkflowRunner::cardMatchesFilters(['alice'], [1], ['alice'], [1]));
- }
-
- public function testEmptyFiltersAreNeverDead(): void {
- // "no restriction" must not be reported as a broken filter, however
- // little the board has left to offer.
- $this->assertSame([], WorkflowRunner::findDeadFilters([], [], [], []));
- $this->assertSame([], WorkflowRunner::findDeadFilters([], [], ['alice'], [1]));
- }
-
- public function testFilterSurvivesAsLongAsOneEntryRemains(): void {
- $this->assertSame([], WorkflowRunner::findDeadFilters(['alice', 'bob'], [1, 2], ['bob'], [2]));
- }
-
- public function testFilterIsDeadWhenEveryEntryIsGone(): void {
- $this->assertSame(['label'], WorkflowRunner::findDeadFilters([], [1, 2], [], [3]));
- $this->assertSame(['user'], WorkflowRunner::findDeadFilters(['alice'], [], ['bob'], []));
- $this->assertSame(
- ['label', 'user'],
- WorkflowRunner::findDeadFilters(['alice'], [1], ['bob'], [2]),
- );
- }
-
- public function testAnEmptyBoardKillsOnlyTheFiltersThatAreSet(): void {
- $this->assertSame(['label'], WorkflowRunner::findDeadFilters([], [1], [], []));
- }
-}
diff --git a/tests/Unit/Service/WorkflowRunnerTest.php b/tests/Unit/Service/WorkflowRunnerTest.php
new file mode 100644
index 0000000..75870de
--- /dev/null
+++ b/tests/Unit/Service/WorkflowRunnerTest.php
@@ -0,0 +1,37 @@
+assertFalse(WorkflowRunner::isOverdue(null, new DateTimeImmutable('2026-01-15 12:00:00')));
+ }
+
+ public function testIsOverdueReturnsFalseForFutureDueDate(): void {
+ $now = new DateTimeImmutable('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');
+ $this->assertTrue(WorkflowRunner::isOverdue($now->modify('-1 hour'), $now));
+ $this->assertTrue(WorkflowRunner::isOverdue($now->modify('-30 days'), $now));
+ }
+
+ public function testIsOverdueReturnsTrueForADueTimeEarlierTheSameDay(): 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.
+ $now = new DateTimeImmutable('2026-01-15 12:00:00');
+ $this->assertTrue(WorkflowRunner::isOverdue(new DateTimeImmutable('2026-01-15 06:00:00'), $now));
+ }
+}
|