From e6eadb169e38217905fc3f5fc1c963fd86cb1e1f Mon Sep 17 00:00:00 2001 From: Paul Spooren Date: Fri, 13 Mar 2026 15:16:57 +0100 Subject: [PATCH] feat: add start date field to cards Add a native startdate column to cards, complementing the existing duedate. This maps to DTSTART in CalDAV's VTODO spec (RFC 5545), making card scheduling more expressive. - Add database migration for startdate column - Wire startdate through Card entity, CardService, and all controllers - Add StartDateSelector component in card sidebar - Add updateCardStartDate Vuex action - Add unit tests for Card entity serialization and CardService update - Add Behat integration test for setting/clearing startdate via API Co-Authored-By: Claude Opus 4.6 Signed-off-by: Paul Spooren --- lib/Controller/CardApiController.php | 8 +- lib/Controller/CardController.php | 8 +- lib/Controller/CardOcsController.php | 9 +- lib/Db/Card.php | 7 ++ .../Version11002Date20260312000000.php | 29 +++++ lib/Service/CardService.php | 6 +- src/components/card/CardSidebarTabDetails.vue | 18 +++ src/components/card/StartDateSelector.vue | 111 ++++++++++++++++++ src/store/card.js | 5 + tests/integration/features/decks.feature | 23 ++++ tests/unit/Db/CardTest.php | 11 ++ tests/unit/Service/CardServiceTest.php | 25 ++++ 12 files changed, 246 insertions(+), 14 deletions(-) create mode 100644 lib/Migration/Version11002Date20260312000000.php create mode 100644 src/components/card/StartDateSelector.vue diff --git a/lib/Controller/CardApiController.php b/lib/Controller/CardApiController.php index 4a3187e01c..d9cf71eea1 100644 --- a/lib/Controller/CardApiController.php +++ b/lib/Controller/CardApiController.php @@ -68,8 +68,8 @@ public function get() { * * Get a specific card. */ - public function create($title, $type = 'plain', $order = 999, $description = '', $duedate = null, $labels = [], $users = []) { - $card = $this->cardService->create($title, $this->request->getParam('stackId'), $type, $order, $this->userId, $description, $duedate); + public function create($title, $type = 'plain', $order = 999, $description = '', $duedate = null, $startdate = null, $labels = [], $users = []) { + $card = $this->cardService->create($title, $this->request->getParam('stackId'), $type, $order, $this->userId, $description, $duedate, $startdate); foreach ($labels as $labelId) { $this->cardService->assignLabel($card->getId(), $labelId); @@ -88,9 +88,9 @@ public function create($title, $type = 'plain', $order = 999, $description = '', #[NoAdminRequired] #[CORS] #[NoCSRFRequired] - public function update(string $title, $type, string $owner, string $description = '', int $order = 0, $duedate = null, $archived = null): DataResponse { + public function update(string $title, $type, string $owner, string $description = '', int $order = 0, $duedate = null, $startdate = null, $archived = null): DataResponse { $done = array_key_exists('done', $this->request->getParams()) ? new OptionalNullableValue($this->request->getParam('done', null)) : null; - $card = $this->cardService->update($this->request->getParam('cardId'), $title, $this->request->getParam('stackId'), $type, $owner, $description, $order, $duedate, 0, $archived, $done); + $card = $this->cardService->update($this->request->getParam('cardId'), $title, $this->request->getParam('stackId'), $type, $owner, $description, $order, $duedate, 0, $archived, $done, $startdate); return new DataResponse($card, HTTP::STATUS_OK); } diff --git a/lib/Controller/CardController.php b/lib/Controller/CardController.php index eb85a520ab..da42d733ef 100644 --- a/lib/Controller/CardController.php +++ b/lib/Controller/CardController.php @@ -46,8 +46,8 @@ public function rename(int $cardId, string $title): Card { } #[NoAdminRequired] - public function create(string $title, int $stackId, string $type = 'plain', int $order = 999, string $description = '', $duedate = null, array $labels = [], array $users = []): Card { - $card = $this->cardService->create($title, $stackId, $type, $order, $this->userId, $description, $duedate); + public function create(string $title, int $stackId, string $type = 'plain', int $order = 999, string $description = '', $duedate = null, $startdate = null, array $labels = [], array $users = []): Card { + $card = $this->cardService->create($title, $stackId, $type, $order, $this->userId, $description, $duedate, $startdate); foreach ($labels as $label) { $this->assignLabel($card->getId(), $label); @@ -64,11 +64,11 @@ public function create(string $title, int $stackId, string $type = 'plain', int * @param $duedate */ #[NoAdminRequired] - public function update(int $id, string $title, int $stackId, string $type, int $order, string $description, $duedate, $deletedAt, $archived = null): Card { + public function update(int $id, string $title, int $stackId, string $type, int $order, string $description, $duedate, $deletedAt, $archived = null, $startdate = null): Card { $done = array_key_exists('done', $this->request->getParams()) ? new OptionalNullableValue($this->request->getParam('done', null)) : null; - return $this->cardService->update($id, $title, $stackId, $type, $this->userId, $description, $order, $duedate, $deletedAt, $archived, $done); + return $this->cardService->update($id, $title, $stackId, $type, $this->userId, $description, $order, $duedate, $deletedAt, $archived, $done, $startdate); } #[NoAdminRequired] diff --git a/lib/Controller/CardOcsController.php b/lib/Controller/CardOcsController.php index d0a4fe70ec..afca1815aa 100644 --- a/lib/Controller/CardOcsController.php +++ b/lib/Controller/CardOcsController.php @@ -37,7 +37,7 @@ public function __construct( #[PublicPage] #[NoCSRFRequired] #[RequestHeader(name: 'x-nextcloud-federation', description: 'Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request', indirect: true)] - public function create(string $title, int $stackId, ?int $boardId = null, ?string $type = 'plain', ?string $owner = null, ?int $order = 999, ?string $description = '', $duedate = null, ?array $labels = [], ?array $users = []) { + public function create(string $title, int $stackId, ?int $boardId = null, ?string $type = 'plain', ?string $owner = null, ?int $order = 999, ?string $description = '', $duedate = null, $startdate = null, ?array $labels = [], ?array $users = []) { if ($boardId) { $board = $this->boardService->find($boardId, false); if ($board->getExternalId()) { @@ -49,7 +49,7 @@ public function create(string $title, int $stackId, ?int $boardId = null, ?strin if (!$owner) { $owner = $this->userId; } - $card = $this->cardService->create($title, $stackId, $type, $order, $owner, $description, $duedate); + $card = $this->cardService->create($title, $stackId, $type, $order, $owner, $description, $duedate, $startdate); // foreach ($labels as $label) { // $this->assignLabel($card->getId(), $label); @@ -95,7 +95,7 @@ public function removeLabel(?int $boardId, int $cardId, int $labelId): DataRespo #[PublicPage] #[NoCSRFRequired] #[RequestHeader(name: 'x-nextcloud-federation', description: 'Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request', indirect: true)] - public function update(int $id, string $title, int $stackId, string $type, int $order, string $description, $duedate, $deletedAt, int $boardId, array|string|null $owner = null, $archived = null): DataResponse { + public function update(int $id, string $title, int $stackId, string $type, int $order, string $description, $duedate, $deletedAt, int $boardId, array|string|null $owner = null, $archived = null, $startdate = null): DataResponse { $done = array_key_exists('done', $this->request->getParams()) ? new OptionalNullableValue($this->request->getParam('done', null)) : null; @@ -135,7 +135,8 @@ public function update(int $id, string $title, int $stackId, string $type, int $ $duedate, $deletedAt, $archived, - $done + $done, + $startdate )); } } diff --git a/lib/Db/Card.php b/lib/Db/Card.php index cd22f1330e..443952ce46 100644 --- a/lib/Db/Card.php +++ b/lib/Db/Card.php @@ -32,6 +32,8 @@ * @method bool getNotified() * @method ?DateTime getDone() * @method void setDone(?DateTime $done) + * @method ?DateTime getStartdate() + * @method void setStartdate(?DateTime $startdate) * * @method void setLabels(Label[] $labels) * @method null|Label[] getLabels() @@ -80,6 +82,7 @@ class Card extends RelationalEntity { protected $archived = false; protected $done = null; protected $duedate; + protected $startdate; protected $notified = false; protected $deletedAt = 0; protected $commentsUnread = 0; @@ -106,6 +109,7 @@ public function __construct() { $this->addType('notified', 'boolean'); $this->addType('deletedAt', 'integer'); $this->addType('duedate', 'datetime'); + $this->addType('startdate', 'datetime'); $this->addRelation('labels'); $this->addRelation('assignedUsers'); $this->addRelation('attachments'); @@ -133,6 +137,9 @@ public function getCalendarObject(): VCalendar { $event->DTSTAMP = $creationDate; $event->DUE = new DateTime($this->getDuedate()->format('c'), new DateTimeZone('UTC')); } + if ($this->getStartdate()) { + $event->DTSTART = new DateTime($this->getStartdate()->format('c'), new DateTimeZone('UTC')); + } $event->add('RELATED-TO', 'deck-stack-' . $this->getStackId()); // FIXME: For write support: CANCELLED / IN-PROCESS handling diff --git a/lib/Migration/Version11002Date20260312000000.php b/lib/Migration/Version11002Date20260312000000.php new file mode 100644 index 0000000000..f7eb4070bc --- /dev/null +++ b/lib/Migration/Version11002Date20260312000000.php @@ -0,0 +1,29 @@ +hasTable('deck_cards')) { + $table = $schema->getTable('deck_cards'); + if (!$table->hasColumn('startdate')) { + $table->addColumn('startdate', 'datetime', [ + 'notnull' => false, + ]); + } + } + return $schema; + } +} diff --git a/lib/Service/CardService.php b/lib/Service/CardService.php index 3b9f97d42b..4d1d5663d7 100644 --- a/lib/Service/CardService.php +++ b/lib/Service/CardService.php @@ -176,7 +176,7 @@ public function findCalendarEntries(int $boardId): array { * @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException * @throws BadrequestException */ - public function create(string $title, int $stackId, string $type, int $order, string $owner, string $description = '', $duedate = null): Card { + public function create(string $title, int $stackId, string $type, int $order, string $owner, string $description = '', $duedate = null, $startdate = null): Card { $this->cardServiceValidator->check(compact('title', 'stackId', 'type', 'order', 'owner')); $this->permissionService->checkPermission($this->stackMapper, $stackId, Acl::PERMISSION_EDIT); @@ -191,6 +191,7 @@ public function create(string $title, int $stackId, string $type, int $order, st $card->setOwner($owner); $card->setDescription($description); $card->setDuedate($duedate); + $card->setStartdate($startdate); $card = $this->cardMapper->insert($card); $this->activityManager->triggerEvent(ActivityManager::DECK_OBJECT_CARD, $card, ActivityManager::SUBJECT_CARD_CREATE, [], $card->getOwner()); @@ -233,7 +234,7 @@ public function delete(int $id): Card { * @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException * @throws BadRequestException */ - public function update(int $id, string $title, int $stackId, string $type, string $owner, string $description = '', int $order = 0, ?string $duedate = null, ?int $deletedAt = null, ?bool $archived = null, ?OptionalNullableValue $done = null): Card { + public function update(int $id, string $title, int $stackId, string $type, string $owner, string $description = '', int $order = 0, ?string $duedate = null, ?int $deletedAt = null, ?bool $archived = null, ?OptionalNullableValue $done = null, ?string $startdate = null): Card { $this->cardServiceValidator->check(compact('id', 'title', 'stackId', 'type', 'owner', 'order')); $this->permissionService->checkPermission($this->cardMapper, $id, Acl::PERMISSION_EDIT, allowDeletedCard: true); @@ -276,6 +277,7 @@ public function update(int $id, string $title, int $stackId, string $type, strin $card->setOrder($order); $card->setOwner($owner); $card->setDuedate($duedate ? new \DateTime($duedate) : null); + $card->setStartdate($startdate ? new \DateTime($startdate) : null); $resetDuedateNotification = false; if ( $card->getDuedate() === null diff --git a/src/components/card/CardSidebarTabDetails.vue b/src/components/card/CardSidebarTabDetails.vue index 3a0e622d28..68605bd4ab 100644 --- a/src/components/card/CardSidebarTabDetails.vue +++ b/src/components/card/CardSidebarTabDetails.vue @@ -18,6 +18,11 @@ @select="assignUserToCard" @remove="removeUserFromCard" /> + + + + + + diff --git a/src/store/card.js b/src/store/card.js index e9d0098a96..44403450eb 100644 --- a/src/store/card.js +++ b/src/store/card.js @@ -369,6 +369,11 @@ export default function cardModuleFactory() { const updatedCard = await apiClient.updateCard(card, stack.boardId) commit('updateCardProperty', { property: 'duedate', card: updatedCard }) }, + async updateCardStartDate({ commit, getters }, card) { + const stack = getters.stackById(card.stackId) + const updatedCard = await apiClient.updateCard(card, stack.boardId) + commit('updateCardProperty', { property: 'startdate', card: updatedCard }) + }, addCardData({ commit }, cardData) { const card = { ...cardData } diff --git a/tests/integration/features/decks.feature b/tests/integration/features/decks.feature index c0d186ef3e..8d29cf7532 100644 --- a/tests/integration/features/decks.feature +++ b/tests/integration/features/decks.feature @@ -59,6 +59,29 @@ Feature: decks |duedate|| |overdue|0| + Scenario: Setting a startdate on a card + Given acting as user "user0" + And creates a board named "MyBoard" with color "000000" + And create a stack named "ToDo" + And create a card named "Scheduled task" + When get the card details + And the response should be a JSON array with the following mandatory values + |key|value| + |title|Scheduled task| + |startdate|| + And set the card attribute "startdate" to "2026-03-01 09:00:00" + When get the card details + And the response should be a JSON array with the following mandatory values + |key|value| + |title|Scheduled task| + |startdate|2026-03-01T09:00:00+00:00| + And set the card attribute "startdate" to "" + When get the card details + And the response should be a JSON array with the following mandatory values + |key|value| + |title|Scheduled task| + |startdate|| + Scenario: Cannot access card on a deleted board Given acting as user "user0" And creates a board named "MyBoard" with color "000000" diff --git a/tests/unit/Db/CardTest.php b/tests/unit/Db/CardTest.php index b33b8c9615..4b566bf8cc 100644 --- a/tests/unit/Db/CardTest.php +++ b/tests/unit/Db/CardTest.php @@ -65,6 +65,14 @@ public function testDuedate(DateTime $duedate, $state) { $this->assertEquals($state, (new CardDetails($card))->jsonSerialize()['overdue']); } + public function testStartdate() { + $card = $this->createCard(); + $startdate = new DateTime('2026-03-01 09:00:00'); + $card->setStartdate($startdate->format('Y-m-d H:i:s')); + $json = (new CardDetails($card))->jsonSerialize(); + $this->assertNotNull($json['startdate']); + } + public function testJsonSerialize() { $card = $this->createCard(); $this->assertEquals([ @@ -79,6 +87,7 @@ public function testJsonSerialize() { 'stackId' => 1, 'labels' => null, 'duedate' => null, + 'startdate' => null, 'overdue' => 0, 'archived' => false, 'attachments' => [], @@ -108,6 +117,7 @@ public function testJsonSerializeLabels() { 'stackId' => 1, 'labels' => [], 'duedate' => null, + 'startdate' => null, 'overdue' => 0, 'archived' => false, 'attachments' => [], @@ -139,6 +149,7 @@ public function testJsonSerializeAsignedUsers() { 'stackId' => 1, 'labels' => [], 'duedate' => null, + 'startdate' => null, 'overdue' => 0, 'archived' => false, 'attachments' => [], diff --git a/tests/unit/Service/CardServiceTest.php b/tests/unit/Service/CardServiceTest.php index 041c012ff5..e26d566e4e 100644 --- a/tests/unit/Service/CardServiceTest.php +++ b/tests/unit/Service/CardServiceTest.php @@ -349,6 +349,31 @@ public function testUpdate() { $this->assertEquals(new \DateTime('2017-01-01T00:00:00+00:00'), $actual->getDuedate()); } + public function testUpdateWithStartdate() { + $card = Card::fromParams([ + 'title' => 'Card title', + 'archived' => 'false', + 'stackId' => 234, + ]); + $stack = Stack::fromParams([ + 'id' => 234, + 'boardId' => 1337, + ]); + $this->cardMapper->expects($this->once())->method('find')->willReturn($card); + $this->cardMapper->expects($this->once())->method('update')->willReturnCallback(function ($c) { + $c->setId(1); + return $c; + }); + $this->stackMapper->expects($this->once()) + ->method('find') + ->with(234) + ->willReturn($stack); + $actual = $this->cardService->update(123, 'newtitle', 234, 'text', 'admin', 'foo', 999, '2017-01-01 00:00:00', null, null, null, '2016-12-15 00:00:00'); + $this->assertEquals('newtitle', $actual->getTitle()); + $this->assertEquals(new \DateTime('2017-01-01T00:00:00+00:00'), $actual->getDuedate()); + $this->assertEquals(new \DateTime('2016-12-15T00:00:00+00:00'), $actual->getStartdate()); + } + public function testUpdateArchived() { $card = new Card(); $card->setTitle('title');