Fix: Bi-weekly recurring task scheduling for multi-day patterns (#1005)
* 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
This commit is contained in:
parent
e8c7eed226
commit
01a84c3598
3 changed files with 195 additions and 22 deletions
|
|
@ -71,19 +71,53 @@ const calculateWeeklyRecurrence = (fromDate, interval, weekday, weekdays) => {
|
|||
const sorted = [...parsedWeekdays].sort((a, b) => a - b);
|
||||
const currentDay = nextDate.getUTCDay();
|
||||
|
||||
// Find next weekday later in the current week
|
||||
const laterInWeek = sorted.filter((d) => d > currentDay);
|
||||
if (laterInWeek.length > 0) {
|
||||
nextDate.setUTCDate(
|
||||
nextDate.getUTCDate() + (laterInWeek[0] - currentDay)
|
||||
);
|
||||
} else {
|
||||
// Wrap to first weekday of next cycle (interval weeks ahead)
|
||||
const daysToNextFirst = (7 - currentDay + sorted[0]) % 7 || 7;
|
||||
nextDate.setUTCDate(
|
||||
nextDate.getUTCDate() + daysToNextFirst + (interval - 1) * 7
|
||||
);
|
||||
// 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;
|
||||
|
|
|
|||
127
backend/tests/unit/modules/tasks/biweekly-bug.test.js
Normal file
127
backend/tests/unit/modules/tasks/biweekly-bug.test.js
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
const {
|
||||
calculateWeeklyRecurrence,
|
||||
calculateVirtualOccurrences,
|
||||
} = require('../../../../modules/tasks/recurringTaskService');
|
||||
|
||||
describe('Bug #1004 - Bi-weekly recurrence with weekend days', () => {
|
||||
describe('Saturday + Sunday pattern', () => {
|
||||
it('should correctly schedule every 2 weeks on Saturday and Sunday', () => {
|
||||
const saturday = new Date(Date.UTC(2026, 3, 11, 0, 0, 0, 0));
|
||||
const weekdays = [0, 6];
|
||||
|
||||
const firstNext = calculateWeeklyRecurrence(
|
||||
saturday,
|
||||
2,
|
||||
null,
|
||||
weekdays
|
||||
);
|
||||
expect(firstNext.getUTCDate()).toBe(12);
|
||||
expect(firstNext.getUTCDay()).toBe(0);
|
||||
|
||||
const secondNext = calculateWeeklyRecurrence(
|
||||
firstNext,
|
||||
2,
|
||||
null,
|
||||
weekdays
|
||||
);
|
||||
expect(secondNext.getUTCDate()).toBe(25);
|
||||
expect(secondNext.getUTCDay()).toBe(6);
|
||||
|
||||
const thirdNext = calculateWeeklyRecurrence(
|
||||
secondNext,
|
||||
2,
|
||||
null,
|
||||
weekdays
|
||||
);
|
||||
expect(thirdNext.getUTCDate()).toBe(26);
|
||||
expect(thirdNext.getUTCDay()).toBe(0);
|
||||
});
|
||||
|
||||
it('should generate correct virtual occurrences for bi-weekly weekend pattern', () => {
|
||||
const task = {
|
||||
due_date: new Date(Date.UTC(2026, 3, 11, 0, 0, 0, 0)),
|
||||
recurrence_type: 'weekly',
|
||||
recurrence_interval: 2,
|
||||
recurrence_weekdays: [0, 6],
|
||||
};
|
||||
|
||||
const occurrences = calculateVirtualOccurrences(task, 6);
|
||||
|
||||
const dates = occurrences.map((o) =>
|
||||
new Date(o.due_date).getUTCDate()
|
||||
);
|
||||
const days = occurrences.map((o) =>
|
||||
new Date(o.due_date).getUTCDay()
|
||||
);
|
||||
|
||||
expect(dates).toEqual([11, 12, 25, 26, 9, 10]);
|
||||
expect(days).toEqual([6, 0, 6, 0, 6, 0]);
|
||||
});
|
||||
|
||||
it('should work when starting from Sunday', () => {
|
||||
const sunday = new Date(Date.UTC(2026, 3, 12, 0, 0, 0, 0));
|
||||
const weekdays = [0, 6];
|
||||
|
||||
const firstNext = calculateWeeklyRecurrence(
|
||||
sunday,
|
||||
2,
|
||||
null,
|
||||
weekdays
|
||||
);
|
||||
expect(firstNext.getUTCDate()).toBe(25);
|
||||
expect(firstNext.getUTCDay()).toBe(6);
|
||||
|
||||
const secondNext = calculateWeeklyRecurrence(
|
||||
firstNext,
|
||||
2,
|
||||
null,
|
||||
weekdays
|
||||
);
|
||||
expect(secondNext.getUTCDate()).toBe(26);
|
||||
expect(secondNext.getUTCDay()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Other multi-day patterns with interval > 1', () => {
|
||||
it('should handle Tuesday + Thursday every 2 weeks', () => {
|
||||
const tuesday = new Date(Date.UTC(2026, 3, 14, 0, 0, 0, 0));
|
||||
const weekdays = [2, 4];
|
||||
|
||||
const firstNext = calculateWeeklyRecurrence(
|
||||
tuesday,
|
||||
2,
|
||||
null,
|
||||
weekdays
|
||||
);
|
||||
expect(firstNext.getUTCDay()).toBe(4);
|
||||
expect(firstNext.getUTCDate()).toBe(16);
|
||||
|
||||
const secondNext = calculateWeeklyRecurrence(
|
||||
firstNext,
|
||||
2,
|
||||
null,
|
||||
weekdays
|
||||
);
|
||||
expect(secondNext.getUTCDay()).toBe(2);
|
||||
expect(secondNext.getUTCDate()).toBe(28);
|
||||
});
|
||||
|
||||
it('should handle Friday + Saturday + Sunday every 3 weeks', () => {
|
||||
const friday = new Date(Date.UTC(2026, 3, 10, 0, 0, 0, 0));
|
||||
const weekdays = [0, 5, 6];
|
||||
|
||||
const occurrences = [friday];
|
||||
let current = friday;
|
||||
for (let i = 0; i < 8; i++) {
|
||||
current = calculateWeeklyRecurrence(current, 3, null, weekdays);
|
||||
occurrences.push(current);
|
||||
}
|
||||
|
||||
const days = occurrences.map((d) => d.getUTCDay());
|
||||
expect(days).toEqual([5, 6, 0, 5, 6, 0, 5, 6, 0]);
|
||||
|
||||
const dates = occurrences.map((d) => d.getUTCDate());
|
||||
expect(dates).toEqual([10, 11, 12, 1, 2, 3, 22, 23, 24]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -6,13 +6,16 @@ This document contains preferences, patterns, and memory items specific to worki
|
|||
|
||||
## Pull Request Preferences
|
||||
|
||||
### PR Descriptions
|
||||
### PR Template
|
||||
- **ALWAYS use** the PR template from `.github/pull_request_template.md`
|
||||
- **Do NOT add** the "🤖 Generated with [Claude Code](https://claude.com/claude-code)" footer to pull requests
|
||||
- Keep PR descriptions focused on the changes and test plan
|
||||
- Follow the standard format:
|
||||
- Summary section with bullet points
|
||||
- Changes section with detailed breakdown
|
||||
- Test plan section with checkboxes
|
||||
- Required sections:
|
||||
- **Description**: What does this PR do? Why is this change needed?
|
||||
- **Type of Change**: Bug fix / New feature / Breaking change / Documentation
|
||||
- **Related Issues**: Link issues using "Fixes #123"
|
||||
- **Testing**: Describe testing steps and commands run
|
||||
- **Checklist**: Mark all applicable items (including "This is not AI slop crap")
|
||||
- **Additional Notes**: Any other context for reviewers
|
||||
|
||||
### PR Creation Workflow
|
||||
- Always create PRs against the `main` branch
|
||||
|
|
@ -64,9 +67,18 @@ This document contains preferences, patterns, and memory items specific to worki
|
|||
## GitHub Issue Preferences
|
||||
|
||||
### Bug Reports
|
||||
- **Always follow** the GitHub bug template (`.github/ISSUE_TEMPLATE/bug_report.yml`)
|
||||
- Include all required fields: Description, Steps to Reproduce, Expected Behavior, Actual Behavior, OS, Browser
|
||||
- Add technical context in Additional Context section (root cause, solution, files affected)
|
||||
- **ALWAYS use** the GitHub bug report template from `.github/ISSUE_TEMPLATE/bug_report.yml`
|
||||
- Required fields:
|
||||
- **Description**: Clear description of the bug
|
||||
- **Steps to Reproduce**: Detailed steps to reproduce the issue
|
||||
- **Expected Behavior**: What should happen
|
||||
- **Actual Behavior**: What actually happens
|
||||
- **Operating System**: User's OS
|
||||
- **Browser**: Browser name and version
|
||||
- Optional fields:
|
||||
- **Version**: Tududi version (if known)
|
||||
- **Screenshots**: Visual evidence of the issue
|
||||
- **Additional Context**: Technical details (root cause, solution, files affected)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -82,5 +94,5 @@ This document contains preferences, patterns, and memory items specific to worki
|
|||
|
||||
---
|
||||
|
||||
**Last Updated:** 2026-03-14
|
||||
**Last Updated:** 2026-04-12
|
||||
**Maintained by:** Claude Code sessions - update as new patterns emerge
|
||||
Loading…
Add table
Add a link
Reference in a new issue