From d1296f1a90f38dae8569a571823f003c2cd6c324 Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 28 Dec 2025 17:08:33 +0200 Subject: [PATCH] Fix skipping month (#745) --- backend/services/recurringTaskService.js | 25 +++++++++++++--- .../tests/integration/recurring-tasks.test.js | 30 +++++++++++++++++++ 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/backend/services/recurringTaskService.js b/backend/services/recurringTaskService.js index a31c209..1203f69 100644 --- a/backend/services/recurringTaskService.js +++ b/backend/services/recurringTaskService.js @@ -145,12 +145,29 @@ const calculateMonthlyWeekdayRecurrence = ( }; const calculateMonthlyLastDayRecurrence = (fromDate, interval) => { - const nextDate = new Date(fromDate); - nextDate.setUTCMonth(nextDate.getUTCMonth() + interval); + // Calculate target year and month directly to avoid date overflow + // (e.g., Jan 31 + 1 month via setUTCMonth would overflow to March) + const currentMonth = fromDate.getUTCMonth(); + const currentYear = fromDate.getUTCFullYear(); - nextDate.setUTCMonth(nextDate.getUTCMonth() + 1, 0); + const totalMonths = currentMonth + interval; + const targetYear = currentYear + Math.floor(totalMonths / 12); + const targetMonth = totalMonths % 12; - return nextDate; + // Get last day of target month by creating date at day 0 of following month + const lastDayOfMonth = new Date( + Date.UTC( + targetYear, + targetMonth + 1, // next month + 0, // day 0 = last day of previous month + fromDate.getUTCHours(), + fromDate.getUTCMinutes(), + fromDate.getUTCSeconds(), + fromDate.getUTCMilliseconds() + ) + ); + + return lastDayOfMonth; }; const getFirstWeekdayOfMonth = (year, month, weekday) => { diff --git a/backend/tests/integration/recurring-tasks.test.js b/backend/tests/integration/recurring-tasks.test.js index 2ae1e02..76a3f84 100644 --- a/backend/tests/integration/recurring-tasks.test.js +++ b/backend/tests/integration/recurring-tasks.test.js @@ -303,6 +303,36 @@ describe('Recurring Tasks', () => { expect(nextDate.getUTCDate()).toBe(expectedDate.getUTCDate()); }); + it('should not skip months for monthly_last_day when starting from 31st', async () => { + // Bug fix: Jan 31 -> should go to Feb 28, not March 31 + const jan31 = new Date(Date.UTC(2025, 0, 31, 0, 0, 0, 0)); + + const taskData = { + name: 'End of Month Task', + recurrence_type: 'monthly_last_day', + recurrence_interval: 1, + due_date: jan31.toISOString().split('T')[0], + }; + + const response = await agent.post('/api/task').send(taskData); + const task = await Task.findByPk(response.body.id); + + // First occurrence: Jan 31 -> Feb 28 + const nextDate1 = calculateNextDueDate(task, jan31); + expect(nextDate1.getUTCMonth()).toBe(1); // February + expect(nextDate1.getUTCDate()).toBe(28); + + // Second occurrence: Feb 28 -> Mar 31 + const nextDate2 = calculateNextDueDate(task, nextDate1); + expect(nextDate2.getUTCMonth()).toBe(2); // March + expect(nextDate2.getUTCDate()).toBe(31); + + // Third occurrence: Mar 31 -> Apr 30 + const nextDate3 = calculateNextDueDate(task, nextDate2); + expect(nextDate3.getUTCMonth()).toBe(3); // April + expect(nextDate3.getUTCDate()).toBe(30); + }); + it('should handle monthly recurrence when day does not exist in target month', async () => { // Create a task for Jan 31 const jan31 = new Date(Date.UTC(2024, 0, 31, 0, 0, 0, 0));