diff --git a/backend/modules/tasks/operations/recurring.js b/backend/modules/tasks/operations/recurring.js index 8ded1ff..1a31725 100644 --- a/backend/modules/tasks/operations/recurring.js +++ b/backend/modules/tasks/operations/recurring.js @@ -152,18 +152,21 @@ async function calculateNextIterations(task, startFromDate, userTimezone) { const weekdays = Array.isArray(task.recurrence_weekdays) ? task.recurrence_weekdays : JSON.parse(task.recurrence_weekdays); - let found = false; - for (let daysAhead = 1; daysAhead <= 7; daysAhead++) { - const testDate = new Date(nextDate); - testDate.setUTCDate(testDate.getUTCDate() + daysAhead); - if (weekdays.includes(testDate.getUTCDay())) { - nextDate = testDate; - found = true; - break; - } - } - if (!found) { - nextDate.setUTCDate(nextDate.getUTCDate() + interval * 7); + const sorted = [...weekdays].sort((a, b) => a - b); + const currentDay = nextDate.getUTCDay(); + const laterInWeek = sorted.filter((d) => d > currentDay); + if (laterInWeek.length > 0) { + nextDate.setUTCDate( + nextDate.getUTCDate() + (laterInWeek[0] - currentDay) + ); + } else { + const daysToNextFirst = + (7 - currentDay + sorted[0]) % 7 || 7; + nextDate.setUTCDate( + nextDate.getUTCDate() + + daysToNextFirst + + (interval - 1) * 7 + ); } } else if ( task.recurrence_weekday !== null && @@ -213,28 +216,26 @@ async function calculateNextIterations(task, startFromDate, userTimezone) { // Handle multiple weekdays if (task.recurrence_weekdays) { - // Sequelize getter already parses JSON, so it's already an array const weekdays = Array.isArray(task.recurrence_weekdays) ? task.recurrence_weekdays : JSON.parse(task.recurrence_weekdays); + const interval = task.recurrence_interval || 1; + const sorted = [...weekdays].sort((a, b) => a - b); + const currentDay = nextDate.getUTCDay(); + const laterInWeek = sorted.filter((d) => d > currentDay); - // Find next matching weekday - let found = false; - for (let daysAhead = 1; daysAhead <= 7; daysAhead++) { - const testDate = new Date(nextDate); - testDate.setUTCDate(testDate.getUTCDate() + daysAhead); - const testWeekday = testDate.getUTCDay(); - - if (weekdays.includes(testWeekday)) { - nextDate = testDate; - found = true; - break; - } - } - - if (!found) { - // Fallback: add 7 days - nextDate.setUTCDate(nextDate.getUTCDate() + 7); + if (laterInWeek.length > 0) { + nextDate.setUTCDate( + nextDate.getUTCDate() + (laterInWeek[0] - currentDay) + ); + } else { + const daysToNextFirst = + (7 - currentDay + sorted[0]) % 7 || 7; + nextDate.setUTCDate( + nextDate.getUTCDate() + + daysToNextFirst + + (interval - 1) * 7 + ); } } else { // Old behavior for single weekday diff --git a/backend/modules/tasks/recurringTaskService.js b/backend/modules/tasks/recurringTaskService.js index 74f4e55..dda95fd 100644 --- a/backend/modules/tasks/recurringTaskService.js +++ b/backend/modules/tasks/recurringTaskService.js @@ -68,16 +68,22 @@ const calculateWeeklyRecurrence = (fromDate, interval, weekday, weekdays) => { : null; if (parsedWeekdays && parsedWeekdays.length > 0) { - // Find the next matching weekday from tomorrow onward - for (let daysAhead = 1; daysAhead <= 7; daysAhead++) { - const testDate = new Date(nextDate); - testDate.setUTCDate(testDate.getUTCDate() + daysAhead); - if (parsedWeekdays.includes(testDate.getUTCDay())) { - return testDate; - } + const sorted = [...parsedWeekdays].sort((a, b) => a - b); + const currentDay = nextDate.getUTCDay(); + + // Find next weekday later in the current week + const laterInWeek = sorted.filter((d) => d > currentDay); + if (laterInWeek.length > 0) { + nextDate.setUTCDate( + nextDate.getUTCDate() + (laterInWeek[0] - currentDay) + ); + } else { + // Wrap to first weekday of next cycle (interval weeks ahead) + const daysToNextFirst = (7 - currentDay + sorted[0]) % 7 || 7; + nextDate.setUTCDate( + nextDate.getUTCDate() + daysToNextFirst + (interval - 1) * 7 + ); } - // Fallback: advance by interval weeks - nextDate.setUTCDate(nextDate.getUTCDate() + interval * 7); } else if (weekday !== null && weekday !== undefined) { const currentWeekday = nextDate.getUTCDay(); const daysUntilTarget = (weekday - currentWeekday + 7) % 7; diff --git a/backend/tests/integration/recurring-tasks.test.js b/backend/tests/integration/recurring-tasks.test.js index acd5d22..3c3e304 100644 --- a/backend/tests/integration/recurring-tasks.test.js +++ b/backend/tests/integration/recurring-tasks.test.js @@ -295,6 +295,68 @@ describe('Recurring Tasks', () => { expect(nextDueDate.getUTCDay()).toBe(2); // Tuesday }); + it('should respect bi-weekly interval with multiple weekdays (Issue #844)', async () => { + // Use a fixed Tuesday: 2026-02-10 + const tuesday = new Date(Date.UTC(2026, 1, 10, 0, 0, 0, 0)); + expect(tuesday.getUTCDay()).toBe(2); // Sanity: Tuesday + + const task = await Task.create({ + name: 'Bi-weekly Tue/Thu Task', + recurrence_type: 'weekly', + recurrence_interval: 2, + recurrence_weekdays: [2, 4], // Tuesday, Thursday + due_date: tuesday, + user_id: user.id, + status: Task.STATUS.NOT_STARTED, + }); + + // Tuesday -> Thursday (same cycle week) + const next1 = calculateNextDueDate(task, tuesday); + expect(next1.getUTCDay()).toBe(4); // Thursday + expect(next1.toISOString().split('T')[0]).toBe('2026-02-12'); + + // Thursday -> next cycle Tuesday (skip 1 week, land on week 3's Tuesday) + const next2 = calculateNextDueDate(task, next1); + expect(next2.getUTCDay()).toBe(2); // Tuesday + expect(next2.toISOString().split('T')[0]).toBe('2026-02-24'); + + // Tuesday (week 3) -> Thursday (week 3, same cycle) + const next3 = calculateNextDueDate(task, next2); + expect(next3.getUTCDay()).toBe(4); // Thursday + expect(next3.toISOString().split('T')[0]).toBe('2026-02-26'); + + // Thursday (week 3) -> Tuesday (week 5) + const next4 = calculateNextDueDate(task, next3); + expect(next4.getUTCDay()).toBe(2); // Tuesday + expect(next4.toISOString().split('T')[0]).toBe('2026-03-10'); + }); + + it('should respect tri-weekly interval with single weekday via weekdays array (Issue #844)', async () => { + // Use a fixed Monday: 2026-02-09 + const monday = new Date(Date.UTC(2026, 1, 9, 0, 0, 0, 0)); + expect(monday.getUTCDay()).toBe(1); + + const task = await Task.create({ + name: 'Every 3 weeks on Monday', + recurrence_type: 'weekly', + recurrence_interval: 3, + recurrence_weekdays: [1], // Monday only + due_date: monday, + user_id: user.id, + status: Task.STATUS.NOT_STARTED, + }); + + // Monday Feb 9 -> Monday Mar 2 (3 weeks later) + const next1 = calculateNextDueDate(task, monday); + expect(next1.getUTCDay()).toBe(1); + expect(next1.toISOString().split('T')[0]).toBe('2026-03-02'); + + // Monday Mar 2 -> Monday Mar 23 (3 weeks later) + const next2 = calculateNextDueDate(task, next1); + expect(next2.getUTCDay()).toBe(1); + expect(next2.toISOString().split('T')[0]).toBe('2026-03-23'); + }); + it('should handle three weekdays (Mon/Wed/Fri)', async () => { // Use a fixed Monday: 2026-02-09 const monday = new Date(Date.UTC(2026, 1, 9, 0, 0, 0, 0));