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:
parent
8128180075
commit
65b9bbce39
3 changed files with 387 additions and 23 deletions
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
250
backend/tests/unit/modules/tasks/builders.test.js
Normal file
250
backend/tests/unit/modules/tasks/builders.test.js
Normal 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;
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue