From 01a84c359829ebcdc9a8a28cdff474d830af6aa9 Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 12 Apr 2026 08:52:13 +0300 Subject: [PATCH] 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 --- backend/modules/tasks/recurringTaskService.js | 58 ++++++-- .../unit/modules/tasks/biweekly-bug.test.js | 127 ++++++++++++++++++ docs/MEMORY.md | 32 +++-- 3 files changed, 195 insertions(+), 22 deletions(-) create mode 100644 backend/tests/unit/modules/tasks/biweekly-bug.test.js diff --git a/backend/modules/tasks/recurringTaskService.js b/backend/modules/tasks/recurringTaskService.js index dda95fd..7aceb5d 100644 --- a/backend/modules/tasks/recurringTaskService.js +++ b/backend/modules/tasks/recurringTaskService.js @@ -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; diff --git a/backend/tests/unit/modules/tasks/biweekly-bug.test.js b/backend/tests/unit/modules/tasks/biweekly-bug.test.js new file mode 100644 index 0000000..1a2e468 --- /dev/null +++ b/backend/tests/unit/modules/tasks/biweekly-bug.test.js @@ -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]); + }); + }); +}); diff --git a/docs/MEMORY.md b/docs/MEMORY.md index 1abb938..f1cca3e 100644 --- a/docs/MEMORY.md +++ b/docs/MEMORY.md @@ -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 \ No newline at end of file