From 65b9bbce396d756c5cc74d1ed6c1aae891588c9f Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 26 Mar 2026 17:19:59 +0200 Subject: [PATCH] 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 --- backend/modules/tasks/core/builders.js | 79 ++++-- .../tests/integration/recurring-tasks.test.js | 81 ++++++ .../tests/unit/modules/tasks/builders.test.js | 250 ++++++++++++++++++ 3 files changed, 387 insertions(+), 23 deletions(-) create mode 100644 backend/tests/unit/modules/tasks/builders.test.js diff --git a/backend/modules/tasks/core/builders.js b/backend/modules/tasks/core/builders.js index ed852bf..c288b1d 100644 --- a/backend/modules/tasks/core/builders.js +++ b/backend/modules/tasks/core/builders.js @@ -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, }; diff --git a/backend/tests/integration/recurring-tasks.test.js b/backend/tests/integration/recurring-tasks.test.js index f2695e9..f4809a7 100644 --- a/backend/tests/integration/recurring-tasks.test.js +++ b/backend/tests/integration/recurring-tasks.test.js @@ -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', () => { diff --git a/backend/tests/unit/modules/tasks/builders.test.js b/backend/tests/unit/modules/tasks/builders.test.js new file mode 100644 index 0000000..14286b1 --- /dev/null +++ b/backend/tests/unit/modules/tasks/builders.test.js @@ -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; + }); + }); +});