diff --git a/src/components/TaskBody.vue b/src/components/TaskBody.vue
index 3e302503e..a6d4079aa 100644
--- a/src/components/TaskBody.vue
+++ b/src/components/TaskBody.vue
@@ -44,6 +44,7 @@ License along with this library. If not, see .
class="no-nav"
:cancelled="task.status === 'CANCELLED'"
:read-only="readOnly"
+ :recurring="task.recurring"
:priority-class="priorityClass"
@toggle-completed="toggleCompleted(task)" />
diff --git a/src/components/TaskCheckbox.vue b/src/components/TaskCheckbox.vue
index 7d86a9c62..b5b9e480d 100644
--- a/src/components/TaskCheckbox.vue
+++ b/src/components/TaskCheckbox.vue
@@ -33,6 +33,7 @@ License along with this library. If not, see .
+
@@ -45,6 +46,7 @@ import CheckboxBlank from 'vue-material-design-icons/CheckboxBlank.vue'
import CheckboxBlankOffOutline from 'vue-material-design-icons/CheckboxBlankOffOutline.vue'
import CheckboxBlankOutline from 'vue-material-design-icons/CheckboxBlankOutline.vue'
import CheckboxOutline from 'vue-material-design-icons/CheckboxOutline.vue'
+import Repeat from 'vue-material-design-icons/Repeat.vue'
export default {
components: {
@@ -52,6 +54,7 @@ export default {
CheckboxBlankOffOutline,
CheckboxBlankOutline,
CheckboxOutline,
+ Repeat,
},
props: {
completed: {
@@ -66,6 +69,10 @@ export default {
type: Boolean,
required: true,
},
+ recurring: {
+ type: Boolean,
+ required: true
+ },
priorityClass: {
type: String,
default: '',
diff --git a/src/models/task.js b/src/models/task.js
index 402065206..37064d420 100644
--- a/src/models/task.js
+++ b/src/models/task.js
@@ -95,6 +95,7 @@ export default class Task {
this._completed = !!comp
this._completedDate = comp ? comp.toJSDate() : null
this._completedDateMoment = moment(this._completedDate, 'YYYYMMDDTHHmmss')
+ this._recurrence = this.vtodo.getFirstPropertyValue('rrule')
this._status = this.vtodo.getFirstPropertyValue('status')
this._note = this.vtodo.getFirstPropertyValue('description') || ''
this._related = this.getParent()?.getFirstValue() || null
@@ -329,6 +330,19 @@ export default class Task {
return this._completedDateMoment.clone()
}
+ get recurrence() {
+ return this._recurrence
+ }
+
+ get recurring() {
+ if (this._start === null || this._recurrence === null) {
+ return false
+ }
+ const iter = this._recurrence.iterator(this.start);
+ iter.next();
+ return iter.next() !== null
+ }
+
get status() {
return this._status
}
@@ -674,6 +688,27 @@ export default class Task {
).toSeconds()
}
+ /**
+ * For completing a recurring task, tries to set the task start date to the next recurrence date.
+ *
+ * Does nothing if we are at the end of the recurrence (RRULE:UNTIL was reached).
+ */
+ completeRecurring() {
+ // Get recurrence iterator, starting at start date
+ const iter = this.recurrence.iterator(this.start);
+ // Skip the start date itself
+ iter.next();
+ // If there is a next recurrence, update the start date to next recurrence date
+ let nextRecurrence = iter.next();
+ if (nextRecurrence !== null) {
+ this.start = nextRecurrence;
+ // If the due date now lies before start date, clear it
+ if (this.due !== null && this.due.compare(this.start) < 0) {
+ this.due = null
+ }
+ }
+ }
+
/**
* Checks if the task matches the search query
*
diff --git a/src/store/tasks.js b/src/store/tasks.js
index 8bcb5ae62..fd8250b4d 100644
--- a/src/store/tasks.js
+++ b/src/store/tasks.js
@@ -1038,6 +1038,12 @@ const actions = {
if (task.calendar.isSharedWithMe && task.class !== 'PUBLIC') {
return
}
+ // Don't complete a task if it is still recurring, but update its start date instead
+ if (task.recurring) {
+ task.completeRecurring()
+ await context.dispatch('updateTask', task)
+ return
+ }
if (task.completed) {
await context.dispatch('setPercentComplete', { task, complete: 0 })
} else {
diff --git a/src/views/AppSidebar.vue b/src/views/AppSidebar.vue
index 3ff10a770..78b1e13bf 100644
--- a/src/views/AppSidebar.vue
+++ b/src/views/AppSidebar.vue
@@ -129,6 +129,7 @@ License along with this library. If not, see .
@@ -518,6 +519,14 @@ export default {
readOnly() {
return this.task.calendar.readOnly || (this.task.calendar.isSharedWithMe && this.task.class !== 'PUBLIC')
},
+ /**
+ * Whether this is a recurring task.
+ *
+ * @return {boolean} Is the task recurring
+ */
+ recurring() {
+ return this.task.recurring
+ },
/**
* Whether the dates of a task are all-day
* When no dates are set, we consider the last used value.