* fix: correct bi-weekly recurring task scheduling for multi-day patterns Fixes #1004 Previously, when a recurring task was set to repeat every N weeks (where N > 1) on multiple weekdays that span a week boundary (e.g., Saturday + Sunday), the algorithm incorrectly calculated the next occurrence dates. The issue was in the calculateWeeklyRecurrence function, which didn't properly determine when to add the interval skip for multi-weekday patterns. It would: - Correctly handle Sat -> Sun (adjacent days, same cycle) - Incorrectly handle Sun -> Sat (should skip weeks, but didn't) This fix improves the logic to: 1. Detect when the current day is the last weekday in the pattern cycle 2. Account for Sunday (day 0) wrapping around in the day-of-week numbering 3. Only add interval skips when truly moving to a new cycle, not when moving between weekdays within the same cycle Test coverage added for: - Bi-weekly Saturday + Sunday pattern (the reported bug) - Starting from different days in the pattern - Bi-weekly Tuesday + Thursday pattern - Tri-weekly Friday + Saturday + Sunday pattern * docs: update MEMORY.md with GitHub template requirements - Add detailed PR template requirements and structure - Expand bug report template requirements with all fields - Update last modified date
313 lines
9.7 KiB
JavaScript
313 lines
9.7 KiB
JavaScript
const calculateNextDueDate = (task, fromDate) => {
|
|
if (
|
|
!task ||
|
|
!task.recurrence_type ||
|
|
!fromDate ||
|
|
isNaN(fromDate.getTime())
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
const startDate = new Date(fromDate.getTime());
|
|
|
|
switch (task.recurrence_type) {
|
|
case 'daily':
|
|
return calculateDailyRecurrence(
|
|
startDate,
|
|
task.recurrence_interval || 1
|
|
);
|
|
|
|
case 'weekly':
|
|
return calculateWeeklyRecurrence(
|
|
startDate,
|
|
task.recurrence_interval || 1,
|
|
task.recurrence_weekday,
|
|
task.recurrence_weekdays
|
|
);
|
|
|
|
case 'monthly':
|
|
return calculateMonthlyRecurrence(
|
|
startDate,
|
|
task.recurrence_interval || 1,
|
|
task.recurrence_month_day
|
|
);
|
|
|
|
case 'monthly_weekday':
|
|
return calculateMonthlyWeekdayRecurrence(
|
|
startDate,
|
|
task.recurrence_interval || 1,
|
|
task.recurrence_weekday,
|
|
task.recurrence_week_of_month
|
|
);
|
|
|
|
case 'monthly_last_day':
|
|
return calculateMonthlyLastDayRecurrence(
|
|
startDate,
|
|
task.recurrence_interval || 1
|
|
);
|
|
|
|
default:
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const calculateDailyRecurrence = (fromDate, interval) => {
|
|
const nextDate = new Date(fromDate);
|
|
nextDate.setUTCDate(nextDate.getUTCDate() + interval);
|
|
return nextDate;
|
|
};
|
|
|
|
const calculateWeeklyRecurrence = (fromDate, interval, weekday, weekdays) => {
|
|
const nextDate = new Date(fromDate);
|
|
|
|
// Handle multiple weekdays (e.g. Tuesday AND Thursday)
|
|
const parsedWeekdays = weekdays
|
|
? Array.isArray(weekdays)
|
|
? weekdays
|
|
: JSON.parse(weekdays)
|
|
: null;
|
|
|
|
if (parsedWeekdays && parsedWeekdays.length > 0) {
|
|
const sorted = [...parsedWeekdays].sort((a, b) => a - b);
|
|
const currentDay = nextDate.getUTCDay();
|
|
|
|
// Find next weekday in calendar order (accounting for week wrap)
|
|
let nextWeekday = null;
|
|
let daysToNext = null;
|
|
|
|
for (let i = 1; i <= 7; i++) {
|
|
const testDay = (currentDay + i) % 7;
|
|
if (sorted.includes(testDay)) {
|
|
nextWeekday = testDay;
|
|
daysToNext = i;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (daysToNext === null) {
|
|
// No weekday found (shouldn't happen), fallback
|
|
daysToNext = interval * 7;
|
|
} else if (daysToNext < 7) {
|
|
// Next weekday is within current 7-day period
|
|
// Determine if we should skip weeks based on interval
|
|
const isAdjacentDay = daysToNext === 1;
|
|
|
|
// Determine if current day is the last weekday in the pattern
|
|
// (considering calendar order, where the highest day number is last,
|
|
// except Sunday(0) which wraps around)
|
|
const maxWeekday = Math.max(...sorted);
|
|
const isOnLastWeekday = currentDay === maxWeekday;
|
|
|
|
// Special case: if pattern includes Sunday(0), check if we're on it
|
|
// and all other weekdays are higher (meaning we're at the end of the pattern)
|
|
const hasSunday = sorted.includes(0);
|
|
const isOnSundayWithHigherDays =
|
|
currentDay === 0 && sorted.every((d) => d === 0 || d > 0);
|
|
|
|
if (
|
|
interval > 1 &&
|
|
!isAdjacentDay &&
|
|
(isOnLastWeekday || isOnSundayWithHigherDays)
|
|
) {
|
|
// We're on the last weekday of the pattern cycle, add interval skip
|
|
daysToNext += (interval - 1) * 7;
|
|
}
|
|
} else {
|
|
// Next occurrence is 7 days away, add interval skip
|
|
daysToNext += (interval - 1) * 7;
|
|
}
|
|
|
|
nextDate.setUTCDate(nextDate.getUTCDate() + daysToNext);
|
|
} else if (weekday !== null && weekday !== undefined) {
|
|
const currentWeekday = nextDate.getUTCDay();
|
|
const daysUntilTarget = (weekday - currentWeekday + 7) % 7;
|
|
|
|
if (
|
|
daysUntilTarget === 0 &&
|
|
nextDate.getTime() === fromDate.getTime()
|
|
) {
|
|
nextDate.setUTCDate(nextDate.getUTCDate() + interval * 7);
|
|
} else {
|
|
nextDate.setUTCDate(nextDate.getUTCDate() + daysUntilTarget);
|
|
if (nextDate <= fromDate) {
|
|
nextDate.setUTCDate(nextDate.getUTCDate() + interval * 7);
|
|
}
|
|
}
|
|
} else {
|
|
nextDate.setUTCDate(nextDate.getUTCDate() + interval * 7);
|
|
}
|
|
|
|
return nextDate;
|
|
};
|
|
|
|
const calculateMonthlyRecurrence = (fromDate, interval, dayOfMonth) => {
|
|
const nextDate = new Date(fromDate);
|
|
const targetDay = dayOfMonth || fromDate.getUTCDate();
|
|
|
|
const targetMonth = nextDate.getUTCMonth() + interval;
|
|
const targetYear = nextDate.getUTCFullYear() + Math.floor(targetMonth / 12);
|
|
const finalMonth = targetMonth % 12;
|
|
|
|
const maxDay = new Date(
|
|
Date.UTC(targetYear, finalMonth + 1, 0)
|
|
).getUTCDate();
|
|
const finalDay = Math.min(targetDay, maxDay);
|
|
|
|
const result = new Date(
|
|
Date.UTC(
|
|
targetYear,
|
|
finalMonth,
|
|
finalDay,
|
|
fromDate.getUTCHours(),
|
|
fromDate.getUTCMinutes(),
|
|
fromDate.getUTCSeconds(),
|
|
fromDate.getUTCMilliseconds()
|
|
)
|
|
);
|
|
|
|
return result;
|
|
};
|
|
|
|
const calculateMonthlyWeekdayRecurrence = (
|
|
fromDate,
|
|
interval,
|
|
weekday,
|
|
weekOfMonth
|
|
) => {
|
|
const nextDate = new Date(fromDate);
|
|
nextDate.setUTCMonth(nextDate.getUTCMonth() + interval);
|
|
|
|
const firstOfMonth = new Date(
|
|
Date.UTC(nextDate.getUTCFullYear(), nextDate.getUTCMonth(), 1)
|
|
);
|
|
const firstWeekday = firstOfMonth.getUTCDay();
|
|
|
|
const daysToAdd = (weekday - firstWeekday + 7) % 7;
|
|
const firstOccurrence = new Date(firstOfMonth);
|
|
firstOccurrence.setUTCDate(1 + daysToAdd);
|
|
|
|
const targetDate = new Date(firstOccurrence);
|
|
targetDate.setUTCDate(firstOccurrence.getUTCDate() + (weekOfMonth - 1) * 7);
|
|
|
|
if (targetDate.getUTCMonth() !== nextDate.getUTCMonth()) {
|
|
targetDate.setUTCDate(targetDate.getUTCDate() - 7);
|
|
}
|
|
|
|
targetDate.setUTCHours(
|
|
fromDate.getUTCHours(),
|
|
fromDate.getUTCMinutes(),
|
|
fromDate.getUTCSeconds(),
|
|
fromDate.getUTCMilliseconds()
|
|
);
|
|
|
|
return targetDate;
|
|
};
|
|
|
|
const calculateMonthlyLastDayRecurrence = (fromDate, interval) => {
|
|
// Calculate target year and month directly to avoid date overflow
|
|
// (e.g., Jan 31 + 1 month via setUTCMonth would overflow to March)
|
|
const currentMonth = fromDate.getUTCMonth();
|
|
const currentYear = fromDate.getUTCFullYear();
|
|
|
|
const totalMonths = currentMonth + interval;
|
|
const targetYear = currentYear + Math.floor(totalMonths / 12);
|
|
const targetMonth = totalMonths % 12;
|
|
|
|
// Get last day of target month by creating date at day 0 of following month
|
|
const lastDayOfMonth = new Date(
|
|
Date.UTC(
|
|
targetYear,
|
|
targetMonth + 1, // next month
|
|
0, // day 0 = last day of previous month
|
|
fromDate.getUTCHours(),
|
|
fromDate.getUTCMinutes(),
|
|
fromDate.getUTCSeconds(),
|
|
fromDate.getUTCMilliseconds()
|
|
)
|
|
);
|
|
|
|
return lastDayOfMonth;
|
|
};
|
|
|
|
const getFirstWeekdayOfMonth = (year, month, weekday) => {
|
|
const firstOfMonth = new Date(year, month, 1);
|
|
const firstWeekday = firstOfMonth.getDay();
|
|
const daysToAdd = (weekday - firstWeekday + 7) % 7;
|
|
return new Date(year, month, 1 + daysToAdd);
|
|
};
|
|
|
|
const getLastWeekdayOfMonth = (year, month, weekday) => {
|
|
const lastOfMonth = new Date(year, month + 1, 0);
|
|
const lastWeekday = lastOfMonth.getDay();
|
|
const daysToSubtract = (lastWeekday - weekday + 7) % 7;
|
|
return new Date(year, month, lastOfMonth.getDate() - daysToSubtract);
|
|
};
|
|
|
|
const getNthWeekdayOfMonth = (year, month, weekday, n) => {
|
|
const firstOccurrence = getFirstWeekdayOfMonth(year, month, weekday);
|
|
const targetDate = new Date(firstOccurrence);
|
|
targetDate.setDate(firstOccurrence.getDate() + (n - 1) * 7);
|
|
|
|
if (targetDate.getMonth() !== month) {
|
|
return null;
|
|
}
|
|
|
|
return targetDate;
|
|
};
|
|
|
|
const shouldGenerateNextTask = (task, nextDate) => {
|
|
if (!task.recurrence_end_date) {
|
|
return true;
|
|
}
|
|
return nextDate < task.recurrence_end_date;
|
|
};
|
|
|
|
const calculateVirtualOccurrences = (task, count = 7, startFrom = null) => {
|
|
const occurrences = [];
|
|
let currentDate = startFrom
|
|
? new Date(startFrom)
|
|
: task.due_date
|
|
? new Date(task.due_date)
|
|
: new Date();
|
|
let iterationCount = 0;
|
|
const MAX_ITERATIONS = 100;
|
|
|
|
while (occurrences.length < count && iterationCount < MAX_ITERATIONS) {
|
|
if (
|
|
task.recurrence_end_date &&
|
|
currentDate > new Date(task.recurrence_end_date)
|
|
) {
|
|
break;
|
|
}
|
|
|
|
occurrences.push({
|
|
due_date: currentDate.toISOString().split('T')[0],
|
|
is_virtual: true,
|
|
});
|
|
|
|
currentDate = calculateNextDueDate(task, currentDate);
|
|
if (!currentDate) break;
|
|
|
|
iterationCount++;
|
|
}
|
|
|
|
return occurrences;
|
|
};
|
|
|
|
module.exports = {
|
|
calculateNextDueDate,
|
|
calculateDailyRecurrence,
|
|
calculateWeeklyRecurrence,
|
|
calculateMonthlyRecurrence,
|
|
calculateMonthlyWeekdayRecurrence,
|
|
calculateMonthlyLastDayRecurrence,
|
|
calculateVirtualOccurrences,
|
|
shouldGenerateNextTask,
|
|
getFirstWeekdayOfMonth,
|
|
getLastWeekdayOfMonth,
|
|
getNthWeekdayOfMonth,
|
|
_getFirstWeekdayOfMonth: getFirstWeekdayOfMonth,
|
|
_getLastWeekdayOfMonth: getLastWeekdayOfMonth,
|
|
_getNthWeekdayOfMonth: getNthWeekdayOfMonth,
|
|
_shouldGenerateNextTask: shouldGenerateNextTask,
|
|
};
|