Fix recurring task initial due date calculation to match recurrence pattern (#965)

* 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
This commit is contained in:
Chris 2026-03-23 18:24:54 +02:00 committed by GitHub
parent 2b71938c30
commit 077addadde
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 1221 additions and 20 deletions

View file

@ -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) {

View file

@ -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 =

View file

@ -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');
});
});
});

View file

@ -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();
});
});
});

View file

@ -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();
});
});
});