+
+ {{ t('tasks', 'The recurrence definition of this task is not fully supported by Nextcloud. If you edit the recurrence-options, certain recurrences may be lost.') }}
+
+
+
+
+
diff --git a/src/components/TaskBody.vue b/src/components/TaskBody.vue
index 90722c0e6..7ad96a3c9 100644
--- a/src/components/TaskBody.vue
+++ b/src/components/TaskBody.vue
@@ -75,13 +75,14 @@ License along with this library. If not, see .
+
-
+
{{ dueDateShort }}{{ dueDateLong }}
@@ -222,6 +223,7 @@ import Plus from 'vue-material-design-icons/Plus.vue'
import TextBoxOutline from 'vue-material-design-icons/TextBoxOutline.vue'
import SortVariant from 'vue-material-design-icons/SortVariant.vue'
import CalendarClock from 'vue-material-design-icons/CalendarClock.vue'
+import Repeat from 'vue-material-design-icons/Repeat.vue'
import Star from 'vue-material-design-icons/StarOutline.vue'
import Undo from 'vue-material-design-icons/Undo.vue'
@@ -255,6 +257,7 @@ export default {
TextBoxOutline,
SortVariant,
CalendarClock,
+ Repeat,
Star,
Undo,
},
@@ -286,9 +289,10 @@ export default {
}),
dueDateShort() {
+ const taskDate = this.task.startMoment.isValid() ? this.task.startMoment : this.task.dueMoment
if (!this.task.completed) {
- return this.task.dueMoment.isValid()
- ? this.task.dueMoment.calendar(null, {
+ return taskDate.isValid()
+ ? taskDate.calendar(null, {
// TRANSLATORS This is a string for moment.js. The square brackets escape the string from moment.js. Please translate the string and keep the brackets.
lastDay: t('tasks', '[Yesterday]'),
// TRANSLATORS This is a string for moment.js. The square brackets escape the string from moment.js. Please translate the string and keep the brackets.
@@ -324,9 +328,10 @@ export default {
if (this.task.allDay) {
return this.dueDateShort
}
+ const taskDate = this.task.startMoment.isValid() ? this.task.startMoment : this.task.dueMoment
if (!this.task.completed) {
- return this.task.dueMoment.isValid()
- ? this.task.dueMoment.calendar(null, {
+ return taskDate.isValid()
+ ? taskDate.calendar(null, {
// TRANSLATORS This is a string for moment.js. The square brackets escape the string from moment.js. Please translate the string and keep the brackets.
lastDay: t('tasks', '[Yesterday at] LT'),
// TRANSLATORS This is a string for moment.js. The square brackets escape the string from moment.js. Please translate the string and keep the brackets.
diff --git a/src/filters/recurrenceRuleFormat.js b/src/filters/recurrenceRuleFormat.js
new file mode 100644
index 000000000..c5f62dcc4
--- /dev/null
+++ b/src/filters/recurrenceRuleFormat.js
@@ -0,0 +1,173 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { translate as t, translatePlural as n } from '@nextcloud/l10n'
+
+/**
+ * Get translated ordinal number (first, second, third, etc.)
+ *
+ * @param {number} ordinal The ordinal number (1-5 for first-fifth, -1 for last, -2 for second to last)
+ * @return {string} Translated ordinal string
+ */
+export function getTranslatedOrdinalNumber(ordinal) {
+ switch (ordinal) {
+ case 1:
+ return t('tasks', 'first')
+ case 2:
+ return t('tasks', 'second')
+ case 3:
+ return t('tasks', 'third')
+ case 4:
+ return t('tasks', 'fourth')
+ case 5:
+ return t('tasks', 'fifth')
+ case -1:
+ return t('tasks', 'last')
+ case -2:
+ return t('tasks', 'second to last')
+ default:
+ return String(ordinal)
+ }
+}
+
+/**
+ * Get translated frequency string
+ *
+ * @param {string} frequency The frequency (DAILY, WEEKLY, MONTHLY, YEARLY)
+ * @param {number} interval The interval
+ * @return {string} Translated frequency string
+ */
+function getTranslatedFrequency(frequency, interval) {
+ switch (frequency) {
+ case 'DAILY':
+ return n('tasks', 'day', 'days', interval)
+ case 'WEEKLY':
+ return n('tasks', 'week', 'weeks', interval)
+ case 'MONTHLY':
+ return n('tasks', 'month', 'months', interval)
+ case 'YEARLY':
+ return n('tasks', 'year', 'years', interval)
+ default:
+ return frequency
+ }
+}
+
+/**
+ * Get translated day name
+ *
+ * @param {string} day The day abbreviation (MO, TU, WE, TH, FR, SA, SU)
+ * @return {string} Translated day name
+ */
+function getTranslatedDayName(day) {
+ const dayNames = {
+ MO: t('tasks', 'Monday'),
+ TU: t('tasks', 'Tuesday'),
+ WE: t('tasks', 'Wednesday'),
+ TH: t('tasks', 'Thursday'),
+ FR: t('tasks', 'Friday'),
+ SA: t('tasks', 'Saturday'),
+ SU: t('tasks', 'Sunday'),
+ }
+ return dayNames[day] || day
+}
+
+/**
+ * Get translated month name
+ *
+ * @param {number} month The month number (1-12)
+ * @return {string} Translated month name
+ */
+function getTranslatedMonthName(month) {
+ const monthNames = [
+ t('tasks', 'January'),
+ t('tasks', 'February'),
+ t('tasks', 'March'),
+ t('tasks', 'April'),
+ t('tasks', 'May'),
+ t('tasks', 'June'),
+ t('tasks', 'July'),
+ t('tasks', 'August'),
+ t('tasks', 'September'),
+ t('tasks', 'October'),
+ t('tasks', 'November'),
+ t('tasks', 'December'),
+ ]
+ return monthNames[month - 1] || String(month)
+}
+
+/**
+ * Format a recurrence rule into a human-readable string
+ *
+ * @param {object} recurrenceRule The recurrence rule object
+ * @param {string} locale The locale (unused, kept for API compatibility)
+ * @return {string} Human-readable recurrence description
+ */
+export default function formatRecurrenceRule(recurrenceRule, locale) {
+ if (!recurrenceRule || recurrenceRule.frequency === 'NONE') {
+ return t('tasks', 'Does not repeat')
+ }
+
+ const { frequency, interval, byDay, byMonthDay, byMonth, bySetPosition, count, until } = recurrenceRule
+
+ let result = ''
+
+ // Build the base frequency string
+ if (interval === 1) {
+ switch (frequency) {
+ case 'DAILY':
+ result = t('tasks', 'Daily')
+ break
+ case 'WEEKLY':
+ result = t('tasks', 'Weekly')
+ break
+ case 'MONTHLY':
+ result = t('tasks', 'Monthly')
+ break
+ case 'YEARLY':
+ result = t('tasks', 'Yearly')
+ break
+ default:
+ result = frequency
+ }
+ } else {
+ result = t('tasks', 'Every {interval} {frequency}', {
+ interval,
+ frequency: getTranslatedFrequency(frequency, interval),
+ })
+ }
+
+ // Add by-day information for weekly
+ if (frequency === 'WEEKLY' && byDay && byDay.length > 0) {
+ const dayNames = byDay.map(getTranslatedDayName).join(', ')
+ result += ' ' + t('tasks', 'on {days}', { days: dayNames })
+ }
+
+ // Add by-month-day or by-set-position information for monthly
+ if (frequency === 'MONTHLY') {
+ if (byMonthDay && byMonthDay.length > 0) {
+ result += ' ' + t('tasks', 'on day {days}', { days: byMonthDay.join(', ') })
+ } else if (bySetPosition !== null && byDay && byDay.length > 0) {
+ const ordinal = getTranslatedOrdinalNumber(bySetPosition)
+ const dayNames = byDay.map(getTranslatedDayName).join(', ')
+ result += ' ' + t('tasks', 'on the {ordinal} {dayNames}', { ordinal, dayNames })
+ }
+ }
+
+ // Add by-month information for yearly
+ if (frequency === 'YEARLY' && byMonth && byMonth.length > 0) {
+ const monthNames = byMonth.map(getTranslatedMonthName).join(', ')
+ result += ' ' + t('tasks', 'in {months}', { months: monthNames })
+ }
+
+ // Add end condition
+ if (count !== null) {
+ result += ', ' + n('tasks', '{count} time', '{count} times', count, { count })
+ } else if (until !== null) {
+ const untilDate = until instanceof Date ? until : new Date(until)
+ result += ', ' + t('tasks', 'until {date}', { date: untilDate.toLocaleDateString() })
+ }
+
+ return result
+}
diff --git a/src/models/recurrenceRule.js b/src/models/recurrenceRule.js
new file mode 100644
index 000000000..1a0d893ca
--- /dev/null
+++ b/src/models/recurrenceRule.js
@@ -0,0 +1,518 @@
+/**
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { getWeekDayFromDate } from '../utils/recurrence.js'
+
+/**
+ * Converts a DateTimeValue to a JavaScript Date object
+ *
+ * @param {object} dateTimeValue The DateTimeValue object
+ * @return {Date}
+ */
+function getDateFromDateTimeValue(dateTimeValue) {
+ return new Date(
+ dateTimeValue.year,
+ dateTimeValue.month - 1,
+ dateTimeValue.day,
+ dateTimeValue.hour,
+ dateTimeValue.minute,
+ 0,
+ 0,
+ )
+}
+
+/**
+ * Creates a complete recurrence-rule-object based on given props
+ *
+ * @param {object} props Recurrence-rule-object-props already provided
+ * @return {object}
+ */
+function getDefaultRecurrenceRuleObject(props = {}) {
+ return { // The calendar-js recurrence-rule value
+ recurrenceRuleValue: null,
+ // The frequency of the recurrence-rule (DAILY, WEEKLY, ...)
+ frequency: 'NONE',
+ // The interval of the recurrence-rule, must be a positive integer
+ interval: 1,
+ // Positive integer if recurrence-rule limited by count, null otherwise
+ count: null,
+ // Date if recurrence-rule limited by date, null otherwise
+ // We do not store a timezone here, since we only care about the date part
+ until: null,
+ // List of byDay components to limit/expand the recurrence-rule
+ byDay: [],
+ // List of byMonth components to limit/expand the recurrence-rule
+ byMonth: [],
+ // List of byMonthDay components to limit/expand the recurrence-rule
+ byMonthDay: [],
+ // A position to limit the recurrence-rule (e.g. -1 for last Friday)
+ bySetPosition: null,
+ // Whether or not the rule is not supported for editing
+ isUnsupported: false,
+ ...props,
+ }
+}
+
+/**
+ * Maps a calendar-js recurrence-rule-value to an recurrence-rule-object
+ *
+ * @param {RecurValue} recurrenceRuleValue The calendar-js recurrence rule value
+ * @param {DateTimeValue} baseDate The base-date used to fill unset values
+ * @return {object}
+ */
+function mapRecurrenceRuleValueToRecurrenceRuleObject(recurrenceRuleValue, baseDate) {
+ switch (recurrenceRuleValue.frequency) {
+ case 'DAILY':
+ return mapDailyRuleValueToRecurrenceRuleObject(recurrenceRuleValue)
+
+ case 'WEEKLY':
+ return mapWeeklyRuleValueToRecurrenceRuleObject(recurrenceRuleValue, baseDate)
+
+ case 'MONTHLY':
+ return mapMonthlyRuleValueToRecurrenceRuleObject(recurrenceRuleValue, baseDate)
+
+ case 'YEARLY':
+ return mapYearlyRuleValueToRecurrenceRuleObject(recurrenceRuleValue, baseDate)
+
+ default: // SECONDLY, MINUTELY, HOURLY
+ return getDefaultRecurrenceRuleObjectForRecurrenceValue(recurrenceRuleValue, {
+ isUnsupported: true,
+ })
+ }
+}
+
+const FORBIDDEN_BY_PARTS_DAILY = [
+ 'BYSECOND',
+ 'BYMINUTE',
+ 'BYHOUR',
+ 'BYDAY',
+ 'BYMONTHDAY',
+ 'BYYEARDAY',
+ 'BYWEEKNO',
+ 'BYMONTH',
+ 'BYSETPOS',
+]
+const FORBIDDEN_BY_PARTS_WEEKLY = [
+ 'BYSECOND',
+ 'BYMINUTE',
+ 'BYHOUR',
+ 'BYMONTHDAY',
+ 'BYYEARDAY',
+ 'BYWEEKNO',
+ 'BYMONTH',
+ 'BYSETPOS',
+]
+const FORBIDDEN_BY_PARTS_MONTHLY = [
+ 'BYSECOND',
+ 'BYMINUTE',
+ 'BYHOUR',
+ 'BYYEARDAY',
+ 'BYWEEKNO',
+ 'BYMONTH',
+]
+const FORBIDDEN_BY_PARTS_YEARLY = [
+ 'BYSECOND',
+ 'BYMINUTE',
+ 'BYHOUR',
+ 'BYYEARDAY',
+ 'BYWEEKNO',
+]
+
+const SUPPORTED_BY_DAY_WEEKLY = [
+ 'SU',
+ 'MO',
+ 'TU',
+ 'WE',
+ 'TH',
+ 'FR',
+ 'SA',
+]
+
+const SUPPORTED_BY_MONTHDAY_MONTHLY = [...Array(31).keys().map((i) => i + 1)]
+
+const SUPPORTED_BY_MONTH_YEARLY = [...Array(12).keys().map((i) => i + 1)]
+
+/**
+ * Maps a daily calendar-js recurrence-rule-value to an recurrence-rule-object
+ *
+ * @param recurrenceRuleValue
+ * @return {object}
+ */
+function mapDailyRuleValueToRecurrenceRuleObject(recurrenceRuleValue) {
+ /**
+ * We only support DAILY rules without any by-parts in the editor.
+ * If the recurrence-rule contains any by-parts, mark it as unsupported.
+ */
+ const isUnsupported = containsRecurrenceComponent(recurrenceRuleValue, FORBIDDEN_BY_PARTS_DAILY)
+
+ return getDefaultRecurrenceRuleObjectForRecurrenceValue(recurrenceRuleValue, {
+ isUnsupported,
+ })
+}
+
+/**
+ * Maps a weekly calendar-js recurrence-rule-value to an recurrence-rule-object
+ *
+ * @param {RecurValue} recurrenceRuleValue The calendar-js recurrence rule value
+ * @param {DateTimeValue} baseDate The base-date used to fill unset values
+ * @return {object}
+ */
+function mapWeeklyRuleValueToRecurrenceRuleObject(recurrenceRuleValue, baseDate) {
+ /**
+ * For WEEKLY recurrences, our editor only allows BYDAY
+ *
+ * As defined in RFC5545 3.3.10. Recurrence Rule:
+ * > Each BYDAY value can also be preceded by a positive (+n) or
+ * > negative (-n) integer. If present, this indicates the nth
+ * > occurrence of a specific day within the MONTHLY or YEARLY "RRULE".
+ *
+ * RFC 5545 specifies other components, which can be used along WEEKLY.
+ * Among them are BYMONTH and BYSETPOS. We don't support those.
+ */
+ const containsUnsupportedByParts = containsRecurrenceComponent(recurrenceRuleValue, FORBIDDEN_BY_PARTS_WEEKLY)
+ const containsInvalidByDayPart = recurrenceRuleValue.getComponent('BYDAY')
+ .some((weekday) => !SUPPORTED_BY_DAY_WEEKLY.includes(weekday))
+
+ const isUnsupported = containsUnsupportedByParts || containsInvalidByDayPart
+
+ const byDay = recurrenceRuleValue.getComponent('BYDAY')
+ .filter((weekday) => SUPPORTED_BY_DAY_WEEKLY.includes(weekday))
+
+ // If the BYDAY is empty, add the day that the task occurs in
+ // E.g. if the task is on a Wednesday, automatically set BYDAY:WE
+ if (byDay.length === 0) {
+ byDay.push(getWeekDayFromDate(baseDate.jsDate))
+ }
+
+ return getDefaultRecurrenceRuleObjectForRecurrenceValue(recurrenceRuleValue, {
+ byDay,
+ isUnsupported,
+ })
+}
+
+/**
+ * Maps a monthly calendar-js recurrence-rule-value to an recurrence-rule-object
+ *
+ * @param {RecurValue} recurrenceRuleValue The calendar-js recurrence rule value
+ * @param {DateTimeValue} baseDate The base-date used to fill unset values
+ * @return {object}
+ */
+function mapMonthlyRuleValueToRecurrenceRuleObject(recurrenceRuleValue, baseDate) {
+ /**
+ * We only supports BYMONTHDAY, BYDAY, BYSETPOS in order to expand the monthly rule.
+ * It supports either BYMONTHDAY or the combination of BYDAY and BYSETPOS. They have to be used exclusively
+ * and cannot be combined.
+ *
+ * We do not support other BY-parts like BYMONTH
+ *
+ * For monthly recurrence-rules, BYDAY components are allowed to be preceded by positive or negative integers.
+ * The Nextcloud-editor supports at most one BYDAY component with an integer.
+ * If it's presented with such a BYDAY component, it will internally be converted to BYDAY without integer and BYSETPOS.
+ * e.g.
+ * BYDAY=3WE => BYDAY=WE,BYSETPOS=3
+ *
+ * BYSETPOS is limited to -2, -1, 1, 2, 3, 4, 5
+ * Other values are not supported
+ *
+ * BYDAY is limited to "MO", "TU", "WE", "TH", "FR", "SA", "SU",
+ * "MO,TU,WE,TH,FR,SA,SU", "MO,TU,WE,TH,FR", "SA,SU"
+ *
+ * BYMONTHDAY is limited to "1", "2", ..., "31"
+ */
+ let isUnsupported = containsRecurrenceComponent(recurrenceRuleValue, FORBIDDEN_BY_PARTS_MONTHLY)
+
+ let byDay = []
+ let bySetPosition = null
+ let byMonthDay = []
+
+ // This handles the first case, where we have a BYMONTHDAY rule
+ if (containsRecurrenceComponent(recurrenceRuleValue, ['BYMONTHDAY'])) {
+ // verify there is no BYDAY or BYSETPOS at the same time
+ if (containsRecurrenceComponent(recurrenceRuleValue, ['BYDAY', 'BYSETPOS'])) {
+ isUnsupported = true
+ }
+
+ const containsInvalidByMonthDay = recurrenceRuleValue.getComponent('BYMONTHDAY')
+ .some((monthDay) => !SUPPORTED_BY_MONTHDAY_MONTHLY.includes(monthDay))
+ isUnsupported = isUnsupported || containsInvalidByMonthDay
+
+ byMonthDay = recurrenceRuleValue.getComponent('BYMONTHDAY')
+ .filter((monthDay) => SUPPORTED_BY_MONTHDAY_MONTHLY.includes(monthDay))
+ .map((monthDay) => monthDay)
+
+ // This handles cases where we have both BYDAY and BYSETPOS
+ } else if (containsRecurrenceComponent(recurrenceRuleValue, ['BYDAY']) && containsRecurrenceComponent(recurrenceRuleValue, ['BYSETPOS'])) {
+ if (isAllowedByDay(recurrenceRuleValue.getComponent('BYDAY'))) {
+ byDay = recurrenceRuleValue.getComponent('BYDAY')
+ } else {
+ byDay = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU']
+ isUnsupported = true
+ }
+
+ const setPositionArray = recurrenceRuleValue.getComponent('BYSETPOS')
+ if (setPositionArray.length === 1 && isAllowedBySetPos(setPositionArray[0])) {
+ bySetPosition = setPositionArray[0]
+ } else {
+ bySetPosition = 1
+ isUnsupported = true
+ }
+
+ // This handles cases where we only have a BYDAY
+ } else if (containsRecurrenceComponent(recurrenceRuleValue, ['BYDAY'])) {
+ const byDayArray = recurrenceRuleValue.getComponent('BYDAY')
+
+ if (byDayArray.length > 1) {
+ byMonthDay.push(baseDate.day)
+ isUnsupported = true
+ } else {
+ const firstElement = byDayArray[0]
+
+ const match = /^(-?\d)([A-Z]{2})$/.exec(firstElement)
+ if (match) {
+ const matchedBySetPosition = match[1]
+ const matchedByDay = match[2]
+
+ if (isAllowedBySetPos(matchedBySetPosition)) {
+ byDay = [matchedByDay]
+ bySetPosition = parseInt(matchedBySetPosition, 10)
+ } else {
+ byDay = [matchedByDay]
+ bySetPosition = 1
+ isUnsupported = true
+ }
+ } else {
+ byMonthDay.push(baseDate.day)
+ isUnsupported = true
+ }
+ }
+
+ // This is a fallback where we just default BYMONTHDAY to the start date of the event
+ } else {
+ byMonthDay.push(baseDate.day)
+ }
+
+ return getDefaultRecurrenceRuleObjectForRecurrenceValue(recurrenceRuleValue, {
+ byDay,
+ bySetPosition,
+ byMonthDay,
+ isUnsupported,
+ })
+}
+
+/**
+ * Maps a yearly calendar-js recurrence-rule-value to an recurrence-rule-object
+ *
+ * @param {RecurValue} recurrenceRuleValue The calendar-js recurrence rule value
+ * @param {DateTimeValue} baseDate The base-date used to fill unset values
+ * @return {object}
+ */
+function mapYearlyRuleValueToRecurrenceRuleObject(recurrenceRuleValue, baseDate) {
+ /**
+ * We only supports BYMONTH, BYDAY, BYSETPOS in order to expand the yearly rule.
+ * It supports a combination of them.
+ *
+ * We do not support other BY-parts.
+ *
+ * For yearly recurrence-rules, BYDAY components are allowed to be preceded by positive or negative integers.
+ * The Nextcloud-editor supports at most one BYDAY component with an integer.
+ * If it's presented with such a BYDAY component, it will internally be converted to BYDAY without integer and BYSETPOS.
+ * e.g.
+ * BYDAY=3WE => BYDAY=WE,BYSETPOS=3
+ *
+ * BYSETPOS is limited to -2, -1, 1, 2, 3, 4, 5
+ * Other values are not supported
+ *
+ * BYDAY is limited to "MO", "TU", "WE", "TH", "FR", "SA", "SU",
+ * "MO,TU,WE,TH,FR,SA,SU", "MO,TU,WE,TH,FR", "SA,SU"
+ */
+ let isUnsupported = containsRecurrenceComponent(recurrenceRuleValue, FORBIDDEN_BY_PARTS_YEARLY)
+
+ let byDay = []
+ let bySetPosition = null
+ let byMonth = []
+ let byMonthDay = []
+
+ if (containsRecurrenceComponent(recurrenceRuleValue, ['BYMONTH'])) {
+ // This handles the first case, where we have a BYMONTH rule
+
+ const containsInvalidByMonth = recurrenceRuleValue.getComponent('BYMONTH')
+ .some((month) => !SUPPORTED_BY_MONTH_YEARLY.includes(month))
+ isUnsupported = isUnsupported || containsInvalidByMonth
+
+ byMonth = recurrenceRuleValue.getComponent('BYMONTH')
+ .filter((month) => SUPPORTED_BY_MONTH_YEARLY.includes(month))
+ .map((month) => month)
+ } else {
+ // This is a fallback where we just default BYMONTH to the start date of the event
+
+ byMonth.push(baseDate.month)
+ }
+
+ if (containsRecurrenceComponent(recurrenceRuleValue, ['BYMONTHDAY'])) {
+ // This handles the first case, where we have a BYMONTHDAY rule
+
+ // verify there is no BYDAY or BYSETPOS at the same time
+ if (containsRecurrenceComponent(recurrenceRuleValue, ['BYDAY', 'BYSETPOS'])) {
+ isUnsupported = true
+ }
+
+ const containsInvalidByMonthDay = recurrenceRuleValue.getComponent('BYMONTHDAY')
+ .some((monthDay) => !SUPPORTED_BY_MONTHDAY_MONTHLY.includes(monthDay))
+ isUnsupported = isUnsupported || containsInvalidByMonthDay
+
+ byMonthDay = recurrenceRuleValue.getComponent('BYMONTHDAY')
+ .filter((monthDay) => SUPPORTED_BY_MONTHDAY_MONTHLY.includes(monthDay))
+ .map((monthDay) => monthDay)
+ } else if (containsRecurrenceComponent(recurrenceRuleValue, ['BYDAY']) && containsRecurrenceComponent(recurrenceRuleValue, ['BYSETPOS'])) {
+ // This handles cases where we have both BYDAY and BYSETPOS
+
+ if (isAllowedByDay(recurrenceRuleValue.getComponent('BYDAY'))) {
+ byDay = recurrenceRuleValue.getComponent('BYDAY')
+ } else {
+ byDay = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU']
+ isUnsupported = true
+ }
+
+ const setPositionArray = recurrenceRuleValue.getComponent('BYSETPOS')
+ if (setPositionArray.length === 1 && isAllowedBySetPos(setPositionArray[0])) {
+ bySetPosition = setPositionArray[0]
+ } else {
+ bySetPosition = 1
+ isUnsupported = true
+ }
+ } else if (containsRecurrenceComponent(recurrenceRuleValue, ['BYDAY'])) {
+ // This handles cases where we only have a BYDAY
+
+ const byDayArray = recurrenceRuleValue.getComponent('BYDAY')
+
+ if (byDayArray.length > 1) {
+ byMonthDay.push(baseDate.day)
+ isUnsupported = true
+ } else {
+ const firstElement = byDayArray[0]
+
+ const match = /^(-?\d)([A-Z]{2})$/.exec(firstElement)
+ if (match) {
+ const matchedBySetPosition = match[1]
+ const matchedByDay = match[2]
+
+ if (isAllowedBySetPos(matchedBySetPosition)) {
+ byDay = [matchedByDay]
+ bySetPosition = parseInt(matchedBySetPosition, 10)
+ } else {
+ byDay = [matchedByDay]
+ bySetPosition = 1
+ isUnsupported = true
+ }
+ } else {
+ byMonthDay.push(baseDate.day)
+ isUnsupported = true
+ }
+ }
+ } else {
+ // This is a fallback where we just default BYMONTHDAY to the start date of the event
+ byMonthDay.push(baseDate.day)
+ }
+
+ return getDefaultRecurrenceRuleObjectForRecurrenceValue(recurrenceRuleValue, {
+ byDay,
+ bySetPosition,
+ byMonth,
+ byMonthDay,
+ isUnsupported,
+ })
+}
+
+/**
+ * Checks if the given parameter is a supported BYDAY value
+ *
+ * @param {string[]} byDay The byDay component to check
+ * @return {boolean}
+ */
+function isAllowedByDay(byDay) {
+ return [
+ 'MO',
+ 'TU',
+ 'WE',
+ 'TH',
+ 'FR',
+ 'SA',
+ 'SU',
+ 'FR,MO,SA,SU,TH,TU,WE',
+ 'FR,MO,TH,TU,WE',
+ 'SA,SU',
+ ].includes(byDay.slice().sort().join(','))
+}
+
+/**
+ * Checks if the given parameter is a supported BYSETPOS value
+ *
+ * @param {string} bySetPos The bySetPos component to check
+ * @return {boolean}
+ */
+function isAllowedBySetPos(bySetPos) {
+ return [
+ '-2',
+ '-1',
+ '1',
+ '2',
+ '3',
+ '4',
+ '5',
+ ].includes(bySetPos.toString())
+}
+
+/**
+ * Checks if the recurrence-rule contains any of the given components
+ *
+ * @param {RecurValue} recurrenceRule The recurrence-rule value to check for the given components
+ * @param {string[]} components List of components to check for
+ * @return {boolean}
+ */
+function containsRecurrenceComponent(recurrenceRule, components) {
+ for (const component of components) {
+ const componentValue = recurrenceRule.getComponent(component)
+ if (componentValue.length > 0) {
+ return true
+ }
+ }
+
+ return false
+}
+
+/**
+ * Returns a full recurrence-rule-object with default values derived from recurrenceRuleValue
+ * and additional props
+ *
+ * @param {RecurValue} recurrenceRuleValue The recurrence-rule value to get default values from
+ * @param {object} props The properties to provide on top of default one
+ * @return {object}
+ */
+function getDefaultRecurrenceRuleObjectForRecurrenceValue(recurrenceRuleValue, props) {
+ const isUnsupported = recurrenceRuleValue.count !== null && recurrenceRuleValue.until !== null
+ let isUnsupportedProps = {}
+
+ if (isUnsupported) {
+ isUnsupportedProps = {
+ isUnsupported,
+ }
+ }
+
+ return getDefaultRecurrenceRuleObject({
+ recurrenceRuleValue,
+ frequency: recurrenceRuleValue.frequency,
+ interval: parseInt(recurrenceRuleValue.interval, 10) || 1,
+ count: recurrenceRuleValue.count,
+ until: recurrenceRuleValue.until
+ ? getDateFromDateTimeValue(recurrenceRuleValue.until)
+ : null,
+ ...props,
+ ...isUnsupportedProps,
+ })
+}
+
+export {
+ getDefaultRecurrenceRuleObject,
+ mapRecurrenceRuleValueToRecurrenceRuleObject,
+}
diff --git a/src/models/task.js b/src/models/task.js
index e3652b25b..242c793c4 100644
--- a/src/models/task.js
+++ b/src/models/task.js
@@ -27,7 +27,12 @@
import moment from '@nextcloud/moment'
import ICAL from 'ical.js'
+import { RecurValue, DateTimeValue } from '@nextcloud/calendar-js'
import { randomUUID } from '../utils/crypto.js'
+import {
+ getDefaultRecurrenceRuleObject,
+ mapRecurrenceRuleValueToRecurrenceRuleObject,
+} from './recurrenceRule.js'
export default class Task {
@@ -121,6 +126,40 @@ export default class Task {
this._location = this.vtodo.getFirstPropertyValue('location') || ''
this._customUrl = this.vtodo.getFirstPropertyValue('url') || ''
+ // Check for RECURRENCE-ID property (this is an exception instance)
+ this._recurrenceId = this.vtodo.getFirstPropertyValue('recurrence-id')
+
+ // Extract recurrence-rule only if this is NOT an exception instance
+ if (this.vtodo && !this._recurrenceId) {
+ const recurrenceRules = this.vtodo.getAllProperties('rrule')
+ const firstRecurrenceRule = recurrenceRules?.[0]
+
+ if (firstRecurrenceRule) {
+ try {
+ // Get the ICAL.Recur value and convert directly to RecurValue
+ const icalRecur = firstRecurrenceRule.getFirstValue()
+ const recurValue = RecurValue.fromICALJs(icalRecur)
+
+ // Get reference date for the mapping function
+ const referenceDate = this._due || this._start
+ const jsDate = referenceDate?.toJSDate() || null
+
+ this._recurrenceRule = mapRecurrenceRuleValueToRecurrenceRuleObject(recurValue, jsDate)
+ this._hasMultipleRRules = recurrenceRules.length > 1
+ } catch (e) {
+ console.warn('Failed to parse recurrence rule:', e)
+ this._recurrenceRule = getDefaultRecurrenceRuleObject()
+ this._hasMultipleRRules = false
+ }
+ }
+ }
+
+ // Set default if not already set
+ if (!this._recurrenceRule) {
+ this._recurrenceRule = getDefaultRecurrenceRuleObject()
+ this._hasMultipleRRules = false
+ }
+
let sortOrder = this.vtodo.getFirstPropertyValue('x-apple-sort-order')
if (sortOrder === null) {
sortOrder = this.getSortOrder()
@@ -502,6 +541,12 @@ export default class Task {
this.vtodo.updatePropertyWithValue('dtstart', start)
} else {
this.vtodo.removeProperty('dtstart')
+ // Remove RRULE when start date is removed (if no due date exists)
+ if (!this._due) {
+ this.vtodo.removeAllProperties('rrule')
+ this._recurrenceRule = getDefaultRecurrenceRuleObject()
+ this._hasMultipleRRules = false
+ }
}
this._start = start
this._startMoment = moment(start, 'YYYYMMDDTHHmmssZ')
@@ -528,6 +573,12 @@ export default class Task {
this.vtodo.updatePropertyWithValue('due', due)
} else {
this.vtodo.removeProperty('due')
+ // Remove RRULE when due date is removed (if no start date exists)
+ if (!this._start) {
+ this.vtodo.removeAllProperties('rrule')
+ this._recurrenceRule = getDefaultRecurrenceRuleObject()
+ this._hasMultipleRRules = false
+ }
}
this._due = due
this._dueMoment = moment(due, 'YYYYMMDDTHHmmssZ')
@@ -771,6 +822,76 @@ export default class Task {
this._sortOrder = sortOrder
}
+ /**
+ * Gets the recurrence rule
+ *
+ * @return {object} The recurrence rule
+ */
+ get recurrenceRule() {
+ return this._recurrenceRule
+ }
+
+ /**
+ * Sets the recurrence rule
+ *
+ * @param {object} recurrenceRule The recurrence rule
+ */
+ set recurrenceRule(recurrenceRule) {
+ // Auto-set DTSTART if no date exists (Thunderbird compatibility)
+ if (!this._start && !this._due && recurrenceRule.frequency !== 'NONE') {
+ const now = ICAL.Time.now()
+ now.isDate = true // Make it all-day by default
+ this.setStart(now)
+ }
+ this._recurrenceRule = recurrenceRule
+ }
+
+ /**
+ * Checks if the task is recurring
+ *
+ * @return {boolean} True if recurring
+ */
+ get isRecurring() {
+ return this._recurrenceRule && this._recurrenceRule.frequency !== 'NONE'
+ }
+
+ /**
+ * Checks if the task has multiple recurrence rules
+ *
+ * @return {boolean} True if has multiple RRULEs
+ */
+ get hasMultipleRRules() {
+ return this._hasMultipleRRules
+ }
+
+ /**
+ * Returns the recurrence ID of this task (if it's an exception instance)
+ *
+ * @return {ICAL.Time|null}
+ */
+ get recurrenceId() {
+ return this._recurrenceId
+ }
+
+ /**
+ * Checks if this task is a recurring exception instance
+ *
+ * @return {boolean}
+ */
+ get isRecurrenceException() {
+ return this._recurrenceId !== null
+ }
+
+ /**
+ * Checks if a recurrence exception can be created for this task
+ *
+ * @return {boolean} True if exception can be created
+ */
+ get canCreateRecurrenceException() {
+ // Can create exception if task is recurring and not completed
+ return this.isRecurring && !this.completed
+ }
+
/**
* Construct the default value for the sort order
* from the created date.
diff --git a/src/store/tasks.js b/src/store/tasks.js
index 84c95841f..56d68e398 100644
--- a/src/store/tasks.js
+++ b/src/store/tasks.js
@@ -24,9 +24,11 @@
import { Calendar } from './calendars.js'
import { findVTODObyUid } from './cdav-requests.js'
import { isParentInList, momentToICALTime, parseString } from './storeHelper.js'
+import { getDefaultRecurrenceRuleObject } from '../models/recurrenceRule.js'
import SyncStatus from '../models/syncStatus.js'
import Task from '../models/task.js'
+import { DateTimeValue, RecurValue } from '@nextcloud/calendar-js'
import { showError } from '@nextcloud/dialogs'
import { emit } from '@nextcloud/event-bus'
import { translate as t } from '@nextcloud/l10n'
@@ -35,6 +37,50 @@ import moment from '@nextcloud/moment'
import ICAL from 'ical.js'
import { randomUUID } from '../utils/crypto.js'
+/**
+ * Clone an ICAL.Time without triggering timezone lookups.
+ * ical.js may not have all timezones registered, causing errors when
+ * clone() tries to resolve timezone components.
+ *
+ * This function converts to local time first to handle the case where
+ * the ICAL.Time is stored in UTC - we need the local hour values, not UTC.
+ *
+ * @param {ICAL.Time} time The ICAL.Time to clone
+ * @param {boolean} [isDate] Override isDate property (optional)
+ * @return {ICAL.Time} A new ICAL.Time with the same local values
+ */
+function cloneTimeWithoutTimezone(time, isDate = null) {
+ let year = time.year
+ let month = time.month
+ let day = time.day
+ let hour = time.hour
+ let minute = time.minute
+ let second = time.second
+
+ // If the time is in UTC, convert to local time without triggering timezone lookups
+ // We check for UTC timezone by looking at the zone property
+ if (time.zone && time.zone.tzid === 'UTC') {
+ // Create a Date from UTC values, then extract local time components
+ const jsDate = new Date(Date.UTC(year, month - 1, day, hour, minute, second))
+ year = jsDate.getFullYear()
+ month = jsDate.getMonth() + 1
+ day = jsDate.getDate()
+ hour = jsDate.getHours()
+ minute = jsDate.getMinutes()
+ second = jsDate.getSeconds()
+ }
+
+ return new ICAL.Time({
+ year,
+ month,
+ day,
+ hour,
+ minute,
+ second,
+ isDate: isDate !== null ? isDate : time.isDate,
+ })
+}
+
const state = {
tasks: {},
searchQuery: '',
@@ -1221,6 +1267,12 @@ const actions = {
await context.dispatch('setPercentComplete', { task: subTask, complete: 100 })
}
}))
+
+ // Handle recurring tasks
+ if (task.isRecurring && task.recurrenceRule.recurrenceRuleValue) {
+ await context.dispatch('handleRecurringTaskCompletion', { task })
+ return // The handler will update the task
+ }
}
context.commit('setComplete', { task, complete })
context.dispatch('updateTask', task)
@@ -1522,7 +1574,7 @@ const actions = {
context.commit('setStart', { task, start: newStart })
context.dispatch('updateTask', task)
}
- // Adjust due date
+ // Adjust due date if start is not set but due is
} else if (due.isValid()) {
diff = due.diff(moment().startOf('day'), 'days')
diff = diff < 0 ? 0 : diff
@@ -1531,9 +1583,9 @@ const actions = {
context.commit('setDue', { task, due: newDue })
context.dispatch('updateTask', task)
}
- // Set the due date to appropriate value
+ // Set the start date to appropriate value (make start the default)
} else {
- context.commit('setDue', { task, due: day })
+ context.commit('setStart', { task, start: day })
context.dispatch('updateTask', task)
}
},
@@ -1612,6 +1664,316 @@ const actions = {
}
},
+ /**
+ * Sets the recurrence rule for a task
+ *
+ * @param {object} context The store context
+ * @param {object} data Destructuring object
+ * @param {Task} data.task The task to update
+ * @param {object} data.recurrenceRule The recurrence rule data
+ */
+ async setRecurrenceRule(context, { task, recurrenceRule }) {
+ // Create base RecurValue with frequency and interval
+ const recurrenceValue = RecurValue.fromData({
+ freq: recurrenceRule.frequency,
+ interval: recurrenceRule.interval || 1,
+ })
+
+ // Use setComponent() for by-parts (following Calendar app pattern)
+ // Add BYDAY if provided (for weekly, monthly, yearly)
+ if (recurrenceRule.byDay && recurrenceRule.byDay.length > 0) {
+ recurrenceValue.setComponent('BYDAY', recurrenceRule.byDay)
+ }
+
+ // Add BYMONTH if provided (for yearly)
+ if (recurrenceRule.byMonth && recurrenceRule.byMonth.length > 0) {
+ recurrenceValue.setComponent('BYMONTH', recurrenceRule.byMonth)
+ }
+
+ // Add BYMONTHDAY if provided (for monthly, yearly)
+ if (recurrenceRule.byMonthDay && recurrenceRule.byMonthDay.length > 0) {
+ recurrenceValue.setComponent('BYMONTHDAY', recurrenceRule.byMonthDay)
+ }
+
+ // Add BYSETPOS if provided (for "on the first/last" options)
+ if (recurrenceRule.bySetPosition !== null && recurrenceRule.bySetPosition !== undefined) {
+ recurrenceValue.setComponent('BYSETPOS', [recurrenceRule.bySetPosition])
+ }
+
+ // Set end condition
+ if (recurrenceRule.until) {
+ recurrenceValue.until = DateTimeValue.fromJSDate(new Date(recurrenceRule.until), { zone: 'utc' })
+ } else if (recurrenceRule.count) {
+ recurrenceValue.count = recurrenceRule.count
+ }
+
+ // Convert RecurValue to ICAL.Recur
+ const icalRecur = recurrenceValue.toICALJs()
+
+ // Add or update the RRULE property on the vtodo
+ task.vtodo.removeAllProperties('rrule')
+ task.vtodo.updatePropertyWithValue('rrule', icalRecur)
+
+ // Update the task model
+ task._recurrenceRule = {
+ recurrenceRuleValue: recurrenceValue,
+ frequency: recurrenceRule.frequency,
+ interval: recurrenceRule.interval || 1,
+ count: recurrenceRule.count || null,
+ until: recurrenceRule.until || null,
+ byDay: recurrenceRule.byDay || [],
+ byMonth: recurrenceRule.byMonth || [],
+ byMonthDay: recurrenceRule.byMonthDay || [],
+ bySetPosition: recurrenceRule.bySetPosition || null,
+ isUnsupported: false,
+ }
+ task._hasMultipleRRules = false
+
+ await context.dispatch('updateTask', task)
+ },
+
+ /**
+ * Removes the recurrence rule from a task
+ *
+ * @param {object} context The store context
+ * @param {object} data Destructuring object
+ * @param {Task} data.task The task to update
+ */
+ async removeRecurrenceRule(context, { task }) {
+ // Remove the RRULE property from vtodo
+ task.vtodo.removeAllProperties('rrule')
+
+ // Reset the recurrence rule in the task model
+ task._recurrenceRule = getDefaultRecurrenceRuleObject()
+ task._hasMultipleRRules = false
+
+ await context.dispatch('updateTask', task)
+ },
+
+ /**
+ * Handles completion of a recurring task by creating an exception instance with RECURRENCE-ID
+ * This is compatible with Thunderbird and other CalDAV clients
+ *
+ * @param {object} context The store context
+ * @param {object} data Destructuring object
+ * @param {Task} data.task The task that was completed
+ */
+ async handleRecurringTaskCompletion(context, { task }) {
+ // Only process if task is recurring
+ if (!task.isRecurring || !task.recurrenceRule.recurrenceRuleValue) {
+ return
+ }
+
+ try {
+ // Get the instance date (the current due/start date)
+ const instanceDate = task.due || task.start
+
+ if (!instanceDate) {
+ // Task has no date - just mark it complete without creating exception
+ console.warn('Recurring task has no due/start date - cannot create exception or advance to next occurrence')
+ context.commit('setComplete', { task, complete: 100 })
+ context.dispatch('updateTask', task)
+ return
+ }
+
+ // Get the calendar
+ const calendar = task.calendar
+ if (!calendar) {
+ console.error('Cannot find calendar for task')
+ return
+ }
+
+ // Create a new exception VTODO for this completed instance
+ const comp = new ICAL.Component('vcalendar')
+ comp.updatePropertyWithValue('prodid', '-//Nextcloud Tasks')
+ comp.updatePropertyWithValue('version', '2.0')
+
+ const vtodo = new ICAL.Component('vtodo')
+ comp.addSubcomponent(vtodo)
+
+ // Copy properties from master task
+ vtodo.updatePropertyWithValue('uid', task.uid)
+ vtodo.updatePropertyWithValue('summary', task.summary)
+
+ if (task.description) {
+ vtodo.updatePropertyWithValue('description', task.description)
+ }
+ if (task.location) {
+ vtodo.updatePropertyWithValue('location', task.location)
+ }
+ if (task.url) {
+ vtodo.updatePropertyWithValue('url', task.url)
+ }
+ if (task.priority) {
+ vtodo.updatePropertyWithValue('priority', task.priority)
+ }
+ if (task.class) {
+ vtodo.updatePropertyWithValue('class', task.class)
+ }
+
+ // Set completion properties
+ vtodo.updatePropertyWithValue('status', 'COMPLETED')
+ vtodo.updatePropertyWithValue('percent-complete', 100)
+ const completedDate = ICAL.Time.now()
+ vtodo.updatePropertyWithValue('completed', completedDate)
+
+ // Set RECURRENCE-ID to mark this as an exception instance
+ const recurrenceId = cloneTimeWithoutTimezone(instanceDate)
+ vtodo.updatePropertyWithValue('recurrence-id', recurrenceId)
+
+ // Set the original due/start date for this instance
+ if (task.due) {
+ vtodo.updatePropertyWithValue('due', cloneTimeWithoutTimezone(task.due, task.allDay))
+ }
+ if (task.start) {
+ vtodo.updatePropertyWithValue('dtstart', cloneTimeWithoutTimezone(task.start, task.allDay))
+ }
+
+ // Set created and last-modified
+ vtodo.updatePropertyWithValue('created', ICAL.Time.now())
+ vtodo.updatePropertyWithValue('last-modified', ICAL.Time.now())
+ vtodo.updatePropertyWithValue('dtstamp', ICAL.Time.now())
+
+ // Create the exception task on the server
+ const vData = comp.toString()
+ await context.dispatch('appendTaskToCalendar', {
+ calendar,
+ taskData: vData,
+ })
+
+ // Reload the task to get fresh ETag after exception was created
+ const freshTask = context.getters.getTaskByUri(task.uri)
+ if (!freshTask) {
+ console.error('Cannot find task after creating exception')
+ return
+ }
+
+ // Now update the master task to show next occurrence
+ if (!freshTask.recurrenceRule?.recurrenceRuleValue) {
+ console.warn('Cannot advance recurring task: no recurrence rule value')
+ return
+ }
+
+ // Convert RecurValue to ICAL.Recur to get the iterator
+ const icalRecur = freshTask.recurrenceRule.recurrenceRuleValue.toICALJs()
+
+ // Check if recurrence rule has a COUNT limit
+ // When using COUNT, the iterator counts from startTime, which breaks when we
+ // advance the task's dates. We need to handle COUNT by decrementing it.
+ const hasCountLimit = icalRecur.count !== null && icalRecur.count > 0
+
+ // For COUNT-limited rules, check if we've reached the limit
+ if (hasCountLimit && icalRecur.count <= 1) {
+ // This is the last occurrence - mark master task as completed
+ freshTask.setComplete(100)
+ freshTask.setCompleted(true)
+ freshTask.setStatus('COMPLETED')
+ await context.dispatch('updateTask', freshTask)
+ return
+ }
+
+ // Create a floating time to avoid timezone lookup issues
+ // Recurrence rules operate on local time (e.g., "repeat daily" = same local time each day)
+ const startTime = cloneTimeWithoutTimezone(instanceDate)
+
+ const iterator = icalRecur.iterator(startTime)
+
+ // Get the first occurrence from the iterator
+ // The iterator returns occurrences >= startTime
+ let nextOccurrence = iterator.next()
+
+ // Check if the first occurrence matches the current instance date
+ // This happens when the instance date IS a valid occurrence of the rule
+ // (e.g., task on Sunday with rule "every Sunday")
+ // If it matches, we need to skip it and get the actual next occurrence
+ // If it doesn't match, the instance date is NOT a valid occurrence
+ // (e.g., task on Tuesday with rule "every Sunday"), and the first
+ // result is already the next valid occurrence we want
+ if (nextOccurrence
+ && nextOccurrence.year === startTime.year
+ && nextOccurrence.month === startTime.month
+ && nextOccurrence.day === startTime.day) {
+ // Current date is a valid occurrence, skip to next
+ nextOccurrence = iterator.next()
+ }
+
+ // For UNTIL rules, this correctly returns null when we've passed the end date
+ // For COUNT rules, we ignore the iterator's count tracking since we handle it separately
+ if (nextOccurrence) {
+ // Build moment directly from floating time components to preserve local time
+ // toJSDate() on floating times interprets values as UTC, causing timezone shift
+ const nextMoment = moment({
+ year: nextOccurrence.year,
+ month: nextOccurrence.month - 1, // moment months are 0-indexed
+ date: nextOccurrence.day,
+ hour: nextOccurrence.hour,
+ minute: nextOccurrence.minute,
+ second: nextOccurrence.second,
+ })
+
+ // Calculate offset between start and due if both are set
+ let startOffset = null
+ if (freshTask.start && freshTask.due) {
+ const currentStart = freshTask.startMoment
+ const currentDue = freshTask.dueMoment
+ if (currentStart.isValid() && currentDue.isValid()) {
+ startOffset = currentDue.diff(currentStart)
+ }
+ }
+
+ // Update the appropriate date field
+ if (freshTask.due) {
+ context.commit('setDue', {
+ task: freshTask,
+ due: nextMoment,
+ allDay: freshTask.allDay,
+ })
+ } else if (freshTask.start) {
+ // If only start date exists, update that
+ context.commit('setStart', {
+ task: freshTask,
+ start: nextMoment,
+ allDay: freshTask.allDay,
+ })
+ }
+
+ // Update start date if both dates were set, maintaining the offset
+ if (freshTask.due && startOffset !== null) {
+ const nextStart = nextMoment.clone().subtract(startOffset, 'ms')
+ context.commit('setStart', {
+ task: freshTask,
+ start: nextStart,
+ allDay: freshTask.allDay,
+ })
+ }
+
+ // Reset completion status on master task
+ freshTask.setComplete(0)
+ freshTask.setCompleted(false)
+ freshTask.setStatus(null)
+
+ // Decrement COUNT if the rule has a count limit
+ // This ensures the count reflects remaining occurrences
+ if (hasCountLimit) {
+ const rruleProp = freshTask.vtodo.getFirstProperty('rrule')
+ if (rruleProp) {
+ const rruleValue = rruleProp.getFirstValue()
+ rruleValue.count = rruleValue.count - 1
+ }
+ }
+
+ // Save the updated master task
+ await context.dispatch('updateTask', freshTask)
+ } else {
+ // No more occurrences - keep task completed
+ // The exception will show as completed, master can be left as-is
+ }
+ } catch (error) {
+ console.error('Error handling recurring task completion:', error)
+ showError(t('tasks', 'Error processing recurring task'))
+ }
+ },
+
/**
* Moves a task to a new calendar or parent task
*
diff --git a/src/utils/recurrence.js b/src/utils/recurrence.js
new file mode 100644
index 000000000..90bf3ec28
--- /dev/null
+++ b/src/utils/recurrence.js
@@ -0,0 +1,55 @@
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+/**
+ * Gets the ByDay and BySetPosition
+ *
+ * @param {Date} jsDate The date to get the weekday of
+ * @return {object}
+ */
+export function getBySetPositionAndBySetFromDate(jsDate) {
+ const byDay = getWeekDayFromDate(jsDate)
+ let bySetPosition = 1
+ let dayOfMonth = jsDate.getDate()
+ for (; dayOfMonth > 7; dayOfMonth -= 7) {
+ bySetPosition++
+ }
+
+ return {
+ byDay,
+ bySetPosition,
+ }
+}
+
+/**
+ * Gets the string-representation of the weekday of a given date
+ *
+ * @param {Date} jsDate The date to get the weekday of
+ * @return {string}
+ */
+export function getWeekDayFromDate(jsDate) {
+ // If no date provided, default to Monday
+ if (!jsDate) {
+ return 'MO'
+ }
+ switch (jsDate.getDay()) {
+ case 0:
+ return 'SU'
+ case 1:
+ return 'MO'
+ case 2:
+ return 'TU'
+ case 3:
+ return 'WE'
+ case 4:
+ return 'TH'
+ case 5:
+ return 'FR'
+ case 6:
+ return 'SA'
+ default:
+ throw TypeError('Invalid date-object given')
+ }
+}
diff --git a/src/views/AppSidebar.vue b/src/views/AppSidebar.vue
index 356ee10bc..138281ab8 100644
--- a/src/views/AppSidebar.vue
+++ b/src/views/AppSidebar.vue
@@ -66,6 +66,13 @@ License along with this library. If not, see .
:read-only="readOnly"
:property-string="t('tasks', 'All day')"
@set-checked="toggleAllDay(task)" />
+
+
+
+
+ {
'use strict'
+ it('RecurValue should be available', () => {
+ expect(RecurValue).toBeDefined()
+ expect(typeof RecurValue.fromData).toEqual('function')
+
+ // Test creating a simple recurrence rule
+ const recurValue = RecurValue.fromData({ freq: 'DAILY', interval: 1 })
+ expect(recurValue).toBeDefined()
+ expect(recurValue.frequency).toEqual('DAILY')
+ })
+
+ it('Should manually parse RRULE', () => {
+ const ics = loadICS('vcalendars/vcalendar-recurring-daily')
+ const jCal = ICAL.parse(ics)
+ const vCalendar = new ICAL.Component(jCal)
+ const vtodo = vCalendar.getFirstSubcomponent('vtodo')
+ const rruleProp = vtodo.getFirstProperty('rrule')
+ const icalRecur = rruleProp.getFirstValue()
+
+ // Try to convert to RecurValue
+ const recurData = {
+ freq: icalRecur.freq,
+ interval: icalRecur.interval || 1,
+ }
+ const recurValue = RecurValue.fromData(recurData)
+
+ expect(recurValue).toBeDefined()
+ expect(recurValue.frequency).toEqual('DAILY')
+ })
+
+ it('Should parse RRULE when task is created', () => {
+ // Log to see what's happening
+ const origWarn = console.warn
+ const warnings = []
+ console.warn = (...args) => { warnings.push(args.join(' ')); origWarn(...args) }
+
+ const task = new Task(loadICS('vcalendars/vcalendar-recurring-daily'), {})
+
+ console.warn = origWarn
+
+ // Check if there were warnings
+ if (warnings.length > 0) {
+ console.log('Warnings during task creation:', warnings)
+ }
+
+ // The task should have recurrence parsed
+ console.log('Task isRecurring:', task.isRecurring)
+ console.log('Task recurrenceRule:', JSON.stringify(task.recurrenceRule, null, 2))
+
+ expect(task.isRecurring).toEqual(true)
+ })
+
it('Should set status to "COMPLETED" on completion.', () => {
const task = new Task(loadICS('vcalendars/vcalendar-default'), {})
task.complete = 100
@@ -279,4 +331,628 @@ describe('task', () => {
task.customUrl = expected
expect(task.customUrl).toEqual(expected)
})
+
+ describe('Recurring Tasks', () => {
+ it('Should load RRULE from ICS file', () => {
+ const task = new Task(loadICS('vcalendars/vcalendar-recurring-daily'), {})
+ // Debug: check what we actually got
+ const rruleProp = task.vtodo.getFirstProperty('rrule')
+ console.log('RRULE property:', rruleProp)
+ if (rruleProp) {
+ const rruleValue = rruleProp.getFirstValue()
+ console.log('RRULE value:', rruleValue)
+ console.log('RRULE value.freq:', rruleValue.freq)
+ console.log('RRULE value.interval:', rruleValue.interval)
+ console.log('RRULE value.parts:', rruleValue.parts)
+ console.log('RRULE value.toString():', rruleValue.toString())
+ }
+ console.log('Task due:', task.due)
+ console.log('Task _due:', task._due)
+ console.log('Task recurrenceRule:', task.recurrenceRule)
+ expect(rruleProp).toBeDefined()
+ })
+
+ it('Should parse daily recurrence rule', () => {
+ const task = new Task(loadICS('vcalendars/vcalendar-recurring-daily'), {})
+ expect(task.isRecurring).toEqual(true)
+ expect(task.recurrenceRule).toBeDefined()
+ expect(task.recurrenceRule.frequency).toEqual('DAILY')
+ expect(task.recurrenceRule.interval).toEqual(1)
+ })
+
+ it('Should parse weekly recurrence rule with interval', () => {
+ const task = new Task(loadICS('vcalendars/vcalendar-recurring-weekly'), {})
+ expect(task.isRecurring).toEqual(true)
+ expect(task.recurrenceRule.frequency).toEqual('WEEKLY')
+ expect(task.recurrenceRule.interval).toEqual(2)
+ })
+
+ it('Should parse recurrence rule with count', () => {
+ const task = new Task(loadICS('vcalendars/vcalendar-recurring-with-count'), {})
+ expect(task.isRecurring).toEqual(true)
+ expect(task.recurrenceRule.count).toEqual(5)
+ })
+
+ it('Should parse recurrence rule with until date', () => {
+ const task = new Task(loadICS('vcalendars/vcalendar-recurring-with-until'), {})
+ expect(task.isRecurring).toEqual(true)
+ expect(task.recurrenceRule.until).toBeDefined()
+ })
+
+ it('Should not be recurring without RRULE', () => {
+ const task = new Task(loadICS('vcalendars/vcalendar-default'), {})
+ expect(task.isRecurring).toEqual(false)
+ expect(task.recurrenceRule.frequency).toEqual('NONE')
+ })
+
+ it('Should have recurrenceRuleValue when recurring', () => {
+ const task = new Task(loadICS('vcalendars/vcalendar-recurring-daily'), {})
+ expect(task.isRecurring).toEqual(true)
+ expect(task.recurrenceRule.recurrenceRuleValue).toBeDefined()
+ expect(task.recurrenceRule.recurrenceRuleValue.frequency).toEqual('DAILY')
+ })
+
+ it('Should detect multiple RRULE properties', () => {
+ const task = new Task(loadICS('vcalendars/vcalendar-recurring-daily'), {})
+ expect(task.hasMultipleRRules).toEqual(false)
+ })
+
+ it('Should parse RRULE with DTSTART instead of DUE', () => {
+ const task = new Task(loadICS('vcalendars/vcalendar-recurring-dtstart'), {})
+ expect(task.isRecurring).toEqual(true)
+ expect(task.recurrenceRule.frequency).toEqual('DAILY')
+ })
+
+ it('Should parse RRULE even without due or start date (Thunderbird compatibility)', () => {
+ const task = new Task(loadICS('vcalendars/vcalendar-recurring-no-date'), {})
+ expect(task.isRecurring).toEqual(true)
+ expect(task.recurrenceRule.frequency).toEqual('DAILY')
+ })
+
+ it('Should be able to create recurrence exceptions when recurring', () => {
+ const task = new Task(loadICS('vcalendars/vcalendar-recurring-daily'), {})
+ expect(task.canCreateRecurrenceException).toEqual(true)
+ })
+
+ it('Should not be able to create recurrence exceptions when not recurring', () => {
+ const task = new Task(loadICS('vcalendars/vcalendar-default'), {})
+ expect(task.canCreateRecurrenceException).toEqual(false)
+ })
+
+ it('Should parse weekly recurrence with multiple BYDAY values', () => {
+ const task = new Task(loadICS('vcalendars/vcalendar-recurring-weekly-byday'), {})
+ expect(task.isRecurring).toEqual(true)
+ expect(task.recurrenceRule.frequency).toEqual('WEEKLY')
+ expect(task.recurrenceRule.byDay).toContain('MO')
+ expect(task.recurrenceRule.byDay).toContain('WE')
+ expect(task.recurrenceRule.byDay).toContain('FR')
+ expect(task.recurrenceRule.byDay.length).toEqual(3)
+ })
+
+ it('Should parse monthly recurrence with multiple BYMONTHDAY values', () => {
+ const task = new Task(loadICS('vcalendars/vcalendar-recurring-monthly-bymonthday'), {})
+ expect(task.isRecurring).toEqual(true)
+ expect(task.recurrenceRule.frequency).toEqual('MONTHLY')
+ expect(task.recurrenceRule.byMonthDay).toContain(1)
+ expect(task.recurrenceRule.byMonthDay).toContain(15)
+ expect(task.recurrenceRule.byMonthDay.length).toEqual(2)
+ })
+
+ it('Should parse monthly recurrence with BYSETPOS (first Monday)', () => {
+ const task = new Task(loadICS('vcalendars/vcalendar-recurring-monthly-bysetpos'), {})
+ expect(task.isRecurring).toEqual(true)
+ expect(task.recurrenceRule.frequency).toEqual('MONTHLY')
+ expect(task.recurrenceRule.byDay).toContain('MO')
+ expect(task.recurrenceRule.bySetPosition).toEqual(1)
+ })
+
+ it('Should parse yearly recurrence with BYMONTH', () => {
+ const task = new Task(loadICS('vcalendars/vcalendar-recurring-yearly-bymonth'), {})
+ expect(task.isRecurring).toEqual(true)
+ expect(task.recurrenceRule.frequency).toEqual('YEARLY')
+ expect(task.recurrenceRule.byMonth).toContain(1)
+ expect(task.recurrenceRule.byMonth).toContain(7)
+ expect(task.recurrenceRule.byMonthDay).toContain(15)
+ })
+
+ it('Should create RecurValue with multiple BYDAY using setComponent', () => {
+ // Test that setComponent correctly sets multiple BYDAY values
+ const recurrenceValue = RecurValue.fromData({
+ freq: 'WEEKLY',
+ interval: 1,
+ })
+
+ // Use setComponent like we do in setRecurrenceRule
+ recurrenceValue.setComponent('BYDAY', ['MO', 'WE', 'FR'])
+
+ // Verify the components are set
+ const byDayComponent = recurrenceValue.getComponent('BYDAY')
+ expect(byDayComponent).toContain('MO')
+ expect(byDayComponent).toContain('WE')
+ expect(byDayComponent).toContain('FR')
+ expect(byDayComponent.length).toEqual(3)
+
+ // Convert to ICAL and verify the string output
+ const icalRecur = recurrenceValue.toICALJs()
+ const rruleString = icalRecur.toString()
+ expect(rruleString).toContain('BYDAY=MO,WE,FR')
+ })
+
+ it('Should create RecurValue with BYMONTHDAY using setComponent', () => {
+ const recurrenceValue = RecurValue.fromData({
+ freq: 'MONTHLY',
+ interval: 1,
+ })
+
+ recurrenceValue.setComponent('BYMONTHDAY', [1, 15])
+
+ const byMonthDayComponent = recurrenceValue.getComponent('BYMONTHDAY')
+ expect(byMonthDayComponent).toContain(1)
+ expect(byMonthDayComponent).toContain(15)
+
+ const icalRecur = recurrenceValue.toICALJs()
+ const rruleString = icalRecur.toString()
+ expect(rruleString).toContain('BYMONTHDAY=1,15')
+ })
+
+ it('Should create RecurValue with BYSETPOS using setComponent', () => {
+ const recurrenceValue = RecurValue.fromData({
+ freq: 'MONTHLY',
+ interval: 1,
+ })
+
+ recurrenceValue.setComponent('BYDAY', ['MO'])
+ recurrenceValue.setComponent('BYSETPOS', [1])
+
+ const byDayComponent = recurrenceValue.getComponent('BYDAY')
+ expect(byDayComponent).toContain('MO')
+
+ const bySetPosComponent = recurrenceValue.getComponent('BYSETPOS')
+ expect(bySetPosComponent).toContain(1)
+
+ const icalRecur = recurrenceValue.toICALJs()
+ const rruleString = icalRecur.toString()
+ expect(rruleString).toContain('BYDAY=MO')
+ expect(rruleString).toContain('BYSETPOS=1')
+ })
+
+ it('Should iterate UNTIL-bounded recurrence using cloned time (preserves timezone)', () => {
+ // This tests the fix for: "TypeError: can't access property getAllSubcomponents, this.component is null"
+ // The error occurred when creating a floating time without timezone info for UNTIL comparison
+ const task = new Task(loadICS('vcalendars/vcalendar-recurring-with-until'), {})
+ expect(task.isRecurring).toEqual(true)
+
+ // Get the ICAL.Recur from the recurrence rule
+ const icalRecur = task.recurrenceRule.recurrenceRuleValue.toICALJs()
+ expect(icalRecur.until).toBeDefined()
+
+ // Clone the due date to preserve timezone info (the fix)
+ const startTime = task.due.clone()
+
+ // Create iterator and verify it works without throwing
+ const iterator = icalRecur.iterator(startTime)
+ expect(iterator).toBeDefined()
+
+ // Should be able to call next() without error
+ const firstOccurrence = iterator.next()
+ expect(firstOccurrence).toBeDefined()
+
+ // Should eventually return null when past UNTIL date
+ let occurrenceCount = 1
+ let occurrence = iterator.next()
+ while (occurrence !== null && occurrenceCount < 100) {
+ occurrenceCount++
+ occurrence = iterator.next()
+ }
+ // UNTIL is 7 days after the start, so we should have ~7 occurrences for daily
+ expect(occurrenceCount).toBeLessThanOrEqual(8)
+ })
+
+ it('Should be able to decrement COUNT in RRULE for tracking remaining occurrences', () => {
+ // This tests the fix for: "max occurrences appear to be ignored"
+ // The COUNT needs to be decremented when advancing to next occurrence
+ const task = new Task(loadICS('vcalendars/vcalendar-recurring-with-count'), {})
+ expect(task.isRecurring).toEqual(true)
+ expect(task.recurrenceRule.count).toEqual(5)
+
+ // Get the RRULE property from vtodo
+ const rruleProp = task.vtodo.getFirstProperty('rrule')
+ expect(rruleProp).toBeDefined()
+
+ const rruleValue = rruleProp.getFirstValue()
+ expect(rruleValue.count).toEqual(5)
+
+ // Simulate decrementing COUNT (as done in handleRecurringTaskCompletion)
+ rruleValue.count = rruleValue.count - 1
+ expect(rruleValue.count).toEqual(4)
+
+ // Verify the change persists
+ const rruleValueAfter = rruleProp.getFirstValue()
+ expect(rruleValueAfter.count).toEqual(4)
+ })
+
+ it('Should detect when COUNT reaches 1 (last occurrence)', () => {
+ // This verifies the logic for stopping recurrence when count limit is reached
+ const task = new Task(loadICS('vcalendars/vcalendar-recurring-with-count'), {})
+ const icalRecur = task.recurrenceRule.recurrenceRuleValue.toICALJs()
+
+ expect(icalRecur.count).toEqual(5)
+ const hasCountLimit = icalRecur.count !== null && icalRecur.count > 0
+ expect(hasCountLimit).toEqual(true)
+
+ // When count is 1, this is the last occurrence
+ icalRecur.count = 1
+ const isLastOccurrence = hasCountLimit && icalRecur.count <= 1
+ expect(isLastOccurrence).toEqual(true)
+ })
+ })
+
+ describe('RECURRENCE-ID Exception Instances', () => {
+ it('Should parse RECURRENCE-ID property', () => {
+ const task = new Task(loadICS('vcalendars/vcalendar-recurrence-exception'), {})
+ expect(task.isRecurrenceException).toEqual(true)
+ expect(task.recurrenceId).toBeTruthy()
+ })
+
+ it('Should NOT parse RRULE when RECURRENCE-ID is present', () => {
+ const task = new Task(loadICS('vcalendars/vcalendar-recurrence-exception'), {})
+ expect(task.isRecurrenceException).toEqual(true)
+ expect(task.isRecurring).toEqual(false)
+ })
+
+ it('Should not have RECURRENCE-ID on normal tasks', () => {
+ const task = new Task(loadICS('vcalendars/vcalendar-default'), {})
+ expect(task.isRecurrenceException).toEqual(false)
+ expect(task.recurrenceId).toBeNull()
+ })
+ })
+
+ describe('setRecurrenceRule Logic', () => {
+ it('Should create RRULE with weekly BYDAY for multiple days', () => {
+ // Test the logic used in setRecurrenceRule store action
+ const recurrenceValue = RecurValue.fromData({
+ freq: 'WEEKLY',
+ interval: 1,
+ })
+
+ // Set multiple days (Mon, Wed, Fri)
+ recurrenceValue.setComponent('BYDAY', ['MO', 'WE', 'FR'])
+
+ const icalRecur = recurrenceValue.toICALJs()
+ const rruleString = icalRecur.toString()
+
+ expect(rruleString).toContain('FREQ=WEEKLY')
+ expect(rruleString).toContain('BYDAY=MO,WE,FR')
+ })
+
+ it('Should create RRULE with monthly BYMONTHDAY for multiple days', () => {
+ const recurrenceValue = RecurValue.fromData({
+ freq: 'MONTHLY',
+ interval: 1,
+ })
+
+ // Set multiple month days (1st and 15th)
+ recurrenceValue.setComponent('BYMONTHDAY', [1, 15])
+
+ const icalRecur = recurrenceValue.toICALJs()
+ const rruleString = icalRecur.toString()
+
+ expect(rruleString).toContain('FREQ=MONTHLY')
+ expect(rruleString).toContain('BYMONTHDAY=1,15')
+ })
+
+ it('Should create RRULE with yearly BYMONTH for multiple months', () => {
+ const recurrenceValue = RecurValue.fromData({
+ freq: 'YEARLY',
+ interval: 1,
+ })
+
+ // Set multiple months (Jan and Jul) with day 15
+ recurrenceValue.setComponent('BYMONTH', [1, 7])
+ recurrenceValue.setComponent('BYMONTHDAY', [15])
+
+ const icalRecur = recurrenceValue.toICALJs()
+ const rruleString = icalRecur.toString()
+
+ expect(rruleString).toContain('FREQ=YEARLY')
+ expect(rruleString).toContain('BYMONTH=1,7')
+ expect(rruleString).toContain('BYMONTHDAY=15')
+ })
+
+ it('Should create RRULE with COUNT limit', () => {
+ const recurrenceValue = RecurValue.fromData({
+ freq: 'DAILY',
+ interval: 1,
+ })
+
+ recurrenceValue.count = 10
+
+ const icalRecur = recurrenceValue.toICALJs()
+ const rruleString = icalRecur.toString()
+
+ expect(rruleString).toContain('FREQ=DAILY')
+ expect(rruleString).toContain('COUNT=10')
+ })
+
+ it('Should create RRULE with UNTIL date', () => {
+ const recurrenceValue = RecurValue.fromData({
+ freq: 'DAILY',
+ interval: 1,
+ })
+
+ // Set UNTIL to a specific date
+ recurrenceValue.until = DateTimeValue.fromJSDate(new Date('2026-12-31T23:59:59Z'), { zone: 'utc' })
+
+ const icalRecur = recurrenceValue.toICALJs()
+ const rruleString = icalRecur.toString()
+
+ expect(rruleString).toContain('FREQ=DAILY')
+ expect(rruleString).toContain('UNTIL=')
+ expect(rruleString).toContain('20261231')
+ })
+
+ it('Should update task vtodo RRULE property', () => {
+ // Create a non-recurring task
+ const task = new Task(loadICS('vcalendars/vcalendar-default'), {})
+ expect(task.isRecurring).toEqual(false)
+
+ // Build a recurrence value like setRecurrenceRule does
+ const recurrenceValue = RecurValue.fromData({
+ freq: 'WEEKLY',
+ interval: 2,
+ })
+ recurrenceValue.setComponent('BYDAY', ['MO', 'FR'])
+
+ // Convert to ICAL and update task
+ const icalRecur = recurrenceValue.toICALJs()
+ task.vtodo.removeAllProperties('rrule')
+ task.vtodo.updatePropertyWithValue('rrule', icalRecur)
+
+ // Verify the RRULE was set
+ const rruleProp = task.vtodo.getFirstProperty('rrule')
+ expect(rruleProp).toBeDefined()
+
+ const rruleValue = rruleProp.getFirstValue()
+ expect(rruleValue.freq).toEqual('WEEKLY')
+ expect(rruleValue.interval).toEqual(2)
+ })
+ })
+
+ describe('handleRecurringTaskCompletion Logic', () => {
+ it('Should advance daily recurrence to next day', () => {
+ const task = new Task(loadICS('vcalendars/vcalendar-recurring-daily'), {})
+ expect(task.isRecurring).toEqual(true)
+
+ const icalRecur = task.recurrenceRule.recurrenceRuleValue.toICALJs()
+ const startTime = task.due.clone()
+
+ // Get iterator - first next() returns start, second returns next occurrence
+ const iterator = icalRecur.iterator(startTime)
+ iterator.next() // Skip the start date
+ const nextOccurrence = iterator.next()
+
+ expect(nextOccurrence).toBeDefined()
+ // Next occurrence should be 1 day after start
+ const dayDiff = (nextOccurrence.toUnixTime() - startTime.toUnixTime()) / 86400
+ expect(dayDiff).toEqual(1)
+ })
+
+ it('Should advance weekly recurrence to next week', () => {
+ const task = new Task(loadICS('vcalendars/vcalendar-recurring-weekly'), {})
+ expect(task.isRecurring).toEqual(true)
+ expect(task.recurrenceRule.interval).toEqual(2) // Every 2 weeks
+
+ const icalRecur = task.recurrenceRule.recurrenceRuleValue.toICALJs()
+ const startTime = task.due.clone()
+
+ // Get iterator - first next() returns start, second returns next occurrence
+ const iterator = icalRecur.iterator(startTime)
+ iterator.next() // Skip the start date
+ const nextOccurrence = iterator.next()
+
+ expect(nextOccurrence).toBeDefined()
+ // Next occurrence should be 2 weeks (14 days) after start
+ const dayDiff = (nextOccurrence.toUnixTime() - startTime.toUnixTime()) / 86400
+ expect(dayDiff).toEqual(14)
+ })
+
+ it('Should stop iteration when UNTIL date is reached', () => {
+ const task = new Task(loadICS('vcalendars/vcalendar-recurring-with-until'), {})
+ expect(task.isRecurring).toEqual(true)
+
+ const icalRecur = task.recurrenceRule.recurrenceRuleValue.toICALJs()
+ expect(icalRecur.until).toBeDefined()
+
+ const startTime = task.due.clone()
+ const iterator = icalRecur.iterator(startTime)
+
+ // Count occurrences until iterator returns null
+ let count = 0
+ while (iterator.next() !== null && count < 100) {
+ count++
+ }
+
+ // Should have limited occurrences (UNTIL is 7 days after start for daily)
+ expect(count).toBeLessThan(10)
+ expect(count).toBeGreaterThan(0)
+ })
+
+ it('Should correctly identify last COUNT occurrence', () => {
+ const task = new Task(loadICS('vcalendars/vcalendar-recurring-with-count'), {})
+ const icalRecur = task.recurrenceRule.recurrenceRuleValue.toICALJs()
+
+ // Initial count is 5
+ expect(icalRecur.count).toEqual(5)
+
+ // Simulate completing occurrences
+ icalRecur.count = 2
+ expect(icalRecur.count <= 1).toEqual(false) // Not last yet
+
+ icalRecur.count = 1
+ expect(icalRecur.count <= 1).toEqual(true) // This is the last occurrence
+ })
+
+ it('Should handle weekly recurrence with multiple BYDAY correctly', () => {
+ const task = new Task(loadICS('vcalendars/vcalendar-recurring-weekly-byday'), {})
+ expect(task.isRecurring).toEqual(true)
+ expect(task.recurrenceRule.byDay).toContain('MO')
+ expect(task.recurrenceRule.byDay).toContain('WE')
+ expect(task.recurrenceRule.byDay).toContain('FR')
+
+ const icalRecur = task.recurrenceRule.recurrenceRuleValue.toICALJs()
+ const startTime = task.due.clone()
+
+ const iterator = icalRecur.iterator(startTime)
+
+ // Get multiple occurrences
+ const occurrences = []
+ for (let i = 0; i < 5; i++) {
+ const occ = iterator.next()
+ if (occ) occurrences.push(occ)
+ }
+
+ // Should have 5 occurrences
+ expect(occurrences.length).toEqual(5)
+ })
+
+ it('Should find next Sunday when task starts on Tuesday with BYDAY=SU (bug fix)', () => {
+ // This tests the fix for: "start date Tuesday, recurrence Sunday -> next should be Feb 1, not Feb 8"
+ // The issue was that the iterator was unconditionally skipping the first occurrence,
+ // but when the start date is NOT a valid occurrence, the first iterator.next()
+ // already returns the correct next occurrence.
+ const task = new Task(loadICS('vcalendars/vcalendar-recurring-weekly-different-day'), {})
+ expect(task.isRecurring).toEqual(true)
+ expect(task.recurrenceRule.frequency).toEqual('WEEKLY')
+ expect(task.recurrenceRule.byDay).toContain('SU')
+
+ const icalRecur = task.recurrenceRule.recurrenceRuleValue.toICALJs()
+ const startTime = task.start.clone() // Jan 27, 2026 (Tuesday)
+
+ const iterator = icalRecur.iterator(startTime)
+
+ // First next() returns the first valid occurrence >= startTime
+ // Since Jan 27 is Tuesday and rule is BYDAY=SU, first occurrence is Feb 1 (Sunday)
+ const firstOccurrence = iterator.next()
+ expect(firstOccurrence).toBeDefined()
+
+ // Feb 1, 2026 is the next Sunday after Jan 27
+ expect(firstOccurrence.month).toEqual(2) // February
+ expect(firstOccurrence.day).toEqual(1) // 1st
+
+ // Verify Jan 27 is NOT a valid occurrence (it's Tuesday, not Sunday)
+ expect(firstOccurrence.year === startTime.year
+ && firstOccurrence.month === startTime.month
+ && firstOccurrence.day === startTime.day).toEqual(false)
+ })
+
+ it('Should find next 15th when task starts on 27th with BYMONTHDAY=15 (bug fix)', () => {
+ // Monthly: task on Jan 27 with recurrence on 15th -> next should be Feb 15
+ const task = new Task(loadICS('vcalendars/vcalendar-recurring-monthly-different-day'), {})
+ expect(task.isRecurring).toEqual(true)
+ expect(task.recurrenceRule.frequency).toEqual('MONTHLY')
+ expect(task.recurrenceRule.byMonthDay).toContain(15)
+
+ const icalRecur = task.recurrenceRule.recurrenceRuleValue.toICALJs()
+ const startTime = task.start.clone() // Jan 27, 2026
+
+ const iterator = icalRecur.iterator(startTime)
+
+ // First occurrence should be Feb 15, not March 15
+ const firstOccurrence = iterator.next()
+ expect(firstOccurrence).toBeDefined()
+ expect(firstOccurrence.month).toEqual(2) // February
+ expect(firstOccurrence.day).toEqual(15)
+ })
+
+ it('Should find next July when task starts in January with BYMONTH=7 (bug fix)', () => {
+ // Yearly: task in Jan with recurrence in July -> next should be July of same year
+ const task = new Task(loadICS('vcalendars/vcalendar-recurring-yearly-different-month'), {})
+ expect(task.isRecurring).toEqual(true)
+ expect(task.recurrenceRule.frequency).toEqual('YEARLY')
+ expect(task.recurrenceRule.byMonth).toContain(7)
+
+ const icalRecur = task.recurrenceRule.recurrenceRuleValue.toICALJs()
+ const startTime = task.start.clone() // Jan 27, 2026
+
+ const iterator = icalRecur.iterator(startTime)
+
+ // First occurrence should be July 15, 2026, not July 15, 2027
+ const firstOccurrence = iterator.next()
+ expect(firstOccurrence).toBeDefined()
+ expect(firstOccurrence.year).toEqual(2026)
+ expect(firstOccurrence.month).toEqual(7) // July
+ expect(firstOccurrence.day).toEqual(15)
+ })
+
+ it('Should still skip start when it IS a valid occurrence (BYDAY=SU on Sunday)', () => {
+ // When the start date IS a valid occurrence, we should still skip it
+ // to get the NEXT occurrence (one week later)
+ const task = new Task(loadICS('vcalendars/vcalendar-recurring-weekly-same-day'), {})
+ expect(task.isRecurring).toEqual(true)
+ expect(task.recurrenceRule.frequency).toEqual('WEEKLY')
+ expect(task.recurrenceRule.byDay).toContain('SU')
+
+ const icalRecur = task.recurrenceRule.recurrenceRuleValue.toICALJs()
+ const startTime = task.start.clone() // Jan 25, 2026 (Sunday)
+
+ const iterator = icalRecur.iterator(startTime)
+
+ // First next() returns Jan 25 (which equals startTime)
+ const firstOccurrence = iterator.next()
+ expect(firstOccurrence).toBeDefined()
+ expect(firstOccurrence.year).toEqual(startTime.year)
+ expect(firstOccurrence.month).toEqual(startTime.month)
+ expect(firstOccurrence.day).toEqual(startTime.day)
+
+ // In handleRecurringTaskCompletion, we would detect this and call next() again
+ // to get Feb 1 (the actual next occurrence)
+ const secondOccurrence = iterator.next()
+ expect(secondOccurrence).toBeDefined()
+ expect(secondOccurrence.month).toEqual(2) // February
+ expect(secondOccurrence.day).toEqual(1) // 1st (next Sunday)
+ })
+
+ it('Should correctly calculate next occurrence regardless of whether start is valid', () => {
+ // This simulates the exact logic from handleRecurringTaskCompletion
+ // to verify the fix works correctly
+
+ // Case 1: Start is NOT a valid occurrence (Tuesday with BYDAY=SU)
+ const task1 = new Task(loadICS('vcalendars/vcalendar-recurring-weekly-different-day'), {})
+ const icalRecur1 = task1.recurrenceRule.recurrenceRuleValue.toICALJs()
+ const startTime1 = task1.start.clone()
+ const iterator1 = icalRecur1.iterator(startTime1)
+
+ let nextOccurrence1 = iterator1.next()
+ // Check if first occurrence matches start - if so, skip it
+ if (nextOccurrence1
+ && nextOccurrence1.year === startTime1.year
+ && nextOccurrence1.month === startTime1.month
+ && nextOccurrence1.day === startTime1.day) {
+ nextOccurrence1 = iterator1.next()
+ }
+
+ // Should be Feb 1, 2026 (first Sunday after Jan 27)
+ expect(nextOccurrence1.month).toEqual(2)
+ expect(nextOccurrence1.day).toEqual(1)
+
+ // Case 2: Start IS a valid occurrence (Sunday with BYDAY=SU)
+ const task2 = new Task(loadICS('vcalendars/vcalendar-recurring-weekly-same-day'), {})
+ const icalRecur2 = task2.recurrenceRule.recurrenceRuleValue.toICALJs()
+ const startTime2 = task2.start.clone()
+ const iterator2 = icalRecur2.iterator(startTime2)
+
+ let nextOccurrence2 = iterator2.next()
+ // Check if first occurrence matches start - if so, skip it
+ if (nextOccurrence2
+ && nextOccurrence2.year === startTime2.year
+ && nextOccurrence2.month === startTime2.month
+ && nextOccurrence2.day === startTime2.day) {
+ nextOccurrence2 = iterator2.next()
+ }
+
+ // Should be Feb 1, 2026 (next Sunday after Jan 25)
+ expect(nextOccurrence2.month).toEqual(2)
+ expect(nextOccurrence2.day).toEqual(1)
+ })
+ })
})