diff --git a/tests/__mocks__/rrule.ts b/tests/__mocks__/rrule.ts index 63bb6766..13670606 100644 --- a/tests/__mocks__/rrule.ts +++ b/tests/__mocks__/rrule.ts @@ -59,9 +59,50 @@ export class RRule { return this.options; } - between(start: Date, end: Date): Date[] { - // Simple mock - return empty array or test dates - return []; + between(start: Date, end: Date, inclusive: boolean = false): Date[] { + // Mock implementation that generates dates based on the recurrence rule + const dates: Date[] = []; + + if (!this.options || !this.options.dtstart) { + return dates; + } + + const { freq, byweekday, dtstart } = this.options; + + if (freq === Frequency.WEEKLY && byweekday && byweekday.length > 0) { + // For weekly recurrence, generate dates that match the specified weekdays + const current = new Date(start); + current.setUTCHours(0, 0, 0, 0); + + while (current <= end) { + const dayOfWeek = current.getUTCDay(); + + // Check if this day matches any of the specified weekdays + const matchesWeekday = byweekday.some((wd: any) => { + const targetDay = wd.weekday !== undefined ? wd.weekday : wd; + // Convert Sunday=0 to Sunday=6 for our comparison + const adjustedDayOfWeek = dayOfWeek === 0 ? 6 : dayOfWeek - 1; + return targetDay === adjustedDayOfWeek; + }); + + if (matchesWeekday && current >= start) { + dates.push(new Date(current)); + } + + current.setUTCDate(current.getUTCDate() + 1); + } + } else if (freq === Frequency.DAILY) { + // For daily recurrence, generate all dates in the range + const current = new Date(start); + current.setUTCHours(0, 0, 0, 0); + + while (current <= end) { + dates.push(new Date(current)); + current.setUTCDate(current.getUTCDate() + 1); + } + } + + return dates; } after(date: Date): Date | null { @@ -133,8 +174,8 @@ export class RRule { return result; } - static fromString = jest.fn((str: string): RRule => { - // Parse the RRule string and create a mock rule with corresponding options + static parseString = jest.fn((str: string): any => { + // Parse the RRule string and return options object const options: any = {}; if (str.includes('FREQ=DAILY')) options.freq = Frequency.DAILY; @@ -185,6 +226,13 @@ export class RRule { options.count = parseInt(countMatch[1]); } + return options; + }); + + static fromString = jest.fn((str: string): RRule => { + // Parse the RRule string and create a mock rule with corresponding options + const options = RRule.parseString(str); + const rule = new RRule(options); rule.toText = jest.fn(() => { if (str.includes('FREQ=DAILY')) return 'every day'; diff --git a/tests/coverage-report.md b/tests/coverage-report.md index 3892de5b..81fa973a 100644 --- a/tests/coverage-report.md +++ b/tests/coverage-report.md @@ -11,11 +11,15 @@ This document provides a comprehensive analysis of the test suite implementation #### Services (`tests/unit/services/`) - ✅ **TaskService.test.ts** - Complete CRUD operations, file management, error handling - ✅ **NaturalLanguageParser.test.ts** - Input parsing, date extraction, pattern recognition +- ✅ **task-service-completion.test.ts** - Task completion functionality, recurring task handling (Issue #160) #### Utilities (`tests/unit/utils/`) - ✅ **dateUtils.test.ts** - Date parsing, formatting, timezone handling, validation - ✅ **helpers.test.ts** - Helper functions, time calculations, file operations +#### Issue-Specific Tests (`tests/unit/issues/`) +- ✅ **issue-160-off-by-one-completions.test.ts** - Comprehensive tests for GitHub issue #160 off-by-one date bugs + #### UI Components (`tests/unit/ui/`) - ✅ **TaskCard.test.ts** - Task card rendering, interactions, status updates diff --git a/tests/unit/issues/issue-160-date-formatting-inconsistency.test.ts b/tests/unit/issues/issue-160-date-formatting-inconsistency.test.ts new file mode 100644 index 00000000..7c0e0e43 --- /dev/null +++ b/tests/unit/issues/issue-160-date-formatting-inconsistency.test.ts @@ -0,0 +1,115 @@ +/** + * Test for Issue #160: Date formatting inconsistency between calendar and completion logic + * + * The bug occurs because: + * 1. Completion calendar uses formatUTCDateForCalendar() (UTC methods) + * 2. "Mark as completed" uses format() from date-fns (local timezone) + * + * This causes off-by-one issues when timezone offsets affect date interpretation + */ + +import { format } from 'date-fns'; +import { formatUTCDateForCalendar } from '../../../src/utils/dateUtils'; + +describe('Issue #160: Date formatting inconsistency', () => { + it('should show how calendar dates and completion dates can be off by one', () => { + // Simulate a Friday in January 2024 + const fridayDate = new Date('2024-01-12T00:00:00.000Z'); // Friday UTC + + console.log('=== Date Formatting Inconsistency Test ==='); + console.log('Original date:', fridayDate.toISOString()); + console.log('Day of week (UTC):', fridayDate.getUTCDay()); // Should be 5 (Friday) + + // Method 1: How completion calendar formats dates (UTC) + const calendarDateStr = formatUTCDateForCalendar(fridayDate); + console.log('Calendar formatting (UTC):', calendarDateStr); + + // Method 2: How "Mark as completed" formats dates (local timezone) + const completionDateStr = format(fridayDate, 'yyyy-MM-dd'); + console.log('Completion formatting (local):', completionDateStr); + + // Check if they're different + if (calendarDateStr !== completionDateStr) { + console.log('🐛 BUG FOUND: Date formatting inconsistency!'); + console.log(' Calendar shows:', calendarDateStr); + console.log(' Completion records:', completionDateStr); + } else { + console.log('✅ No inconsistency found'); + } + + // Test with a local timezone date (as might come from date-fns) + console.log('\n=== Testing with local timezone date ==='); + const localDate = new Date(2024, 0, 12, 0, 0, 0); // January 12, 2024 in local timezone + console.log('Local date:', localDate.toISOString()); + console.log('Local day of week:', localDate.getDay()); + + const calendarFromLocal = formatUTCDateForCalendar(localDate); + const completionFromLocal = format(localDate, 'yyyy-MM-dd'); + + console.log('Calendar formatting from local:', calendarFromLocal); + console.log('Completion formatting from local:', completionFromLocal); + + if (calendarFromLocal !== completionFromLocal) { + console.log('🐛 BUG FOUND: Local date formatting inconsistency!'); + } else { + console.log('✅ Local dates consistent'); + } + + // The test doesn't assert - it just demonstrates the issue + // In a real scenario, the timezone offset could cause these to be different + }); + + it('should reproduce the specific off-by-one scenario', () => { + console.log('\n=== Reproducing Off-by-One Scenario ==='); + + // Simulate what happens in the completion calendar vs mark as completed + const testDate = new Date('2024-01-12'); // This creates a local timezone date + + console.log('Test date:', testDate.toISOString()); + console.log('Local day of week:', testDate.getDay()); + console.log('UTC day of week:', testDate.getUTCDay()); + + // What the calendar would show (treating as UTC) + const calendarDay = formatUTCDateForCalendar(testDate); + + // What "mark as completed" would record (using local timezone) + const completionDay = format(testDate, 'yyyy-MM-dd'); + + console.log('Calendar would highlight:', calendarDay); + console.log('Completion would record:', completionDay); + + // In certain timezones, these could be different dates! + if (calendarDay !== completionDay) { + console.log('🐛 OFF-BY-ONE BUG: Different dates!'); + console.log(' This explains why Friday tasks appear on Saturday'); + console.log(' The calendar and completion logic use different dates'); + } + }); + + it('should show the timezone offset effect', () => { + console.log('\n=== Timezone Offset Effect ==='); + + // Create a date at the boundary where timezone offset matters + const boundaryDate = new Date('2024-01-12T00:00:00.000Z'); // Midnight UTC + + // Simulate different timezone interpretations + console.log('UTC interpretation:'); + console.log(' Date:', boundaryDate.toISOString()); + console.log(' Day of week:', boundaryDate.getUTCDay()); + console.log(' Formatted (UTC):', formatUTCDateForCalendar(boundaryDate)); + + console.log('Local interpretation:'); + console.log(' Local string:', boundaryDate.toLocaleString()); + console.log(' Local day of week:', boundaryDate.getDay()); + console.log(' Formatted (local):', format(boundaryDate, 'yyyy-MM-dd')); + + // Show current timezone offset + const offset = boundaryDate.getTimezoneOffset(); + console.log('Current timezone offset (minutes):', offset); + console.log('Hours behind UTC:', offset / 60); + + if (offset > 0) { + console.log('⚠️ Negative timezone offset detected - this can cause off-by-one issues'); + } + }); +}); \ No newline at end of file diff --git a/tests/unit/issues/issue-160-modal-vs-service-inconsistency.test.ts b/tests/unit/issues/issue-160-modal-vs-service-inconsistency.test.ts new file mode 100644 index 00000000..44fe4d10 --- /dev/null +++ b/tests/unit/issues/issue-160-modal-vs-service-inconsistency.test.ts @@ -0,0 +1,144 @@ +/** + * Test for Issue #160: Inconsistency between TaskEditModal calendar and TaskService completion + * + * This test specifically targets the interaction between: + * 1. TaskEditModal completion calendar (uses formatUTCDateForCalendar) + * 2. TaskService toggleRecurringTaskComplete (uses date-fns format) + */ + +import { format } from 'date-fns'; +import { formatUTCDateForCalendar, createUTCDateForRRule } from '../../../src/utils/dateUtils'; +import { generateRecurringInstances } from '../../../src/utils/helpers'; +import { TaskInfo } from '../../../src/types'; +import { TaskFactory } from '../../helpers/mock-factories'; + +describe('Issue #160: TaskEditModal vs TaskService inconsistency', () => { + it('should reproduce the exact bug: calendar highlighting vs completion recording', () => { + console.log('=== TaskEditModal vs TaskService Bug Reproduction ==='); + + // Create a Friday recurring task + const fridayTask: TaskInfo = TaskFactory.createTask({ + id: 'test-friday-task', + title: 'Weekly Friday Task', + recurrence: 'FREQ=WEEKLY;BYDAY=FR', + scheduled: '2024-01-12', // Friday + complete_instances: [] + }); + + // Simulate what happens in TaskEditModal completion calendar + console.log('\n1. TaskEditModal Calendar Logic:'); + + // Calendar generates dates using date-fns (which creates local timezone dates) + const weekStart = new Date(2024, 0, 8); // Local timezone: Monday Jan 8 + const weekEnd = new Date(2024, 0, 14); // Local timezone: Sunday Jan 14 + + console.log('Week start (local):', weekStart.toISOString()); + console.log('Week end (local):', weekEnd.toISOString()); + + // Simulate eachDayOfInterval - creates local timezone dates + const allDays: Date[] = []; + for (let day = new Date(weekStart); day <= weekEnd; day.setDate(day.getDate() + 1)) { + allDays.push(new Date(day)); // These are local timezone dates + } + + // Calendar formats these local dates using formatUTCDateForCalendar (treating them as UTC) + const calendarDateStrings = allDays.map(day => { + const formatted = formatUTCDateForCalendar(day); + console.log(` ${day.toISOString()} → ${formatted} (day ${day.getUTCDay()})`); + return formatted; + }); + + // Generate recurring instances (these are properly UTC) + const recurringDates = generateRecurringInstances(fridayTask, weekStart, weekEnd); + const recurringDateStrings = new Set(recurringDates.map(d => formatUTCDateForCalendar(d))); + + console.log('\nRecurring date strings (UTC):', Array.from(recurringDateStrings)); + console.log('Calendar date strings (local treated as UTC):', calendarDateStrings); + + // Find which day the calendar would highlight as recurring + const highlightedDays = calendarDateStrings.filter(dateStr => recurringDateStrings.has(dateStr)); + console.log('Days highlighted in calendar:', highlightedDays); + + // Simulate what happens when user clicks on a calendar day + console.log('\n2. TaskService Completion Logic:'); + + // User clicks on what they think is Friday (Jan 12) + const userClickedDay = allDays[4]; // Should be Friday (index 4 = Friday) + console.log('User clicked day:', userClickedDay.toISOString()); + console.log('User clicked day (local):', userClickedDay.toLocaleDateString()); + + // TaskService formats this using date-fns format (local timezone) + const completionDateStr = format(userClickedDay, 'yyyy-MM-dd'); + console.log('TaskService would record:', completionDateStr); + + // Calendar formats the same date using UTC methods + const calendarDateStr = formatUTCDateForCalendar(userClickedDay); + console.log('Calendar shows this as:', calendarDateStr); + + console.log('\n3. Bug Analysis:'); + if (completionDateStr !== calendarDateStr) { + console.log('🐛 BUG CONFIRMED: Date inconsistency!'); + console.log(` Calendar shows: ${calendarDateStr}`); + console.log(` Service records: ${completionDateStr}`); + console.log(' This is why Friday tasks appear to be completed on Saturday!'); + } else { + console.log('✅ No inconsistency found'); + } + + // Check if the recorded completion would match the recurring day + const fridayRecurringDay = Array.from(recurringDateStrings)[0]; + if (fridayRecurringDay && completionDateStr !== fridayRecurringDay) { + console.log('🐛 ADDITIONAL BUG: Completion doesn\'t match recurring day!'); + console.log(` Recurring day: ${fridayRecurringDay}`); + console.log(` Completion recorded: ${completionDateStr}`); + } + }); + + it('should demonstrate the fix by using consistent date handling', () => { + console.log('\n=== Demonstrating the Fix ==='); + + const fridayTask: TaskInfo = TaskFactory.createTask({ + id: 'test-friday-task', + title: 'Weekly Friday Task', + recurrence: 'FREQ=WEEKLY;BYDAY=FR', + scheduled: '2024-01-12', // Friday + complete_instances: [] + }); + + // FIXED approach: Use UTC dates consistently + console.log('Using consistent UTC date handling:'); + + // Create UTC dates for the week + const weekStartUTC = new Date('2024-01-08T00:00:00.000Z'); // Monday UTC + const weekEndUTC = new Date('2024-01-14T23:59:59.999Z'); // Sunday UTC + + const allDaysUTC: Date[] = []; + for (let day = new Date(weekStartUTC); day <= weekEndUTC; day.setUTCDate(day.getUTCDate() + 1)) { + allDaysUTC.push(new Date(day)); + } + + // Both calendar and service use the same formatting + const calendarDateStrings = allDaysUTC.map(day => formatUTCDateForCalendar(day)); + const recurringDates = generateRecurringInstances(fridayTask, weekStartUTC, weekEndUTC); + const recurringDateStrings = new Set(recurringDates.map(d => formatUTCDateForCalendar(d))); + + console.log('Calendar dates (UTC):', calendarDateStrings); + console.log('Recurring dates (UTC):', Array.from(recurringDateStrings)); + + // When user clicks, use the same UTC formatting + const userClickedDayUTC = allDaysUTC[4]; // Friday UTC + const completionDateStr = formatUTCDateForCalendar(userClickedDayUTC); // Use same function! + + console.log('User clicked (UTC):', userClickedDayUTC.toISOString()); + console.log('Completion recorded (UTC):', completionDateStr); + + // Check consistency + const fridayRecurringDay = Array.from(recurringDateStrings)[0]; + if (completionDateStr === fridayRecurringDay) { + console.log('✅ FIXED: Completion matches recurring day!'); + console.log(` Both use: ${completionDateStr}`); + } else { + console.log('❌ Still inconsistent'); + } + }); +}); \ No newline at end of file diff --git a/tests/unit/issues/issue-160-off-by-one-completions.test.ts b/tests/unit/issues/issue-160-off-by-one-completions.test.ts new file mode 100644 index 00000000..5d712366 --- /dev/null +++ b/tests/unit/issues/issue-160-off-by-one-completions.test.ts @@ -0,0 +1,317 @@ +/** + * Tests for Issue #160: Off-by-one issues in completions calendar + * + * Issue: https://github.com/callumalpass/tasknotes/issues/160 + * + * Problem: + * - Recurring task set for Fridays shows Saturdays highlighted in completions calendar + * - "Mark as completed" adds completion for tomorrow instead of today + * - Recording a completion for today does not style the task as completed + * - Adding a completion for Saturday will style it as completed + */ + +import { TaskInfo } from '../../../src/types'; +import { TaskFactory } from '../../helpers/mock-factories'; +import { isDueByRRule, generateRecurringInstances } from '../../../src/utils/helpers'; +import { createUTCDateForRRule, formatUTCDateForCalendar } from '../../../src/utils/dateUtils'; +import { RRule } from 'rrule'; + +// Don't mock date-fns - we want the real implementation +// Only mock specific functions if absolutely necessary +const realDateFns = jest.requireActual('date-fns'); + +describe('Issue #160: Off-by-one issues in completions calendar', () => { + // Test various days of the week to check for off-by-one pattern + const testCases = [ + { day: 'Monday', date: '2024-01-08', rrule: 'FREQ=WEEKLY;BYDAY=MO', expectedDayOfWeek: 1 }, + { day: 'Tuesday', date: '2024-01-09', rrule: 'FREQ=WEEKLY;BYDAY=TU', expectedDayOfWeek: 2 }, + { day: 'Wednesday', date: '2024-01-10', rrule: 'FREQ=WEEKLY;BYDAY=WE', expectedDayOfWeek: 3 }, + { day: 'Thursday', date: '2024-01-11', rrule: 'FREQ=WEEKLY;BYDAY=TH', expectedDayOfWeek: 4 }, + { day: 'Friday', date: '2024-01-12', rrule: 'FREQ=WEEKLY;BYDAY=FR', expectedDayOfWeek: 5 }, + { day: 'Saturday', date: '2024-01-13', rrule: 'FREQ=WEEKLY;BYDAY=SA', expectedDayOfWeek: 6 }, + { day: 'Sunday', date: '2024-01-14', rrule: 'FREQ=WEEKLY;BYDAY=SU', expectedDayOfWeek: 0 } + ]; + + beforeEach(() => { + // Reset all mocks + jest.clearAllMocks(); + }); + + describe('General off-by-one recurring task detection', () => { + testCases.forEach(({ day, date, rrule, expectedDayOfWeek }) => { + it(`should correctly identify ${day} as a recurring day`, () => { + const task = TaskFactory.createTask({ + id: `test-${day.toLowerCase()}-task`, + title: `Weekly ${day} Task`, + recurrence: rrule, + scheduled: date, + complete_instances: [] + }); + + const testDate = new Date(date + 'T00:00:00.000Z'); + const isDueOnCorrectDay = isDueByRRule(task, testDate); + + // This should return true for the intended day + expect(isDueOnCorrectDay).toBe(true); + + // Verify the date's actual day of week matches what we expect + expect(testDate.getUTCDay()).toBe(expectedDayOfWeek); + }); + + it(`should NOT identify ${day} task as due on the next day (off-by-one check)`, () => { + const task = TaskFactory.createTask({ + id: `test-${day.toLowerCase()}-task`, + title: `Weekly ${day} Task`, + recurrence: rrule, + scheduled: date, + complete_instances: [] + }); + + // Test the next day + const nextDate = new Date(date + 'T00:00:00.000Z'); + nextDate.setUTCDate(nextDate.getUTCDate() + 1); + + const isDueOnNextDay = isDueByRRule(task, nextDate); + + // This should return false for the next day + expect(isDueOnNextDay).toBe(false); + }); + }); + }); + + describe('Recurring instances generation (off-by-one check)', () => { + testCases.forEach(({ day, date, rrule, expectedDayOfWeek }) => { + it(`should generate instances only for ${day}, not the next day`, () => { + const task = TaskFactory.createTask({ + id: `test-${day.toLowerCase()}-task`, + title: `Weekly ${day} Task`, + recurrence: rrule, + scheduled: date, + complete_instances: [] + }); + + // Generate instances for a week containing our test date + const weekStart = new Date(date + 'T00:00:00.000Z'); + weekStart.setUTCDate(weekStart.getUTCDate() - 3); // Start a few days before + + const weekEnd = new Date(date + 'T00:00:00.000Z'); + weekEnd.setUTCDate(weekEnd.getUTCDate() + 3); // End a few days after + + const instances = generateRecurringInstances(task, weekStart, weekEnd); + const dateStrings = instances.map(d => formatUTCDateForCalendar(d)); + + // Should include the intended date + expect(dateStrings).toContain(date); + + // Should NOT include the next day (off-by-one check) + const nextDate = new Date(date + 'T00:00:00.000Z'); + nextDate.setUTCDate(nextDate.getUTCDate() + 1); + const nextDateStr = formatUTCDateForCalendar(nextDate); + + expect(dateStrings).not.toContain(nextDateStr); + }); + }); + }); + + describe('Completion calendar highlighting (off-by-one check)', () => { + it('should highlight the correct day when task recurs on that day', () => { + const { day, date, rrule } = testCases[4]; // Friday test case + + const task = TaskFactory.createTask({ + id: `test-${day.toLowerCase()}-task`, + title: `Weekly ${day} Task`, + recurrence: rrule, + scheduled: date, + complete_instances: [] + }); + + // Simulate the calendar logic from TaskEditModal + const weekStart = new Date(date + 'T00:00:00.000Z'); + weekStart.setUTCDate(weekStart.getUTCDate() - 3); + + const weekEnd = new Date(date + 'T00:00:00.000Z'); + weekEnd.setUTCDate(weekEnd.getUTCDate() + 3); + + const recurringDates = generateRecurringInstances(task, weekStart, weekEnd); + const recurringDateStrings = new Set(recurringDates.map(d => formatUTCDateForCalendar(d))); + + // The intended day should be in the recurring dates set + expect(recurringDateStrings.has(date)).toBe(true); + + // The next day should NOT be in the recurring dates set (off-by-one check) + const nextDate = new Date(date + 'T00:00:00.000Z'); + nextDate.setUTCDate(nextDate.getUTCDate() + 1); + const nextDateStr = formatUTCDateForCalendar(nextDate); + + expect(recurringDateStrings.has(nextDateStr)).toBe(false); + }); + }); + + describe('Task completion status (off-by-one check)', () => { + it('should mark task as completed when completion matches recurring day', () => { + const { day, date, rrule } = testCases[4]; // Friday test case + + const task = TaskFactory.createTask({ + id: `test-${day.toLowerCase()}-task`, + title: `Weekly ${day} Task`, + recurrence: rrule, + scheduled: date, + complete_instances: [date] // Completion on the correct day + }); + + const completedInstances = new Set(task.complete_instances || []); + + // Should be marked as completed for the correct day + expect(completedInstances.has(date)).toBe(true); + }); + + it('should NOT mark task as completed when completion is off by one day', () => { + const { day, date, rrule } = testCases[4]; // Friday test case + + // Calculate the next day + const nextDate = new Date(date + 'T00:00:00.000Z'); + nextDate.setUTCDate(nextDate.getUTCDate() + 1); + const nextDateStr = formatUTCDateForCalendar(nextDate); + + const task = TaskFactory.createTask({ + id: `test-${day.toLowerCase()}-task`, + title: `Weekly ${day} Task`, + recurrence: rrule, + scheduled: date, + complete_instances: [nextDateStr] // Completion on the wrong day (off-by-one) + }); + + const completedInstances = new Set(task.complete_instances || []); + + // Should NOT be marked as completed for the intended day + expect(completedInstances.has(date)).toBe(false); + + // But would incorrectly show as completed for the next day + expect(completedInstances.has(nextDateStr)).toBe(true); + }); + }); + + describe('Date handling edge cases (off-by-one check)', () => { + it('should handle timezone boundaries correctly', () => { + const { day, date, rrule } = testCases[4]; // Friday test case + + const task = TaskFactory.createTask({ + id: `test-${day.toLowerCase()}-task`, + title: `Weekly ${day} Task`, + recurrence: rrule, + scheduled: date, + complete_instances: [] + }); + + // Test with dates at timezone boundaries + const utcDate = createUTCDateForRRule(date); + const nextDay = createUTCDateForRRule(date); + nextDay.setUTCDate(nextDay.getUTCDate() + 1); + + // Verify the intended day is recognized as recurring + const isDueOnIntendedDay = isDueByRRule(task, utcDate); + expect(isDueOnIntendedDay).toBe(true); + + // Verify the next day is NOT recognized as recurring (off-by-one check) + const isDueOnNextDay = isDueByRRule(task, nextDay); + expect(isDueOnNextDay).toBe(false); + }); + }); + + describe('Regression tests - reproduce the actual off-by-one bug', () => { + it('should reproduce the original bug: tasks appearing on the wrong day', () => { + const { day, date, rrule } = testCases[4]; // Friday test case + + const task = TaskFactory.createTask({ + id: `test-${day.toLowerCase()}-task`, + title: `Weekly ${day} Task`, + recurrence: rrule, + scheduled: date, + complete_instances: [] + }); + + // Generate recurring instances for a range around the test date + const weekStart = new Date(date + 'T00:00:00.000Z'); + weekStart.setUTCDate(weekStart.getUTCDate() - 3); + + const weekEnd = new Date(date + 'T00:00:00.000Z'); + weekEnd.setUTCDate(weekEnd.getUTCDate() + 3); + + const recurringDates = generateRecurringInstances(task, weekStart, weekEnd); + const recurringDateStrings = new Set(recurringDates.map(d => formatUTCDateForCalendar(d))); + + // The bug would cause the next day to be highlighted instead of the intended day + // This assertion should pass when the bug is fixed + expect(recurringDateStrings.has(date)).toBe(true); // Intended day should be highlighted + + // Calculate next day + const nextDate = new Date(date + 'T00:00:00.000Z'); + nextDate.setUTCDate(nextDate.getUTCDate() + 1); + const nextDateStr = formatUTCDateForCalendar(nextDate); + + expect(recurringDateStrings.has(nextDateStr)).toBe(false); // Next day should NOT be highlighted + }); + + it('should reproduce the completion status bug', () => { + const { day, date, rrule } = testCases[4]; // Friday test case + + // This test reproduces the "Mark as completed" bug + const task = TaskFactory.createTask({ + id: `test-${day.toLowerCase()}-task`, + title: `Weekly ${day} Task`, + recurrence: rrule, + scheduled: date, + complete_instances: [date] // Mark as completed on the intended day + }); + + // Task should be marked as completed for the intended day + const completedInstances = new Set(task.complete_instances || []); + expect(completedInstances.has(date)).toBe(true); + + // Task should NOT be marked as completed for the next day + const nextDate = new Date(date + 'T00:00:00.000Z'); + nextDate.setUTCDate(nextDate.getUTCDate() + 1); + const nextDateStr = formatUTCDateForCalendar(nextDate); + + expect(completedInstances.has(nextDateStr)).toBe(false); + }); + }); + + describe('Calendar UI integration - off-by-one demonstration', () => { + it('should correctly apply CSS classes for recurring and completed days', () => { + const { day, date, rrule } = testCases[4]; // Friday test case + + const task = TaskFactory.createTask({ + id: `test-${day.toLowerCase()}-task`, + title: `Weekly ${day} Task`, + recurrence: rrule, + scheduled: date, + complete_instances: [date] // Mark as completed on the intended day + }); + + // Simulate the calendar rendering logic + const weekStart = new Date(date + 'T00:00:00.000Z'); + weekStart.setUTCDate(weekStart.getUTCDate() - 3); + + const weekEnd = new Date(date + 'T00:00:00.000Z'); + weekEnd.setUTCDate(weekEnd.getUTCDate() + 3); + + const recurringDates = generateRecurringInstances(task, weekStart, weekEnd); + const recurringDateStrings = new Set(recurringDates.map(d => formatUTCDateForCalendar(d))); + const completedInstances = new Set(task.complete_instances || []); + + // Test the intended day vs the next day (off-by-one check) + const nextDate = new Date(date + 'T00:00:00.000Z'); + nextDate.setUTCDate(nextDate.getUTCDate() + 1); + const nextDateStr = formatUTCDateForCalendar(nextDate); + + // The intended day should be marked as recurring and completed + expect(recurringDateStrings.has(date)).toBe(true); + expect(completedInstances.has(date)).toBe(true); + + // The next day should NOT be marked as recurring or completed + expect(recurringDateStrings.has(nextDateStr)).toBe(false); + expect(completedInstances.has(nextDateStr)).toBe(false); + }); + }); +}); \ No newline at end of file diff --git a/tests/unit/services/task-service-completion.test.ts b/tests/unit/services/task-service-completion.test.ts new file mode 100644 index 00000000..268e43c8 --- /dev/null +++ b/tests/unit/services/task-service-completion.test.ts @@ -0,0 +1,319 @@ +/** + * Tests for TaskService completion functionality related to Issue #160 + * + * Tests the actual service methods that handle task completion to ensure + * they work correctly with recurring tasks and don't introduce off-by-one errors. + */ + +import { TaskService } from '../../../src/services/TaskService'; +import { TaskInfo } from '../../../src/types'; +import { TaskFactory } from '../../helpers/mock-factories'; +import { getTodayString } from '../../../src/utils/dateUtils'; + +// Mock the required dependencies +jest.mock('../../../src/utils/dateUtils', () => ({ + ...jest.requireActual('../../../src/utils/dateUtils'), + getTodayString: jest.fn(), +})); + +const mockGetTodayString = getTodayString as jest.MockedFunction; + +describe('TaskService Completion (Issue #160)', () => { + let taskService: TaskService; + let fridayRecurringTask: TaskInfo; + + beforeEach(() => { + // Mock today to be a Friday + mockGetTodayString.mockReturnValue('2024-01-12'); // Friday + + // Create a mock plugin with minimal required properties + const mockPlugin = { + vault: { + getAbstractFileByPath: jest.fn(), + modify: jest.fn(), + }, + settings: { + taskFolder: 'tasks', + fieldMapping: {} + } + } as any; + + taskService = new TaskService(mockPlugin); + + // Create a Friday recurring task + fridayRecurringTask = TaskFactory.createTask({ + id: 'friday-task', + title: 'Weekly Friday Task', + recurrence: 'FREQ=WEEKLY;BYDAY=FR', + scheduled: '2024-01-12', // Friday + complete_instances: [] + }); + }); + + describe('toggleRecurringTaskComplete', () => { + it('should add completion for the correct date (Friday)', async () => { + // Mock the vault operations + const mockFile = { path: 'tasks/friday-task.md' }; + (taskService as any).plugin.vault.getAbstractFileByPath.mockReturnValue(mockFile); + (taskService as any).plugin.vault.modify.mockResolvedValue(undefined); + + // Mock the task loading and property update + jest.spyOn(taskService, 'updateProperty').mockResolvedValue(fridayRecurringTask); + + const targetDate = '2024-01-12'; // Friday + await taskService.toggleRecurringTaskComplete(fridayRecurringTask.id, targetDate); + + // Verify that updateProperty was called with the correct date + expect(taskService.updateProperty).toHaveBeenCalledWith( + fridayRecurringTask.id, + 'complete_instances', + expect.arrayContaining([targetDate]) + ); + }); + + it('should NOT add completion for the wrong date (Saturday)', async () => { + // Mock the vault operations + const mockFile = { path: 'tasks/friday-task.md' }; + (taskService as any).plugin.vault.getAbstractFileByPath.mockReturnValue(mockFile); + (taskService as any).plugin.vault.modify.mockResolvedValue(undefined); + + // Mock the task loading and property update + jest.spyOn(taskService, 'updateProperty').mockResolvedValue(fridayRecurringTask); + + const wrongDate = '2024-01-13'; // Saturday (wrong day) + await taskService.toggleRecurringTaskComplete(fridayRecurringTask.id, wrongDate); + + // Verify that updateProperty was called with Saturday (this might be the bug) + expect(taskService.updateProperty).toHaveBeenCalledWith( + fridayRecurringTask.id, + 'complete_instances', + expect.arrayContaining([wrongDate]) + ); + }); + + it('should remove completion when toggling off', async () => { + // Start with a task that has Friday completion + const taskWithCompletion = TaskFactory.createTask({ + ...fridayRecurringTask, + complete_instances: ['2024-01-12'] // Friday completion + }); + + // Mock the vault operations + const mockFile = { path: 'tasks/friday-task.md' }; + (taskService as any).plugin.vault.getAbstractFileByPath.mockReturnValue(mockFile); + (taskService as any).plugin.vault.modify.mockResolvedValue(undefined); + + // Mock the task loading and property update + jest.spyOn(taskService, 'updateProperty').mockResolvedValue(taskWithCompletion); + + const targetDate = '2024-01-12'; // Friday + await taskService.toggleRecurringTaskComplete(taskWithCompletion.id, targetDate); + + // Verify that updateProperty was called with the completion removed + expect(taskService.updateProperty).toHaveBeenCalledWith( + taskWithCompletion.id, + 'complete_instances', + [] // Should be empty after toggling off + ); + }); + }); + + describe('toggleStatus for recurring tasks', () => { + it('should use current date when marking recurring task complete', async () => { + const today = '2024-01-12'; // Friday + mockGetTodayString.mockReturnValue(today); + + // Mock the vault operations + const mockFile = { path: 'tasks/friday-task.md' }; + (taskService as any).plugin.vault.getAbstractFileByPath.mockReturnValue(mockFile); + (taskService as any).plugin.vault.modify.mockResolvedValue(undefined); + + // Mock the task loading and property update + jest.spyOn(taskService, 'updateProperty').mockResolvedValue(fridayRecurringTask); + + await taskService.toggleStatus(fridayRecurringTask.id); + + // For recurring tasks, toggleStatus should add today's date to complete_instances + expect(taskService.updateProperty).toHaveBeenCalledWith( + fridayRecurringTask.id, + 'complete_instances', + expect.arrayContaining([today]) + ); + }); + + it('should NOT use wrong date when marking recurring task complete', async () => { + const today = '2024-01-12'; // Friday + const tomorrow = '2024-01-13'; // Saturday + mockGetTodayString.mockReturnValue(today); + + // Mock the vault operations + const mockFile = { path: 'tasks/friday-task.md' }; + (taskService as any).plugin.vault.getAbstractFileByPath.mockReturnValue(mockFile); + (taskService as any).plugin.vault.modify.mockResolvedValue(undefined); + + // Mock the task loading and property update + jest.spyOn(taskService, 'updateProperty').mockResolvedValue(fridayRecurringTask); + + await taskService.toggleStatus(fridayRecurringTask.id); + + // Should NOT use tomorrow's date + expect(taskService.updateProperty).not.toHaveBeenCalledWith( + fridayRecurringTask.id, + 'complete_instances', + expect.arrayContaining([tomorrow]) + ); + }); + }); + + describe('Edge cases and date handling', () => { + it('should handle timezone boundaries correctly', async () => { + // Test with dates that might be affected by timezone issues + const testDates = [ + '2024-01-12', // Friday + '2024-01-19', // Another Friday + '2024-12-27', // Friday near year boundary + ]; + + for (const date of testDates) { + mockGetTodayString.mockReturnValue(date); + + // Mock the vault operations + const mockFile = { path: 'tasks/friday-task.md' }; + (taskService as any).plugin.vault.getAbstractFileByPath.mockReturnValue(mockFile); + (taskService as any).plugin.vault.modify.mockResolvedValue(undefined); + + // Mock the task loading + jest.spyOn(taskService, 'loadTaskFromCache').mockResolvedValue(fridayRecurringTask); + jest.spyOn(taskService, 'updateProperty').mockResolvedValue(undefined); + + await taskService.toggleRecurringTaskComplete(fridayRecurringTask.id, date); + + // Verify that the exact date was used + expect(taskService.updateProperty).toHaveBeenCalledWith( + fridayRecurringTask.id, + 'complete_instances', + expect.arrayContaining([date]) + ); + } + }); + + it('should handle month boundaries correctly', async () => { + // Test with dates at month boundaries + const testCases = [ + { date: '2024-01-31', name: 'End of January' }, + { date: '2024-02-01', name: 'Start of February' }, + { date: '2024-02-29', name: 'Leap day' }, + { date: '2024-03-01', name: 'Start of March' } + ]; + + for (const { date, name } of testCases) { + // Create a task that recurs on the specific day of week + const dayOfWeek = new Date(date + 'T00:00:00.000Z').getUTCDay(); + const dayNames = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA']; + const dayName = dayNames[dayOfWeek]; + + const recurringTask = TaskFactory.createTask({ + id: `task-${date}`, + title: `Task for ${name}`, + recurrence: `FREQ=WEEKLY;BYDAY=${dayName}`, + scheduled: date, + complete_instances: [] + }); + + mockGetTodayString.mockReturnValue(date); + + // Mock the vault operations + const mockFile = { path: `tasks/task-${date}.md` }; + (taskService as any).plugin.vault.getAbstractFileByPath.mockReturnValue(mockFile); + (taskService as any).plugin.vault.modify.mockResolvedValue(undefined); + + // Mock the task loading + jest.spyOn(taskService, 'loadTaskFromCache').mockResolvedValue(recurringTask); + jest.spyOn(taskService, 'updateProperty').mockResolvedValue(undefined); + + await taskService.toggleRecurringTaskComplete(recurringTask.id, date); + + // Verify that the exact date was used + expect(taskService.updateProperty).toHaveBeenCalledWith( + recurringTask.id, + 'complete_instances', + expect.arrayContaining([date]) + ); + } + }); + }); + + describe('Real-world scenarios', () => { + it('should handle user clicking "Mark as completed" on Friday', async () => { + const friday = '2024-01-12'; + mockGetTodayString.mockReturnValue(friday); + + // Mock the vault operations + const mockFile = { path: 'tasks/friday-task.md' }; + (taskService as any).plugin.vault.getAbstractFileByPath.mockReturnValue(mockFile); + (taskService as any).plugin.vault.modify.mockResolvedValue(undefined); + + // Mock the task loading and property update + jest.spyOn(taskService, 'updateProperty').mockResolvedValue(fridayRecurringTask); + + // Simulate user clicking "Mark as completed" + await taskService.toggleStatus(fridayRecurringTask.id); + + // Should add completion for Friday (today) + expect(taskService.updateProperty).toHaveBeenCalledWith( + fridayRecurringTask.id, + 'complete_instances', + expect.arrayContaining([friday]) + ); + }); + + it('should handle user clicking on calendar date', async () => { + const specificFriday = '2024-01-19'; // Another Friday + + // Mock the vault operations + const mockFile = { path: 'tasks/friday-task.md' }; + (taskService as any).plugin.vault.getAbstractFileByPath.mockReturnValue(mockFile); + (taskService as any).plugin.vault.modify.mockResolvedValue(undefined); + + // Mock the task loading and property update + jest.spyOn(taskService, 'updateProperty').mockResolvedValue(fridayRecurringTask); + + // Simulate user clicking on a specific date in the calendar + await taskService.toggleRecurringTaskComplete(fridayRecurringTask.id, specificFriday); + + // Should add completion for the specific Friday + expect(taskService.updateProperty).toHaveBeenCalledWith( + fridayRecurringTask.id, + 'complete_instances', + expect.arrayContaining([specificFriday]) + ); + }); + + it('should handle task with existing completions', async () => { + const existingCompletion = '2024-01-05'; // Previous Friday + const newCompletion = '2024-01-12'; // This Friday + + const taskWithCompletion = TaskFactory.createTask({ + ...fridayRecurringTask, + complete_instances: [existingCompletion] + }); + + // Mock the vault operations + const mockFile = { path: 'tasks/friday-task.md' }; + (taskService as any).plugin.vault.getAbstractFileByPath.mockReturnValue(mockFile); + (taskService as any).plugin.vault.modify.mockResolvedValue(undefined); + + // Mock the task loading and property update + jest.spyOn(taskService, 'updateProperty').mockResolvedValue(taskWithCompletion); + + await taskService.toggleRecurringTaskComplete(taskWithCompletion.id, newCompletion); + + // Should add the new completion while preserving existing ones + expect(taskService.updateProperty).toHaveBeenCalledWith( + taskWithCompletion.id, + 'complete_instances', + expect.arrayContaining([existingCompletion, newCompletion]) + ); + }); + }); +}); \ No newline at end of file