Skip to content

Commit 92aa194

Browse files
aparcarclaude
andcommitted
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 <noreply@anthropic.com> Signed-off-by: Paul Spooren <mail@aparcar.org>
1 parent 4c0887b commit 92aa194

File tree

16 files changed

+269
-16
lines changed

16 files changed

+269
-16
lines changed

lib/Controller/CardApiController.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,8 @@ public function get() {
6868
*
6969
* Get a specific card.
7070
*/
71-
public function create($title, $type = 'plain', $order = 999, $description = '', $duedate = null, $labels = [], $users = []) {
72-
$card = $this->cardService->create($title, $this->request->getParam('stackId'), $type, $order, $this->userId, $description, $duedate);
71+
public function create($title, $type = 'plain', $order = 999, $description = '', $duedate = null, $startdate = null, $labels = [], $users = []) {
72+
$card = $this->cardService->create($title, $this->request->getParam('stackId'), $type, $order, $this->userId, $description, $duedate, $startdate);
7373

7474
foreach ($labels as $labelId) {
7575
$this->cardService->assignLabel($card->getId(), $labelId);
@@ -88,9 +88,9 @@ public function create($title, $type = 'plain', $order = 999, $description = '',
8888
#[NoAdminRequired]
8989
#[CORS]
9090
#[NoCSRFRequired]
91-
public function update(string $title, $type, string $owner, string $description = '', int $order = 0, $duedate = null, $archived = null): DataResponse {
91+
public function update(string $title, $type, string $owner, string $description = '', int $order = 0, $duedate = null, $startdate = null, $archived = null): DataResponse {
9292
$done = array_key_exists('done', $this->request->getParams()) ? new OptionalNullableValue($this->request->getParam('done', null)) : null;
93-
$card = $this->cardService->update($this->request->getParam('cardId'), $title, $this->request->getParam('stackId'), $type, $owner, $description, $order, $duedate, 0, $archived, $done);
93+
$card = $this->cardService->update($this->request->getParam('cardId'), $title, $this->request->getParam('stackId'), $type, $owner, $description, $order, $duedate, 0, $archived, $done, $startdate);
9494
return new DataResponse($card, HTTP::STATUS_OK);
9595
}
9696

lib/Controller/CardController.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,8 @@ public function rename(int $cardId, string $title): Card {
4646
}
4747

4848
#[NoAdminRequired]
49-
public function create(string $title, int $stackId, string $type = 'plain', int $order = 999, string $description = '', $duedate = null, array $labels = [], array $users = []): Card {
50-
$card = $this->cardService->create($title, $stackId, $type, $order, $this->userId, $description, $duedate);
49+
public function create(string $title, int $stackId, string $type = 'plain', int $order = 999, string $description = '', $duedate = null, $startdate = null, array $labels = [], array $users = []): Card {
50+
$card = $this->cardService->create($title, $stackId, $type, $order, $this->userId, $description, $duedate, $startdate);
5151

5252
foreach ($labels as $label) {
5353
$this->assignLabel($card->getId(), $label);
@@ -64,11 +64,11 @@ public function create(string $title, int $stackId, string $type = 'plain', int
6464
* @param $duedate
6565
*/
6666
#[NoAdminRequired]
67-
public function update(int $id, string $title, int $stackId, string $type, int $order, string $description, $duedate, $deletedAt, $archived = null): Card {
67+
public function update(int $id, string $title, int $stackId, string $type, int $order, string $description, $duedate, $deletedAt, $archived = null, $startdate = null): Card {
6868
$done = array_key_exists('done', $this->request->getParams())
6969
? new OptionalNullableValue($this->request->getParam('done', null))
7070
: null;
71-
return $this->cardService->update($id, $title, $stackId, $type, $this->userId, $description, $order, $duedate, $deletedAt, $archived, $done);
71+
return $this->cardService->update($id, $title, $stackId, $type, $this->userId, $description, $order, $duedate, $deletedAt, $archived, $done, $startdate);
7272
}
7373

7474
#[NoAdminRequired]

lib/Controller/CardOcsController.php

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ public function __construct(
3737
#[PublicPage]
3838
#[NoCSRFRequired]
3939
#[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)]
40-
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 = []) {
40+
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 = []) {
4141
if ($boardId) {
4242
$board = $this->boardService->find($boardId, false);
4343
if ($board->getExternalId()) {
@@ -49,7 +49,7 @@ public function create(string $title, int $stackId, ?int $boardId = null, ?strin
4949
if (!$owner) {
5050
$owner = $this->userId;
5151
}
52-
$card = $this->cardService->create($title, $stackId, $type, $order, $owner, $description, $duedate);
52+
$card = $this->cardService->create($title, $stackId, $type, $order, $owner, $description, $duedate, $startdate);
5353

5454
// foreach ($labels as $label) {
5555
// $this->assignLabel($card->getId(), $label);
@@ -95,7 +95,7 @@ public function removeLabel(?int $boardId, int $cardId, int $labelId): DataRespo
9595
#[PublicPage]
9696
#[NoCSRFRequired]
9797
#[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)]
98-
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 {
98+
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 {
9999
$done = array_key_exists('done', $this->request->getParams())
100100
? new OptionalNullableValue($this->request->getParam('done', null))
101101
: null;
@@ -135,7 +135,8 @@ public function update(int $id, string $title, int $stackId, string $type, int $
135135
$duedate,
136136
$deletedAt,
137137
$archived,
138-
$done
138+
$done,
139+
$startdate
139140
));
140141
}
141142

lib/Db/Card.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@
3232
* @method bool getNotified()
3333
* @method ?DateTime getDone()
3434
* @method void setDone(?DateTime $done)
35+
* @method ?DateTime getStartdate()
36+
* @method void setStartdate(?DateTime $startdate)
3537
*
3638
* @method void setLabels(Label[] $labels)
3739
* @method null|Label[] getLabels()
@@ -80,6 +82,7 @@ class Card extends RelationalEntity {
8082
protected $archived = false;
8183
protected $done = null;
8284
protected $duedate;
85+
protected $startdate;
8386
protected $notified = false;
8487
protected $deletedAt = 0;
8588
protected $commentsUnread = 0;
@@ -106,6 +109,7 @@ public function __construct() {
106109
$this->addType('notified', 'boolean');
107110
$this->addType('deletedAt', 'integer');
108111
$this->addType('duedate', 'datetime');
112+
$this->addType('startdate', 'datetime');
109113
$this->addRelation('labels');
110114
$this->addRelation('assignedUsers');
111115
$this->addRelation('attachments');
@@ -133,6 +137,9 @@ public function getCalendarObject(): VCalendar {
133137
$event->DTSTAMP = $creationDate;
134138
$event->DUE = new DateTime($this->getDuedate()->format('c'), new DateTimeZone('UTC'));
135139
}
140+
if ($this->getStartdate()) {
141+
$event->DTSTART = new DateTime($this->getStartdate()->format('c'), new DateTimeZone('UTC'));
142+
}
136143
$event->add('RELATED-TO', 'deck-stack-' . $this->getStackId());
137144

138145
// FIXME: For write support: CANCELLED / IN-PROCESS handling
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
/**
4+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
5+
* SPDX-License-Identifier: AGPL-3.0-or-later
6+
*/
7+
8+
declare(strict_types=1);
9+
namespace OCA\Deck\Migration;
10+
11+
use Closure;
12+
use OCP\Migration\IOutput;
13+
use OCP\Migration\SimpleMigrationStep;
14+
15+
class Version11002Date20260312000000 extends SimpleMigrationStep {
16+
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options) {
17+
$schema = $schemaClosure();
18+
19+
if ($schema->hasTable('deck_cards')) {
20+
$table = $schema->getTable('deck_cards');
21+
if (!$table->hasColumn('startdate')) {
22+
$table->addColumn('startdate', 'datetime', [
23+
'notnull' => false,
24+
]);
25+
}
26+
}
27+
return $schema;
28+
}
29+
}

lib/Service/CardService.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ public function findCalendarEntries(int $boardId): array {
176176
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
177177
* @throws BadrequestException
178178
*/
179-
public function create(string $title, int $stackId, string $type, int $order, string $owner, string $description = '', $duedate = null): Card {
179+
public function create(string $title, int $stackId, string $type, int $order, string $owner, string $description = '', $duedate = null, $startdate = null): Card {
180180
$this->cardServiceValidator->check(compact('title', 'stackId', 'type', 'order', 'owner'));
181181

182182
$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
191191
$card->setOwner($owner);
192192
$card->setDescription($description);
193193
$card->setDuedate($duedate);
194+
$card->setStartdate($startdate);
194195
$card = $this->cardMapper->insert($card);
195196

196197
$this->activityManager->triggerEvent(ActivityManager::DECK_OBJECT_CARD, $card, ActivityManager::SUBJECT_CARD_CREATE, [], $card->getOwner());
@@ -233,7 +234,7 @@ public function delete(int $id): Card {
233234
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
234235
* @throws BadRequestException
235236
*/
236-
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 {
237+
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 {
237238
$this->cardServiceValidator->check(compact('id', 'title', 'stackId', 'type', 'owner', 'order'));
238239

239240
$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
276277
$card->setOrder($order);
277278
$card->setOwner($owner);
278279
$card->setDuedate($duedate ? new \DateTime($duedate) : null);
280+
$card->setStartdate($startdate ? new \DateTime($startdate) : null);
279281
$resetDuedateNotification = false;
280282
if (
281283
$card->getDuedate() === null

lib/Service/Importer/Systems/DeckJsonService.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,7 @@ public function getCards(): array {
211211
$boardOwner = $this->getBoard()->getOwner();
212212
$card->setOwner($this->mapOwner(is_string($boardOwner) ? $boardOwner : $boardOwner->getUID()));
213213
$card->setDuedate($cardSource->duedate ? \DateTime::createFromFormat(\DateTime::ATOM, $cardSource->duedate) : null);
214+
$card->setStartdate(isset($cardSource->startdate) && $cardSource->startdate !== null ? \DateTime::createFromFormat(\DateTime::ATOM, $cardSource->startdate) : null);
214215
$card->setDone(isset($cardSource->done) && $cardSource->done !== null ? \DateTime::createFromFormat(\DateTime::ATOM, $cardSource->done) : null);
215216
$cards[$cardSource->id] = $card;
216217
}

src/components/card/CardSidebarTabDetails.vue

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@
1818
@select="assignUserToCard"
1919
@remove="removeUserFromCard" />
2020

21+
<StartDateSelector :card="card"
22+
:can-edit="canEdit"
23+
@change="updateCardStartDate"
24+
@input="debouncedUpdateCardStartDate" />
25+
2126
<DueDateSelector :card="card"
2227
:can-edit="canEdit"
2328
@change="updateCardDue"
@@ -52,12 +57,14 @@ import Description from './Description.vue'
5257
import TagSelector from './TagSelector.vue'
5358
import AssignmentSelector from './AssignmentSelector.vue'
5459
import DueDateSelector from './DueDateSelector.vue'
60+
import StartDateSelector from './StartDateSelector.vue'
5561
import { debounce } from 'lodash'
5662
5763
export default {
5864
name: 'CardSidebarTabDetails',
5965
components: {
6066
DueDateSelector,
67+
StartDateSelector,
6168
AssignmentSelector,
6269
TagSelector,
6370
Description,
@@ -151,6 +158,17 @@ export default {
151158
this.updateCardDue(val)
152159
}, 500),
153160
161+
updateCardStartDate(val) {
162+
this.$store.dispatch('updateCardStartDate', {
163+
...this.copiedCard,
164+
startdate: val ? (new Date(val)).toISOString() : null,
165+
})
166+
},
167+
168+
debouncedUpdateCardStartDate: debounce(function(val) {
169+
this.updateCardStartDate(val)
170+
}, 500),
171+
154172
addLabelToCard(newLabel) {
155173
this.copiedCard.labels.push(newLabel)
156174
const data = {
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
<!--
2+
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
- SPDX-License-Identifier: AGPL-3.0-or-later
4+
-->
5+
<template>
6+
<CardDetailEntry :label="t('deck', 'Assign a start date to this card…')">
7+
<CalendarStart slot="icon" :size="20" />
8+
<template v-if="!card.done && !card.archived">
9+
<NcDateTimePickerNative v-if="startdate"
10+
id="card-startdate-picker"
11+
v-model="startdate"
12+
:placeholder="t('deck', 'Set a start date')"
13+
:hide-label="true"
14+
type="datetime-local" />
15+
<NcActions v-if="canEdit"
16+
:menu-title="!startdate ? t('deck', 'Add start date') : null"
17+
type="tertiary">
18+
<template v-if="!startdate" #icon>
19+
<Plus :size="20" />
20+
</template>
21+
<NcActionButton v-if="!startdate"
22+
close-after-click
23+
@click="initDate">
24+
<template #icon>
25+
<Plus :size="20" />
26+
</template>
27+
{{ t('deck', 'Choose a date') }}
28+
</NcActionButton>
29+
<NcActionButton v-else
30+
icon="icon-delete"
31+
close-after-click
32+
@click="removeStartDate">
33+
{{ t('deck', 'Remove start date') }}
34+
</NcActionButton>
35+
</NcActions>
36+
</template>
37+
<template v-else>
38+
<div v-if="startdate" class="start-info">
39+
{{ formatReadableDate(startdate) }}
40+
</div>
41+
</template>
42+
</CardDetailEntry>
43+
</template>
44+
45+
<script>
46+
import { defineComponent } from 'vue'
47+
import {
48+
NcActionButton,
49+
NcActions,
50+
NcDateTimePickerNative,
51+
} from '@nextcloud/vue'
52+
import readableDate from '../../mixins/readableDate.js'
53+
import Plus from 'vue-material-design-icons/Plus.vue'
54+
import CalendarStart from 'vue-material-design-icons/CalendarArrowLeft.vue'
55+
import CardDetailEntry from './CardDetailEntry.vue'
56+
57+
export default defineComponent({
58+
name: 'StartDateSelector',
59+
components: {
60+
CardDetailEntry,
61+
Plus,
62+
CalendarStart,
63+
NcActions,
64+
NcActionButton,
65+
NcDateTimePickerNative,
66+
},
67+
mixins: [
68+
readableDate,
69+
],
70+
props: {
71+
card: {
72+
type: Object,
73+
default: null,
74+
},
75+
canEdit: {
76+
type: Boolean,
77+
default: false,
78+
},
79+
},
80+
computed: {
81+
startdate: {
82+
get() {
83+
return this.card?.startdate ? new Date(this.card.startdate) : null
84+
},
85+
set(val) {
86+
this.$emit('input', val ? new Date(val) : null)
87+
},
88+
},
89+
},
90+
methods: {
91+
initDate() {
92+
if (this.startdate === null) {
93+
const now = new Date()
94+
now.setHours(8)
95+
now.setMinutes(0)
96+
now.setMilliseconds(0)
97+
this.startdate = now
98+
}
99+
},
100+
removeStartDate() {
101+
this.startdate = null
102+
this.$emit('change', null)
103+
},
104+
},
105+
})
106+
</script>
107+
<style scoped lang="scss">
108+
.start-info {
109+
flex-grow: 1;
110+
}
111+
</style>

src/store/card.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,11 @@ export default function cardModuleFactory() {
381381
const updatedCard = await apiClient.updateCard(card, stack.boardId)
382382
commit('updateCardProperty', { property: 'duedate', card: updatedCard })
383383
},
384+
async updateCardStartDate({ commit, getters }, card) {
385+
const stack = getters.stackById(card.stackId)
386+
const updatedCard = await apiClient.updateCard(card, stack.boardId)
387+
commit('updateCardProperty', { property: 'startdate', card: updatedCard })
388+
},
384389

385390
addCardData({ commit }, cardData) {
386391
const card = { ...cardData }

0 commit comments

Comments
 (0)