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
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:
1 parent
5bd3181f10
commit
8268f1068d
18 files changed
+286
-71
No files matched your search
@@ -4,7 +4,7 @@ Leitfaden für Claude Code (claude.ai/code) in diesem Repository.
|
||||
|
||||
## Worum es geht
|
||||
|
||||
Nextcloud-App `workflow_deck_automation` (Namespace `WorkflowDeckAutomation`) für **Nextcloud 35** (`main`, `min-version="35" max-version="35"`). Jeder Nutzer legt in seinen persönlichen Einstellungen „Workflows“ an, die überfällige [Deck](https://github.com/nextcloud/deck)-Karten von einem Stack in einen anderen verschieben, optional mit Mail. Es gibt bewusst **keine Kartenfilter**: ein Workflow verschiebt *jede* überfällige Karte seines Quell-Stacks. Ein Hintergrund-`Job` wertet alle aktiven Workflows bei jedem Cron-Lauf aus.
|
||||
Nextcloud-App `workflow_deck_automation` (Namespace `WorkflowDeckAutomation`) für **Nextcloud 35** (`main`, `min-version="35" max-version="35"`). Jeder Nutzer legt in seinen persönlichen Einstellungen „Workflows“ an, die [Deck](https://github.com/nextcloud/deck)-Karten von einem Stack in einen anderen verschieben, sobald ihr Fälligkeits- oder Startdatum erreicht ist (`trigger_date`, pro Workflow), optional mit Mail. Es gibt bewusst **keine Kartenfilter**: ein Workflow verschiebt *jede* Karte seines Quell-Stacks, deren Datum erreicht ist. Ein Hintergrund-`Job` wertet alle aktiven Workflows bei jedem Cron-Lauf aus.
|
||||
|
||||
## Harte Vorgabe: keine HTTP/OCS-Aufrufe gegen Deck
|
||||
|
||||
@@ -25,7 +25,7 @@ Alle Lesezugriffe (Boards, Stacks, Karten) und das Verschieben laufen über Deck
|
||||
|
||||
**Regel:** Bevor ein weiterer `OCA\Deck\*`-Dienst im Runner genutzt wird, prüfen, ob er eine eingefrorene `$userId` trägt – der Fehler ist still und nutzerübergreifend. Bevorzugt Mapper (QBMapper ohne Nutzerzustand) oder Methoden mit explizitem uid-Parameter verwenden. Deshalb wird `BoardService::getUserBoards()` nicht verwendet.
|
||||
|
||||
Die Fälligkeitslogik `WorkflowRunner::isOverdue()` und die Empfängerregel `NotificationPolicy::shouldNotify()` sind bewusst `static` und seiteneffektfrei, also ohne Deck unit-testbar (`tests/Unit/Service/`). Das sind die einzigen getesteten Teile der App. Neue reine Logik in dieser Form halten.
|
||||
Der Datumsvergleich `WorkflowRunner::hasPassed()`, `TriggerDate` und die Empfängerregel `NotificationPolicy::shouldNotify()` sind bewusst `static` und seiteneffektfrei, also ohne Deck unit-testbar (`tests/Unit/Service/`). Das sind die einzigen getesteten Teile der App. Neue reine Logik in dieser Form halten.
|
||||
|
||||
## Workflows werden deaktiviert, wenn Board/Stack verschwinden
|
||||
|
||||
@@ -71,9 +71,17 @@ Wird ein Board in Deck übertragen, wandern seine Workflows mit.
|
||||
|
||||
Die App wird nur intern genutzt und spricht ausschließlich Deutsch. Oberfläche, Controller-Fehlermeldungen (erscheinen im Formular), Sektionsname, Mails und Nextcloud-Benachrichtigungen sind direkt auf Deutsch geschrieben. Es gibt kein `IL10N` und keinen `l10n/`-Ordner. Nextclouds eigene Mail-Fußzeile wird per `addFooter('', 'de')` ebenfalls deutsch, sonst würde sie im Cron der Instanz-Standardsprache folgen. Log-Meldungen bleiben englisch. Neue nutzersichtbare Texte auf Deutsch schreiben, mit „du“ wie im Rest der App.
|
||||
|
||||
## Fälligkeits- oder Startdatum löst das Verschieben aus
|
||||
|
||||
`trigger_date` (`TriggerDate::ALL`: `due`/`start`, Default und Migrationswert `due`, `Version1005…`) wählt pro Workflow, welches Kartendatum der Runner mit „jetzt“ vergleicht. Unbekannte Werte zählen als `due`. Karten ohne das gewählte Datum bleiben liegen.
|
||||
|
||||
- **`startdate` gibt es erst seit Deck 1.18.** `Card`-Getter sind magisch (`Entity::__call`), `method_exists()` sieht sie nicht und `getStartdate()` würde erst beim Aufruf werfen. Daher `DeckIntegrationService::supportsStartdate()` (`property_exists`). Der Runner liest `getStartdate()` nur bei `start` und überspringt solche Workflows ohne Deaktivierung, wenn Deck das Feld nicht kennt. Der Controller lehnt `start` dann ab.
|
||||
- **Texte:** `TriggerDate::describeReason()` liefert den Satzteil („ist überfällig“ / „hat ihr Startdatum erreicht“) für Mail und Notifier. `NotificationPusher` speichert `triggerDate` in den Subject-Parametern. Ältere Benachrichtigungen ohne den Schlüssel gelten als `due`.
|
||||
- **Empfänger:** Auch Startdatums-Workflows nutzen bei `notify_mode = board` Decks `notify-due`-Einstellung. Deck hat keine eigene für das Startdatum.
|
||||
|
||||
## Fälligkeit wird als UTC-Zeitpunkt verglichen
|
||||
|
||||
Decks `duedate` ist eine `DATETIME`-Spalte ohne Zeitzone mit UTC-Uhrzeit (Frontend sendet `toISOString()`). `Entity` baut daraus `new \DateTime($string)` in PHPs Standardzone, die Nextcloud auf UTC setzt. `WorkflowRunner::asStoredUtc()` legt die Uhrzeit trotzdem ausdrücklich auf UTC fest, damit das Ergebnis nicht von dieser Standardzone abhängt. Decks eigene Pfade `Card::getDaysUntilDue()` (tagesgenau) und `CardMapper::findOverdue()` (vergleicht mit dem `NOW()` der Datenbank, also Ortszeit) sind dafür ungeeignet und werden nicht verwendet.
|
||||
Decks `duedate` und `startdate` sind `DATETIME`-Spalten ohne Zeitzone mit UTC-Uhrzeit (Frontend sendet `toISOString()`). `Entity` baut daraus `new \DateTime($string)` in PHPs Standardzone, die Nextcloud auf UTC setzt. `WorkflowRunner::asStoredUtc()` legt die Uhrzeit trotzdem ausdrücklich auf UTC fest, damit das Ergebnis nicht von dieser Standardzone abhängt. Decks eigene Pfade `Card::getDaysUntilDue()` (tagesgenau) und `CardMapper::findOverdue()` (vergleicht mit dem `NOW()` der Datenbank, also Ortszeit) sind dafür ungeeignet und werden nicht verwendet.
|
||||
|
||||
## Kartenfilter wurden entfernt
|
||||
|
||||
|
||||
+4
-1
@@ -21,6 +21,7 @@ Die Tabelle `wfda_workflows` enthält eine Zeile pro Workflow:
|
||||
| --- | --- |
|
||||
| `board_id`, `source_stack_id`, `target_stack_id` | Deck-IDs |
|
||||
| `user_id` | Board-Eigentümer, in dessen Namen der Job läuft. Wird bei jedem Lauf aus Deck aktualisiert. |
|
||||
| `trigger_date` | `due` oder `start`: Fälligkeits- oder Startdatum löst das Verschieben aus |
|
||||
| `notify_mode` | `off`, `owner` oder `board` (wer) |
|
||||
| `notify_channel` | `mail`, `notification` oder `both` (wie) |
|
||||
| `enabled`, `last_run` | aktiv? / letzter Lauf |
|
||||
@@ -30,6 +31,7 @@ Migrationen in [`lib/Migration/`](lib/Migration/):
|
||||
- `Version1001…` entfernt die früheren Filter-Spalten.
|
||||
- `Version1002…` und `Version1003…` ersetzen `notify_email` durch `notify_mode`. Ein gesetztes Häkchen wird dabei zu `owner`.
|
||||
- `Version1004…` fügt `notify_channel` hinzu. Bestehende Workflows bekommen `mail`.
|
||||
- `Version1005…` fügt `trigger_date` hinzu. Bestehende Workflows bekommen `due`.
|
||||
|
||||
Migrationen laufen nur, wenn die App-Version steigt.
|
||||
|
||||
@@ -53,7 +55,8 @@ composer run test:unit # PHPUnit
|
||||
```
|
||||
|
||||
Die Unit-Tests decken nur reine Logik ab, ohne Deck-Installation:
|
||||
- Fälligkeitsprüfung (`WorkflowRunner::isOverdue()`)
|
||||
- Datumsvergleich für Fälligkeits- und Startdatum (`WorkflowRunner::hasPassed()`)
|
||||
- Auslöser und Begründungstext (`TriggerDate`)
|
||||
- Empfängerregel (`NotificationPolicy::shouldNotify()`)
|
||||
- Benachrichtigungsweg (`NotificationPolicy::sendsMail()`/`sendsNotification()`)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Deck Workflow-Automatisierung
|
||||
|
||||
Nextcloud-App, die überfällige Karten der [Deck](https://apps.nextcloud.com/apps/deck)-App automatisch von einem Stapel in einen anderen verschiebt und auf Wunsch per E-Mail oder Nextcloud-Benachrichtigung (Push) darüber informiert.
|
||||
Nextcloud-App, die Karten der [Deck](https://apps.nextcloud.com/apps/deck)-App automatisch von einem Stapel in einen anderen verschiebt, sobald ihr Fälligkeits- oder Startdatum erreicht ist, und auf Wunsch per E-Mail oder Nextcloud-Benachrichtigung (Push) darüber informiert.
|
||||
|
||||
Die Regeln („Workflows“) richtet der **Eigentümer eines Boards** selbst in seinen **persönlichen Einstellungen** ein. Admin-Rechte braucht er dafür nicht. Für geteilte Boards anderer Nutzer lassen sich keine Workflows anlegen.
|
||||
|
||||
@@ -8,11 +8,16 @@ Die Regeln („Workflows“) richtet der **Eigentümer eines Boards** selbst in
|
||||
|
||||
## Was die App macht
|
||||
|
||||
Ein Workflow besteht aus einem Board, einem **Quell-Stapel** und einem **Ziel-Stapel**. Bei jedem Durchlauf verschiebt die App **alle überfälligen Karten** des Quell-Stapels oben in den Ziel-Stapel.
|
||||
Ein Workflow besteht aus einem Board, einem **Quell-Stapel**, einem **Ziel-Stapel** und dem **Datum**, das das Verschieben auslöst. Bei jedem Durchlauf verschiebt die App **alle Karten** des Quell-Stapels, bei denen dieses Datum erreicht ist, oben in den Ziel-Stapel.
|
||||
|
||||
Überfällig ist eine Karte, deren Fälligkeitsdatum samt Uhrzeit überschritten ist. Karten ohne Fälligkeitsdatum sowie erledigte, archivierte oder gelöschte Karten bleiben liegen.
|
||||
- **Fälligkeitsdatum** (Standard): Die Karte wird verschoben, sobald sie überfällig ist, also ihr Fälligkeitsdatum samt Uhrzeit überschritten ist.
|
||||
- **Startdatum**: Die Karte wird verschoben, sobald ihr Startdatum samt Uhrzeit erreicht ist. Das Startdatum setzt du in Deck in den Kartendetails neben dem Fälligkeitsdatum.
|
||||
|
||||
Beispiel: Ein Workflow „Waiting → ToDo“ holt jede Karte aus „Waiting / Blocked“ nach „Work in Progress / ToDo“, sobald ihr Fälligkeitsdatum erreicht ist.
|
||||
Karten ohne das gewählte Datum sowie erledigte, archivierte oder gelöschte Karten bleiben liegen.
|
||||
|
||||
Beispiele:
|
||||
- Ein Workflow „Waiting → ToDo“ holt jede Karte aus „Waiting / Blocked“ nach „Work in Progress / ToDo“, sobald ihr Fälligkeitsdatum erreicht ist.
|
||||
- Ein Workflow „Geplant → In Arbeit“ mit Startdatum holt jede Karte aus „Geplant“ nach „In Arbeit“, sobald an ihrem Startdatum die Arbeit beginnen soll.
|
||||
|
||||
## Einrichtung (für Administratoren)
|
||||
|
||||
@@ -30,6 +35,7 @@ Die Einstellungen liegen unter **Persönliche Einstellungen → Deck Workflow-Au
|
||||
|
||||
- **Board:** Zur Auswahl stehen nur Boards, deren **Eigentümer** du bist. Geteilte, archivierte und gelöschte Boards erscheinen nicht.
|
||||
- **Quell- und Ziel-Stapel:** Das müssen zwei verschiedene Stapel desselben Boards sein.
|
||||
- **Karte verschieben, wenn:** das Fälligkeitsdatum überschritten oder das Startdatum erreicht ist.
|
||||
- **Benachrichtigung bei verschobenen Karten** und **Benachrichtigen per:** siehe unten.
|
||||
- **Workflow aktiv:** Deaktivierte Workflows bleiben gespeichert, verschieben aber nichts.
|
||||
|
||||
@@ -55,6 +61,8 @@ Bei der dritten Stufe entscheidet jedes Board-Mitglied selbst. Dazu gehören der
|
||||
|
||||
Bei einem Board, das mit niemandem geteilt ist, bekommt der Eigentümer jede Karte gemeldet, außer er hat die Erinnerungen ausgeschaltet. So verhält sich auch Deck selbst.
|
||||
|
||||
Das gilt genauso für Workflows mit Startdatum. Deck hat für das Startdatum keine eigene Benachrichtigungseinstellung.
|
||||
|
||||
### Wie?
|
||||
|
||||
| Einstellung | Was kommt an? |
|
||||
@@ -69,9 +77,13 @@ Die Empfänger sind in allen drei Fällen dieselben.
|
||||
|
||||
> „Essensvorräte prüfen“ (Board „Persönlich“) ist überfällig und wurde von „Waiting / Blocked“ nach „Work in Progress / ToDo“ verschoben.
|
||||
|
||||
Bei Workflows mit Startdatum heißt es stattdessen:
|
||||
|
||||
> „Essensvorräte prüfen“ (Board „Persönlich“) hat ihr Startdatum erreicht und wurde von „Geplant“ nach „In Arbeit“ verschoben.
|
||||
|
||||
Der Kartentitel ist ein Link direkt zur Karte in Deck. Die Kartenbeschreibung wird nicht mitgeschickt.
|
||||
|
||||
**Inhalt der Nextcloud-Benachrichtigung:** „*Kartentitel* wurde nach *Ziel-Stapel* verschoben“, darunter Board und Quell-Stapel. Ein Klick öffnet die Karte. Wird dieselbe Karte später noch einmal verschoben, ersetzt die neue Benachrichtigung die alte.
|
||||
**Inhalt der Nextcloud-Benachrichtigung:** „*Kartentitel* wurde nach *Ziel-Stapel* verschoben“, darunter Board, Quell-Stapel und ob die Karte überfällig ist oder ihr Startdatum erreicht hat. Ein Klick öffnet die Karte. Wird dieselbe Karte später noch einmal verschoben, ersetzt die neue Benachrichtigung die alte.
|
||||
|
||||
**Keine E-Mail erhalten?**
|
||||
- Ist in deinem Nextcloud-Profil eine E-Mail-Adresse hinterlegt? Ohne Adresse wird die E-Mail übersprungen.
|
||||
|
||||
+9
-7
@@ -4,27 +4,29 @@
|
||||
<id>workflow_deck_automation</id>
|
||||
<name lang="de">Deck Workflow-Automatisierung</name>
|
||||
<name lang="en">Deck Workflow Automation</name>
|
||||
<summary lang="de">Verschiebt überfällige Deck-Karten automatisch anhand konfigurierbarer Regeln</summary>
|
||||
<summary lang="en">Automatically moves overdue Deck cards based on configurable rules</summary>
|
||||
<summary lang="de">Verschiebt Deck-Karten automatisch, sobald ihr Fälligkeits- oder Startdatum erreicht ist</summary>
|
||||
<summary lang="en">Automatically moves Deck cards once their due date or start date is reached</summary>
|
||||
<description lang="de"><![CDATA[
|
||||
Diese App erlaubt es jedem Nutzer, in den persönlichen Einstellungen Automatisierungs-Workflows für seine eigenen Deck-Boards anzulegen:
|
||||
|
||||
* Quell-Board und Quell-Stapel wählen
|
||||
* Ziel-Stapel wählen, in den überfällige Karten verschoben werden
|
||||
* Ziel-Stapel wählen, in den die Karten verschoben werden
|
||||
* wählen, ob das Fälligkeitsdatum oder das Startdatum der Karte das Verschieben auslöst
|
||||
* optional benachrichtigen, per E-Mail und/oder als Nextcloud-Benachrichtigung (Push): nur sich selbst oder alle Board-Mitglieder gemäß ihrer Deck-Benachrichtigungseinstellung
|
||||
|
||||
Ein Hintergrundjob prüft regelmäßig alle aktiven Workflows und verschiebt Karten, deren Fälligkeitsdatum überschritten wurde.
|
||||
Ein Hintergrundjob prüft regelmäßig alle aktiven Workflows und verschiebt Karten, deren Fälligkeits- bzw. Startdatum erreicht wurde.
|
||||
]]></description>
|
||||
<description lang="en"><![CDATA[
|
||||
This app lets every user configure Deck automation workflows for the boards they own from Personal settings:
|
||||
|
||||
* pick a source board and source stack
|
||||
* pick a destination stack overdue cards get moved to
|
||||
* pick a destination stack the cards get moved to
|
||||
* choose whether the card's due date or start date triggers the move
|
||||
* optionally notify by email and/or Nextcloud notification (push): themselves only, or every board member according to their Deck notification setting
|
||||
|
||||
A background job periodically evaluates all enabled workflows and moves cards whose due date has passed.
|
||||
A background job periodically evaluates all enabled workflows and moves cards whose due date or start date has been reached.
|
||||
]]></description>
|
||||
<version>35.3.0</version>
|
||||
<version>35.4.0</version>
|
||||
<licence>agpl</licence>
|
||||
<author mail="patrick@niebel.ing">Patrick Niebeling</author>
|
||||
<namespace>WorkflowDeckAutomation</namespace>
|
||||
|
||||
@@ -9,6 +9,7 @@ use OCA\WorkflowDeckAutomation\Db\WorkflowMapper;
|
||||
use OCA\WorkflowDeckAutomation\Service\DeckIntegrationService;
|
||||
use OCA\WorkflowDeckAutomation\Service\DeckUnavailableException;
|
||||
use OCA\WorkflowDeckAutomation\Service\NotificationPolicy;
|
||||
use OCA\WorkflowDeckAutomation\Service\TriggerDate;
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
|
||||
@@ -56,15 +57,16 @@ class WorkflowController extends OCSController {
|
||||
int $boardId,
|
||||
int $sourceStackId,
|
||||
int $targetStackId,
|
||||
string $triggerDate = TriggerDate::DUE,
|
||||
string $notifyMode = NotificationPolicy::MODE_OFF,
|
||||
string $notifyChannel = NotificationPolicy::CHANNEL_MAIL,
|
||||
bool $enabled = true,
|
||||
): DataResponse {
|
||||
$this->validate($title, $boardId, $sourceStackId, $targetStackId, $notifyMode, $notifyChannel);
|
||||
$this->validate($title, $boardId, $sourceStackId, $targetStackId, $triggerDate, $notifyMode, $notifyChannel);
|
||||
|
||||
$workflow = new Workflow();
|
||||
$workflow->setUserId($this->currentUserId());
|
||||
$this->applyFields($workflow, $title, $boardId, $sourceStackId, $targetStackId, $notifyMode, $notifyChannel, $enabled);
|
||||
$this->applyFields($workflow, $title, $boardId, $sourceStackId, $targetStackId, $triggerDate, $notifyMode, $notifyChannel, $enabled);
|
||||
|
||||
$workflow = $this->workflowMapper->insert($workflow);
|
||||
return new DataResponse($workflow->jsonSerialize(), Http::STATUS_CREATED);
|
||||
@@ -77,17 +79,18 @@ class WorkflowController extends OCSController {
|
||||
int $boardId,
|
||||
int $sourceStackId,
|
||||
int $targetStackId,
|
||||
string $triggerDate = TriggerDate::DUE,
|
||||
string $notifyMode = NotificationPolicy::MODE_OFF,
|
||||
string $notifyChannel = NotificationPolicy::CHANNEL_MAIL,
|
||||
bool $enabled = true,
|
||||
): DataResponse {
|
||||
$workflow = $this->findOwnWorkflow($id);
|
||||
$this->validate($title, $boardId, $sourceStackId, $targetStackId, $notifyMode, $notifyChannel);
|
||||
$this->validate($title, $boardId, $sourceStackId, $targetStackId, $triggerDate, $notifyMode, $notifyChannel);
|
||||
|
||||
// Kept in step with the board owner so the job runs as, and mails,
|
||||
// the right user; validate() made sure the editor owns the board.
|
||||
$workflow->setUserId($this->currentUserId());
|
||||
$this->applyFields($workflow, $title, $boardId, $sourceStackId, $targetStackId, $notifyMode, $notifyChannel, $enabled);
|
||||
$this->applyFields($workflow, $title, $boardId, $sourceStackId, $targetStackId, $triggerDate, $notifyMode, $notifyChannel, $enabled);
|
||||
$workflow = $this->workflowMapper->update($workflow);
|
||||
|
||||
return new DataResponse($workflow->jsonSerialize());
|
||||
@@ -158,13 +161,16 @@ class WorkflowController extends OCSController {
|
||||
}
|
||||
}
|
||||
|
||||
private function validate(string $title, int $boardId, int $sourceStackId, int $targetStackId, string $notifyMode, string $notifyChannel): void {
|
||||
private function validate(string $title, int $boardId, int $sourceStackId, int $targetStackId, string $triggerDate, string $notifyMode, string $notifyChannel): void {
|
||||
if (trim($title) === '') {
|
||||
throw new OCSBadRequestException('Bitte einen Titel angeben.');
|
||||
}
|
||||
if ($sourceStackId === $targetStackId) {
|
||||
throw new OCSBadRequestException('Quell- und Ziel-Stapel müssen sich unterscheiden.');
|
||||
}
|
||||
if (!TriggerDate::isValid($triggerDate)) {
|
||||
throw new OCSBadRequestException('Ungültiges Datum für die Verschiebung, erlaubt sind: ' . implode(', ', TriggerDate::ALL));
|
||||
}
|
||||
if (!NotificationPolicy::isValidMode($notifyMode)) {
|
||||
throw new OCSBadRequestException('Ungültige Benachrichtigungs-Einstellung, erlaubt sind: ' . implode(', ', NotificationPolicy::MODES));
|
||||
}
|
||||
@@ -177,6 +183,9 @@ class WorkflowController extends OCSController {
|
||||
// assertDeck() ran first, so an empty list here means "not there",
|
||||
// not "Deck is unreachable".
|
||||
$this->assertDeck();
|
||||
if ($triggerDate === TriggerDate::START && !$this->deckService->supportsStartdate()) {
|
||||
throw new OCSBadRequestException('Die installierte Deck-Version kennt kein Startdatum.');
|
||||
}
|
||||
|
||||
// Only boards the user owns are listed, so this also enforces the
|
||||
// owner rule. Archived boards stay listed (their workflows must stay
|
||||
@@ -207,6 +216,7 @@ class WorkflowController extends OCSController {
|
||||
int $boardId,
|
||||
int $sourceStackId,
|
||||
int $targetStackId,
|
||||
string $triggerDate,
|
||||
string $notifyMode,
|
||||
string $notifyChannel,
|
||||
bool $enabled,
|
||||
@@ -215,6 +225,7 @@ class WorkflowController extends OCSController {
|
||||
$workflow->setBoardId($boardId);
|
||||
$workflow->setSourceStackId($sourceStackId);
|
||||
$workflow->setTargetStackId($targetStackId);
|
||||
$workflow->setTriggerDate($triggerDate);
|
||||
$workflow->setNotifyMode($notifyMode);
|
||||
$workflow->setNotifyChannel($notifyChannel);
|
||||
$workflow->setEnabled($enabled);
|
||||
|
||||
@@ -18,6 +18,8 @@ use OCP\DB\Types;
|
||||
* @method void setSourceStackId(int $sourceStackId)
|
||||
* @method int getTargetStackId()
|
||||
* @method void setTargetStackId(int $targetStackId)
|
||||
* @method string getTriggerDate()
|
||||
* @method void setTriggerDate(string $triggerDate)
|
||||
* @method string getNotifyMode()
|
||||
* @method void setNotifyMode(string $notifyMode)
|
||||
* @method string getNotifyChannel()
|
||||
@@ -33,6 +35,7 @@ class Workflow extends Entity implements \JsonSerializable {
|
||||
protected $boardId;
|
||||
protected $sourceStackId;
|
||||
protected $targetStackId;
|
||||
protected $triggerDate;
|
||||
protected $notifyMode;
|
||||
protected $notifyChannel;
|
||||
protected $enabled;
|
||||
@@ -45,6 +48,7 @@ class Workflow extends Entity implements \JsonSerializable {
|
||||
$this->addType('boardId', Types::INTEGER);
|
||||
$this->addType('sourceStackId', Types::INTEGER);
|
||||
$this->addType('targetStackId', Types::INTEGER);
|
||||
$this->addType('triggerDate', Types::STRING);
|
||||
$this->addType('notifyMode', Types::STRING);
|
||||
$this->addType('notifyChannel', Types::STRING);
|
||||
$this->addType('enabled', Types::BOOLEAN);
|
||||
@@ -59,6 +63,7 @@ class Workflow extends Entity implements \JsonSerializable {
|
||||
'boardId' => $this->getBoardId(),
|
||||
'sourceStackId' => $this->getSourceStackId(),
|
||||
'targetStackId' => $this->getTargetStackId(),
|
||||
'triggerDate' => (string)$this->getTriggerDate(),
|
||||
'notifyMode' => (string)$this->getNotifyMode(),
|
||||
'notifyChannel' => (string)$this->getNotifyChannel(),
|
||||
'enabled' => (bool)$this->getEnabled(),
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\WorkflowDeckAutomation\Migration;
|
||||
|
||||
use Closure;
|
||||
use OCP\DB\ISchemaWrapper;
|
||||
use OCP\DB\Types;
|
||||
use OCP\Migration\IOutput;
|
||||
use OCP\Migration\SimpleMigrationStep;
|
||||
|
||||
/**
|
||||
* Adds `trigger_date` (due / start, see TriggerDate::ALL): whether a card
|
||||
* moves once its due date or once its start date is reached. Existing
|
||||
* workflows get `due`, the only trigger there was, so nothing changes for
|
||||
* them.
|
||||
*/
|
||||
class Version1005Date20261003120000 extends SimpleMigrationStep {
|
||||
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
|
||||
/** @var ISchemaWrapper $schema */
|
||||
$schema = $schemaClosure();
|
||||
|
||||
if (!$schema->hasTable('wfda_workflows')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$table = $schema->getTable('wfda_workflows');
|
||||
if ($table->hasColumn('trigger_date')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$table->addColumn('trigger_date', Types::STRING, [
|
||||
'notnull' => true,
|
||||
'length' => 16,
|
||||
'default' => 'due',
|
||||
]);
|
||||
|
||||
return $schema;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ namespace OCA\WorkflowDeckAutomation\Notification;
|
||||
use OCA\WorkflowDeckAutomation\AppInfo\Application;
|
||||
use OCA\WorkflowDeckAutomation\Service\NotificationMailer;
|
||||
use OCA\WorkflowDeckAutomation\Service\NotificationPusher;
|
||||
use OCA\WorkflowDeckAutomation\Service\TriggerDate;
|
||||
use OCP\IURLGenerator;
|
||||
use OCP\Notification\INotification;
|
||||
use OCP\Notification\INotifier;
|
||||
@@ -95,7 +96,12 @@ class Notifier implements INotifier {
|
||||
self::setText(
|
||||
$notification,
|
||||
'{deck-card} wurde nach {target-stack} verschoben',
|
||||
'Die Karte auf {deck-board} ist überfällig und wurde automatisch von {source-stack} nach {target-stack} verschoben.',
|
||||
sprintf(
|
||||
'Die Karte auf {deck-board} %s und wurde automatisch von {source-stack} nach {target-stack} verschoben.',
|
||||
// Missing on notifications from before the start date existed,
|
||||
// which were all about due dates.
|
||||
TriggerDate::describeReason((string)($params['triggerDate'] ?? TriggerDate::DUE)),
|
||||
),
|
||||
$objects,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -163,6 +163,15 @@ class DeckIntegrationService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether Deck's cards carry a start date (`deck_cards.startdate`, added
|
||||
* in Deck 1.18). Checked on the property, not with method_exists():
|
||||
* Entity getters are magic, so getStartdate() would only fail when called.
|
||||
*/
|
||||
public function supportsStartdate(): bool {
|
||||
return class_exists(Card::class) && property_exists(Card::class, 'startdate');
|
||||
}
|
||||
|
||||
/**
|
||||
* The boards `$userId` owns — the only boards workflows may live on, and
|
||||
* the boards whose workflows that user sees and manages.
|
||||
|
||||
@@ -99,9 +99,10 @@ class NotificationMailer {
|
||||
*/
|
||||
private function addCard(IEMailTemplate $template, Workflow $workflow, Card $card, array $targets): void {
|
||||
// The card description and the workflow's name are deliberately left
|
||||
// out — the mail only says what moved where. The card title itself is
|
||||
// the link to the card.
|
||||
$sentence = '„%1$s“ (Board „%2$s“) ist überfällig und wurde von „%3$s“ nach „%4$s“ verschoben.';
|
||||
// out — the mail only says what moved where, and why. The card title
|
||||
// itself is the link to the card.
|
||||
$sentence = '„%1$s“ (Board „%2$s“) %5$s und wurde von „%3$s“ nach „%4$s“ verschoben.';
|
||||
$reason = TriggerDate::describeReason((string)$workflow->getTriggerDate());
|
||||
$url = $this->urlGenerator->getAbsoluteURL('/apps/deck/board/' . $workflow->getBoardId() . '/card/' . $card->getId());
|
||||
|
||||
// With both parts passed, addBodyText() escapes nothing: every title
|
||||
@@ -112,6 +113,7 @@ class NotificationMailer {
|
||||
self::escape($targets['board']),
|
||||
self::escape($targets['sourceStack']),
|
||||
self::escape($targets['targetStack']),
|
||||
self::escape($reason),
|
||||
);
|
||||
$plain = sprintf(
|
||||
$sentence,
|
||||
@@ -119,6 +121,7 @@ class NotificationMailer {
|
||||
$targets['board'],
|
||||
$targets['sourceStack'],
|
||||
$targets['targetStack'],
|
||||
$reason,
|
||||
) . PHP_EOL . $url;
|
||||
|
||||
$template->addBodyText($html, $plain);
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace OCA\WorkflowDeckAutomation\Service;
|
||||
|
||||
/**
|
||||
* Who hears about a moved card, and how — pure logic, no Nextcloud or Deck
|
||||
* dependencies, so it is unit-testable like WorkflowRunner::isOverdue().
|
||||
* dependencies, so it is unit-testable like WorkflowRunner::hasPassed().
|
||||
*/
|
||||
class NotificationPolicy {
|
||||
/** No mail at all. */
|
||||
|
||||
@@ -82,6 +82,7 @@ class NotificationPusher {
|
||||
'sourceStackTitle' => $targets['sourceStack'],
|
||||
'targetStackId' => $workflow->getTargetStackId(),
|
||||
'targetStackTitle' => $targets['targetStack'],
|
||||
'triggerDate' => (string)$workflow->getTriggerDate(),
|
||||
]);
|
||||
$this->notificationManager->notify($notification);
|
||||
} catch (Throwable $e) {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\WorkflowDeckAutomation\Service;
|
||||
|
||||
/**
|
||||
* Which of a card's dates moves it — pure logic, no Nextcloud or Deck
|
||||
* dependencies, so it is unit-testable like NotificationPolicy.
|
||||
*/
|
||||
class TriggerDate {
|
||||
/** Deck's `duedate`: the card is moved once it is overdue. */
|
||||
public const DUE = 'due';
|
||||
/** Deck's `startdate`: the card is moved once its start date is reached. */
|
||||
public const START = 'start';
|
||||
|
||||
public const ALL = [self::DUE, self::START];
|
||||
|
||||
public static function isValid(string $trigger): bool {
|
||||
return in_array($trigger, self::ALL, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Completes "„<card>“ … %s und wurde … verschoben" — shared by
|
||||
* NotificationMailer and Notification\Notifier so mail and notification
|
||||
* give the same reason. An unknown value counts as `due`, the column
|
||||
* default — the due date was the only trigger before the column existed.
|
||||
*/
|
||||
public static function describeReason(string $trigger): string {
|
||||
return $trigger === self::START
|
||||
? 'hat ihr Startdatum erreicht'
|
||||
: 'ist überfällig';
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,8 @@ use Psr\Log\LoggerInterface;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Evaluates all enabled workflows and moves overdue cards.
|
||||
* Evaluates all enabled workflows and moves every card whose due date or
|
||||
* start date — whichever the workflow uses, see TriggerDate — has passed.
|
||||
*
|
||||
* This runner has to evaluate rules for many different users within a
|
||||
* single background-job process, so workflows are grouped by owner and
|
||||
@@ -224,11 +225,24 @@ class WorkflowRunner {
|
||||
return;
|
||||
}
|
||||
|
||||
$trigger = (string)$workflow->getTriggerDate();
|
||||
if ($trigger === TriggerDate::START && !$this->deckService->supportsStartdate()) {
|
||||
// Not disabled: the workflow is intact, Deck is just too old for
|
||||
// it, and comes back to life with a Deck update.
|
||||
$this->logger->warning('Skipping workflow {id}: this Deck version has no start date', [
|
||||
'app' => 'workflow_deck_automation',
|
||||
'id' => $workflow->getId(),
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$moved = [];
|
||||
$now = $this->timeFactory->getDateTime();
|
||||
|
||||
foreach ($cards as $card) {
|
||||
if (!self::isOverdue($card->getDuedate(), $now)) {
|
||||
// Anything but `start` counts as `due`, the column default.
|
||||
$date = $trigger === TriggerDate::START ? $card->getStartdate() : $card->getDuedate();
|
||||
if (!self::hasPassed($date, $now)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -424,7 +438,10 @@ class WorkflowRunner {
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares the card's actual due timestamp against $now instead of Deck's own
|
||||
* Whether the card's trigger date (due or start date, see TriggerDate)
|
||||
* lies before $now.
|
||||
*
|
||||
* Compares the card's actual timestamp against $now instead of Deck's own
|
||||
* Card::getDaysUntilDue(), which truncates both sides to midnight before diffing
|
||||
* (DateInterval's %a is a whole-day count) and therefore reports 0 - not overdue -
|
||||
* for any card whose due time has already passed today. That silently held back
|
||||
@@ -432,30 +449,31 @@ class WorkflowRunner {
|
||||
* 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.
|
||||
* Both sides are compared as absolute instants, and the card's date is
|
||||
* first pinned to UTC via asStoredUtc() — see there for why.
|
||||
*/
|
||||
public static function isOverdue(?DateTimeInterface $duedate, DateTimeInterface $now): bool {
|
||||
return $duedate !== null && self::asStoredUtc($duedate) < $now;
|
||||
public static function hasPassed(?DateTimeInterface $date, DateTimeInterface $now): bool {
|
||||
return $date !== null && self::asStoredUtc($date) < $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's `duedate` and `startdate` are DATETIME columns without a zone
|
||||
* that hold 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 date by its UTC
|
||||
* offset.
|
||||
*
|
||||
* Deck itself has two timezone-sensitive paths this deliberately avoids:
|
||||
* Card::getDaysUntilDue() (day-truncated, see isOverdue()) and
|
||||
* Card::getDaysUntilDue() (day-truncated, see hasPassed()) 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'));
|
||||
private static function asStoredUtc(DateTimeInterface $date): DateTimeImmutable {
|
||||
return new DateTimeImmutable($date->format('Y-m-d H:i:s.u'), new DateTimeZone('UTC'));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "workflow_deck_automation",
|
||||
"version": "35.3.0",
|
||||
"version": "35.4.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Automates moving overdue Deck cards between stacks, configurable per user.",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<NcSettingsSection
|
||||
name="Deck Workflow-Automatisierung"
|
||||
description="Verschiebt überfällige Karten aus einem Deck-Stapel automatisch in einen anderen Stapel. Ein Hintergrundjob prüft die Regeln alle paar Minuten.">
|
||||
description="Verschiebt Karten aus einem Deck-Stapel automatisch in einen anderen Stapel, sobald ihr Fälligkeits- oder Startdatum erreicht ist. Ein Hintergrundjob prüft die Regeln alle paar Minuten.">
|
||||
<NcNoteCard v-if="loadError" type="error">
|
||||
{{ loadError }}
|
||||
</NcNoteCard>
|
||||
@@ -18,6 +18,7 @@
|
||||
<th>Titel</th>
|
||||
<th>Von Stapel</th>
|
||||
<th>Nach Stapel</th>
|
||||
<th>Auslöser</th>
|
||||
<th>Benachrichtigung</th>
|
||||
<th>Aktiv</th>
|
||||
<th />
|
||||
@@ -34,6 +35,7 @@
|
||||
</td>
|
||||
<td>{{ stackLabel(workflow.boardId, workflow.sourceStackId) }}</td>
|
||||
<td>{{ stackLabel(workflow.boardId, workflow.targetStackId) }}</td>
|
||||
<td>{{ triggerLabel(workflow) }}</td>
|
||||
<td>{{ notifyLabel(workflow) }}</td>
|
||||
<td>{{ yesNo(workflow.enabled) }}</td>
|
||||
<td class="wfda-actions">
|
||||
@@ -88,6 +90,22 @@
|
||||
:disabled="!form.boardId"
|
||||
placeholder="Ziel-Stapel wählen" />
|
||||
|
||||
<fieldset class="wfda-notify">
|
||||
<legend>Karte verschieben, wenn</legend>
|
||||
<NcCheckboxRadioSwitch
|
||||
v-for="option in triggerOptions"
|
||||
:key="option.value"
|
||||
v-model="form.triggerDate"
|
||||
type="radio"
|
||||
name="wfda-trigger-date"
|
||||
:value="option.value">
|
||||
{{ option.label }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
<p class="wfda-hint">
|
||||
Karten ohne das gewählte Datum bleiben liegen.
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="wfda-notify">
|
||||
<legend>Benachrichtigung bei verschobenen Karten</legend>
|
||||
<NcCheckboxRadioSwitch
|
||||
@@ -203,7 +221,7 @@ const boardOptions = computed(() => boards.value
|
||||
|
||||
/**
|
||||
* Alphabetical by the columns as displayed, left to right: board, title,
|
||||
* source stack, target stack, notification, active.
|
||||
* source stack, target stack, trigger, notification, active.
|
||||
*/
|
||||
const sortedWorkflows = computed(() => {
|
||||
const sortKeys = (workflow) => [
|
||||
@@ -211,6 +229,7 @@ const sortedWorkflows = computed(() => {
|
||||
workflow.title,
|
||||
stackLabel(workflow.boardId, workflow.sourceStackId),
|
||||
stackLabel(workflow.boardId, workflow.targetStackId),
|
||||
triggerLabel(workflow),
|
||||
notifyLabel(workflow),
|
||||
yesNo(workflow.enabled),
|
||||
]
|
||||
@@ -274,12 +293,19 @@ function emptyForm() {
|
||||
boardId: null,
|
||||
sourceStackId: null,
|
||||
targetStackId: null,
|
||||
triggerDate: 'due',
|
||||
notifyMode: 'off',
|
||||
notifyChannel: 'mail',
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Values match TriggerDate::ALL on the server.
|
||||
const triggerOptions = [
|
||||
{ value: 'due', label: 'das Fälligkeitsdatum überschritten ist (Karte ist überfällig)', short: 'Fälligkeit' },
|
||||
{ value: 'start', label: 'das Startdatum erreicht ist', short: 'Startdatum' },
|
||||
]
|
||||
|
||||
// Values match NotificationPolicy::MODES on the server.
|
||||
const notifyOptions = [
|
||||
{ value: 'off', label: 'Keine Benachrichtigung', short: 'Keine' },
|
||||
@@ -294,6 +320,10 @@ const channelOptions = [
|
||||
{ value: 'both', label: 'E-Mail und Nextcloud-Benachrichtigung', short: 'E-Mail + Push' },
|
||||
]
|
||||
|
||||
function triggerLabel(workflow) {
|
||||
return triggerOptions.find((option) => option.value === workflow.triggerDate)?.short ?? workflow.triggerDate
|
||||
}
|
||||
|
||||
function notifyLabel(workflow) {
|
||||
const mode = notifyOptions.find((option) => option.value === workflow.notifyMode)?.short ?? workflow.notifyMode
|
||||
if (workflow.notifyMode === 'off') {
|
||||
@@ -398,6 +428,7 @@ async function editWorkflow(workflow) {
|
||||
boardId: workflow.boardId,
|
||||
sourceStackId: workflow.sourceStackId,
|
||||
targetStackId: workflow.targetStackId,
|
||||
triggerDate: workflow.triggerDate || 'due',
|
||||
notifyMode: workflow.notifyMode,
|
||||
notifyChannel: workflow.notifyChannel || 'mail',
|
||||
enabled: workflow.enabled,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\WorkflowDeckAutomation\Tests\Unit\Service;
|
||||
|
||||
use OCA\WorkflowDeckAutomation\Service\TriggerDate;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class TriggerDateTest extends TestCase {
|
||||
public function testKnownTriggersAreValid(): void {
|
||||
$this->assertTrue(TriggerDate::isValid('due'));
|
||||
$this->assertTrue(TriggerDate::isValid('start'));
|
||||
}
|
||||
|
||||
public function testUnknownTriggersAreInvalid(): void {
|
||||
$this->assertFalse(TriggerDate::isValid(''));
|
||||
$this->assertFalse(TriggerDate::isValid('duedate'));
|
||||
$this->assertFalse(TriggerDate::isValid('Start'));
|
||||
}
|
||||
|
||||
public function testReasonNamesTheTriggeringDate(): void {
|
||||
$this->assertSame('ist überfällig', TriggerDate::describeReason('due'));
|
||||
$this->assertSame('hat ihr Startdatum erreicht', TriggerDate::describeReason('start'));
|
||||
}
|
||||
|
||||
public function testUnknownTriggerIsDescribedAsDue(): void {
|
||||
// Notifications stored before the column existed carry no trigger.
|
||||
$this->assertSame('ist überfällig', TriggerDate::describeReason(''));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user