Fix initial due date calculation for weekly tasks with multiple weekdays (#974) (#975)

Fix calculateInitialDueDate() to properly handle recurrence_weekdays array
when creating or updating weekly recurring tasks with multiple weekdays.

Previously, the function only checked for recurrence_weekday (singular) and
ignored recurrence_weekdays (plural array), causing tasks with multiple
weekdays to incorrectly get today's date instead of the next occurrence.

Changes:
- Add support for recurrence_weekdays array in calculateInitialDueDate()
- Fix buildUpdateAttributes() to pass recurrence_weekdays parameter
- Add 8 unit tests covering multiple weekdays scenarios
- Add 3 integration tests for API CREATE and UPDATE operations
- Maintain backward compatibility with single recurrence_weekday

The fix mirrors the proven logic from calculateWeeklyRecurrence() in
recurringTaskService.js and properly handles edge cases like unsorted
arrays, wrapping to next week, and JSON string parsing.

Fixes #974
This commit is contained in:
Chris 2026-03-26 17:19:59 +02:00 committed by GitHub
parent 8128180075
commit 65b9bbce39
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 387 additions and 23 deletions

View file

@ -59,31 +59,61 @@ function calculateInitialDueDate(body) {
return `${year}-${month}-${day}`;
}
// For weekly recurrence with specific weekday
if (
recurrenceType === 'weekly' &&
body.recurrence_weekday !== undefined &&
body.recurrence_weekday !== null
) {
const targetWeekday = body.recurrence_weekday;
const currentWeekday = now.getUTCDay();
const daysUntilTarget = (targetWeekday - currentWeekday + 7) % 7;
// For weekly recurrence with specific weekday(s)
if (recurrenceType === 'weekly') {
const parsedWeekdays = body.recurrence_weekdays
? Array.isArray(body.recurrence_weekdays)
? body.recurrence_weekdays
: JSON.parse(body.recurrence_weekdays)
: null;
const firstOccurrence = new Date(now);
if (daysUntilTarget === 0) {
// Today is the target weekday, use today
firstOccurrence.setUTCDate(now.getUTCDate());
} else {
firstOccurrence.setUTCDate(now.getUTCDate() + daysUntilTarget);
if (parsedWeekdays && parsedWeekdays.length > 0) {
const sorted = [...parsedWeekdays].sort((a, b) => a - b);
const currentDay = now.getUTCDay();
const laterInWeek = sorted.filter((d) => d > currentDay);
let firstOccurrence;
if (laterInWeek.length > 0) {
const daysAhead = laterInWeek[0] - currentDay;
firstOccurrence = new Date(now);
firstOccurrence.setUTCDate(now.getUTCDate() + daysAhead);
} else {
const daysToNextFirst = (7 - currentDay + sorted[0]) % 7 || 7;
firstOccurrence = new Date(now);
firstOccurrence.setUTCDate(now.getUTCDate() + daysToNextFirst);
}
const year = firstOccurrence.getUTCFullYear();
const month = String(firstOccurrence.getUTCMonth() + 1).padStart(
2,
'0'
);
const day = String(firstOccurrence.getUTCDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
} else if (
body.recurrence_weekday !== undefined &&
body.recurrence_weekday !== null
) {
const targetWeekday = body.recurrence_weekday;
const currentWeekday = now.getUTCDay();
const daysUntilTarget = (targetWeekday - currentWeekday + 7) % 7;
const firstOccurrence = new Date(now);
if (daysUntilTarget === 0) {
firstOccurrence.setUTCDate(now.getUTCDate());
} else {
firstOccurrence.setUTCDate(now.getUTCDate() + daysUntilTarget);
}
const year = firstOccurrence.getUTCFullYear();
const month = String(firstOccurrence.getUTCMonth() + 1).padStart(
2,
'0'
);
const day = String(firstOccurrence.getUTCDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
const year = firstOccurrence.getUTCFullYear();
const month = String(firstOccurrence.getUTCMonth() + 1).padStart(
2,
'0'
);
const day = String(firstOccurrence.getUTCDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
// For other recurrence types (daily, monthly_last_day, etc.), use today
@ -202,6 +232,7 @@ function buildUpdateAttributes(body, task, timezone) {
recurrence_type: recurrenceType,
recurrence_month_day: attrs.recurrence_month_day,
recurrence_weekday: attrs.recurrence_weekday,
recurrence_weekdays: attrs.recurrence_weekdays,
});
attrs.due_date = processDueDateForStorage(dueDateString, timezone);
} else {
@ -213,6 +244,7 @@ function buildUpdateAttributes(body, task, timezone) {
recurrence_type: recurrenceType,
recurrence_month_day: attrs.recurrence_month_day,
recurrence_weekday: attrs.recurrence_weekday,
recurrence_weekdays: attrs.recurrence_weekdays,
});
attrs.due_date = processDueDateForStorage(dueDateString, timezone);
}
@ -230,4 +262,5 @@ function buildUpdateAttributes(body, task, timezone) {
module.exports = {
buildTaskAttributes,
buildUpdateAttributes,
calculateInitialDueDate,
};

View file

@ -387,6 +387,87 @@ describe('Recurring Tasks', () => {
expect(next3.getUTCDay()).toBe(1);
expect(next3.toISOString().split('T')[0]).toBe('2026-02-16');
});
it('should calculate correct initial due date when creating task with recurrence_weekdays and no due_date (Issue #974)', async () => {
const now = new Date();
now.setUTCHours(0, 0, 0, 0);
const currentWeekday = now.getUTCDay();
const targetWeekdays = [
(currentWeekday + 1) % 7,
(currentWeekday + 3) % 7,
].sort();
const response = await agent.post('/api/task').send({
name: 'Multi-day Task Without Due Date',
recurrence_type: 'weekly',
recurrence_weekdays: targetWeekdays,
recurrence_interval: 1,
});
expect(response.status).toBe(201);
expect(response.body.recurrence_weekdays).toEqual(
targetWeekdays
);
expect(response.body.due_date).toBeDefined();
const dueDate = new Date(response.body.due_date);
dueDate.setUTCHours(0, 0, 0, 0);
expect(targetWeekdays).toContain(dueDate.getUTCDay());
expect(dueDate.getTime()).toBeGreaterThanOrEqual(now.getTime());
});
it('should calculate correct initial due date when adding recurrence_weekdays to existing task (Issue #974)', async () => {
const task = await Task.create({
name: 'Regular Task',
user_id: user.id,
status: Task.STATUS.NOT_STARTED,
});
const now = new Date();
now.setUTCHours(0, 0, 0, 0);
const currentWeekday = now.getUTCDay();
const targetWeekdays = [
(currentWeekday + 1) % 7,
(currentWeekday + 2) % 7,
].sort();
const response = await agent
.patch(`/api/task/${task.uid}`)
.send({
recurrence_type: 'weekly',
recurrence_weekdays: targetWeekdays,
recurrence_interval: 1,
});
expect(response.status).toBe(200);
expect(response.body.recurrence_weekdays).toEqual(
targetWeekdays
);
expect(response.body.due_date).toBeDefined();
const dueDate = new Date(response.body.due_date);
dueDate.setUTCHours(0, 0, 0, 0);
expect(targetWeekdays).toContain(dueDate.getUTCDay());
expect(dueDate.getTime()).toBeGreaterThanOrEqual(now.getTime());
});
it('should respect explicit due_date over calculated date for recurrence_weekdays (Issue #974)', async () => {
const explicitDate = '2026-12-25';
const response = await agent.post('/api/task').send({
name: 'Task with explicit date',
recurrence_type: 'weekly',
recurrence_weekdays: [1, 4],
recurrence_interval: 1,
due_date: explicitDate,
});
expect(response.status).toBe(201);
expect(response.body.due_date).toBe(explicitDate);
});
});
describe('Monthly Recurrence', () => {

View file

@ -0,0 +1,250 @@
const {
calculateInitialDueDate,
} = require('../../../../modules/tasks/core/builders');
describe('calculateInitialDueDate', () => {
describe('Weekly recurrence with multiple weekdays', () => {
it('should find next occurrence when today is Monday and target is Tue/Thu', () => {
const monday = new Date(Date.UTC(2026, 2, 23, 0, 0, 0, 0));
expect(monday.getUTCDay()).toBe(1);
const RealDate = Date;
global.Date = class extends RealDate {
constructor(...args) {
if (args.length === 0) {
super(monday);
} else {
super(...args);
}
}
static [Symbol.hasInstance](instance) {
return instance instanceof RealDate;
}
};
global.Date.UTC = RealDate.UTC;
global.Date.parse = RealDate.parse;
global.Date.now = () => monday.getTime();
const result = calculateInitialDueDate({
recurrence_type: 'weekly',
recurrence_weekdays: [2, 4],
});
expect(result).toBe('2026-03-24');
global.Date = RealDate;
});
it('should find next occurrence among three weekdays (Mon/Wed/Fri)', () => {
const monday = new Date(Date.UTC(2026, 2, 23, 0, 0, 0, 0));
expect(monday.getUTCDay()).toBe(1);
const RealDate = Date;
global.Date = class extends RealDate {
constructor(...args) {
if (args.length === 0) {
super(monday);
} else {
super(...args);
}
}
static [Symbol.hasInstance](instance) {
return instance instanceof RealDate;
}
};
global.Date.UTC = RealDate.UTC;
global.Date.parse = RealDate.parse;
global.Date.now = () => monday.getTime();
const result = calculateInitialDueDate({
recurrence_type: 'weekly',
recurrence_weekdays: [1, 3, 5],
});
expect(result).toBe('2026-03-25');
global.Date = RealDate;
});
it('should wrap to first day of next week when today is after all weekdays', () => {
const saturday = new Date(Date.UTC(2026, 2, 28, 0, 0, 0, 0));
expect(saturday.getUTCDay()).toBe(6);
const RealDate = Date;
global.Date = class extends RealDate {
constructor(...args) {
if (args.length === 0) {
super(saturday);
} else {
super(...args);
}
}
static [Symbol.hasInstance](instance) {
return instance instanceof RealDate;
}
};
global.Date.UTC = RealDate.UTC;
global.Date.parse = RealDate.parse;
global.Date.now = () => saturday.getTime();
const result = calculateInitialDueDate({
recurrence_type: 'weekly',
recurrence_weekdays: [1, 3, 5],
});
expect(result).toBe('2026-03-30');
global.Date = RealDate;
});
it('should handle unsorted weekdays array', () => {
const monday = new Date(Date.UTC(2026, 2, 23, 0, 0, 0, 0));
expect(monday.getUTCDay()).toBe(1);
const RealDate = Date;
global.Date = class extends RealDate {
constructor(...args) {
if (args.length === 0) {
super(monday);
} else {
super(...args);
}
}
static [Symbol.hasInstance](instance) {
return instance instanceof RealDate;
}
};
global.Date.UTC = RealDate.UTC;
global.Date.parse = RealDate.parse;
global.Date.now = () => monday.getTime();
const result = calculateInitialDueDate({
recurrence_type: 'weekly',
recurrence_weekdays: [5, 1, 3],
});
expect(result).toBe('2026-03-25');
global.Date = RealDate;
});
it('should work with single-element array like single weekday', () => {
const monday = new Date(Date.UTC(2026, 2, 23, 0, 0, 0, 0));
expect(monday.getUTCDay()).toBe(1);
const RealDate = Date;
global.Date = class extends RealDate {
constructor(...args) {
if (args.length === 0) {
super(monday);
} else {
super(...args);
}
}
static [Symbol.hasInstance](instance) {
return instance instanceof RealDate;
}
};
global.Date.UTC = RealDate.UTC;
global.Date.parse = RealDate.parse;
global.Date.now = () => monday.getTime();
const result = calculateInitialDueDate({
recurrence_type: 'weekly',
recurrence_weekdays: [5],
});
expect(result).toBe('2026-03-27');
global.Date = RealDate;
});
it('should parse JSON string for recurrence_weekdays', () => {
const monday = new Date(Date.UTC(2026, 2, 23, 0, 0, 0, 0));
expect(monday.getUTCDay()).toBe(1);
const RealDate = Date;
global.Date = class extends RealDate {
constructor(...args) {
if (args.length === 0) {
super(monday);
} else {
super(...args);
}
}
static [Symbol.hasInstance](instance) {
return instance instanceof RealDate;
}
};
global.Date.UTC = RealDate.UTC;
global.Date.parse = RealDate.parse;
global.Date.now = () => monday.getTime();
const result = calculateInitialDueDate({
recurrence_type: 'weekly',
recurrence_weekdays: '[2, 4]',
});
expect(result).toBe('2026-03-24');
global.Date = RealDate;
});
it('should prioritize recurrence_weekdays over recurrence_weekday', () => {
const monday = new Date(Date.UTC(2026, 2, 23, 0, 0, 0, 0));
expect(monday.getUTCDay()).toBe(1);
const RealDate = Date;
global.Date = class extends RealDate {
constructor(...args) {
if (args.length === 0) {
super(monday);
} else {
super(...args);
}
}
static [Symbol.hasInstance](instance) {
return instance instanceof RealDate;
}
};
global.Date.UTC = RealDate.UTC;
global.Date.parse = RealDate.parse;
global.Date.now = () => monday.getTime();
const result = calculateInitialDueDate({
recurrence_type: 'weekly',
recurrence_weekday: 0,
recurrence_weekdays: [2, 4],
});
expect(result).toBe('2026-03-24');
global.Date = RealDate;
});
});
describe('Weekly recurrence with single weekday (backward compatibility)', () => {
it('should calculate correct due date for single weekday', () => {
const monday = new Date(Date.UTC(2026, 2, 23, 0, 0, 0, 0));
expect(monday.getUTCDay()).toBe(1);
const RealDate = Date;
global.Date = class extends RealDate {
constructor(...args) {
if (args.length === 0) {
super(monday);
} else {
super(...args);
}
}
static [Symbol.hasInstance](instance) {
return instance instanceof RealDate;
}
};
global.Date.UTC = RealDate.UTC;
global.Date.parse = RealDate.parse;
global.Date.now = () => monday.getTime();
const result = calculateInitialDueDate({
recurrence_type: 'weekly',
recurrence_weekday: 5,
});
expect(result).toBe('2026-03-27');
global.Date = RealDate;
});
});
});