* Fix recurring task initial due date calculation to match recurrence pattern Resolves #963 When creating a recurring task without an explicit due date, the system was incorrectly setting it to "today" regardless of the recurrence pattern. This caused issues where: - Monthly tasks set to recur on a specific day (e.g., 28th) would show the wrong due date (today/yesterday instead of the 28th) - Tasks wouldn't appear correctly in the Upcoming view - The base due_date didn't match the recurrence_month_day setting Changes: - Add calculateInitialDueDate() helper to compute correct first occurrence - For monthly recurrence with specific day: calculate next occurrence of that day - For weekly recurrence with specific weekday: calculate next occurrence of that weekday - For other types (daily, etc.): maintain current behavior (use today) - Apply same logic to both task creation and updates Tests: - Add comprehensive test suite (9 new tests) covering: - Monthly recurrence with future day in current month - Monthly recurrence with past day (should use next month) - Weekly recurrence with specific weekday - Daily recurrence (should still default to today) - Edge cases (31st of month, explicitly provided dates) - Task updates adding recurrence All 54 recurring task tests pass. * Fix UTC timezone bug in recurring task expansion and add comprehensive tests - Fix expandRecurringTasks() to use setUTCHours instead of setHours - Add 42 unit tests for recurringTaskService UTC consistency - Add 24 DST transition tests (spring forward/fall back) - Verify no occurrence skips or duplicates during DST - Test month-end handling, leap years, and timezone boundaries
483 lines
18 KiB
JavaScript
483 lines
18 KiB
JavaScript
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();
|
|
});
|
|
});
|
|
});
|