From 052b69eb4d50b1b7fbd2828ca7604879ff5a948d 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 +- .../Importer/Systems/DeckJsonService.php | 1 + src/components/card/CardSidebarTabDetails.vue | 18 +++ src/components/card/StartDateSelector.vue | 111 ++++++++++++++++++ src/store/card.js | 5 + tests/data/deck.json | 13 ++ tests/integration/features/decks.feature | 23 ++++ tests/integration/import/ImportExportTest.php | 2 + tests/unit/Db/CardTest.php | 11 ++ tests/unit/Service/CardServiceTest.php | 25 ++++ .../Importer/Systems/DeckJsonServiceTest.php | 9 +- 16 files changed, 269 insertions(+), 16 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/lib/Service/Importer/Systems/DeckJsonService.php b/lib/Service/Importer/Systems/DeckJsonService.php index 8f74648d36..ad2343ae69 100644 --- a/lib/Service/Importer/Systems/DeckJsonService.php +++ b/lib/Service/Importer/Systems/DeckJsonService.php @@ -211,6 +211,7 @@ public function getCards(): array { $boardOwner = $this->getBoard()->getOwner(); $card->setOwner($this->mapOwner(is_string($boardOwner) ? $boardOwner : $boardOwner->getUID())); $card->setDuedate($cardSource->duedate); + $card->setStartdate($cardSource->startdate ?? null); $card->setDone($cardSource->done ?? null); $cards[$cardSource->id] = $card; } 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/data/deck.json b/tests/data/deck.json index bd680ad650..f37d98f972 100644 --- a/tests/data/deck.json +++ b/tests/data/deck.json @@ -102,6 +102,7 @@ "archived": false, "done": "2023-07-18T10:00:00+00:00", "duedate": "2050-07-24T22:00:00+00:00", + "startdate": "2023-07-10T08:00:00+00:00", "deletedAt": 0, "commentsUnread": 0, "commentsCount": 0, @@ -135,6 +136,7 @@ "order": 999, "archived": false, "duedate": "2023-07-17T02:00:00+00:00", + "startdate": "2023-07-15T08:00:00+00:00", "deletedAt": 0, "commentsUnread": 0, "commentsCount": 0, @@ -180,6 +182,7 @@ "order": 999, "archived": false, "duedate": null, + "startdate": null, "deletedAt": 0, "commentsUnread": 0, "commentsCount": 0, @@ -252,6 +255,7 @@ "order": 999, "archived": false, "duedate": null, + "startdate": null, "deletedAt": 0, "commentsUnread": 0, "commentsCount": 0, @@ -295,6 +299,7 @@ "order": 999, "archived": false, "duedate": null, + "startdate": null, "deletedAt": 0, "commentsUnread": 0, "commentsCount": 0, @@ -340,6 +345,7 @@ "order": 999, "archived": false, "duedate": null, + "startdate": null, "deletedAt": 0, "commentsUnread": 0, "commentsCount": 0, @@ -510,6 +516,7 @@ "order": 0, "archived": false, "duedate": null, + "startdate": null, "deletedAt": 0, "commentsUnread": 0, "commentsCount": 0, @@ -543,6 +550,7 @@ "order": 1, "archived": false, "duedate": null, + "startdate": null, "deletedAt": 0, "commentsUnread": 0, "commentsCount": 0, @@ -576,6 +584,7 @@ "order": 999, "archived": false, "duedate": null, + "startdate": null, "deletedAt": 0, "commentsUnread": 0, "commentsCount": 0, @@ -609,6 +618,7 @@ "order": 999, "archived": false, "duedate": null, + "startdate": null, "deletedAt": 0, "commentsUnread": 0, "commentsCount": 0, @@ -653,6 +663,7 @@ "order": 999, "archived": false, "duedate": null, + "startdate": null, "deletedAt": 0, "commentsUnread": 0, "commentsCount": 0, @@ -697,6 +708,7 @@ "order": 0, "archived": false, "duedate": null, + "startdate": null, "deletedAt": 0, "commentsUnread": 0, "commentsCount": 0, @@ -730,6 +742,7 @@ "order": 1, "archived": false, "duedate": null, + "startdate": null, "deletedAt": 0, "commentsUnread": 0, "commentsCount": 0, 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/integration/import/ImportExportTest.php b/tests/integration/import/ImportExportTest.php index 83dca3b0ff..d61ae0057f 100644 --- a/tests/integration/import/ImportExportTest.php +++ b/tests/integration/import/ImportExportTest.php @@ -329,12 +329,14 @@ public function assertDatabase(string $owner = 'admin') { 'createdAt' => 1689667569, 'owner' => $owner, 'done' => new \DateTime('2023-07-18T10:00:00+00:00'), + 'startdate' => new \DateTime('2023-07-10T08:00:00+00:00'), 'duedate' => new \DateTime('2050-07-24T22:00:00.000000+0000'), 'order' => 999, 'stackId' => $stacks[0]->getId(), ]), $cards[0]); self::assertEntity(Card::fromRow([ 'title' => '2', + 'startdate' => new \DateTime('2023-07-15T08:00:00+00:00'), 'duedate' => new \DateTime('2050-07-24T22:00:00.000000+0000'), ]), $cards[1], true); self::assertEntity(Card::fromParams([ 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'); diff --git a/tests/unit/Service/Importer/Systems/DeckJsonServiceTest.php b/tests/unit/Service/Importer/Systems/DeckJsonServiceTest.php index b4947d8558..035bea0947 100644 --- a/tests/unit/Service/Importer/Systems/DeckJsonServiceTest.php +++ b/tests/unit/Service/Importer/Systems/DeckJsonServiceTest.php @@ -88,18 +88,23 @@ public function testGetCards() { $this->assertInstanceOf(\DateTime::class, $card114->getDone()); $this->assertEquals('2023-07-18T10:00:00+00:00', $card114->getDone()->format(\DateTime::ATOM)); $this->assertEquals('2050-07-24T22:00:00+00:00', $card114->getDuedate()->format(\DateTime::ATOM)); + $this->assertInstanceOf(\DateTime::class, $card114->getStartdate()); + $this->assertEquals('2023-07-10T08:00:00+00:00', $card114->getStartdate()->format(\DateTime::ATOM)); $this->assertFalse($card114->getArchived()); $this->assertEquals('admin', $card114->getOwner()); - // Card 115 (title "2") has no done value in the fixture + // Card 115 (title "2") has a startdate but no done value in the fixture $card115 = $cards[115]; $this->assertEquals('2', $card115->getTitle()); $this->assertNull($card115->getDone()); + $this->assertInstanceOf(\DateTime::class, $card115->getStartdate()); + $this->assertEquals('2023-07-15T08:00:00+00:00', $card115->getStartdate()->format(\DateTime::ATOM)); - // Card 119 (title "6") — from stack B, no done value + // Card 119 (title "6") — from stack B, no done or startdate value $card119 = $cards[119]; $this->assertEquals('6', $card119->getTitle()); $this->assertNull($card119->getDone()); + $this->assertNull($card119->getStartdate()); $this->assertEquals('# Test description' . "\n\n" . 'Hello world', $card119->getDescription()); }