diff --git a/backend/modules/tasks/core/builders.js b/backend/modules/tasks/core/builders.js index 2bceb4a..ed852bf 100644 --- a/backend/modules/tasks/core/builders.js +++ b/backend/modules/tasks/core/builders.js @@ -5,6 +5,95 @@ const { processDeferUntilForStorage, } = require('../../../utils/timezone-utils'); +function calculateInitialDueDate(body) { + const recurrenceType = body.recurrence_type; + const now = new Date(); + now.setUTCHours(0, 0, 0, 0); + + // For monthly recurrence with specific day of month + if ( + recurrenceType === 'monthly' && + body.recurrence_month_day !== undefined && + body.recurrence_month_day !== null + ) { + const targetDay = body.recurrence_month_day; + const currentDay = now.getUTCDate(); + const currentMonth = now.getUTCMonth(); + const currentYear = now.getUTCFullYear(); + + // Get max day in current month + const maxDayInMonth = new Date( + Date.UTC(currentYear, currentMonth + 1, 0) + ).getUTCDate(); + const actualTargetDay = Math.min(targetDay, maxDayInMonth); + + let firstOccurrence; + if (actualTargetDay >= currentDay) { + // Target day is today or later this month + firstOccurrence = new Date( + Date.UTC(currentYear, currentMonth, actualTargetDay) + ); + } else { + // Target day already passed this month, use next month + const nextMonth = currentMonth + 1; + const nextYear = currentYear + Math.floor(nextMonth / 12); + const finalMonth = nextMonth % 12; + const maxDayInNextMonth = new Date( + Date.UTC(nextYear, finalMonth + 1, 0) + ).getUTCDate(); + const actualTargetDayNextMonth = Math.min( + targetDay, + maxDayInNextMonth + ); + firstOccurrence = new Date( + Date.UTC(nextYear, finalMonth, actualTargetDayNextMonth) + ); + } + + 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 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; + + 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); + } + + 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 + // as the starting point is reasonable + const year = now.getUTCFullYear(); + const month = String(now.getUTCMonth() + 1).padStart(2, '0'); + const day = String(now.getUTCDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + function buildTaskAttributes(body, userId, timezone, isUpdate = false) { const recurrenceType = body.recurrence_type || 'none'; const isRecurring = recurrenceType && recurrenceType !== 'none'; @@ -14,11 +103,8 @@ function buildTaskAttributes(body, userId, timezone, isUpdate = false) { isRecurring && (dueDate === undefined || dueDate === null || dueDate === '') ) { - const now = new Date(); - const year = now.getFullYear(); - const month = String(now.getMonth() + 1).padStart(2, '0'); - const day = String(now.getDate()).padStart(2, '0'); - dueDate = `${year}-${month}-${day}`; + // Calculate proper first occurrence based on recurrence pattern + dueDate = calculateInitialDueDate(body); } const attrs = { @@ -111,24 +197,24 @@ function buildUpdateAttributes(body, task, timezone) { if (body.due_date !== undefined) { if (isRecurring && (body.due_date === null || body.due_date === '')) { - const now = new Date(); - const year = now.getFullYear(); - const month = String(now.getMonth() + 1).padStart(2, '0'); - const day = String(now.getDate()).padStart(2, '0'); - attrs.due_date = processDueDateForStorage( - `${year}-${month}-${day}`, - timezone - ); + // Calculate proper first occurrence based on recurrence pattern + const dueDateString = calculateInitialDueDate({ + recurrence_type: recurrenceType, + recurrence_month_day: attrs.recurrence_month_day, + recurrence_weekday: attrs.recurrence_weekday, + }); + attrs.due_date = processDueDateForStorage(dueDateString, timezone); } else { attrs.due_date = processDueDateForStorage(body.due_date, timezone); } } else if (isAddingRecurrence && (!task.due_date || task.due_date === '')) { - const now = new Date(); - const year = now.getFullYear(); - const month = String(now.getMonth() + 1).padStart(2, '0'); - const day = String(now.getDate()).padStart(2, '0'); - const dueDate = `${year}-${month}-${day}`; - attrs.due_date = processDueDateForStorage(dueDate, timezone); + // Calculate proper first occurrence based on recurrence pattern + const dueDateString = calculateInitialDueDate({ + recurrence_type: recurrenceType, + recurrence_month_day: attrs.recurrence_month_day, + recurrence_weekday: attrs.recurrence_weekday, + }); + attrs.due_date = processDueDateForStorage(dueDateString, timezone); } if (body.defer_until !== undefined) { diff --git a/backend/modules/tasks/routes.js b/backend/modules/tasks/routes.js index 7957944..0ec5b66 100644 --- a/backend/modules/tasks/routes.js +++ b/backend/modules/tasks/routes.js @@ -111,7 +111,7 @@ async function getRecurringParentEndDate(recurringParentId, userId) { function expandRecurringTasks(tasks, maxDays = 7, statusFilter = null) { const expandedTasks = []; const now = new Date(); - now.setHours(0, 0, 0, 0); + now.setUTCHours(0, 0, 0, 0); tasks.forEach((task) => { const isRecurring = diff --git a/backend/tests/integration/recurring-tasks-dst.test.js b/backend/tests/integration/recurring-tasks-dst.test.js new file mode 100644 index 0000000..fbee445 --- /dev/null +++ b/backend/tests/integration/recurring-tasks-dst.test.js @@ -0,0 +1,426 @@ +const request = require('supertest'); +const app = require('../../app'); +const { Task, sequelize } = require('../../models'); +const { createTestUser } = require('../helpers/testUtils'); +const { + calculateNextDueDate, +} = require('../../modules/tasks/recurringTaskService'); + +describe('Recurring Tasks - DST Transition Handling', () => { + let user, agent; + + beforeEach(async () => { + user = await createTestUser({ + email: 'test@example.com', + }); + + agent = request.agent(app); + await agent.post('/api/login').send({ + email: 'test@example.com', + password: 'password123', + }); + }); + + describe('DST Spring Forward (March 10, 2024 - America/New_York)', () => { + const dstSpringDate = new Date(Date.UTC(2024, 2, 10, 7, 0, 0, 0)); + + it('should create daily recurring task on DST transition day', async () => { + const taskData = { + name: 'DST Spring Daily Task', + recurrence_type: 'daily', + recurrence_interval: 1, + due_date: dstSpringDate.toISOString().split('T')[0], + }; + + const response = await agent.post('/api/task').send(taskData); + + expect(response.status).toBe(201); + expect(response.body.due_date).toBe('2024-03-10'); + }); + + it('should advance daily task correctly from before DST to after DST', async () => { + const beforeDST = new Date(Date.UTC(2024, 2, 9, 5, 0, 0, 0)); + const task = { + recurrence_type: 'daily', + recurrence_interval: 1, + }; + + const nextDate = calculateNextDueDate(task, beforeDST); + + expect(nextDate.toISOString().split('T')[0]).toBe('2024-03-10'); + expect(nextDate.getUTCDate()).toBe(10); + }); + + it('should advance daily task correctly from DST day to next day', async () => { + const task = { + recurrence_type: 'daily', + recurrence_interval: 1, + }; + + const nextDate = calculateNextDueDate(task, dstSpringDate); + + expect(nextDate.toISOString().split('T')[0]).toBe('2024-03-11'); + expect(nextDate.getUTCDate()).toBe(11); + }); + + it('should handle weekly recurring task spanning DST transition', async () => { + const sunday = new Date(Date.UTC(2024, 2, 3, 5, 0, 0, 0)); + const task = { + recurrence_type: 'weekly', + recurrence_interval: 1, + recurrence_weekday: 0, + }; + + const nextDate = calculateNextDueDate(task, sunday); + + expect(nextDate.toISOString().split('T')[0]).toBe('2024-03-10'); + expect(nextDate.getUTCDay()).toBe(0); + }); + + it('should not skip occurrences during DST spring forward', async () => { + const march8 = new Date(Date.UTC(2024, 2, 8, 5, 0, 0, 0)); + const task = { + recurrence_type: 'daily', + recurrence_interval: 1, + }; + + let currentDate = march8; + const dates = []; + + for (let i = 0; i < 5; i++) { + dates.push(currentDate.toISOString().split('T')[0]); + currentDate = calculateNextDueDate(task, currentDate); + } + + expect(dates).toEqual([ + '2024-03-08', + '2024-03-09', + '2024-03-10', + '2024-03-11', + '2024-03-12', + ]); + }); + + it('should handle monthly task due on DST transition day', async () => { + const feb10 = new Date(Date.UTC(2024, 1, 10, 5, 0, 0, 0)); + const task = { + recurrence_type: 'monthly', + recurrence_interval: 1, + recurrence_month_day: 10, + }; + + const nextDate = calculateNextDueDate(task, feb10); + + expect(nextDate.toISOString().split('T')[0]).toBe('2024-03-10'); + expect(nextDate.getUTCDate()).toBe(10); + }); + }); + + describe('DST Fall Back (November 3, 2024 - America/New_York)', () => { + const dstFallDate = new Date(Date.UTC(2024, 10, 3, 6, 0, 0, 0)); + + it('should create daily recurring task on DST end day', async () => { + const taskData = { + name: 'DST Fall Daily Task', + recurrence_type: 'daily', + recurrence_interval: 1, + due_date: dstFallDate.toISOString().split('T')[0], + }; + + const response = await agent.post('/api/task').send(taskData); + + expect(response.status).toBe(201); + expect(response.body.due_date).toBe('2024-11-03'); + }); + + it('should advance daily task correctly from before DST end to after', async () => { + const beforeDSTEnd = new Date(Date.UTC(2024, 10, 2, 4, 0, 0, 0)); + const task = { + recurrence_type: 'daily', + recurrence_interval: 1, + }; + + const nextDate = calculateNextDueDate(task, beforeDSTEnd); + + expect(nextDate.toISOString().split('T')[0]).toBe('2024-11-03'); + expect(nextDate.getUTCDate()).toBe(3); + }); + + it('should advance daily task correctly from DST end day to next day', async () => { + const task = { + recurrence_type: 'daily', + recurrence_interval: 1, + }; + + const nextDate = calculateNextDueDate(task, dstFallDate); + + expect(nextDate.toISOString().split('T')[0]).toBe('2024-11-04'); + expect(nextDate.getUTCDate()).toBe(4); + }); + + it('should not duplicate occurrences during DST fall back', async () => { + const nov1 = new Date(Date.UTC(2024, 10, 1, 4, 0, 0, 0)); + const task = { + recurrence_type: 'daily', + recurrence_interval: 1, + }; + + let currentDate = nov1; + const dates = []; + + for (let i = 0; i < 5; i++) { + dates.push(currentDate.toISOString().split('T')[0]); + currentDate = calculateNextDueDate(task, currentDate); + } + + expect(dates).toEqual([ + '2024-11-01', + '2024-11-02', + '2024-11-03', + '2024-11-04', + '2024-11-05', + ]); + }); + + it('should handle weekly recurring task spanning DST end', async () => { + const sunday = new Date(Date.UTC(2024, 9, 27, 4, 0, 0, 0)); + const task = { + recurrence_type: 'weekly', + recurrence_interval: 1, + recurrence_weekday: 0, + }; + + const nextDate = calculateNextDueDate(task, sunday); + + expect(nextDate.toISOString().split('T')[0]).toBe('2024-11-03'); + expect(nextDate.getUTCDay()).toBe(0); + }); + + it('should maintain date consistency through DST end', async () => { + const oct15 = new Date(Date.UTC(2024, 9, 15, 4, 0, 0, 0)); + const task = { + recurrence_type: 'monthly', + recurrence_interval: 1, + recurrence_month_day: 15, + }; + + const nextDate = calculateNextDueDate(task, oct15); + + expect(nextDate.toISOString().split('T')[0]).toBe('2024-11-15'); + expect(nextDate.getUTCDate()).toBe(15); + }); + }); + + describe('DST Across Multiple Timezones', () => { + it('should handle Europe/London DST (different dates than US)', async () => { + const march28 = new Date(Date.UTC(2024, 2, 28, 1, 0, 0, 0)); + const task = { + recurrence_type: 'weekly', + recurrence_interval: 1, + recurrence_weekday: 4, + }; + + const nextDate = calculateNextDueDate(task, march28); + + expect(nextDate.getUTCDay()).toBe(4); + expect(nextDate.getUTCDate()).toBe(4); + }); + + it('should handle timezones without DST correctly', async () => { + const arizona = new Date(Date.UTC(2024, 2, 10, 7, 0, 0, 0)); + const task = { + recurrence_type: 'daily', + recurrence_interval: 1, + }; + + const nextDate = calculateNextDueDate(task, arizona); + + expect(nextDate.toISOString().split('T')[0]).toBe('2024-03-11'); + }); + + it('should handle Australia/Sydney DST (opposite hemisphere)', async () => { + const aprilSydney = new Date(Date.UTC(2024, 3, 7, 0, 0, 0, 0)); + const task = { + recurrence_type: 'weekly', + recurrence_interval: 1, + recurrence_weekday: 0, + }; + + const nextDate = calculateNextDueDate(task, aprilSydney); + + expect(nextDate.getUTCDay()).toBe(0); + }); + }); + + describe('Virtual Occurrences Spanning DST', () => { + it('should generate virtual occurrences correctly across DST spring forward', async () => { + const march7 = new Date(Date.UTC(2024, 2, 7, 5, 0, 0, 0)); + const task = { + recurrence_type: 'daily', + recurrence_interval: 1, + }; + + let currentDate = march7; + const occurrences = []; + + for (let i = 0; i < 7; i++) { + occurrences.push({ + due_date: currentDate.toISOString().split('T')[0], + }); + currentDate = calculateNextDueDate(task, currentDate); + } + + expect(occurrences.map((o) => o.due_date)).toEqual([ + '2024-03-07', + '2024-03-08', + '2024-03-09', + '2024-03-10', + '2024-03-11', + '2024-03-12', + '2024-03-13', + ]); + }); + + it('should generate virtual occurrences correctly across DST fall back', async () => { + const oct31 = new Date(Date.UTC(2024, 9, 31, 4, 0, 0, 0)); + const task = { + recurrence_type: 'daily', + recurrence_interval: 1, + }; + + let currentDate = oct31; + const occurrences = []; + + for (let i = 0; i < 7; i++) { + occurrences.push({ + due_date: currentDate.toISOString().split('T')[0], + }); + currentDate = calculateNextDueDate(task, currentDate); + } + + expect(occurrences.map((o) => o.due_date)).toEqual([ + '2024-10-31', + '2024-11-01', + '2024-11-02', + '2024-11-03', + '2024-11-04', + '2024-11-05', + '2024-11-06', + ]); + }); + + it('should handle bi-weekly recurrence across DST transition', async () => { + const feb25 = new Date(Date.UTC(2024, 1, 25, 5, 0, 0, 0)); + const task = { + recurrence_type: 'weekly', + recurrence_interval: 2, + recurrence_weekday: 0, + }; + + let currentDate = feb25; + const occurrences = []; + + for (let i = 0; i < 4; i++) { + occurrences.push({ + due_date: currentDate.toISOString().split('T')[0], + }); + currentDate = calculateNextDueDate(task, currentDate); + } + + expect(occurrences.map((o) => o.due_date)).toEqual([ + '2024-02-25', + '2024-03-10', + '2024-03-24', + '2024-04-07', + ]); + }); + }); + + describe('Monthly Recurrence During DST Months', () => { + it('should handle monthly recurrence during DST start month', async () => { + const feb15 = new Date(Date.UTC(2024, 1, 15, 5, 0, 0, 0)); + const task = { + recurrence_type: 'monthly', + recurrence_interval: 1, + recurrence_month_day: 15, + }; + + const nextDate = calculateNextDueDate(task, feb15); + + expect(nextDate.toISOString().split('T')[0]).toBe('2024-03-15'); + expect(nextDate.getUTCDate()).toBe(15); + }); + + it('should handle monthly recurrence during DST end month', async () => { + const oct20 = new Date(Date.UTC(2024, 9, 20, 4, 0, 0, 0)); + const task = { + recurrence_type: 'monthly', + recurrence_interval: 1, + recurrence_month_day: 20, + }; + + const nextDate = calculateNextDueDate(task, oct20); + + expect(nextDate.toISOString().split('T')[0]).toBe('2024-11-20'); + expect(nextDate.getUTCDate()).toBe(20); + }); + + it('should handle monthly weekday recurrence across DST', async () => { + const feb1st = new Date(Date.UTC(2024, 1, 5, 5, 0, 0, 0)); + const task = { + recurrence_type: 'monthly_weekday', + recurrence_interval: 1, + recurrence_weekday: 1, + recurrence_week_of_month: 1, + }; + + const nextDate = calculateNextDueDate(task, feb1st); + + expect(nextDate.getUTCDay()).toBe(1); + expect(nextDate.getUTCMonth()).toBe(2); + }); + + it('should handle monthly last day across DST', async () => { + const feb29 = new Date(Date.UTC(2024, 1, 29, 5, 0, 0, 0)); + const task = { + recurrence_type: 'monthly_last_day', + recurrence_interval: 1, + }; + + const nextDate = calculateNextDueDate(task, feb29); + + expect(nextDate.getUTCMonth()).toBe(2); + expect(nextDate.getUTCDate()).toBe(31); + }); + }); + + describe('Edge Cases During DST Transition Hour', () => { + it('should handle task created exactly at DST transition (2 AM)', async () => { + const dstTransitionMoment = new Date( + Date.UTC(2024, 2, 10, 7, 0, 0, 0) + ); + const task = { + recurrence_type: 'daily', + recurrence_interval: 1, + }; + + const nextDate = calculateNextDueDate(task, dstTransitionMoment); + + expect(nextDate.toISOString().split('T')[0]).toBe('2024-03-11'); + }); + + it('should handle weekly task due Sunday when DST transitions Sunday', async () => { + const sundayDST = new Date(Date.UTC(2024, 2, 10, 5, 0, 0, 0)); + const task = { + recurrence_type: 'weekly', + recurrence_interval: 1, + recurrence_weekday: 0, + }; + + const nextDate = calculateNextDueDate(task, sundayDST); + + expect(nextDate.getUTCDay()).toBe(0); + expect(nextDate.toISOString().split('T')[0]).toBe('2024-03-17'); + }); + }); +}); diff --git a/backend/tests/integration/recurring-tasks.test.js b/backend/tests/integration/recurring-tasks.test.js index 2182c32..f2695e9 100644 --- a/backend/tests/integration/recurring-tasks.test.js +++ b/backend/tests/integration/recurring-tasks.test.js @@ -1463,4 +1463,210 @@ describe('Recurring Tasks', () => { ); }); }); + + describe('Auto-calculation of initial due date (Issue #963)', () => { + it('should calculate correct first occurrence for monthly task on specific day in future', async () => { + // Always use day 28 as target, which works for all months + const targetDay = 28; + + const response = await agent.post('/api/task').send({ + name: 'Monthly Recurring Task', + recurrence_type: 'monthly', + recurrence_month_day: targetDay, + recurrence_interval: 1, + // Intentionally NOT providing due_date + }); + + expect(response.status).toBe(201); + expect(response.body.recurrence_type).toBe('monthly'); + expect(response.body.recurrence_month_day).toBe(targetDay); + expect(response.body.due_date).toBeDefined(); + + // Verify due_date matches the target day (not today or yesterday) + const dueDate = new Date(response.body.due_date); + const dueDateDay = dueDate.getUTCDate(); + + // Should be set to the 28th (or closest valid day if month doesn't have 28 days) + expect(dueDateDay).toBeLessThanOrEqual(targetDay); + expect(dueDateDay).toBeGreaterThanOrEqual(28); + + // Should be today or in the future + const now = new Date(); + now.setUTCHours(0, 0, 0, 0); + expect(dueDate.getTime()).toBeGreaterThanOrEqual( + now.getTime() - 24 * 60 * 60 * 1000 + ); + }); + + it('should calculate correct first occurrence for monthly task when target day already passed', async () => { + // Use day 1 as target, which should always result in next month if we're past the 1st + const targetDay = 1; + + const response = await agent.post('/api/task').send({ + name: 'Monthly Task Past Due Day', + recurrence_type: 'monthly', + recurrence_month_day: targetDay, + recurrence_interval: 1, + }); + + expect(response.status).toBe(201); + expect(response.body.due_date).toBeDefined(); + + const dueDate = new Date(response.body.due_date); + const dueDateDay = dueDate.getUTCDate(); + + // Should be set to the 1st of the month + expect(dueDateDay).toBe(targetDay); + + // Should be today or in the future + const now = new Date(); + now.setUTCHours(0, 0, 0, 0); + expect(dueDate.getTime()).toBeGreaterThanOrEqual( + now.getTime() - 24 * 60 * 60 * 1000 + ); + }); + + it('should calculate correct first occurrence for weekly recurring task', async () => { + const now = new Date(); + now.setUTCHours(0, 0, 0, 0); + const currentWeekday = now.getUTCDay(); + + // Pick a weekday different from today + const targetWeekday = (currentWeekday + 2) % 7; + + const response = await agent.post('/api/task').send({ + name: 'Weekly Task', + recurrence_type: 'weekly', + recurrence_weekday: targetWeekday, + recurrence_interval: 1, + // Intentionally NOT providing due_date + }); + + expect(response.status).toBe(201); + expect(response.body.recurrence_type).toBe('weekly'); + expect(response.body.recurrence_weekday).toBe(targetWeekday); + expect(response.body.due_date).toBeDefined(); + + // Verify due_date is on the correct weekday + const dueDate = new Date(response.body.due_date); + expect(dueDate.getUTCDay()).toBe(targetWeekday); + + // Verify due_date is in the future (or today if target is today) + expect(dueDate.getTime()).toBeGreaterThanOrEqual( + now.getTime() - 24 * 60 * 60 * 1000 + ); // Allow for today + }); + + it('should use today for weekly task when today is the target weekday', async () => { + const now = new Date(); + now.setUTCHours(0, 0, 0, 0); + const currentWeekday = now.getUTCDay(); + + const response = await agent.post('/api/task').send({ + name: 'Weekly Task Today', + recurrence_type: 'weekly', + recurrence_weekday: currentWeekday, + recurrence_interval: 1, + }); + + expect(response.status).toBe(201); + expect(response.body.due_date).toBeDefined(); + + const dueDate = new Date(response.body.due_date); + expect(dueDate.getUTCDay()).toBe(currentWeekday); + + // Should be today + const dueDateStr = dueDate.toISOString().split('T')[0]; + const nowStr = now.toISOString().split('T')[0]; + expect(dueDateStr).toBe(nowStr); + }); + + it('should default to today for daily recurring task without due date', async () => { + const now = new Date(); + now.setUTCHours(0, 0, 0, 0); + + const response = await agent.post('/api/task').send({ + name: 'Daily Task', + recurrence_type: 'daily', + recurrence_interval: 1, + // Intentionally NOT providing due_date + }); + + expect(response.status).toBe(201); + expect(response.body.recurrence_type).toBe('daily'); + expect(response.body.due_date).toBeDefined(); + + // For daily tasks, today is a reasonable default + const dueDate = new Date(response.body.due_date); + const dueDateStr = dueDate.toISOString().split('T')[0]; + const nowStr = now.toISOString().split('T')[0]; + expect(dueDateStr).toBe(nowStr); + }); + + it('should update existing task to add monthly recurrence and calculate correct due date', async () => { + // Create a task without recurrence + const task = await Task.create({ + name: 'Regular Task', + user_id: user.id, + status: 0, + }); + + const targetDay = 15; // Use middle of month for consistency + + // Update to add recurrence without providing due_date + const response = await agent.patch(`/api/task/${task.uid}`).send({ + recurrence_type: 'monthly', + recurrence_month_day: targetDay, + recurrence_interval: 1, + }); + + expect(response.status).toBe(200); + expect(response.body.recurrence_type).toBe('monthly'); + expect(response.body.due_date).toBeDefined(); + + const dueDate = new Date(response.body.due_date); + const dueDateDay = dueDate.getUTCDate(); + + // Should be set to the 15th + expect(dueDateDay).toBe(targetDay); + + // Should be today or in the future + const now = new Date(); + now.setUTCHours(0, 0, 0, 0); + expect(dueDate.getTime()).toBeGreaterThanOrEqual( + now.getTime() - 24 * 60 * 60 * 1000 + ); + }); + + it('should respect explicitly provided due_date over calculated date', async () => { + const explicitDate = '2026-12-25'; + + const response = await agent.post('/api/task').send({ + name: 'Monthly Task with Explicit Date', + recurrence_type: 'monthly', + recurrence_month_day: 15, + recurrence_interval: 1, + due_date: explicitDate, // Explicitly provided + }); + + expect(response.status).toBe(201); + expect(response.body.due_date).toBe(explicitDate); + }); + + it('should handle month edge case (31st of month in February)', async () => { + const response = await agent.post('/api/task').send({ + name: 'Monthly Task 31st', + recurrence_type: 'monthly', + recurrence_month_day: 31, + recurrence_interval: 1, + }); + + expect(response.status).toBe(201); + expect(response.body.due_date).toBeDefined(); + + // Should not crash and should provide a valid date + const dueDate = new Date(response.body.due_date); + expect(dueDate.getTime()).not.toBeNaN(); + }); + }); }); diff --git a/backend/tests/unit/modules/tasks/recurringTaskService.test.js b/backend/tests/unit/modules/tasks/recurringTaskService.test.js new file mode 100644 index 0000000..7fb9a4c --- /dev/null +++ b/backend/tests/unit/modules/tasks/recurringTaskService.test.js @@ -0,0 +1,483 @@ +const { + calculateNextDueDate, + calculateDailyRecurrence, + calculateWeeklyRecurrence, + calculateMonthlyRecurrence, + calculateMonthlyWeekdayRecurrence, + calculateMonthlyLastDayRecurrence, + calculateVirtualOccurrences, +} = require('../../../../modules/tasks/recurringTaskService'); + +describe('RecurringTaskService - UTC Consistency', () => { + describe('calculateDailyRecurrence', () => { + it('should add days using UTC date methods', () => { + const startDate = new Date(Date.UTC(2026, 0, 15, 23, 59, 59, 999)); + const result = calculateDailyRecurrence(startDate, 1); + + expect(result.getUTCDate()).toBe(16); + expect(result.getUTCMonth()).toBe(0); + expect(result.getUTCFullYear()).toBe(2026); + }); + + it('should handle month boundaries in UTC', () => { + const jan31 = new Date(Date.UTC(2026, 0, 31, 12, 0, 0, 0)); + const result = calculateDailyRecurrence(jan31, 1); + + expect(result.getUTCDate()).toBe(1); + expect(result.getUTCMonth()).toBe(1); + expect(result.getUTCFullYear()).toBe(2026); + }); + + it('should handle year boundaries in UTC', () => { + const dec31 = new Date(Date.UTC(2025, 11, 31, 23, 59, 59, 999)); + const result = calculateDailyRecurrence(dec31, 1); + + expect(result.getUTCDate()).toBe(1); + expect(result.getUTCMonth()).toBe(0); + expect(result.getUTCFullYear()).toBe(2026); + }); + + it('should preserve time component', () => { + const startDate = new Date(Date.UTC(2026, 0, 15, 23, 59, 59, 999)); + const result = calculateDailyRecurrence(startDate, 1); + + expect(result.getUTCHours()).toBe(23); + expect(result.getUTCMinutes()).toBe(59); + expect(result.getUTCSeconds()).toBe(59); + expect(result.getUTCMilliseconds()).toBe(999); + }); + + it('should work correctly for large intervals', () => { + const startDate = new Date(Date.UTC(2026, 0, 1, 0, 0, 0, 0)); + const result = calculateDailyRecurrence(startDate, 365); + + expect(result.getUTCDate()).toBe(1); + expect(result.getUTCMonth()).toBe(0); + expect(result.getUTCFullYear()).toBe(2027); + }); + + it('should work correctly with interval=7 (weekly equivalent)', () => { + const monday = new Date(Date.UTC(2026, 2, 9, 0, 0, 0, 0)); + const result = calculateDailyRecurrence(monday, 7); + + expect(result.getUTCDate()).toBe(16); + expect(result.getUTCDay()).toBe(1); + }); + }); + + describe('calculateWeeklyRecurrence', () => { + it('should calculate next weekday using UTC day of week', () => { + const tuesday = new Date(Date.UTC(2026, 2, 10, 12, 0, 0, 0)); + const result = calculateWeeklyRecurrence(tuesday, 1, 4, null); + + expect(result.getUTCDay()).toBe(4); + expect(result.getUTCDate()).toBe(12); + }); + + it('should handle week boundaries across UTC midnight', () => { + const saturday = new Date(Date.UTC(2026, 2, 14, 23, 59, 59, 999)); + const result = calculateWeeklyRecurrence(saturday, 1, 1, null); + + expect(result.getUTCDay()).toBe(1); + expect(result.getUTCDate()).toBe(16); + }); + + it('should handle bi-weekly recurrence', () => { + const monday = new Date(Date.UTC(2026, 2, 9, 0, 0, 0, 0)); + const result = calculateWeeklyRecurrence(monday, 2, 1, null); + + expect(result.getUTCDate()).toBe(23); + expect(result.getUTCDay()).toBe(1); + }); + + it('should handle multiple weekdays correctly', () => { + const tuesday = new Date(Date.UTC(2026, 2, 10, 0, 0, 0, 0)); + const weekdays = [2, 4]; + const result = calculateWeeklyRecurrence( + tuesday, + 1, + null, + weekdays + ); + + expect(result.getUTCDay()).toBe(4); + expect(result.getUTCDate()).toBe(12); + }); + + it('should wrap to next week when past all weekdays in current week', () => { + const friday = new Date(Date.UTC(2026, 2, 13, 0, 0, 0, 0)); + const weekdays = [1, 3]; + const result = calculateWeeklyRecurrence(friday, 1, null, weekdays); + + expect(result.getUTCDay()).toBe(1); + expect(result.getUTCDate()).toBe(16); + }); + + it('should handle month boundaries when wrapping weeks', () => { + const thursday = new Date(Date.UTC(2026, 0, 29, 0, 0, 0, 0)); + const result = calculateWeeklyRecurrence(thursday, 1, 1, null); + + expect(result.getUTCDay()).toBe(1); + expect(result.getUTCMonth()).toBe(1); + expect(result.getUTCDate()).toBe(2); + }); + + it('should preserve time component', () => { + const tuesday = new Date(Date.UTC(2026, 2, 10, 14, 30, 45, 123)); + const result = calculateWeeklyRecurrence(tuesday, 1, 4, null); + + expect(result.getUTCHours()).toBe(14); + expect(result.getUTCMinutes()).toBe(30); + expect(result.getUTCSeconds()).toBe(45); + expect(result.getUTCMilliseconds()).toBe(123); + }); + }); + + describe('calculateMonthlyRecurrence', () => { + it('should use UTC month and date for calculations', () => { + const jan15 = new Date(Date.UTC(2026, 0, 15, 12, 0, 0, 0)); + const result = calculateMonthlyRecurrence(jan15, 1, 15); + + expect(result.getUTCDate()).toBe(15); + expect(result.getUTCMonth()).toBe(1); + expect(result.getUTCFullYear()).toBe(2026); + }); + + it('should handle Jan 31 → Feb 28 in non-leap year', () => { + const jan31 = new Date(Date.UTC(2026, 0, 31, 0, 0, 0, 0)); + const result = calculateMonthlyRecurrence(jan31, 1, 31); + + expect(result.getUTCDate()).toBe(28); + expect(result.getUTCMonth()).toBe(1); + expect(result.getUTCFullYear()).toBe(2026); + }); + + it('should handle Jan 31 → Feb 29 in leap year', () => { + const jan31 = new Date(Date.UTC(2024, 0, 31, 0, 0, 0, 0)); + const result = calculateMonthlyRecurrence(jan31, 1, 31); + + expect(result.getUTCDate()).toBe(29); + expect(result.getUTCMonth()).toBe(1); + expect(result.getUTCFullYear()).toBe(2024); + }); + + it('should handle month-end clamping for short months', () => { + const jan31 = new Date(Date.UTC(2026, 0, 31, 0, 0, 0, 0)); + const result = calculateMonthlyRecurrence(jan31, 3, 31); + + expect(result.getUTCDate()).toBe(30); + expect(result.getUTCMonth()).toBe(3); + }); + + it('should handle year rollover', () => { + const dec15 = new Date(Date.UTC(2025, 11, 15, 0, 0, 0, 0)); + const result = calculateMonthlyRecurrence(dec15, 1, 15); + + expect(result.getUTCDate()).toBe(15); + expect(result.getUTCMonth()).toBe(0); + expect(result.getUTCFullYear()).toBe(2026); + }); + + it('should preserve UTC time component', () => { + const jan15 = new Date(Date.UTC(2026, 0, 15, 23, 59, 59, 999)); + const result = calculateMonthlyRecurrence(jan15, 1, 15); + + expect(result.getUTCHours()).toBe(23); + expect(result.getUTCMinutes()).toBe(59); + expect(result.getUTCSeconds()).toBe(59); + expect(result.getUTCMilliseconds()).toBe(999); + }); + + it('should handle multi-month intervals', () => { + const jan15 = new Date(Date.UTC(2026, 0, 15, 0, 0, 0, 0)); + const result = calculateMonthlyRecurrence(jan15, 3, 15); + + expect(result.getUTCDate()).toBe(15); + expect(result.getUTCMonth()).toBe(3); + expect(result.getUTCFullYear()).toBe(2026); + }); + + it('should handle intervals that span multiple years', () => { + const jan15 = new Date(Date.UTC(2026, 0, 15, 0, 0, 0, 0)); + const result = calculateMonthlyRecurrence(jan15, 15, 15); + + expect(result.getUTCDate()).toBe(15); + expect(result.getUTCMonth()).toBe(3); + expect(result.getUTCFullYear()).toBe(2027); + }); + }); + + describe('calculateMonthlyWeekdayRecurrence', () => { + it('should find Nth weekday of month using UTC', () => { + const firstMonday = new Date(Date.UTC(2026, 2, 2, 0, 0, 0, 0)); + const result = calculateMonthlyWeekdayRecurrence( + firstMonday, + 1, + 1, + 2 + ); + + expect(result.getUTCDay()).toBe(1); + expect(result.getUTCMonth()).toBe(3); + const date = result.getUTCDate(); + expect(date).toBeGreaterThanOrEqual(8); + expect(date).toBeLessThanOrEqual(14); + }); + + it('should handle 1st weekday of month', () => { + const someMonday = new Date(Date.UTC(2026, 2, 16, 0, 0, 0, 0)); + const result = calculateMonthlyWeekdayRecurrence( + someMonday, + 1, + 1, + 1 + ); + + expect(result.getUTCDay()).toBe(1); + expect(result.getUTCMonth()).toBe(3); + expect(result.getUTCDate()).toBeLessThanOrEqual(7); + }); + + it('should handle 3rd Friday of month', () => { + const someFriday = new Date(Date.UTC(2026, 2, 20, 0, 0, 0, 0)); + const result = calculateMonthlyWeekdayRecurrence( + someFriday, + 1, + 5, + 3 + ); + + expect(result.getUTCDay()).toBe(5); + expect(result.getUTCMonth()).toBe(3); + const date = result.getUTCDate(); + expect(date).toBeGreaterThanOrEqual(15); + expect(date).toBeLessThanOrEqual(21); + }); + + it('should handle overflow when 5th week does not exist', () => { + const someDay = new Date(Date.UTC(2026, 2, 1, 0, 0, 0, 0)); + const result = calculateMonthlyWeekdayRecurrence(someDay, 1, 1, 5); + + expect(result.getUTCMonth()).toBe(3); + const date = result.getUTCDate(); + expect(date).toBeLessThan(29); + }); + + it('should preserve UTC time component', () => { + const monday = new Date(Date.UTC(2026, 2, 2, 15, 30, 45, 500)); + const result = calculateMonthlyWeekdayRecurrence(monday, 1, 1, 1); + + expect(result.getUTCHours()).toBe(15); + expect(result.getUTCMinutes()).toBe(30); + expect(result.getUTCSeconds()).toBe(45); + expect(result.getUTCMilliseconds()).toBe(500); + }); + }); + + describe('calculateMonthlyLastDayRecurrence', () => { + it('should calculate last day of next month', () => { + const jan31 = new Date(Date.UTC(2026, 0, 31, 0, 0, 0, 0)); + const result = calculateMonthlyLastDayRecurrence(jan31, 1); + + expect(result.getUTCDate()).toBe(28); + expect(result.getUTCMonth()).toBe(1); + expect(result.getUTCFullYear()).toBe(2026); + }); + + it('should handle February in leap year', () => { + const jan31 = new Date(Date.UTC(2024, 0, 31, 0, 0, 0, 0)); + const result = calculateMonthlyLastDayRecurrence(jan31, 1); + + expect(result.getUTCDate()).toBe(29); + expect(result.getUTCMonth()).toBe(1); + }); + + it('should handle 31-day months', () => { + const feb28 = new Date(Date.UTC(2026, 1, 28, 0, 0, 0, 0)); + const result = calculateMonthlyLastDayRecurrence(feb28, 1); + + expect(result.getUTCDate()).toBe(31); + expect(result.getUTCMonth()).toBe(2); + }); + + it('should handle 30-day months', () => { + const mar31 = new Date(Date.UTC(2026, 2, 31, 0, 0, 0, 0)); + const result = calculateMonthlyLastDayRecurrence(mar31, 1); + + expect(result.getUTCDate()).toBe(30); + expect(result.getUTCMonth()).toBe(3); + }); + + it('should handle year boundaries', () => { + const dec31 = new Date(Date.UTC(2025, 11, 31, 0, 0, 0, 0)); + const result = calculateMonthlyLastDayRecurrence(dec31, 1); + + expect(result.getUTCDate()).toBe(31); + expect(result.getUTCMonth()).toBe(0); + expect(result.getUTCFullYear()).toBe(2026); + }); + + it('should preserve UTC time component', () => { + const jan31 = new Date(Date.UTC(2026, 0, 31, 23, 59, 59, 999)); + const result = calculateMonthlyLastDayRecurrence(jan31, 1); + + expect(result.getUTCHours()).toBe(23); + expect(result.getUTCMinutes()).toBe(59); + expect(result.getUTCSeconds()).toBe(59); + expect(result.getUTCMilliseconds()).toBe(999); + }); + }); + + describe('calculateVirtualOccurrences', () => { + it('should generate consistent dates in UTC', () => { + const task = { + recurrence_type: 'daily', + recurrence_interval: 1, + }; + const startDate = new Date(Date.UTC(2026, 0, 1, 0, 0, 0, 0)); + + const occurrences = calculateVirtualOccurrences(task, 7, startDate); + + expect(occurrences).toHaveLength(7); + expect(occurrences[0].due_date).toBe('2026-01-01'); + expect(occurrences[1].due_date).toBe('2026-01-02'); + expect(occurrences[6].due_date).toBe('2026-01-07'); + }); + + it('should respect end date limit', () => { + const task = { + recurrence_type: 'daily', + recurrence_interval: 1, + recurrence_end_date: new Date(Date.UTC(2026, 0, 5, 23, 59, 59)), + }; + const startDate = new Date(Date.UTC(2026, 0, 1, 0, 0, 0, 0)); + + const occurrences = calculateVirtualOccurrences( + task, + 10, + startDate + ); + + expect(occurrences.length).toBeLessThanOrEqual(5); + }); + + it('should handle weekly recurrence', () => { + const task = { + recurrence_type: 'weekly', + recurrence_interval: 1, + recurrence_weekday: 1, + }; + const monday = new Date(Date.UTC(2026, 2, 9, 0, 0, 0, 0)); + + const occurrences = calculateVirtualOccurrences(task, 3, monday); + + expect(occurrences).toHaveLength(3); + expect(occurrences[0].due_date).toBe('2026-03-09'); + expect(occurrences[1].due_date).toBe('2026-03-16'); + expect(occurrences[2].due_date).toBe('2026-03-23'); + }); + + it('should handle monthly recurrence across months', () => { + const task = { + recurrence_type: 'monthly', + recurrence_interval: 1, + recurrence_month_day: 15, + }; + const jan15 = new Date(Date.UTC(2026, 0, 15, 0, 0, 0, 0)); + + const occurrences = calculateVirtualOccurrences(task, 3, jan15); + + expect(occurrences).toHaveLength(3); + expect(occurrences[0].due_date).toBe('2026-01-15'); + expect(occurrences[1].due_date).toBe('2026-02-15'); + expect(occurrences[2].due_date).toBe('2026-03-15'); + }); + + it('should mark all occurrences as virtual', () => { + const task = { + recurrence_type: 'daily', + recurrence_interval: 1, + }; + const startDate = new Date(Date.UTC(2026, 0, 1, 0, 0, 0, 0)); + + const occurrences = calculateVirtualOccurrences(task, 3, startDate); + + occurrences.forEach((occurrence) => { + expect(occurrence.is_virtual).toBe(true); + }); + }); + + it('should respect MAX_ITERATIONS to prevent infinite loops', () => { + const task = { + recurrence_type: 'daily', + recurrence_interval: 1, + }; + const startDate = new Date(Date.UTC(2026, 0, 1, 0, 0, 0, 0)); + + const occurrences = calculateVirtualOccurrences( + task, + 1000, + startDate + ); + + expect(occurrences.length).toBeLessThanOrEqual(100); + }); + }); + + describe('calculateNextDueDate - UTC Independence', () => { + it('should calculate daily recurrence identically regardless of server timezone', () => { + const startDate = new Date(Date.UTC(2026, 0, 15, 12, 0, 0, 0)); + const task = { + recurrence_type: 'daily', + recurrence_interval: 1, + }; + + const result = calculateNextDueDate(task, startDate); + + expect(result.toISOString().split('T')[0]).toBe('2026-01-16'); + expect(result.getUTCDate()).toBe(16); + }); + + it('should preserve UTC midnight when calculating', () => { + const midnight = new Date(Date.UTC(2026, 0, 15, 0, 0, 0, 0)); + const task = { + recurrence_type: 'daily', + recurrence_interval: 1, + }; + + const result = calculateNextDueDate(task, midnight); + + expect(result.getUTCHours()).toBe(0); + expect(result.getUTCMinutes()).toBe(0); + expect(result.getUTCSeconds()).toBe(0); + expect(result.getUTCMilliseconds()).toBe(0); + }); + + it('should handle null or invalid input gracefully', () => { + expect(calculateNextDueDate(null, new Date())).toBeNull(); + expect(calculateNextDueDate({}, new Date())).toBeNull(); + expect( + calculateNextDueDate({ recurrence_type: 'daily' }, null) + ).toBeNull(); + expect( + calculateNextDueDate( + { recurrence_type: 'daily' }, + new Date('invalid') + ) + ).toBeNull(); + }); + + it('should return null for unknown recurrence type', () => { + const task = { + recurrence_type: 'unknown', + recurrence_interval: 1, + }; + const result = calculateNextDueDate( + task, + new Date(Date.UTC(2026, 0, 1)) + ); + + expect(result).toBeNull(); + }); + }); +});