Skip to content

Commit c656c2a

Browse files
authored
Fix bi-weekly+ recurring tasks reverting to weekly (#844) (#890)
1 parent b81abd9 commit c656c2a

File tree

3 files changed

+108
-39
lines changed

3 files changed

+108
-39
lines changed

backend/modules/tasks/operations/recurring.js

Lines changed: 31 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -152,18 +152,21 @@ async function calculateNextIterations(task, startFromDate, userTimezone) {
152152
const weekdays = Array.isArray(task.recurrence_weekdays)
153153
? task.recurrence_weekdays
154154
: JSON.parse(task.recurrence_weekdays);
155-
let found = false;
156-
for (let daysAhead = 1; daysAhead <= 7; daysAhead++) {
157-
const testDate = new Date(nextDate);
158-
testDate.setUTCDate(testDate.getUTCDate() + daysAhead);
159-
if (weekdays.includes(testDate.getUTCDay())) {
160-
nextDate = testDate;
161-
found = true;
162-
break;
163-
}
164-
}
165-
if (!found) {
166-
nextDate.setUTCDate(nextDate.getUTCDate() + interval * 7);
155+
const sorted = [...weekdays].sort((a, b) => a - b);
156+
const currentDay = nextDate.getUTCDay();
157+
const laterInWeek = sorted.filter((d) => d > currentDay);
158+
if (laterInWeek.length > 0) {
159+
nextDate.setUTCDate(
160+
nextDate.getUTCDate() + (laterInWeek[0] - currentDay)
161+
);
162+
} else {
163+
const daysToNextFirst =
164+
(7 - currentDay + sorted[0]) % 7 || 7;
165+
nextDate.setUTCDate(
166+
nextDate.getUTCDate() +
167+
daysToNextFirst +
168+
(interval - 1) * 7
169+
);
167170
}
168171
} else if (
169172
task.recurrence_weekday !== null &&
@@ -213,28 +216,26 @@ async function calculateNextIterations(task, startFromDate, userTimezone) {
213216

214217
// Handle multiple weekdays
215218
if (task.recurrence_weekdays) {
216-
// Sequelize getter already parses JSON, so it's already an array
217219
const weekdays = Array.isArray(task.recurrence_weekdays)
218220
? task.recurrence_weekdays
219221
: JSON.parse(task.recurrence_weekdays);
222+
const interval = task.recurrence_interval || 1;
223+
const sorted = [...weekdays].sort((a, b) => a - b);
224+
const currentDay = nextDate.getUTCDay();
225+
const laterInWeek = sorted.filter((d) => d > currentDay);
220226

221-
// Find next matching weekday
222-
let found = false;
223-
for (let daysAhead = 1; daysAhead <= 7; daysAhead++) {
224-
const testDate = new Date(nextDate);
225-
testDate.setUTCDate(testDate.getUTCDate() + daysAhead);
226-
const testWeekday = testDate.getUTCDay();
227-
228-
if (weekdays.includes(testWeekday)) {
229-
nextDate = testDate;
230-
found = true;
231-
break;
232-
}
233-
}
234-
235-
if (!found) {
236-
// Fallback: add 7 days
237-
nextDate.setUTCDate(nextDate.getUTCDate() + 7);
227+
if (laterInWeek.length > 0) {
228+
nextDate.setUTCDate(
229+
nextDate.getUTCDate() + (laterInWeek[0] - currentDay)
230+
);
231+
} else {
232+
const daysToNextFirst =
233+
(7 - currentDay + sorted[0]) % 7 || 7;
234+
nextDate.setUTCDate(
235+
nextDate.getUTCDate() +
236+
daysToNextFirst +
237+
(interval - 1) * 7
238+
);
238239
}
239240
} else {
240241
// Old behavior for single weekday

backend/modules/tasks/recurringTaskService.js

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -68,16 +68,22 @@ const calculateWeeklyRecurrence = (fromDate, interval, weekday, weekdays) => {
6868
: null;
6969

7070
if (parsedWeekdays && parsedWeekdays.length > 0) {
71-
// Find the next matching weekday from tomorrow onward
72-
for (let daysAhead = 1; daysAhead <= 7; daysAhead++) {
73-
const testDate = new Date(nextDate);
74-
testDate.setUTCDate(testDate.getUTCDate() + daysAhead);
75-
if (parsedWeekdays.includes(testDate.getUTCDay())) {
76-
return testDate;
77-
}
71+
const sorted = [...parsedWeekdays].sort((a, b) => a - b);
72+
const currentDay = nextDate.getUTCDay();
73+
74+
// Find next weekday later in the current week
75+
const laterInWeek = sorted.filter((d) => d > currentDay);
76+
if (laterInWeek.length > 0) {
77+
nextDate.setUTCDate(
78+
nextDate.getUTCDate() + (laterInWeek[0] - currentDay)
79+
);
80+
} else {
81+
// Wrap to first weekday of next cycle (interval weeks ahead)
82+
const daysToNextFirst = (7 - currentDay + sorted[0]) % 7 || 7;
83+
nextDate.setUTCDate(
84+
nextDate.getUTCDate() + daysToNextFirst + (interval - 1) * 7
85+
);
7886
}
79-
// Fallback: advance by interval weeks
80-
nextDate.setUTCDate(nextDate.getUTCDate() + interval * 7);
8187
} else if (weekday !== null && weekday !== undefined) {
8288
const currentWeekday = nextDate.getUTCDay();
8389
const daysUntilTarget = (weekday - currentWeekday + 7) % 7;

backend/tests/integration/recurring-tasks.test.js

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,68 @@ describe('Recurring Tasks', () => {
295295
expect(nextDueDate.getUTCDay()).toBe(2); // Tuesday
296296
});
297297

298+
it('should respect bi-weekly interval with multiple weekdays (Issue #844)', async () => {
299+
// Use a fixed Tuesday: 2026-02-10
300+
const tuesday = new Date(Date.UTC(2026, 1, 10, 0, 0, 0, 0));
301+
expect(tuesday.getUTCDay()).toBe(2); // Sanity: Tuesday
302+
303+
const task = await Task.create({
304+
name: 'Bi-weekly Tue/Thu Task',
305+
recurrence_type: 'weekly',
306+
recurrence_interval: 2,
307+
recurrence_weekdays: [2, 4], // Tuesday, Thursday
308+
due_date: tuesday,
309+
user_id: user.id,
310+
status: Task.STATUS.NOT_STARTED,
311+
});
312+
313+
// Tuesday -> Thursday (same cycle week)
314+
const next1 = calculateNextDueDate(task, tuesday);
315+
expect(next1.getUTCDay()).toBe(4); // Thursday
316+
expect(next1.toISOString().split('T')[0]).toBe('2026-02-12');
317+
318+
// Thursday -> next cycle Tuesday (skip 1 week, land on week 3's Tuesday)
319+
const next2 = calculateNextDueDate(task, next1);
320+
expect(next2.getUTCDay()).toBe(2); // Tuesday
321+
expect(next2.toISOString().split('T')[0]).toBe('2026-02-24');
322+
323+
// Tuesday (week 3) -> Thursday (week 3, same cycle)
324+
const next3 = calculateNextDueDate(task, next2);
325+
expect(next3.getUTCDay()).toBe(4); // Thursday
326+
expect(next3.toISOString().split('T')[0]).toBe('2026-02-26');
327+
328+
// Thursday (week 3) -> Tuesday (week 5)
329+
const next4 = calculateNextDueDate(task, next3);
330+
expect(next4.getUTCDay()).toBe(2); // Tuesday
331+
expect(next4.toISOString().split('T')[0]).toBe('2026-03-10');
332+
});
333+
334+
it('should respect tri-weekly interval with single weekday via weekdays array (Issue #844)', async () => {
335+
// Use a fixed Monday: 2026-02-09
336+
const monday = new Date(Date.UTC(2026, 1, 9, 0, 0, 0, 0));
337+
expect(monday.getUTCDay()).toBe(1);
338+
339+
const task = await Task.create({
340+
name: 'Every 3 weeks on Monday',
341+
recurrence_type: 'weekly',
342+
recurrence_interval: 3,
343+
recurrence_weekdays: [1], // Monday only
344+
due_date: monday,
345+
user_id: user.id,
346+
status: Task.STATUS.NOT_STARTED,
347+
});
348+
349+
// Monday Feb 9 -> Monday Mar 2 (3 weeks later)
350+
const next1 = calculateNextDueDate(task, monday);
351+
expect(next1.getUTCDay()).toBe(1);
352+
expect(next1.toISOString().split('T')[0]).toBe('2026-03-02');
353+
354+
// Monday Mar 2 -> Monday Mar 23 (3 weeks later)
355+
const next2 = calculateNextDueDate(task, next1);
356+
expect(next2.getUTCDay()).toBe(1);
357+
expect(next2.toISOString().split('T')[0]).toBe('2026-03-23');
358+
});
359+
298360
it('should handle three weekdays (Mon/Wed/Fri)', async () => {
299361
// Use a fixed Monday: 2026-02-09
300362
const monday = new Date(Date.UTC(2026, 1, 9, 0, 0, 0, 0));

0 commit comments

Comments
 (0)