diff --git a/src/components/AppSidebar/RecurrenceItem.vue b/src/components/AppSidebar/RecurrenceItem.vue new file mode 100644 index 000000000..8bc3c8a40 --- /dev/null +++ b/src/components/AppSidebar/RecurrenceItem.vue @@ -0,0 +1,557 @@ + + + + + + + diff --git a/src/components/Repeat/Repeat.vue b/src/components/Repeat/Repeat.vue new file mode 100644 index 000000000..5d03bd8d1 --- /dev/null +++ b/src/components/Repeat/Repeat.vue @@ -0,0 +1,504 @@ + + + + + + + diff --git a/src/components/Repeat/RepeatEndRepeat.vue b/src/components/Repeat/RepeatEndRepeat.vue new file mode 100644 index 000000000..77682403a --- /dev/null +++ b/src/components/Repeat/RepeatEndRepeat.vue @@ -0,0 +1,196 @@ + + + + + diff --git a/src/components/Repeat/RepeatExceptionWarning.vue b/src/components/Repeat/RepeatExceptionWarning.vue new file mode 100644 index 000000000..d9c094245 --- /dev/null +++ b/src/components/Repeat/RepeatExceptionWarning.vue @@ -0,0 +1,23 @@ + + + + + diff --git a/src/components/Repeat/RepeatFirstLastSelect.vue b/src/components/Repeat/RepeatFirstLastSelect.vue new file mode 100644 index 000000000..0c9632673 --- /dev/null +++ b/src/components/Repeat/RepeatFirstLastSelect.vue @@ -0,0 +1,74 @@ + + + + + diff --git a/src/components/Repeat/RepeatForkWarning.vue b/src/components/Repeat/RepeatForkWarning.vue new file mode 100644 index 000000000..2c99ed8f0 --- /dev/null +++ b/src/components/Repeat/RepeatForkWarning.vue @@ -0,0 +1,19 @@ + + + + + diff --git a/src/components/Repeat/RepeatFreqInterval.vue b/src/components/Repeat/RepeatFreqInterval.vue new file mode 100644 index 000000000..3bda58783 --- /dev/null +++ b/src/components/Repeat/RepeatFreqInterval.vue @@ -0,0 +1,89 @@ + + + + + + + diff --git a/src/components/Repeat/RepeatFreqMonthlyOptions.vue b/src/components/Repeat/RepeatFreqMonthlyOptions.vue new file mode 100644 index 000000000..f2908aa92 --- /dev/null +++ b/src/components/Repeat/RepeatFreqMonthlyOptions.vue @@ -0,0 +1,206 @@ + + + + + + + diff --git a/src/components/Repeat/RepeatFreqSelect.vue b/src/components/Repeat/RepeatFreqSelect.vue new file mode 100644 index 000000000..f2ed0e5bf --- /dev/null +++ b/src/components/Repeat/RepeatFreqSelect.vue @@ -0,0 +1,75 @@ + + + + + diff --git a/src/components/Repeat/RepeatFreqWeeklyOptions.vue b/src/components/Repeat/RepeatFreqWeeklyOptions.vue new file mode 100644 index 000000000..5919fed9e --- /dev/null +++ b/src/components/Repeat/RepeatFreqWeeklyOptions.vue @@ -0,0 +1,115 @@ + + + + + + + diff --git a/src/components/Repeat/RepeatFreqYearlyOptions.vue b/src/components/Repeat/RepeatFreqYearlyOptions.vue new file mode 100644 index 000000000..d2b3a9797 --- /dev/null +++ b/src/components/Repeat/RepeatFreqYearlyOptions.vue @@ -0,0 +1,250 @@ + + + + + + + diff --git a/src/components/Repeat/RepeatOnTheSelect.vue b/src/components/Repeat/RepeatOnTheSelect.vue new file mode 100644 index 000000000..2295f5706 --- /dev/null +++ b/src/components/Repeat/RepeatOnTheSelect.vue @@ -0,0 +1,97 @@ + + + + + diff --git a/src/components/Repeat/RepeatSummary.vue b/src/components/Repeat/RepeatSummary.vue new file mode 100644 index 000000000..b0bce1d68 --- /dev/null +++ b/src/components/Repeat/RepeatSummary.vue @@ -0,0 +1,57 @@ + + + + + diff --git a/src/components/Repeat/RepeatUnsupportedWarning.vue b/src/components/Repeat/RepeatUnsupportedWarning.vue new file mode 100644 index 000000000..4f516a4b8 --- /dev/null +++ b/src/components/Repeat/RepeatUnsupportedWarning.vue @@ -0,0 +1,23 @@ + + + + + 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) + }) + }) })